feat: spiral layout with constellation spread for graph aesthetics

- Golden-angle spiral initial layout for documents, memories orbit nearby
- Adaptive spiral scale based on document count
- Strong centering force (0.25) to unify disconnected components
- Moderate charge repulsion (-1500) for organic spread within the mass
- Extract groupByColor helper to deduplicate renderer batch logic
- Remove duplicate extends/fallback in edgeStyle()
- Refactor simulation to use all FORCE_CONFIG constants (no hardcoded values)
- Memory orbit radius scales with hash for organic feel

The layout now matches the reference: documents distributed throughout
with memories radiating outward, forming one unified constellation.
This commit is contained in:
Vorflux AI 2026-03-28 07:56:10 +00:00
parent bcf2ff87fa
commit ddcf661eb3
4 changed files with 56 additions and 54 deletions

View file

@ -18,6 +18,20 @@ export interface RenderState {
// Module-level reusable batch map cleared each frame instead of reallocating
const edgeBatches = new Map<string, PreparedEdge[]>()
/** Group items by their `color` property into batches for efficient canvas drawing */
function groupByColor<T extends { color: string }>(items: T[]): Map<string, T[]> {
const map = new Map<string, T[]>()
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<string, typeof normalDots>()
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<string, typeof normalDots>()
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<string, typeof dimmedDots>()
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) {

View file

@ -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)

View file

@ -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,

View file

@ -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),