force graph, palette

This commit is contained in:
Nikita
2024-08-30 16:19:29 +03:00
parent 9e89959dd4
commit 32352ca5f4
38 changed files with 1602 additions and 243 deletions

View File

@@ -1,40 +1,39 @@
"use client"
import { useAccount } from "@/lib/providers/jazz-provider"
export default function EditProfileRoute() {
const account = useAccount()
return (
<div className="flex flex-1 flex-col">
<p className="h-[74px] p-[20px] text-2xl font-semibold text-white/30">Profile</p>
<div className="flex flex-col items-center border-b border-neutral-900 bg-inherit pb-5 text-white">
<div className="flex flex-1 flex-col text-sm text-black dark:text-white">
<p className="h-[74px] p-[20px] text-2xl font-semibold opacity-60">Edit Profile</p>
<div className="flex flex-col items-center border-b border-neutral-900 bg-inherit pb-5">
<div className="flex w-full max-w-2xl align-top">
<button className="mr-3 h-[130px] w-[130px] flex-col items-center justify-center rounded-xl border border-dashed border-white/10 bg-neutral-100 text-white/50 dark:bg-neutral-900">
<button className="bg-input mr-3 h-[130px] w-[130px] flex-col items-center justify-center rounded-xl border border-dashed border-black/10 bg-neutral-100 dark:border-white/10">
<p className="text-sm tracking-wide">Photo</p>
</button>
<div className="ml-6 flex-1 space-y-4 font-light">
<div className="ml-6 flex-1 space-y-4">
<input
type="text"
placeholder="Your name"
className="w-full rounded-md bg-[#121212] p-3 font-light tracking-wide text-white/70 placeholder-white/20 outline-none"
className="bg-input w-full rounded-md p-3 tracking-wide outline-none"
/>
<input
type="text"
placeholder="Username"
className="w-full rounded-md bg-[#121212] p-3 tracking-wide text-white/70 placeholder-white/20 outline-none"
className="bg-input w-full rounded-md p-3 tracking-wide outline-none"
/>
<p className="text-white/30">learn-anything.xyz/@</p>
<input
type="text"
placeholder="Website"
className="w-full rounded-md bg-[#121212] p-3 tracking-wide text-white/30 placeholder-white/20 outline-none"
className="bg-input tracking-wideoutline-none w-full rounded-md p-3"
/>
<textarea
placeholder="Bio"
className="h-[120px] w-full rounded-md bg-[#121212] p-3 text-left font-light tracking-wide text-white/30 placeholder-white/20 outline-none"
className="bg-input h-[120px] w-full rounded-md p-3 text-left tracking-wide outline-none"
/>
<button className="mt-4 w-[120px] rounded-md bg-[#222222] px-3 py-2 font-light tracking-wide text-white/70 outline-none hover:opacity-60">
<button className="bg-input mt-4 w-[120px] rounded-md px-3 py-2 tracking-wide outline-none hover:opacity-60">
Save
</button>
</div>

View File

@@ -1,31 +1,32 @@
"use client"
import { useCoState } from "@/lib/providers/jazz-provider"
import { PublicGlobalGroup } from "@/lib/schema/global-topic-graph"
import { glob } from "fs"
import { ID } from "jazz-tools"
import { useMemo } from "react"
export default function PublicHomeRoute() {
// const globalGroup = useCoState(PublicGlobalGroup, "co_z6Tmg1sZTfwkPd4pV6qBV9T5SFU" as ID<PublicGlobalGroup>, {
// root: { topicGraph: [{ connectedTopics: [{}] }] }
// })
import * as react from "react"
import type * as force_graph from "./force-graph-client"
let graph_data_promise = import("./graph-data.json").then(a => a.default)
let ForceGraphClient = react.lazy(() => import("./force-graph-client-lazy"))
export function PublicHomeRoute() {
let raw_graph_data = react.use(graph_data_promise)
let graph_items = react.useMemo(() => {
return raw_graph_data.map(
(item): force_graph.ConnectionItem => ({
key: item.name,
title: item.prettyName,
connections: item.connectedTopics
})
)
}, [raw_graph_data])
// const graph = useMemo(() => {
// return globalGroup?.root.topicGraph?.map(
// topic =>
// ({
// name: topic.name,
// prettyName: topic.prettyName,
// connectedTopics: topic.connectedTopics.map(connected => connected?.name)
// }) || []
// )
// }, [globalGroup?.root.topicGraph])
// const [{}]
// console.log(globalGroup, "graph")
return (
<>
<h1>I want to learn</h1>
<input type="text" />
</>
<ForceGraphClient
raw_nodes={raw_graph_data}
onNodeClick={val => {
console.log("clicked", val)
}}
filter_query=""
/>
)
}

View File

@@ -0,0 +1,84 @@
function lerp(start: number, end: number, t: number): number {
return start + (end - start) * t
}
export interface AnimationLoop {
/** User callback to be called on each animation frame. */
callback: FrameRequestCallback
/** {@link loopFrame} bound to this loop. */
frame: FrameRequestCallback
/** The current frame id returned by {@link requestAnimationFrame}. */
frame_id: number
}
export function animationLoop(callback: FrameRequestCallback): AnimationLoop {
const loop: AnimationLoop = {
callback: callback,
frame: t => loopFrame(loop, t),
frame_id: 0,
}
return loop
}
export function loopFrame(loop: AnimationLoop, time: number): void {
loop.frame_id = requestAnimationFrame(loop.frame)
loop.callback(time)
}
export function loopStart(loop: AnimationLoop): void {
loop.frame_id ||= requestAnimationFrame(loop.frame)
}
export function loopClear(loop: AnimationLoop): void {
cancelAnimationFrame(loop.frame_id)
loop.frame_id = 0
}
export const DEFAULT_TARGET_FPS = 44
export interface FrameIterationsLimit {
target_fps: number
last_timestamp: number
}
export function frameIterationsLimit(
target_fps: number = DEFAULT_TARGET_FPS,
): FrameIterationsLimit {
return {
target_fps,
last_timestamp: performance.now(),
}
}
export function calcIterations(limit: FrameIterationsLimit, current_time: number): number {
let target_ms = 1000 / limit.target_fps
let delta_time = current_time - limit.last_timestamp
let times = Math.floor(delta_time / target_ms)
limit.last_timestamp += times * target_ms
return times
}
export interface AlphaUpdateSteps {
increment: number
decrement: number
}
export const DEFAULT_ALPHA_UPDATE_STEPS: AlphaUpdateSteps = {
increment: 0.03,
decrement: 0.005,
}
export const updateAlpha = (
alpha: number,
is_playing: boolean,
update_steps = DEFAULT_ALPHA_UPDATE_STEPS,
): number => {
return is_playing
? lerp(alpha, 1, update_steps.increment)
: lerp(alpha, 0, update_steps.decrement)
}
export const DEFAULT_BUMP_TIMEOUT_DURATION = 2000
export const bump = (
bump_end: number,
duration: number = DEFAULT_BUMP_TIMEOUT_DURATION,
): number => {
const start = performance.now()
const end = start + duration
return end > bump_end ? end : bump_end
}

View File

@@ -0,0 +1,354 @@
"use client"
import * as react from "react"
import * as fg from "@nothing-but/force-graph"
import {ease, trig, raf} from "@nothing-but/utils"
import * as schedule from "@/lib/utils/schedule"
import * as canvas from "@/lib/utils/canvas"
export type RawGraphNode = {
name: string,
prettyName: string,
connectedTopics: string[],
}
type HSL = [hue: number, saturation: number, lightness: number]
const COLORS: readonly HSL[] = [
[3, 86, 64],
[31, 90, 69],
[15, 87, 66]
]
/* use a plain object instead of Map for faster lookups */
type ColorMap = { [key: string]: string }
type HSLMap = Map<fg.graph.Node, HSL>
const MAX_COLOR_ITERATIONS = 10
/**
* Add a color to a node and all its connected nodes.
*/
const visitColorNode = (
prev: fg.graph.Node,
node: fg.graph.Node,
hsl_map: HSLMap,
add: HSL,
iteration: number = 1
): void => {
if (iteration > MAX_COLOR_ITERATIONS) return
const color = hsl_map.get(node)
if (!color) {
hsl_map.set(node, [...add])
} else {
const add_strength = MAX_COLOR_ITERATIONS / iteration
color[0] = (color[0] + add[0] * add_strength) / (1 + add_strength)
color[1] = (color[1] + add[1] * add_strength) / (1 + add_strength)
color[2] = (color[2] + add[2] * add_strength) / (1 + add_strength)
}
for (const edge of node.edges) {
const other_node = edge.a === node ? edge.b : edge.a
if (other_node === prev) continue
visitColorNode(node, other_node, hsl_map, add, iteration + 1)
}
}
function generateColorMap(nodes: readonly fg.graph.Node[]): ColorMap
{
const hls_map: HSLMap = new Map()
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]!
const color = COLORS[i % COLORS.length]!
visitColorNode(node, node, hls_map, color)
}
const color_map: ColorMap = {}
for (const [node, [hue, saturation, lightness]] of hls_map.entries()) {
color_map[node.key as string] = `${hue} ${saturation}% ${lightness}%`
}
return color_map
}
function generateNodesFromRawData(raw_data: RawGraphNode[]): [fg.graph.Node[], fg.graph.Edge[]]
{
const nodes_map = new Map<string, fg.graph.Node>()
const edges: fg.graph.Edge[] = []
for (const raw of raw_data) {
const node = fg.graph.zeroNode()
node.key = raw.name
node.label = raw.prettyName
nodes_map.set(raw.name, node)
}
for (const raw of raw_data) {
const node_a = nodes_map.get(raw.name)!
for (const name_b of raw.connectedTopics) {
const node_b = nodes_map.get(name_b)!
const edge = fg.graph.connect(node_a, node_b)
edges.push(edge)
}
}
const nodes = Array.from(nodes_map.values())
fg.graph.randomizeNodePositions(nodes, GRAPH_OPTIONS.grid_size)
return [nodes, edges]
}
function filterNodes(
graph: fg.graph.Graph,
nodes: readonly fg.graph.Node[],
edges: readonly fg.graph.Edge[],
filter: string
): void {
if (filter === "") {
graph.nodes = nodes.slice()
graph.edges = edges.slice()
fg.graph.resetGraphGrid(graph.grid, graph.nodes)
return
}
// regex matching all letters of the filter (out of order)
const regex = new RegExp(filter.split("").join(".*"), "i")
graph.nodes = nodes.filter((node) => regex.test(node.label))
graph.edges = edges.filter(
(edge) => regex.test(edge.a.label) && regex.test(edge.b.label)
)
fg.graph.resetGraphGrid(graph.grid, graph.nodes)
}
const GRAPH_OPTIONS: fg.graph.Options = {
min_move: 0.001,
inertia_strength: 0.3,
origin_strength: 0.01,
repel_distance: 40,
repel_strength: 2,
link_strength: 0.015,
grid_size: 500,
}
const TITLE_SIZE_PX = 400
const simulateGraph = (
alpha: number,
graph: fg.graph.Graph,
canvas: fg.canvas.CanvasState,
vw: number,
vh: number
): void => {
alpha = alpha / 10 // slow things down a bit
fg.graph.simulate(graph, alpha)
/*
Push nodes away from the center (the title)
*/
let grid_radius = graph.grid.size / 2
let origin_x = grid_radius + canvas.translate.x
let origin_y = grid_radius + canvas.translate.y
let vmax = Math.max(vw, vh)
let push_radius =
(Math.min(TITLE_SIZE_PX, vw / 2, vh / 2) / vmax) *
(graph.grid.size / canvas.scale) +
80 /* additional margin for when scrolled in */
for (let node of graph.nodes) {
let dist_x = node.position.x - origin_x
let dist_y = (node.position.y - origin_y) * 2
let dist = Math.sqrt(dist_x * dist_x + dist_y * dist_y)
if (dist > push_radius) continue
let strength = ease.in_expo((push_radius - dist) / push_radius)
node.velocity.x += strength * (node.position.x - origin_x) * 10 * alpha
node.velocity.y += strength * (node.position.y - origin_y) * 10 * alpha
}
}
const drawGraph = (
canvas: fg.canvas.CanvasState,
color_map: ColorMap
): void => {
fg.canvas.resetFrame(canvas)
fg.canvas.drawEdges(canvas)
/*
Draw text nodes
*/
let {ctx, graph} = canvas
let {width, height} = canvas.ctx.canvas
let max_size = Math.max(width, height)
ctx.textAlign = "center"
ctx.textBaseline = "middle"
for (let node of graph.nodes) {
let opacity = 0.6 + ((node.mass-1) / 50) * 4
ctx.font = `${max_size/200 + (((node.mass-1) / 5) * (max_size/100)) / canvas.scale}px sans-serif`
ctx.fillStyle = node.anchor || canvas.hovered_node === node
? `rgba(129, 140, 248, ${opacity})`
: `hsl(${color_map[node.key as string]} / ${opacity})`
ctx.fillText(node.label,
(node.position.x / graph.grid.size) * max_size,
(node.position.y / graph.grid.size) * max_size)
}
}
class State {
ctx: CanvasRenderingContext2D | null = null
nodes: fg.graph.Node[] = []
edges: fg.graph.Edge[] = []
graph: fg.graph.Graph = fg.graph.makeGraph(GRAPH_OPTIONS, [], [])
gestures: fg.canvas.CanvasGestures | null = null
loop: raf.AnimationLoop | null = null
bump_end = 0
alpha = 9
frame_iter_limit = raf.frameIterationsLimit()
schedule_filter = schedule.scheduleIdle(filterNodes)
ro: ResizeObserver = new ResizeObserver(() => {})
}
function init(
s : State,
props: {
onNodeClick: (name: string) => void
raw_nodes: RawGraphNode[]
canvas_el: HTMLCanvasElement | null
}) {
let {canvas_el, raw_nodes} = props
if (canvas_el == null) return
s.ctx = canvas_el.getContext("2d")
if (s.ctx == null) return
[s.nodes, s.edges] = generateNodesFromRawData(raw_nodes)
let color_map = generateColorMap(s.nodes)
s.graph = fg.graph.makeGraph(GRAPH_OPTIONS, s.nodes.slice(), s.edges.slice())
let canvas_state = fg.canvas.canvasState({
ctx: s.ctx,
graph: s.graph,
max_scale: 3,
init_scale: 1.7,
init_grid_pos: trig.ZERO
})
s.ro = new ResizeObserver(() => {
if (canvas.resizeCanvasToDisplaySize(canvas_el)) {
fg.canvas.updateTranslate(canvas_state, canvas_state.translate.x, canvas_state.translate.y)
}
})
s.ro.observe(canvas_el)
let loop = s.loop = raf.makeAnimationLoop((time) => {
let is_active = gestures.mode.type === fg.canvas.Mode.DraggingNode
let iterations = raf.calcIterations(s.frame_iter_limit, time)
for (let i = Math.min(iterations, 2); i >= 0; i--) {
s.alpha = raf.updateAlpha(s.alpha, is_active || time < s.bump_end)
simulateGraph(s.alpha, s.graph, canvas_state, window.innerWidth, window.innerHeight)
}
drawGraph(canvas_state, color_map)
})
raf.loopStart(loop)
let gestures = s.gestures = fg.canvas.canvasGestures({
canvas: canvas_state,
onGesture: (e) => {
switch (e.type) {
case fg.canvas.GestureEventType.Translate:
s.bump_end = raf.bump(s.bump_end)
break
case fg.canvas.GestureEventType.NodeClick:
props.onNodeClick(e.node.key as string)
break
case fg.canvas.GestureEventType.NodeDrag:
fg.graph.changeNodePosition(
canvas_state.graph.grid,
e.node,
e.pos.x,
e.pos.y
)
break
}
}
})
}
function updateQuery(s: State, filter_query: string) {
s.schedule_filter.trigger(s.graph, s.nodes, s.edges, filter_query)
s.bump_end = raf.bump(s.bump_end)
}
function cleanup(s: State) {
s.loop && raf.loopClear(s.loop)
s.gestures && fg.canvas.cleanupCanvasGestures(s.gestures)
s.schedule_filter.clear()
s.ro.disconnect()
}
export type ForceGraphProps = {
onNodeClick: (name: string) => void
/**
* Filter the displayed nodes by name.
*
* `""` means no filter
*/
filter_query: string
raw_nodes: RawGraphNode[]
}
export default function ForceGraphClient(props: ForceGraphProps): react.JSX.Element {
const [canvas_el, setCanvasEl] = react.useState<HTMLCanvasElement | null>(null)
const state = react.useRef(new State())
react.useEffect(() => {
init(state.current, {
canvas_el: canvas_el,
onNodeClick: props.onNodeClick,
raw_nodes: props.raw_nodes,
})
}, [canvas_el])
react.useEffect(() => {
updateQuery(state.current, props.filter_query)
}, [props.filter_query])
react.useEffect(() => {
return () => cleanup(state.current)
}, [])
return <div className="absolute inset-0 overflow-hidden">
<canvas
ref={setCanvasEl}
style={{
position: "absolute",
top: "-10%",
left: "-10%",
width: "120%",
height: "120%",
}}
/>
</div>
}

View File

@@ -0,0 +1,336 @@
"use client"
import * as react from "react"
import * as fg from "@nothing-but/force-graph"
import { ease, trig } from "@nothing-but/utils"
import * as schedule from "@/lib/utils/schedule"
import * as ws from "@/lib/utils/window-size"
import * as canvas from "@/lib/utils/canvas"
import * as anim from "./anim"
export type ConnectionItem = {
key: string
title: string
connections: string[]
}
export type ForceGraphClientProps = {
items: ConnectionItem[]
}
export default function ForceGraphClient(props: ForceGraphClientProps) {
return (
<code>
<pre>{JSON.stringify(props.items, null, 4)}</pre>
</code>
)
}
export type RawNode = {
name: string
prettyName: string
connections: string[]
}
type HSL = [hue: number, saturation: number, lightness: number]
const COLORS: readonly HSL[] = [
[3, 86, 64],
[31, 90, 69],
[15, 87, 66]
]
/* use a plain object instead of Map for faster lookups */
type ColorMap = { [key: string]: string }
type HSLMap = Map<fg.graph.Node, HSL>
const MAX_COLOR_ITERATIONS = 10
/**
* Add a color to a node and all its connected nodes.
*/
const visitColorNode = (
prev: fg.graph.Node,
node: fg.graph.Node,
hsl_map: HSLMap,
add: HSL,
iteration: number = 1
): void => {
if (iteration > MAX_COLOR_ITERATIONS) return
const color = hsl_map.get(node)
if (!color) {
hsl_map.set(node, [...add])
} else {
const add_strength = MAX_COLOR_ITERATIONS / iteration
color[0] = (color[0] + add[0] * add_strength) / (1 + add_strength)
color[1] = (color[1] + add[1] * add_strength) / (1 + add_strength)
color[2] = (color[2] + add[2] * add_strength) / (1 + add_strength)
}
for (const edge of node.edges) {
const other_node = edge.a === node ? edge.b : edge.a
if (other_node === prev) continue
visitColorNode(node, other_node, hsl_map, add, iteration + 1)
}
}
const generateColorMap = (nodes: readonly fg.graph.Node[]): ColorMap => {
const hls_map: HSLMap = new Map()
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]!
const color = COLORS[i % COLORS.length]!
visitColorNode(node, node, hls_map, color)
}
const color_map: ColorMap = {}
for (const [node, [hue, saturation, lightness]] of hls_map.entries()) {
color_map[node.key as string] = `${hue} ${saturation}% ${lightness}%`
}
return color_map
}
const generateNodesFromRawData = (raw_data: RawNode[]): [fg.graph.Node[], fg.graph.Edge[]] => {
const nodes_map = new Map<string, fg.graph.Node>()
const edges: fg.graph.Edge[] = []
for (const raw of raw_data) {
const node = fg.graph.zeroNode()
node.key = raw.name
node.label = raw.prettyName
nodes_map.set(raw.name, node)
}
for (const raw of raw_data) {
const node_a = nodes_map.get(raw.name)!
for (const name_b of raw.connections) {
const node_b = nodes_map.get(name_b)!
const edge = fg.graph.connect(node_a, node_b)
edges.push(edge)
}
}
const nodes = Array.from(nodes_map.values())
fg.graph.randomizeNodePositions(nodes, graph_options.grid_size)
return [nodes, edges]
}
const filterNodes = (
graph: fg.graph.Graph,
nodes: readonly fg.graph.Node[],
edges: readonly fg.graph.Edge[],
filter: string
): void => {
if (filter === "") {
graph.nodes = nodes.slice()
graph.edges = edges.slice()
fg.graph.resetGraphGrid(graph.grid, graph.nodes)
return
}
// regex matching all letters of the filter (out of order)
const regex = new RegExp(filter.split("").join(".*"), "i")
graph.nodes = nodes.filter(node => regex.test(node.label))
graph.edges = edges.filter(edge => regex.test(edge.a.label) && regex.test(edge.b.label))
fg.graph.resetGraphGrid(graph.grid, graph.nodes)
}
const graph_options: fg.graph.Options = {
min_move: 0.001,
inertia_strength: 0.3,
origin_strength: 0.01,
repel_distance: 40,
repel_strength: 2,
link_strength: 0.015,
grid_size: 500
}
const TITLE_SIZE_PX = 400
const simulateGraph = (
alpha: number,
graph: fg.graph.Graph,
canvas: fg.canvas.CanvasState,
vw: number,
vh: number
): void => {
alpha = alpha / 10 // slow things down a bit
fg.graph.simulate(graph, alpha)
/*
Push nodes away from the center (the title)
*/
const grid_radius = graph.grid.size / 2
const origin_x = grid_radius + canvas.translate.x
const origin_y = grid_radius + canvas.translate.y
const vmax = Math.max(vw, vh)
const push_radius =
(Math.min(TITLE_SIZE_PX, vw / 2, vh / 2) / vmax) * (graph.grid.size / canvas.scale) +
80 /* additional margin for when scrolled in */
for (const node of graph.nodes) {
const dist_x = node.position.x - origin_x
const dist_y = (node.position.y - origin_y) * 2
const dist = Math.sqrt(dist_x * dist_x + dist_y * dist_y)
if (dist > push_radius) continue
const strength = ease.in_expo((push_radius - dist) / push_radius)
node.velocity.x += strength * (node.position.x - origin_x) * 10 * alpha
node.velocity.y += strength * (node.position.y - origin_y) * 10 * alpha
}
}
const drawGraph = (canvas: fg.canvas.CanvasState, color_map: ColorMap): void => {
fg.canvas.resetFrame(canvas)
fg.canvas.drawEdges(canvas)
/*
Draw text nodes
*/
const { ctx, graph } = canvas
ctx.textAlign = "center"
ctx.textBaseline = "middle"
for (const node of graph.nodes) {
const { x, y } = node.position
const opacity = 0.6 + ((node.mass - 1) / 50) * 4
ctx.font = `${
canvas.max_size / 200 + (((node.mass - 1) / 5) * (canvas.max_size / 100)) / canvas.scale
}px sans-serif`
ctx.fillStyle =
node.anchor || canvas.hovered_node === node
? `rgba(129, 140, 248, ${opacity})`
: `hsl(${color_map[node.key as string]} / ${opacity})`
ctx.fillText(node.label, (x / graph.grid.size) * canvas.max_size, (y / graph.grid.size) * canvas.max_size)
}
}
export type ForceGraphProps = {
onNodeClick: (name: string) => void
/**
* Filter the displayed nodes by name.
*
* `""` means no filter
*/
filter_query: string
raw_nodes: RawNode[]
}
export const createForceGraph = (props: ForceGraphProps): react.JSX.Element => {
if (props.raw_nodes.length === 0) {
return <></>
}
let [nodes, edges] = generateNodesFromRawData(props.raw_nodes)
let color_map = generateColorMap(nodes)
let graph = fg.graph.makeGraph(graph_options, nodes.slice(), edges.slice())
/*
Filter nodes when the filter query changes
*/
let schedule_filter_nodes = schedule.scheduleIdle(filterNodes)
react.useEffect(() => {
schedule_filter_nodes.trigger(graph, nodes, edges, props.filter_query)
bump_end = anim.bump(bump_end)
}, [props.filter_query])
let canvas_el = react.useRef<HTMLCanvasElement>(null)
react.useEffect(() => {
let el = canvas_el.current
if (!el) return
let ctx = el.getContext("2d")
if (!ctx) throw new Error("no context")
let canvas_state = fg.canvas.canvasState({
ctx,
graph,
max_scale: 3,
init_scale: 1.7,
init_grid_pos: trig.ZERO
})
let window_size = ws.useWindowSize()
let alpha = 0 // 0 - 1
let bump_end = anim.bump(0)
let frame_iter_limit = anim.frameIterationsLimit()
let loop = anim.animationLoop(time => {
let is_active = gestures.mode.type === fg.canvas.Mode.DraggingNode
let iterations = anim.calcIterations(frame_iter_limit, time)
for (let i = Math.min(iterations, 2); i >= 0; i--) {
alpha = anim.updateAlpha(alpha, is_active || time < bump_end)
simulateGraph(alpha, graph, canvas_state, window_size.width, window_size.height)
}
drawGraph(canvas_state, color_map)
})
anim.loopStart(loop)
let ro = new ResizeObserver(() => {
if (canvas.resizeCanvasToDisplaySize(el)) {
fg.canvas.updateTranslate(canvas_state, canvas_state.translate.x, canvas_state.translate.y)
}
})
ro.observe(el)
let gestures = fg.canvas.canvasGestures({
canvas: canvas_state,
onGesture: e => {
switch (e.type) {
case fg.canvas.GestureEventType.Translate:
bump_end = anim.bump(bump_end)
break
case fg.canvas.GestureEventType.NodeClick:
props.onNodeClick(e.node.key as string)
break
case fg.canvas.GestureEventType.NodeDrag:
fg.graph.changeNodePosition(canvas_state.graph.grid, e.node, e.pos.x, e.pos.y)
break
}
}
})
return () => {
anim.loopClear(loop)
ro.disconnect()
fg.canvas.cleanupCanvasGestures(gestures)
schedule_filter_nodes.clear()
}
}, [canvas_el.current])
return (
<div className="absolute inset-0 overflow-hidden">
<canvas
ref={canvas_el}
style={{
position: "absolute",
top: "-10%",
left: "-10%",
width: "120%",
height: "120%"
}}
/>
</div>
)
}

