From 2e268c9b7b168756fcf6524e634437ceaac5b468 Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Wed, 4 Sep 2024 15:56:22 +0200 Subject: [PATCH] Don't draw text nodes that are outside of the screen --- bun.lockb | Bin 404992 -> 404992 bytes .../routes/force-graph-client-lazy.tsx | 179 +++++++++--------- web/package.json | 2 +- 3 files changed, 95 insertions(+), 86 deletions(-) diff --git a/bun.lockb b/bun.lockb index 31b9917f6e880b4d6abfaf4aae8af3f0dc407be4..f23b9935a4653d868be7c046d1be6c3f2f1be2b9 100755 GIT binary patch delta 269 zcmV+o0rLKU+!%n|7?3U?s?)i{SC5^{(_H?IC7q@jyIF14*9Y#6X+AVb?IEE7u}*fZ zmxLApG?VzO441?N0W^a+t+zO>0aaB%KO@hm4D&mpq`2kGMGvV~%dJQ}B`)^!n{R`Z zfZFi?T0F$;Jy8LUU!sC~mvnHk^XU5xaR{j0+fI&MB6FrZm(2nK7(fPd>H?Cd6f$;& zJXUfo5dhQZau}|A*74lSbzsQSkJILEG5{B*w(p>^)LQKFl$b<4V}{$rl8GOV(pE(S zzN&{M0|K`t0|R>d12`@+x1#<7=K%--UNbH+FfKN=kp!z|0X2s`X9TxBX9YYV12`@+ Tmwbo?PnUuB1S7ZZhy@a*3}bo< delta 279 zcmV+y0qFjK+!%n|7?3U?(dN*7ElG+YlMkLqP+o?0IsaIz01v75wP?LC=1Pe=u}*fZ z0SA-8JS&rMKnRogtPBATlW{yNlW;%?gE+0XIIRIyRY0)o`mrb}--XcM&J)p@AAj`( zk#tfNqm|vI83dNa+Rye~XtSyAw%ddnO)4$Y#QrGzRRm(WmMy^8*t}h$r2?1D0s;<({^Jm=&UDoIs*LmJG zdn%QJI36jZsxS*vhb037wt7ZW-hdpNm dw>@VCJRt)&E;E;Why_oVF}?*Nx9x}p6Qz{%cV7Si diff --git a/web/components/routes/force-graph-client-lazy.tsx b/web/components/routes/force-graph-client-lazy.tsx index 35033497..f493b3d2 100644 --- a/web/components/routes/force-graph-client-lazy.tsx +++ b/web/components/routes/force-graph-client-lazy.tsx @@ -22,21 +22,22 @@ const COLORS: readonly HSL[] = [ ] /* use a plain object instead of Map for faster lookups */ -type ColorMap = { [key: string]: string } -type HSLMap = Map +type ColorMap = {[key: string]: string} +type HSLMap = Map 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, +function visitColorNode( + g: fg.graph.Graph, + prev: fg.graph.Node, + node: fg.graph.Node, + hsl_map: HSLMap, + add: HSL, iteration: number = 1 -): void => { +): void { if (iteration > MAX_COLOR_ITERATIONS) return const color = hsl_map.get(node) @@ -50,20 +51,24 @@ const visitColorNode = ( 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) + for (let edge of g.edges) { + let b: fg.graph.Node + if (edge.a === node) b = edge.b + else if (edge.b === node) b = edge.a + else continue + if (b !== prev) { + visitColorNode(g, node, b, hsl_map, add, iteration + 1) + } } } -function generateColorMap(nodes: readonly fg.graph.Node[]): ColorMap { +function generateColorMap(g: fg.graph.Graph, 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) + visitColorNode(g, node, node, hls_map, color) } const color_map: ColorMap = {} @@ -74,14 +79,15 @@ function generateColorMap(nodes: readonly fg.graph.Node[]): ColorMap { return color_map } -function generateNodesFromRawData(raw_data: RawGraphNode[]): [fg.graph.Node[], fg.graph.Edge[]] { +function generateNodesFromRawData(g: fg.graph.Graph, raw_data: RawGraphNode[]): void { const nodes_map = new Map() - const edges: fg.graph.Edge[] = [] for (const raw of raw_data) { - const node = fg.graph.zeroNode() + const node = fg.graph.make_node() node.key = raw.name node.label = raw.prettyName + + fg.graph.add_node(g, node) nodes_map.set(raw.name, node) } @@ -90,38 +96,31 @@ function generateNodesFromRawData(raw_data: RawGraphNode[]): [fg.graph.Node[], f 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) + fg.graph.connect(g, node_a, node_b) } } - const nodes = Array.from(nodes_map.values()) - - fg.graph.randomizeNodePositions(nodes, GRAPH_OPTIONS.grid_size) - - return [nodes, edges] + fg.graph.spread_positions(g) } function filterNodes( - graph: fg.graph.Graph, - nodes: readonly fg.graph.Node[], - edges: readonly fg.graph.Edge[], + s: State, filter: string ): void { + fg.graph.clear_nodes(s.graph) + if (filter === "") { - graph.nodes = nodes.slice() - graph.edges = edges.slice() - fg.graph.resetGraphGrid(graph.grid, graph.nodes) - return + s.graph.nodes.push(...s.nodes) + s.graph.edges.push(...s.edges) + } else { + // regex matching all letters of the filter (out of order) + const regex = new RegExp(filter.split("").join(".*"), "i") + + s.graph.nodes = s.nodes.filter(node => regex.test(node.label)) + s.graph.edges = s.edges.filter(edge => regex.test(edge.a.label) && regex.test(edge.b.label)) } - // 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) + fg.graph.add_nodes_to_grid(s.graph, s.nodes) } const GRAPH_OPTIONS: fg.graph.Options = { @@ -150,68 +149,74 @@ const simulateGraph = ( /* Push nodes away from the center (the title) */ - let grid_radius = graph.grid.size / 2 + let grid_radius = graph.options.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) + + (Math.min(TITLE_SIZE_PX, vw / 2, vh / 2) / vmax) * (graph.options.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_x = node.pos.x - origin_x + let dist_y = (node.pos.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 + node.vel.x += strength * (node.pos.x - origin_x) * 10 * alpha + node.vel.y += strength * (node.pos.y - origin_y) * 10 * alpha } } -const drawGraph = (canvas: fg.canvas.CanvasState, color_map: ColorMap): void => { - fg.canvas.resetFrame(canvas) - fg.canvas.drawEdges(canvas) +const drawGraph = (c: fg.canvas.CanvasState, color_map: ColorMap): void => { + fg.canvas.resetFrame(c) + fg.canvas.drawEdges(c) /* Draw text nodes */ - let { ctx, graph } = canvas - let { width, height } = canvas.ctx.canvas - let max_size = Math.max(width, height) + let grid_size = c.graph.options.grid_size + let max_size = Math.max(c.ctx.canvas.width, c.ctx.canvas.height) - ctx.textAlign = "center" - ctx.textBaseline = "middle" + let clip_rect = fg.canvas.get_ctx_clip_rect(c.ctx, {x: 100, y: 20}) - for (let node of graph.nodes) { - let opacity = 0.6 + ((node.mass - 1) / 50) * 4 + c.ctx.textAlign = "center" + c.ctx.textBaseline = "middle" - ctx.font = `${max_size / 200 + (((node.mass - 1) / 5) * (max_size / 100)) / canvas.scale}px sans-serif` + for (let node of c.graph.nodes) { - ctx.fillStyle = - node.anchor || canvas.hovered_node === node + let x = node.pos.x / grid_size * max_size + let y = node.pos.y / grid_size * max_size + + if (fg.canvas.in_rect_xy(clip_rect, x, y)) { + + let opacity = 0.6 + ((node.mass - 1) / 50) * 4 + + c.ctx.font = `${max_size / 200 + (((node.mass - 1) / 5) * (max_size / 100)) / c.scale}px sans-serif` + + c.ctx.fillStyle = node.anchor || c.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 - ) + + c.ctx.fillText(node.label, x, y) + } } } class State { ctx: CanvasRenderingContext2D | null = null + /* copy of all nodes to filter them */ nodes: fg.graph.Node[] = [] edges: fg.graph.Edge[] = [] - graph: fg.graph.Graph = fg.graph.makeGraph(GRAPH_OPTIONS, [], []) + + graph: fg.graph.Graph = fg.graph.make_graph(GRAPH_OPTIONS) gestures: fg.canvas.CanvasGestures | null = null - loop: raf.AnimationLoop | null = null + raf_id: number = 0 bump_end = 0 alpha = 9 frame_iter_limit = raf.frameIterationsLimit() @@ -227,17 +232,18 @@ function init( canvas_el: HTMLCanvasElement | null } ) { - let { canvas_el, raw_nodes } = props + 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) + generateNodesFromRawData(s.graph, raw_nodes) + s.nodes = s.graph.nodes.slice() + s.edges = s.graph.edges.slice() - s.graph = fg.graph.makeGraph(GRAPH_OPTIONS, s.nodes.slice(), s.edges.slice()) + let color_map = generateColorMap(s.graph, s.nodes) let canvas_state = fg.canvas.canvasState({ ctx: s.ctx, @@ -254,43 +260,46 @@ function init( }) s.ro.observe(canvas_el) - let loop = (s.loop = raf.makeAnimationLoop(time => { + function loop(time: number) { let is_active = gestures.mode.type === fg.canvas.Mode.DraggingNode - let iterations = raf.calcIterations(s.frame_iter_limit, time) + let iterations = Math.min(2, raf.calcIterations(s.frame_iter_limit, time)) - for (let i = Math.min(iterations, 2); i >= 0; i--) { + for (let i = iterations; 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) + + s.raf_id = requestAnimationFrame(loop) + } + s.raf_id = requestAnimationFrame(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 + 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.set_position(canvas_state.graph, e.node, e.pos) + break } } })) } function updateQuery(s: State, filter_query: string) { - s.schedule_filter.trigger(s.graph, s.nodes, s.edges, filter_query) + s.schedule_filter.trigger(s, filter_query) s.bump_end = raf.bump(s.bump_end) } function cleanup(s: State) { - s.loop && raf.loopClear(s.loop) + cancelAnimationFrame(s.raf_id) s.gestures && fg.canvas.cleanupCanvasGestures(s.gestures) s.schedule_filter.clear() s.ro.disconnect() diff --git a/web/package.json b/web/package.json index 1d4fa76c..3ae6f719 100644 --- a/web/package.json +++ b/web/package.json @@ -13,7 +13,7 @@ "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@hookform/resolvers": "^3.9.0", - "@nothing-but/force-graph": "^0.7.3", + "@nothing-but/force-graph": "^0.8.2", "@omit/react-confirm-dialog": "^1.1.5", "@omit/react-fancy-switch": "^0.1.1", "@radix-ui/react-avatar": "^1.1.0",