supermemory/packages/memory-graph/src/canvas/renderer.ts
Prasanna721 2a6dcda7f6 fix graph styling and initial layout (#1684)
**Before:** Console lost its theme and dots. Incoming nodes needed a drag to reorganize, fit missed later pages, and clicks or drag release could leave the layout moving.

**After:** Restore themed rendering with configurable dots. Automatically settle and fit incoming nodes, keep clicks from reheating forces, and cool the layout after drag release.

**Checked:** Package types/build and Console build linked to this package.

Console companion: [mono#3295](https://github.com/supermemoryai/mono/pull/3295).
2026-09-18 21:18:33 +00:00

1036 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {
DocumentNodeData,
GraphEdge,
GraphNode,
GraphThemeColors,
MemoryNodeData,
} from "../types"
import type { ViewportState } from "./viewport"
import { drawDocIcon, roundRect } from "./document-icons"
import { hashString } from "../utils/hash"
export interface RenderState {
selectedNodeId: string | null
hoveredNodeId: string | null
highlightIds: Set<string>
dimProgress: number
}
// Module-level reusable batch map – cleared each frame instead of reallocating
const edgeBatches = new Map<string, PreparedEdge[]>()
const RELATION_LOD_ZOOM = 0.5
const RELATION_LOD_MAX_BACKGROUND_EDGES = 260
const RELATION_LOD_DENSE_COUNT = 180
const DERIVES_LOD_ZOOM = 0.38
const DERIVES_LOD_MAX_BACKGROUND_EDGES = 3200
const DENSE_POINT_THRESHOLD = 25000
const DENSE_POINT_ZOOM = 0.42
function nodeMatchesDocumentHighlights(
node: GraphNode,
highlightIds: Set<string>,
): boolean {
if (highlightIds.size === 0) return false
if (node.type === "document") return highlightIds.has(node.id)
return highlightIds.has((node.data as MemoryNodeData).documentId)
}
/** Group items by their `color` property into batches for efficient canvas drawing */
function groupByColor<T extends { color: string }>(
items: T[],
): Map<string, T[]> {
return groupByComputedColor(items, (item) => item.color)
}
function groupByComputedColor<T>(
items: T[],
getColor: (item: T) => string,
): Map<string, T[]> {
const map = new Map<string, T[]>()
for (const item of items) {
const color = getColor(item)
let batch = map.get(color)
if (!batch) {
batch = []
map.set(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
export function renderFrame(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
edges: GraphEdge[],
viewport: ViewportState,
width: number,
height: number,
state: RenderState,
nodeMap: Map<string, GraphNode>,
colors: GraphThemeColors,
): void {
ctx.clearRect(0, 0, width, height)
drawEdges(ctx, edges, viewport, width, height, state, nodeMap, colors)
drawNodes(ctx, nodes, viewport, width, height, state, colors)
}
function edgeStyle(
edge: GraphEdge,
colors: GraphThemeColors,
): { color: string; width: number; opacity: number } {
if (edge.edgeType === "derives")
return { color: colors.edgeDerives, width: 1.2, opacity: 0.4 }
if (edge.edgeType === "updates")
return { color: colors.edgeUpdates, width: 1.45, opacity: 0.48 }
// "extends" and any unknown edge types
return { color: colors.edgeExtends, width: 0.8, opacity: 0.16 }
}
export function getRelationEdgeStride(
relationEdgeCount: number,
zoom: number,
): number {
if (
zoom >= RELATION_LOD_ZOOM ||
relationEdgeCount <= RELATION_LOD_MAX_BACKGROUND_EDGES
) {
return 1
}
return Math.ceil(relationEdgeCount / RELATION_LOD_MAX_BACKGROUND_EDGES)
}
function getDerivesEdgeStride(derivesEdgeCount: number, zoom: number): number {
if (
zoom >= DERIVES_LOD_ZOOM ||
derivesEdgeCount <= DERIVES_LOD_MAX_BACKGROUND_EDGES
) {
return 1
}
return Math.ceil(derivesEdgeCount / DERIVES_LOD_MAX_BACKGROUND_EDGES)
}
export function shouldDrawRelationEdge(
edgeId: string,
edgeType: string,
stride: number,
): boolean {
if (edgeType === "derives" || stride <= 1) return true
return hashString(edgeId) % stride === 0
}
function shouldDrawSampledEdge(edgeId: string, stride: number): boolean {
return stride <= 1 || hashString(edgeId) % stride === 0
}
function applyRelationLevelOfDetail(
style: { color: string; width: number; opacity: number },
edgeType: string,
relationEdgeCount: number,
zoom: number,
hasFocus: boolean,
hasActiveHover: boolean,
) {
if (edgeType === "derives") return { style, glow: true }
if (hasFocus || hasActiveHover) {
const isUpdate = edgeType === "updates"
const minOpacity = hasActiveHover ? 0.9 : 0.76
const minWidth = hasActiveHover ? 2.35 : 1.8
return {
style: isUpdate
? {
...style,
width: Math.max(style.width, minWidth),
opacity: Math.max(style.opacity, minOpacity),
}
: style,
glow: isUpdate,
}
}
if (
zoom >= RELATION_LOD_ZOOM ||
relationEdgeCount <= RELATION_LOD_DENSE_COUNT
) {
return { style, glow: edgeType === "updates" }
}
const densityFactor = Math.min(
1,
RELATION_LOD_DENSE_COUNT / relationEdgeCount,
)
const zoomFactor = clampNumber(zoom / RELATION_LOD_ZOOM, 0.25, 1)
const opacityFactor = clampNumber(densityFactor * zoomFactor, 0.06, 0.24)
const widthFactor = clampNumber(zoomFactor * 0.65, 0.22, 0.7)
return {
style: {
...style,
width: Math.max(0.45, style.width * widthFactor),
opacity: style.opacity * opacityFactor,
},
glow: false,
}
}
function clampNumber(value: number, min: number, max: number): number {
return value < min ? min : value > max ? max : value
}
function batchKey(style: {
color: string
width: number
opacity: number
}): string {
return `${style.color}|${style.width}|${style.opacity}`
}
interface PreparedEdge {
startX: number
startY: number
endX: number
endY: number
connected: boolean
style: { color: string; width: number; opacity: number }
edgeType: string
arrowSize: number
glow: boolean
}
function drawEdges(
ctx: CanvasRenderingContext2D,
edges: GraphEdge[],
viewport: ViewportState,
width: number,
height: number,
state: RenderState,
nodeMap: Map<string, GraphNode>,
colors: GraphThemeColors,
): void {
const margin = 100
const hasDim = state.selectedNodeId !== null && state.dimProgress > 0
const relationEdgeCount = edges.reduce(
(count, edge) => count + (edge.edgeType === "derives" ? 0 : 1),
0,
)
const derivesEdgeCount = edges.length - relationEdgeCount
const relationStride = getRelationEdgeStride(relationEdgeCount, viewport.zoom)
const derivesStride = getDerivesEdgeStride(derivesEdgeCount, viewport.zoom)
const prepared: PreparedEdge[] = []
for (const edge of edges) {
const edgeType = edge.edgeType ?? "derives"
const srcId = typeof edge.source === "string" ? edge.source : edge.source.id
const tgtId = typeof edge.target === "string" ? edge.target : edge.target.id
const hoverConnected =
state.hoveredNodeId != null &&
(srcId === state.hoveredNodeId || tgtId === state.hoveredNodeId)
const selectedConnected =
state.selectedNodeId != null &&
(srcId === state.selectedNodeId || tgtId === state.selectedNodeId)
const activeConnected = hoverConnected || selectedConnected
const shouldAlwaysDrawActiveUpdate =
edgeType === "updates" && activeConnected
const edgeStride = edgeType === "derives" ? derivesStride : relationStride
if (
!shouldAlwaysDrawActiveUpdate &&
!hasDim &&
!shouldDrawSampledEdge(edge.id, edgeStride)
) {
continue
}
const src = nodeMap.get(srcId)
const tgt = nodeMap.get(tgtId)
if (!src || !tgt) continue
if (edgeType === "derives") {
const mem = src.type === "memory" ? src : tgt
if (mem.size * viewport.zoom < 3) continue
}
const s = viewport.worldToScreen(src.x, src.y)
const t = viewport.worldToScreen(tgt.x, tgt.y)
if (
(s.x < -margin && t.x < -margin) ||
(s.x > width + margin && t.x > width + margin) ||
(s.y < -margin && t.y < -margin) ||
(s.y > height + margin && t.y > height + margin)
)
continue
const dx = t.x - s.x
const dy = t.y - s.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < 1) continue
const ux = dx / dist
const uy = dy / dist
const sr = src.size * viewport.zoom * 0.5
const tr = tgt.size * viewport.zoom * 0.5
let connected = true
if (hasDim) {
connected = selectedConnected
}
if (
!shouldAlwaysDrawActiveUpdate &&
hasDim &&
!connected &&
!shouldDrawSampledEdge(edge.id, edgeStride)
) {
continue
}
const edgeDetail = applyRelationLevelOfDetail(
edgeStyle(edge, colors),
edgeType,
relationEdgeCount,
viewport.zoom,
hasDim && connected,
edgeType === "updates" && hoverConnected,
)
let style = edgeDetail.style
let glow = edgeDetail.glow
if (edgeType === "derives" && derivesStride > 1 && !activeConnected) {
const zoomFactor = clampNumber(
viewport.zoom / DERIVES_LOD_ZOOM,
0.08,
0.32,
)
style = {
...style,
width: Math.max(0.35, style.width * 0.45),
opacity: style.opacity * zoomFactor,
}
glow = false
}
prepared.push({
startX: s.x + ux * sr,
startY: s.y + uy * sr,
endX: t.x - ux * tr,
endY: t.y - uy * tr,
connected,
style,
edgeType,
arrowSize:
edgeType === "updates"
? Math.max(
shouldAlwaysDrawActiveUpdate ? 8 : 6,
(shouldAlwaysDrawActiveUpdate ? 11 : 8) * viewport.zoom,
)
: 0,
glow,
})
}
// Reuse module-level batch map
edgeBatches.clear()
for (const e of prepared) {
const dimKey = hasDim ? (e.connected ? "|c" : "|d") : ""
const key = `${e.edgeType}|${batchKey(e.style)}|${e.glow ? "g" : "f"}${dimKey}`
let batch = edgeBatches.get(key)
if (!batch) {
batch = []
edgeBatches.set(key, batch)
}
batch.push(e)
}
ctx.setLineDash([])
for (const [key, batch] of edgeBatches) {
const first = batch[0]
if (!first) continue
const isDimmed = key.endsWith("|d")
const batchEdgeType = first.edgeType
// Draw glow pass behind structural/revision edges. Cross-cluster
// extends edges stay flat so dense graphs do not become a mesh.
if (!isDimmed && first.glow && batchEdgeType !== "extends") {
const glowAlpha =
batchEdgeType === "updates"
? first.style.opacity * 0.4
: first.style.opacity * 0.3
const glowWidth =
batchEdgeType === "updates"
? first.style.width + 2
: first.style.width + 1.5
ctx.save()
ctx.globalAlpha = glowAlpha
ctx.strokeStyle = first.style.color
ctx.lineWidth = glowWidth
ctx.beginPath()
for (const e of batch) {
ctx.moveTo(e.startX, e.startY)
ctx.lineTo(e.endX, e.endY)
}
ctx.stroke()
ctx.restore()
}
const baseAlpha = first.style.opacity
ctx.globalAlpha = isDimmed
? baseAlpha * (1 - state.dimProgress * 0.8)
: baseAlpha
ctx.strokeStyle = first.style.color
ctx.lineWidth = first.style.width
ctx.beginPath()
for (const e of batch) {
ctx.moveTo(e.startX, e.startY)
ctx.lineTo(e.endX, e.endY)
}
ctx.stroke()
// Arrowheads for updates edges
if (batchEdgeType === "updates") {
ctx.globalAlpha = isDimmed
? first.style.opacity * 0.6 * (1 - state.dimProgress * 0.8)
: first.style.opacity * 0.6
ctx.fillStyle = first.style.color
for (const e of batch) {
drawArrowHead(ctx, e.startX, e.startY, e.endX, e.endY, e.arrowSize)
}
}
}
ctx.globalAlpha = 1
}
function drawArrowHead(
ctx: CanvasRenderingContext2D,
fromX: number,
fromY: number,
toX: number,
toY: number,
size: number,
): void {
const angle = Math.atan2(toY - fromY, toX - fromX)
ctx.beginPath()
ctx.moveTo(toX, toY)
ctx.lineTo(
toX - size * Math.cos(angle - Math.PI / 6),
toY - size * Math.sin(angle - Math.PI / 6),
)
ctx.lineTo(
toX - size * Math.cos(angle + Math.PI / 6),
toY - size * Math.sin(angle + Math.PI / 6),
)
ctx.closePath()
ctx.fill()
}
function drawNodes(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
viewport: ViewportState,
width: number,
height: number,
state: RenderState,
colors: GraphThemeColors,
): void {
const margin = 60
const densePointMode =
nodes.length > DENSE_POINT_THRESHOLD &&
viewport.zoom < DENSE_POINT_ZOOM &&
!state.selectedNodeId &&
state.highlightIds.size === 0
const pointDots: {
x: number
y: number
r: number
color: string
}[] = []
const memDots: {
x: number
y: number
r: number
color: string
fillColor: string
haloColor: string
dimmed: boolean
updateChain: boolean
}[] = []
const docDots: { x: number; y: number; s: number }[] = []
for (const node of nodes) {
const screen = viewport.worldToScreen(node.x, node.y)
const screenSize = node.size * viewport.zoom
const cullSize = Math.max(screenSize, 2)
if (
screen.x + cullSize < -margin ||
screen.x - cullSize > width + margin ||
screen.y + cullSize < -margin ||
screen.y - cullSize > height + margin
)
continue
const isSelected = node.id === state.selectedNodeId
const isHovered = node.id === state.hoveredNodeId
const isHighlighted = nodeMatchesDocumentHighlights(
node,
state.highlightIds,
)
const highlightFocus = state.highlightIds.size > 0
const fadeNonHighlights =
highlightFocus && !isSelected && !isHovered && !isHighlighted
if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) {
if (node.type === "document") {
docDots.push({ x: screen.x, y: screen.y, s: Math.max(3, screenSize) })
} else if (densePointMode) {
pointDots.push({
x: screen.x,
y: screen.y,
r: Math.max(1.1, screenSize * 0.42),
color:
node.clusterColor || node.borderColor || colors.memStrokeDefault,
})
} else {
const md = node.data as MemoryNodeData
memDots.push({
x: screen.x,
y: screen.y,
r: Math.max(2, screenSize * 0.45),
color: node.borderColor || colors.memStrokeDefault,
fillColor: getMemoryNodeFillColor(node, colors, false),
haloColor: node.clusterColor || node.borderColor || colors.glowColor,
dimmed: md.isLatest === false,
updateChain: isMemoryInUpdateChain(md),
})
}
continue
}
let alpha = 1
if (state.selectedNodeId && state.dimProgress > 0 && !isSelected) {
alpha = 1 - state.dimProgress * 0.7
}
if (fadeNonHighlights) {
alpha *= 0.35
}
ctx.globalAlpha = alpha
if (node.type === "document") {
drawDocumentNode(
ctx,
screen.x,
screen.y,
screenSize,
node,
isSelected,
isHovered,
isHighlighted,
colors,
)
} else {
drawMemoryNode(
ctx,
screen.x,
screen.y,
screenSize,
node,
isSelected,
isHovered,
isHighlighted,
colors,
)
}
if (isSelected || isHighlighted || isHovered) {
drawGlow(
ctx,
screen.x,
screen.y,
screenSize,
node.type,
colors,
isHovered && !isSelected,
)
}
}
const dimAlpha =
state.selectedNodeId && state.dimProgress > 0
? 1 - state.dimProgress * 0.7
: 1
const hlBatchMult = state.highlightIds.size > 0 ? 0.4 : 1
if (pointDots.length > 0) {
ctx.globalAlpha = dimAlpha * 0.78
for (const [color, batch] of groupByColor(pointDots)) {
ctx.fillStyle = color
ctx.beginPath()
for (const d of batch) {
ctx.moveTo(d.x + d.r, d.y)
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
}
ctx.fill()
}
}
if (docDots.length > 0) {
ctx.fillStyle = colors.docFill
ctx.strokeStyle = colors.docStroke
ctx.lineWidth = 1
ctx.globalAlpha = dimAlpha * hlBatchMult
for (const d of docDots) {
const h = d.s * 0.5
ctx.fillRect(d.x - h, d.y - h, d.s, d.s)
ctx.strokeRect(d.x - h, d.y - h, d.s, d.s)
}
}
if (memDots.length > 0) {
// Draw normal (latest) memory dots
const normalDots = memDots.filter((d) => !d.dimmed)
const dimmedDots = memDots.filter((d) => d.dimmed)
if (normalDots.length > 0) {
// Subtle glow behind memory dots for luminous effect
ctx.globalAlpha = dimAlpha * hlBatchMult * 0.25
for (const [color, batch] of groupByComputedColor(
normalDots,
(d) => d.haloColor,
)) {
ctx.fillStyle = color
ctx.beginPath()
for (const d of batch) {
ctx.moveTo(d.x + d.r * 2.5, d.y)
ctx.arc(d.x, d.y, d.r * 2.5, 0, Math.PI * 2)
}
ctx.fill()
}
// Filled dot
ctx.globalAlpha = dimAlpha * hlBatchMult
for (const [fillColor, batch] of groupByComputedColor(
normalDots,
(d) => d.fillColor,
)) {
ctx.fillStyle = fillColor
ctx.beginPath()
for (const d of batch) {
ctx.moveTo(d.x + d.r, d.y)
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
}
ctx.fill()
}
// Colored border
ctx.lineWidth = 1.5
for (const [color, batch] of groupByColor(normalDots)) {
ctx.strokeStyle = color
ctx.beginPath()
for (const d of batch) {
ctx.moveTo(d.x + d.r, d.y)
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
}
ctx.stroke()
}
const updateDots = normalDots.filter((d) => d.updateChain)
if (updateDots.length > 0) {
ctx.globalAlpha = dimAlpha * hlBatchMult * 0.85
ctx.strokeStyle = colors.edgeUpdates
ctx.lineWidth = 1.2
ctx.beginPath()
for (const d of updateDots) {
const r = d.r * 1.65
ctx.moveTo(d.x + r, d.y)
ctx.arc(d.x, d.y, r, 0, Math.PI * 2)
}
ctx.stroke()
}
}
// Draw dimmed (superseded) memory dots at reduced opacity
if (dimmedDots.length > 0) {
ctx.globalAlpha = dimAlpha * hlBatchMult * 0.5
for (const [fillColor, batch] of groupByComputedColor(
dimmedDots,
(d) => d.fillColor,
)) {
ctx.fillStyle = fillColor
ctx.beginPath()
for (const d of batch) {
ctx.moveTo(d.x + d.r, d.y)
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
}
ctx.fill()
}
ctx.lineWidth = 1
for (const [color, batch] of groupByColor(dimmedDots)) {
ctx.strokeStyle = color
ctx.beginPath()
for (const d of batch) {
ctx.moveTo(d.x + d.r, d.y)
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
}
ctx.stroke()
}
const updateDots = dimmedDots.filter((d) => d.updateChain)
if (updateDots.length > 0) {
ctx.globalAlpha = dimAlpha * hlBatchMult * 0.55
ctx.strokeStyle = colors.edgeUpdates
ctx.lineWidth = 1
ctx.beginPath()
for (const d of updateDots) {
const r = d.r * 1.65
ctx.moveTo(d.x + r, d.y)
ctx.arc(d.x, d.y, r, 0, Math.PI * 2)
}
ctx.stroke()
}
}
}
ctx.globalAlpha = 1
}
function drawDocumentNode(
ctx: CanvasRenderingContext2D,
sx: number,
sy: number,
size: number,
node: GraphNode,
isSelected: boolean,
isHovered: boolean,
isHighlighted: boolean,
colors: GraphThemeColors,
): void {
const half = size * 0.5
const cornerR = 8 * (size / 50)
const clusterColor = node.clusterColor ?? colors.docStroke
// Drop shadow for selected/hovered nodes
if (isSelected || isHovered) {
ctx.save()
ctx.shadowColor = isSelected ? colors.accent : clusterColor
ctx.shadowBlur = isSelected ? 16 : 10
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
// Subtle gradient fill for document nodes
const grad = ctx.createLinearGradient(
sx - half,
sy - half,
sx + half,
sy + half,
)
grad.addColorStop(
0,
node.clusterColor
? mixHexColors(colors.docFill, clusterColor, 0.1)
: colors.docFill,
)
grad.addColorStop(
1,
node.clusterColor
? mixHexColors(colors.docFill, clusterColor, 0.22)
: colors.docFill,
)
ctx.fillStyle = grad
ctx.strokeStyle =
isSelected || isHighlighted
? colors.accent
: isHovered
? clusterColor
: node.borderColor || clusterColor
ctx.lineWidth = isSelected || isHighlighted ? 2.5 : isHovered ? 1.5 : 1
roundRect(ctx, sx - half, sy - half, size, size, cornerR)
ctx.fill()
ctx.stroke()
if (isSelected || isHovered) {
ctx.restore()
}
const innerSize = size * 0.72
const innerHalf = innerSize * 0.5
const innerR = 6 * (size / 50)
ctx.fillStyle = node.clusterColor
? mixHexColors(colors.docInnerFill, clusterColor, 0.08)
: colors.docInnerFill
roundRect(ctx, sx - innerHalf, sy - innerHalf, innerSize, innerSize, innerR)
ctx.fill()
const iconSize = size * 0.35
const docType =
node.type === "document" ? (node.data as DocumentNodeData).type : "text"
drawDocIcon(
ctx,
sx,
sy,
iconSize,
docType || "text",
node.clusterColor ? clusterColor : colors.iconColor,
)
}
function drawMemoryNode(
ctx: CanvasRenderingContext2D,
sx: number,
sy: number,
size: number,
node: GraphNode,
isSelected: boolean,
isHovered: boolean,
_isHighlighted: boolean,
colors: GraphThemeColors,
): void {
const memData = node.data as MemoryNodeData
const isSuperseded = memData.isLatest === false
const isForgotten = memData.isForgotten
const isUpdateChain = isMemoryInUpdateChain(memData)
const radius = size * 0.5
// Dim superseded (non-latest) memory nodes with strikethrough effect
if (isSuperseded && !isSelected && !isHovered) {
const prevAlpha = ctx.globalAlpha
ctx.globalAlpha = prevAlpha * 0.5
ctx.fillStyle = getMemoryNodeFillColor(node, colors, false)
drawHexagon(ctx, sx, sy, radius)
ctx.fill()
ctx.strokeStyle = node.borderColor || colors.memStrokeDefault
ctx.lineWidth = 1
ctx.setLineDash([3, 3])
ctx.stroke()
ctx.setLineDash([])
// Draw diagonal strikethrough for superseded nodes (visual clarity)
const strikeR = radius * 0.55
ctx.beginPath()
ctx.moveTo(sx - strikeR, sy - strikeR)
ctx.lineTo(sx + strikeR, sy + strikeR)
ctx.strokeStyle = colors.textMuted
ctx.lineWidth = 1.5
ctx.stroke()
drawUpdateMarker(ctx, sx, sy, radius, colors, 0.85)
ctx.globalAlpha = prevAlpha
return
}
// Drop shadow for selected/hovered memory nodes
if (isSelected || isHovered) {
ctx.save()
const shadowColor = isSelected ? colors.accent : colors.glowColor
ctx.shadowColor = shadowColor
ctx.shadowBlur = isSelected ? 18 : 12
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
ctx.fillStyle = getMemoryNodeFillColor(node, colors, isHovered)
drawHexagon(ctx, sx, sy, radius)
ctx.fill()
const borderColor = node.borderColor || colors.memStrokeDefault
ctx.strokeStyle = isSelected ? colors.accent : borderColor
ctx.lineWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5
ctx.stroke()
if (isUpdateChain) {
drawUpdateMarker(
ctx,
sx,
sy,
radius,
colors,
isSelected || isHovered ? 1 : 0.86,
)
}
if (isSelected || isHovered) {
ctx.restore()
}
// Draw X icon for forgotten nodes
if (isForgotten && size > 14) {
const iconR = radius * 0.3
ctx.save()
ctx.strokeStyle = colors.memBorderForgotten
ctx.lineWidth = Math.max(1.5, size / 20)
ctx.lineCap = "round"
ctx.globalAlpha = 0.9
ctx.beginPath()
ctx.moveTo(sx - iconR, sy - iconR)
ctx.lineTo(sx + iconR, sy + iconR)
ctx.moveTo(sx + iconR, sy - iconR)
ctx.lineTo(sx - iconR, sy + iconR)
ctx.stroke()
ctx.restore()
}
}
function isMemoryInUpdateChain(memData: MemoryNodeData): boolean {
if (memData.isLatest === false || memData.parentMemoryId) return true
if (!memData.memoryRelations) return false
return Object.values(memData.memoryRelations).some(
(relation) => relation === "updates",
)
}
function drawUpdateMarker(
ctx: CanvasRenderingContext2D,
sx: number,
sy: number,
radius: number,
colors: GraphThemeColors,
alpha: number,
) {
const markerR = Math.max(3.5, radius * 0.22)
const cx = sx + radius * 0.48
const cy = sy - radius * 0.48
ctx.save()
ctx.globalAlpha *= alpha
ctx.fillStyle = colors.popoverBg
ctx.strokeStyle = colors.edgeUpdates
ctx.lineWidth = Math.max(1.2, radius * 0.08)
ctx.beginPath()
ctx.arc(cx, cy, markerR, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
ctx.strokeStyle = colors.edgeUpdates
ctx.lineCap = "round"
ctx.lineJoin = "round"
ctx.lineWidth = Math.max(1.2, radius * 0.07)
ctx.beginPath()
ctx.moveTo(cx - markerR * 0.45, cy)
ctx.lineTo(cx + markerR * 0.12, cy)
ctx.lineTo(cx - markerR * 0.06, cy - markerR * 0.2)
ctx.moveTo(cx + markerR * 0.12, cy)
ctx.lineTo(cx - markerR * 0.06, cy + markerR * 0.2)
ctx.stroke()
ctx.restore()
}
function getMemoryNodeFillColor(
node: GraphNode,
colors: GraphThemeColors,
isHovered: boolean,
): string {
const base = isHovered ? colors.memFillHover : colors.memFill
if (!node.clusterColor) return base
return mixHexColors(base, node.clusterColor, isHovered ? 0.42 : 0.32)
}
function drawGlow(
ctx: CanvasRenderingContext2D,
sx: number,
sy: number,
size: number,
nodeType: "document" | "memory",
colors: GraphThemeColors,
isHoverOnly = false,
): void {
ctx.strokeStyle = colors.glowColor
ctx.lineWidth = isHoverOnly ? 1.5 : 2
ctx.setLineDash(isHoverOnly ? [4, 4] : [3, 3])
ctx.globalAlpha = isHoverOnly ? 0.5 : 0.8
const scale = isHoverOnly ? 1.1 : 1.15
if (nodeType === "document") {
const glowSize = size * scale
const half = glowSize * 0.5
const r = 8 * (glowSize / 50)
roundRect(ctx, sx - half, sy - half, glowSize, glowSize, r)
} else {
drawHexagon(ctx, sx, sy, size * 0.5 * scale)
}
ctx.stroke()
ctx.setLineDash([])
ctx.globalAlpha = 1
}
function drawHexagon(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
radius: number,
): void {
ctx.beginPath()
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i - Math.PI / 6
const x = cx + radius * Math.cos(angle)
const y = cy + radius * Math.sin(angle)
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
}
ctx.closePath()
}
/** Lighten a 6-digit hex color by a fraction (0-1). Cached to avoid per-frame parsing. */
export function lightenColor(hex: string, amount: number): string {
if (
_lightenCache &&
_lightenCache.input === hex &&
_lightenCache.amount === amount
) {
return _lightenCache.result
}
const h = hex.replace("#", "")
// Only handle standard 6-digit hex; return input unchanged for other formats
if (h.length !== 6) return hex
const r = Math.min(
255,
Number.parseInt(h.substring(0, 2), 16) + Math.round(255 * amount),
)
const g = Math.min(
255,
Number.parseInt(h.substring(2, 4), 16) + Math.round(255 * amount),
)
const b = Math.min(
255,
Number.parseInt(h.substring(4, 6), 16) + Math.round(255 * amount),
)
const result = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`
_lightenCache = { input: hex, amount, result }
return result
}
export function mixHexColors(
base: string,
overlay: string,
amount: number,
): string {
const baseRgb = parseHexColor(base)
const overlayRgb = parseHexColor(overlay)
if (!baseRgb || !overlayRgb) return base
const t = clampNumber(amount, 0, 1)
const r = Math.round(baseRgb.r + (overlayRgb.r - baseRgb.r) * t)
const g = Math.round(baseRgb.g + (overlayRgb.g - baseRgb.g) * t)
const b = Math.round(baseRgb.b + (overlayRgb.b - baseRgb.b) * t)
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
function parseHexColor(hex: string) {
const raw = hex.startsWith("#") ? hex.slice(1) : hex
if (!/^[0-9a-fA-F]{6}$/.test(raw)) return null
return {
r: Number.parseInt(raw.slice(0, 2), 16),
g: Number.parseInt(raw.slice(2, 4), 16),
b: Number.parseInt(raw.slice(4, 6), 16),
}
}
function toHex(value: number): string {
return value.toString(16).padStart(2, "0")
}