View File

@@ -1,26 +0,0 @@
"use client"
import { useCoState } from "@/lib/providers/jazz-provider"
import { PublicGlobalGroup } from "@/lib/schema/global-topic-graph"
import { glob } from "fs"
import { ID } from "jazz-tools"
import { useMemo } from "react"
export default function ForceGraph() {
const globalGroup = useCoState(PublicGlobalGroup, "co_z6Tmg1sZTfwkPd4pV6qBV9T5SFU" as ID<PublicGlobalGroup>, {
root: { topicGraph: [{ connectedTopics: [{}] }] }
})
const graph = useMemo(() => {
return globalGroup?.root.topicGraph?.map(
(topic: { name: string; prettyName: string; connectedTopics: Array<{ name?: string }> }) =>
({
name: topic.name,
prettyName: topic.prettyName,
connectedTopics: topic.connectedTopics.map(connected => connected?.name)
}) || []
)
}, [globalGroup?.root.topicGraph])
// const [{}]
console.log(globalGroup, "graph")
return <>{JSON.stringify(graph)}</>
}

File diff suppressed because one or more lines are too long

View File

@@ -56,6 +56,8 @@ export const LinkBottomBar: React.FC = () => {
const cancelBtnRef = useRef<HTMLButtonElement>(null)
const confirmBtnRef = useRef<HTMLButtonElement>(null)
const overlayRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const deleteBtnRef = useRef<HTMLButtonElement>(null)
const editMoreBtnRef = useRef<HTMLButtonElement>(null)
@@ -66,6 +68,8 @@ export const LinkBottomBar: React.FC = () => {
useEffect(() => {
setGlobalLinkFormExceptionRefsAtom([
overlayRef,
contentRef,
deleteBtnRef,
editMoreBtnRef,
cancelBtnRef,
@@ -84,17 +88,19 @@ export const LinkBottomBar: React.FC = () => {
alertDialogTitle: {
className: "text-base"
},
customActions(onConfirm, onCancel) {
return (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={onCancel} ref={cancelBtnRef}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm} ref={confirmBtnRef}>
Delete
</Button>
</div>
)
alertDialogOverlay: {
ref: overlayRef
},
alertDialogContent: {
ref: contentRef
},
cancelButton: {
variant: "outline",
ref: cancelBtnRef
},
confirmButton: {
variant: "destructive",
ref: confirmBtnRef
}
})

View File

@@ -1,34 +1,84 @@
"use client"
import { useState } from "react"
import { useCoState } from "@/lib/providers/jazz-provider"
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
import { LaIcon } from "@/components/custom/la-icon"
import AiSearch from "../../custom/ai-search"
import { Topic } from "@/lib/schema"
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
import { ID } from "jazz-tools"
import Link from "next/link"
import { Topic, PersonalLink, PersonalPage } from "@/lib/schema"
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
interface SearchTitleProps {
topics: string[]
topicTitle: string
title: string
count: number
}
interface SearchItemProps {
icon: string
href: string
title: string
subtitle?: string
topic?: Topic
}
const SearchTitle: React.FC<SearchTitleProps> = ({ topicTitle, topics }) => {
return (
<div className="flex w-full items-center">
<h2 className="text-lg font-semibold">{topicTitle}</h2>
<div className="mx-4 flex-grow">
<div className="h-px bg-neutral-200 dark:bg-neutral-700"></div>
</div>
<span className="text-base font-light text-opacity-55">{topics.length}</span>
const SearchTitle: React.FC<SearchTitleProps> = ({ title, count }) => (
<div className="flex w-full items-center">
<h2 className="text-md font-semibold">{title}</h2>
<div className="mx-4 flex-grow">
<div className="bg-result h-px"></div>
</div>
)
}
<span className="text-base font-light text-opacity-55">{count}</span>
</div>
)
const SearchItem: React.FC<SearchItemProps> = ({ icon, href, title, subtitle, topic }) => (
<div className="hover:bg-result group flex min-w-0 items-center gap-x-4 rounded-md p-2">
<LaIcon
name={icon as "Square"}
className="size-4 flex-shrink-0 opacity-0 transition-opacity duration-200 group-hover:opacity-50"
/>
<div className="group flex items-center justify-between">
<Link
href={href}
passHref
prefetch={false}
onClick={e => e.stopPropagation()}
className="hover:text-primary text-sm font-medium hover:opacity-70"
>
{title}
</Link>
{subtitle && (
<Link
href={href}
passHref
prefetch={false}
onClick={e => e.stopPropagation()}
className="text-muted-foreground ml-2 truncate text-xs hover:underline"
>
{subtitle}
</Link>
)}
{topic && (
<span className="ml-2 text-xs opacity-45">
{topic.latestGlobalGuide?.sections?.reduce((total, section) => total + (section?.links?.length || 0), 0) || 0}{" "}
links
</span>
)}
</div>
</div>
)
export const SearchWrapper = () => {
const [searchText, setSearchText] = useState("")
const [showAiSearch, setShowAiSearch] = useState(false)
const [searchResults, setSearchResults] = useState<Topic[]>([])
const [searchResults, setSearchResults] = useState<{
topics: Topic[]
links: PersonalLink[]
pages: PersonalPage[]
}>({ topics: [], links: [], pages: [] })
const { me } = useAccount({
root: { personalLinks: [], personalPages: [] }
})
const globalGroup = useCoState(
PublicGlobalGroup,
@@ -41,21 +91,35 @@ export const SearchWrapper = () => {
)
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
const value = e.target.value.toLowerCase()
setSearchText(value)
const results =
value && globalGroup?.root.topics
? globalGroup.root.topics.filter(
(topic): topic is Topic => topic !== null && topic.prettyName.toLowerCase().startsWith(value.toLowerCase())
)
: []
setSearchResults(results)
if (!value) {
setSearchResults({ topics: [], links: [], pages: [] })
return
}
setSearchResults({
topics:
globalGroup?.root.topics?.filter(
(topic: Topic | null): topic is Topic => topic !== null && topic.prettyName.toLowerCase().startsWith(value)
) || [],
links:
me?.root.personalLinks?.filter(
(link: PersonalLink | null): link is PersonalLink =>
link !== null && link.title.toLowerCase().startsWith(value)
) || [],
pages:
me?.root.personalPages?.filter(
(page): page is PersonalPage =>
page !== null && page.title !== undefined && page.title.toLowerCase().startsWith(value)
) || []
})
}
const clearSearch = () => {
setSearchText("")
setSearchResults([])
setSearchResults({ topics: [], links: [], pages: [] })
setShowAiSearch(false)
}
return (
@@ -64,7 +128,7 @@ export const SearchWrapper = () => {
<div className="w-full max-w-[70%] sm:px-6 lg:px-8">
<div className="relative mb-2 mt-5 flex w-full flex-row items-center transition-colors duration-300">
<div className="relative my-5 flex w-full items-center space-x-2">
<LaIcon name="Search" className="absolute left-4 size-4 flex-shrink-0 text-black/50 dark:text-white/50" />
<LaIcon name="Search" className="text-foreground absolute left-4 size-4 flex-shrink-0" />
<input
autoFocus
type="text"
@@ -73,58 +137,57 @@ export const SearchWrapper = () => {
placeholder="Search something..."
className="dark:bg-input w-full rounded-lg border border-neutral-300 p-2 pl-8 focus:outline-none dark:border-neutral-600"
/>
{searchText && (
<LaIcon
name="X"
className="absolute right-3 size-4 flex-shrink-0 cursor-pointer text-black/50 dark:text-white/50"
className="text-foreground/50 absolute right-3 size-4 flex-shrink-0 cursor-pointer"
onClick={clearSearch}
/>
)}
</div>
</div>
<div className="relative w-full pb-5">
{searchResults.length > 0 ? (
{Object.values(searchResults).some(arr => arr.length > 0) ? (
<div className="space-y-1">
<SearchTitle topicTitle="Topics" topics={searchResults.map(topic => topic.prettyName)} />
{searchResults.map((topic, index) => (
<div
key={topic.id}
className="hover:bg-result group flex min-w-0 items-center gap-x-4 rounded-md p-2"
>
<LaIcon
name="Square"
className="size-4 flex-shrink-0 opacity-0 transition-opacity duration-200 group-hover:opacity-50"
/>
<div className="group">
<Link
{searchResults.links.length > 0 && (
<>
<SearchTitle title="Links" count={searchResults.links.length} />
{searchResults.links.map(link => (
<SearchItem key={link.id} icon="Square" href={link.url} title={link.title} subtitle={link.url} />
))}
</>
)}
{searchResults.pages.length > 0 && (
<>
<SearchTitle title="Pages" count={searchResults.pages.length} />
{searchResults.pages.map(page => (
<SearchItem key={page.id} icon="Square" href={`/pages/${page.id}`} title={page.title || ""} />
))}
</>
)}
{searchResults.topics.length > 0 && (
<>
<SearchTitle title="Topics" count={searchResults.topics.length} />
{searchResults.topics.map(topic => (
<SearchItem
key={topic.id}
icon="Square"
href={`/${topic.name}`}
passHref
prefetch={false}
onClick={e => e.stopPropagation()}
className="hover:text-primary text-sm font-medium hover:opacity-70"
>
{topic.prettyName}
<span className="ml-2 text-xs opacity-45">
{topic.latestGlobalGuide?.sections?.reduce(
(total, section) => total + (section?.links?.length || 0),
0
) || 0}{" "}
links
</span>
</Link>
</div>
</div>
))}
title={topic.prettyName}
topic={topic}
/>
))}
</>
)}
</div>
) : (
<div className="mt-5">
{searchText && searchResults.length === 0 && !showAiSearch && (
{searchText && !showAiSearch && (
<div
className="cursor-pointer rounded-lg bg-blue-700 p-4 font-semibold text-white"
onClick={() => setShowAiSearch(true)}
>
Didn't find what you were looking for? Ask AI
Didn&apos;t find what you were looking for? Ask AI
</div>
)}
{showAiSearch && <AiSearch searchQuery={searchText} />}

View File

@@ -100,7 +100,7 @@ export const LinkItem = React.memo(
setOpenPopoverForId(null)
setIsPopoverOpen(false)
},
[personalLink, personalLinks, me, link, router, setOpenPopoverForId]
[personalLink, personalLinks, me, link, router, setOpenPopoverForId, topic]
)
const handlePopoverOpenChange = useCallback(