mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-14 23:21:20 +00:00
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:
parent
bcf2ff87fa
commit
ddcf661eb3
4 changed files with 56 additions and 54 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue