diff --git a/packages/memory-graph/src/canvas/renderer.ts b/packages/memory-graph/src/canvas/renderer.ts index 169b7ff0..fbbfd760 100644 --- a/packages/memory-graph/src/canvas/renderer.ts +++ b/packages/memory-graph/src/canvas/renderer.ts @@ -18,6 +18,20 @@ export interface RenderState { // Module-level reusable batch map – cleared each frame instead of reallocating const edgeBatches = new Map() +/** Group items by their `color` property into batches for efficient canvas drawing */ +function groupByColor(items: T[]): Map { + const map = new Map() + for (const item of items) { + let batch = map.get(item.color) + if (!batch) { + batch = [] + map.set(item.color, batch) + } + batch.push(item) + } + return map +} + // Cache for lightenColor results to avoid per-frame hex parsing let _lightenCache: { input: string; amount: number; result: string } | null = null @@ -46,8 +60,7 @@ function edgeStyle( return { color: colors.edgeDerives, width: 0.8, opacity: 0.18 } if (edge.edgeType === "updates") return { color: colors.edgeUpdates, width: 1.2, opacity: 0.45 } - if (edge.edgeType === "extends") - return { color: colors.edgeExtends, width: 0.6, opacity: 0.12 } + // "extends" and any unknown edge types share the same subtle style return { color: colors.edgeExtends, width: 0.6, opacity: 0.12 } } @@ -354,16 +367,7 @@ function drawNodes( if (normalDots.length > 0) { // Subtle glow behind memory dots for luminous effect ctx.globalAlpha = dimAlpha * 0.25 - const byColorGlow = new Map() - for (const d of normalDots) { - let batch = byColorGlow.get(d.color) - if (!batch) { - batch = [] - byColorGlow.set(d.color, batch) - } - batch.push(d) - } - for (const [color, batch] of byColorGlow) { + for (const [color, batch] of groupByColor(normalDots)) { ctx.fillStyle = color ctx.beginPath() for (const d of batch) { @@ -385,16 +389,7 @@ function drawNodes( // Colored border ctx.lineWidth = 1.5 - const byColor = new Map() - for (const d of normalDots) { - let batch = byColor.get(d.color) - if (!batch) { - batch = [] - byColor.set(d.color, batch) - } - batch.push(d) - } - for (const [color, batch] of byColor) { + for (const [color, batch] of groupByColor(normalDots)) { ctx.strokeStyle = color ctx.beginPath() for (const d of batch) { @@ -417,16 +412,7 @@ function drawNodes( ctx.fill() ctx.lineWidth = 1 - const byColor = new Map() - for (const d of dimmedDots) { - let batch = byColor.get(d.color) - if (!batch) { - batch = [] - byColor.set(d.color, batch) - } - batch.push(d) - } - for (const [color, batch] of byColor) { + for (const [color, batch] of groupByColor(dimmedDots)) { ctx.strokeStyle = color ctx.beginPath() for (const d of batch) { diff --git a/packages/memory-graph/src/canvas/simulation.ts b/packages/memory-graph/src/canvas/simulation.ts index 918ccced..3a859dde 100644 --- a/packages/memory-graph/src/canvas/simulation.ts +++ b/packages/memory-graph/src/canvas/simulation.ts @@ -32,7 +32,7 @@ export class ForceSimulation { return FORCE_CONFIG.linkStrength.version if (link.edgeType === "extends") return FORCE_CONFIG.linkStrength.docDocBase - return 0.3 + return FORCE_CONFIG.linkStrength.fallback }), ) @@ -50,11 +50,11 @@ export class ForceSimulation { ? FORCE_CONFIG.collisionRadius.document : FORCE_CONFIG.collisionRadius.memory, ) - .strength(0.7), + .strength(FORCE_CONFIG.collisionStrength), ) - this.sim.force("x", d3.forceX().strength(0.03)) - this.sim.force("y", d3.forceY().strength(0.03)) + this.sim.force("x", d3.forceX().strength(FORCE_CONFIG.centeringStrength)) + this.sim.force("y", d3.forceY().strength(FORCE_CONFIG.centeringStrength)) this.sim.stop() this.sim.alpha(1) diff --git a/packages/memory-graph/src/constants.ts b/packages/memory-graph/src/constants.ts index 1e4d43da..d191ee39 100644 --- a/packages/memory-graph/src/constants.ts +++ b/packages/memory-graph/src/constants.ts @@ -9,14 +9,17 @@ export const MEMORY_BORDER_KEYS = { export const FORCE_CONFIG = { linkStrength: { - docMemory: 0.6, + docMemory: 0.25, version: 0.8, docDocBase: 0.15, + fallback: 0.15, }, - linkDistance: 450, - docMemoryDistance: 200, - chargeStrength: -2500, - collisionRadius: { document: 90, memory: 45 }, + linkDistance: 200, + docMemoryDistance: 150, + chargeStrength: -1500, + collisionRadius: { document: 60, memory: 30 }, + collisionStrength: 0.6, + centeringStrength: 0.25, alphaDecay: 0.04, alphaMin: 0.001, velocityDecay: 0.6, diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts index be71a732..ddb1c7ba 100644 --- a/packages/memory-graph/src/hooks/use-graph-data.ts +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -11,7 +11,7 @@ import type { const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000 const ONE_DAY_MS = 24 * 60 * 60 * 1000 -const MEMORY_CLUSTER_SPREAD = 150 +const MEMORY_ORBIT_BASE = 120 export function getMemoryBorderColor( mem: GraphApiMemory, @@ -80,16 +80,24 @@ export function useGraphData( if (!documents || documents.length === 0) return [] const result: GraphNode[] = [] - // Place nodes in the canvas space; force simulation will refine positions - const spreadW = Math.max(canvasWidth * 0.8, 400) - const spreadH = Math.max(canvasHeight * 0.8, 400) - const padX = (canvasWidth - spreadW) / 2 - const padY = (canvasHeight - spreadH) / 2 + // Spiral layout: documents form a compact spiral core, memories orbit + // around their parent documents. The force simulation then gently + // pushes memories outward to create the constellation/starburst effect. + const cx = canvasWidth / 2 + const cy = canvasHeight / 2 + const docCount = documents.length + // Compact spiral -- just enough to avoid overlap. The simulation + // handles the final spread via charge repulsion. + const spiralScale = Math.sqrt(docCount) * 25 - for (const doc of documents) { - // Deterministic initial position based on doc id - const initialX = padX + hashToUnit(doc.id) * spreadW - const initialY = padY + hashToUnit(`${doc.id}-y`) * spreadH + for (let docIdx = 0; docIdx < docCount; docIdx++) { + const doc = documents[docIdx] + // Golden-angle spiral for even distribution + const goldenAngle = Math.PI * (3 - Math.sqrt(5)) + const angle = docIdx * goldenAngle + const radius = spiralScale * Math.sqrt((docIdx + 1) / docCount) + const initialX = cx + Math.cos(angle) * radius + const initialY = cy + Math.sin(angle) * radius let docNode = nodeCache.current.get(doc.id) const docData: DocumentNodeData = { @@ -137,12 +145,17 @@ export function useGraphData( memNode.borderColor = getMemoryBorderColor(mem, colors) memNode.isDragging = draggingNodeId === mem.id } else { - const angle = (i / memCount) * 2 * Math.PI + // Place memories in a ring around their parent document, + // with slight randomness from hash for organic feel + const memAngle = + (i / memCount) * 2 * Math.PI + hashToUnit(mem.id) * 0.5 + const memRadius = + MEMORY_ORBIT_BASE + hashToUnit(`${mem.id}-r`) * 60 memNode = { id: mem.id, type: "memory", - x: docNode.x + Math.cos(angle) * MEMORY_CLUSTER_SPREAD, - y: docNode.y + Math.sin(angle) * MEMORY_CLUSTER_SPREAD, + x: docNode.x + Math.cos(memAngle) * memRadius, + y: docNode.y + Math.sin(memAngle) * memRadius, data: memData, size: 36, borderColor: getMemoryBorderColor(mem, colors),