Improve initial force-graph node positions

Also increase performance of generating color map
This commit is contained in:
Damian Tarnawski
2024-09-06 15:57:41 +02:00
parent aae2e28353
commit f909a01d62
3 changed files with 29 additions and 59 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -2,7 +2,7 @@
import * as react from "react" import * as react from "react"
import * as fg from "@nothing-but/force-graph" import * as fg from "@nothing-but/force-graph"
import { ease, trig, raf } from "@nothing-but/utils" import { ease, trig, raf, color } from "@nothing-but/utils"
import * as schedule from "@/lib/utils/schedule" import * as schedule from "@/lib/utils/schedule"
import * as canvas from "@/lib/utils/canvas" import * as canvas from "@/lib/utils/canvas"
@@ -13,70 +13,37 @@ export type RawGraphNode = {
connectedTopics: string[] connectedTopics: string[]
} }
type HSL = [hue: number, saturation: number, lightness: number] const COLORS: readonly color.HSL[] = [
const COLORS: readonly HSL[] = [
[3, 86, 64], [3, 86, 64],
[15, 87, 66],
[31, 90, 69], [31, 90, 69],
[15, 87, 66] [15, 87, 66],
[31, 90, 69],
[344, 87, 70],
] ]
/* use a plain object instead of Map for faster lookups */ type ColorMap = Record<string, color.HSL>
type ColorMap = {[key: string]: string}
type HSLMap = Map<fg.graph.Node, HSL>
const MAX_COLOR_ITERATIONS = 10 function generateColorMap(g: fg.graph.Graph): ColorMap {
const hsl_map: ColorMap = {}
/** for (let i = 0; i < g.nodes.length; i++) {
* Add a color to a node and all its connected nodes. hsl_map[g.nodes[i].key as string] = COLORS[i % COLORS.length]
*/
function visitColorNode(
g: fg.graph.Graph,
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 (let edge of g.edges) { for (let {a, b} 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(g: fg.graph.Graph, nodes: readonly fg.graph.Node[]): ColorMap { let a_hsl = hsl_map[a.key as string]
const hls_map: HSLMap = new Map() let b_hsl = hsl_map[b.key as string]
for (let i = 0; i < nodes.length; i++) { let am = a.mass-1
const node = nodes[i]! let bm = b.mass-1
const color = COLORS[i % COLORS.length]!
visitColorNode(g, node, node, hls_map, color) hsl_map[a.key as string] = color.mix(a_hsl, b_hsl, am*am*am, bm)
hsl_map[b.key as string] = color.mix(a_hsl, b_hsl, am, bm*bm*bm)
} }
const color_map: ColorMap = {} return hsl_map
for (const [node, [hue, saturation, lightness]] of hls_map.entries()) {
color_map[node.key as string] = `${hue} ${saturation}% ${lightness}%`
}
return color_map
} }
function generateNodesFromRawData(g: fg.graph.Graph, raw_data: RawGraphNode[]): void { function generateNodesFromRawData(g: fg.graph.Graph, raw_data: RawGraphNode[]): void {
@@ -107,8 +74,6 @@ function generateNodesFromRawData(g: fg.graph.Graph, raw_data: RawGraphNode[]):
let edges = fg.graph.get_node_edges(g, node) let edges = fg.graph.get_node_edges(g, node)
node.mass = fg.graph.node_mass_from_edges(edges.length) node.mass = fg.graph.node_mass_from_edges(edges.length)
} }
fg.graph.randomize_positions(g)
} }
function filterNodes( function filterNodes(
@@ -135,7 +100,7 @@ const GRAPH_OPTIONS: fg.graph.Options = {
origin_strength: 0.01, origin_strength: 0.01,
repel_distance: 40, repel_distance: 40,
repel_strength: 2, repel_strength: 2,
link_strength: 0.015, link_strength: 0.03,
grid_size: 500 grid_size: 500
} }
@@ -209,7 +174,7 @@ const drawGraph = (c: fg.canvas.CanvasState, color_map: ColorMap): void => {
c.ctx.fillStyle = node.anchor || c.hovered_node === node c.ctx.fillStyle = node.anchor || c.hovered_node === node
? `rgba(129, 140, 248, ${opacity})` ? `rgba(129, 140, 248, ${opacity})`
: `hsl(${color_map[node.key as string]} / ${opacity})` : color.hsl_to_hsla_string(color_map[node.key as string], opacity)
c.ctx.fillText(node.label, x, y) c.ctx.fillText(node.label, x, y)
} }
@@ -250,10 +215,12 @@ function init(
if (s.ctx == null) return if (s.ctx == null) return
generateNodesFromRawData(s.graph, raw_nodes) generateNodesFromRawData(s.graph, raw_nodes)
fg.graph.set_positions_smart(s.graph)
s.nodes = s.graph.nodes.slice() s.nodes = s.graph.nodes.slice()
s.edges = s.graph.edges.slice() s.edges = s.graph.edges.slice()
let color_map = generateColorMap(s.graph, s.nodes) let color_map = generateColorMap(s.graph)
let canvas_state = fg.canvas.canvasState({ let canvas_state = fg.canvas.canvasState({
ctx: s.ctx, ctx: s.ctx,
@@ -270,6 +237,8 @@ function init(
}) })
s.ro.observe(canvas_el) s.ro.observe(canvas_el)
simulateGraph(6, s.graph, canvas_state, window.innerWidth, window.innerHeight)
function loop(time: number) { function loop(time: number) {
let is_active = gestures.mode.type === fg.canvas.Mode.DraggingNode let is_active = gestures.mode.type === fg.canvas.Mode.DraggingNode
let iterations = Math.min(2, raf.calcIterations(s.frame_iter_limit, time)) let iterations = Math.min(2, raf.calcIterations(s.frame_iter_limit, time))

View File

@@ -13,7 +13,8 @@
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^8.0.0",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@nothing-but/force-graph": "^0.8.3", "@nothing-but/force-graph": "^0.9.3",
"@nothing-but/utils": "^0.16.0",
"@omit/react-confirm-dialog": "^1.1.5", "@omit/react-confirm-dialog": "^1.1.5",
"@omit/react-fancy-switch": "^0.1.1", "@omit/react-fancy-switch": "^0.1.1",
"@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-avatar": "^1.1.0",