mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-15 23:31:05 +00:00
Add visual enhancements: drop shadows, gradient fills, hover glow, forgotten X icon, version edge glow
- Drop shadows on selected/hovered nodes (document + memory) for depth - Subtle gradient fill on document nodes - Improved dimming contrast for superseded memories (0.3 alpha + strikethrough) - Hover glow ring with dashed outline for interactivity feedback - X icon overlay on forgotten memory nodes (size > 14px) - Glow pass behind version edges for visual emphasis - lightenColor utility with cache + guard for non-6-digit hex - Use memData.isForgotten instead of fragile color comparison - Optimize version edge batch: skip filter, use first.isVersion flag - Add 10 unit tests for lightenColor (hex parsing, clamping, cache, edge cases)
This commit is contained in:
parent
13eb5e9cb0
commit
c81c05c14f
2 changed files with 180 additions and 21 deletions
57
packages/memory-graph/src/__tests__/renderer-utils.test.ts
Normal file
57
packages/memory-graph/src/__tests__/renderer-utils.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { lightenColor } from "../canvas/renderer"
|
||||
|
||||
describe("lightenColor", () => {
|
||||
test("lightens a dark hex color", () => {
|
||||
// #1B1F24 lightened by 0.08 → each channel +20 (0.08*255≈20)
|
||||
const result = lightenColor("#1B1F24", 0.08)
|
||||
// R: 0x1B(27)+20=47=0x2f, G: 0x1F(31)+20=51=0x33, B: 0x24(36)+20=56=0x38
|
||||
expect(result).toBe("#2f3338")
|
||||
})
|
||||
|
||||
test("clamps channels at 255", () => {
|
||||
// #FFFFFF lightened by 0.1 → all channels clamped at 255
|
||||
const result = lightenColor("#ffffff", 0.1)
|
||||
expect(result).toBe("#ffffff")
|
||||
})
|
||||
|
||||
test("handles zero amount (no change)", () => {
|
||||
const result = lightenColor("#1B1F24", 0)
|
||||
expect(result).toBe("#1b1f24")
|
||||
})
|
||||
|
||||
test("returns input unchanged for 3-digit hex", () => {
|
||||
expect(lightenColor("#abc", 0.1)).toBe("#abc")
|
||||
})
|
||||
|
||||
test("returns input unchanged for rgb() format", () => {
|
||||
expect(lightenColor("rgb(27, 31, 36)", 0.1)).toBe("rgb(27, 31, 36)")
|
||||
})
|
||||
|
||||
test("returns input unchanged for 8-digit hex with alpha", () => {
|
||||
expect(lightenColor("#1B1F24FF", 0.1)).toBe("#1B1F24FF")
|
||||
})
|
||||
|
||||
test("caches result for repeated calls", () => {
|
||||
const first = lightenColor("#0D2034", 0.08)
|
||||
const second = lightenColor("#0D2034", 0.08)
|
||||
expect(first).toBe(second)
|
||||
})
|
||||
|
||||
test("cache invalidates on different input", () => {
|
||||
const a = lightenColor("#0D2034", 0.08)
|
||||
const b = lightenColor("#1B1F24", 0.08)
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
test("cache invalidates on different amount", () => {
|
||||
const a = lightenColor("#1B1F24", 0.05)
|
||||
const b = lightenColor("#1B1F24", 0.1)
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
test("handles hex without # prefix", () => {
|
||||
const result = lightenColor("1B1F24", 0.08)
|
||||
expect(result).toBe("#2f3338")
|
||||
})
|
||||
})
|
||||
|
|
@ -17,6 +17,9 @@ export interface RenderState {
|
|||
// Module-level reusable batch map – cleared each frame instead of reallocating
|
||||
const edgeBatches = new Map<string, PreparedEdge[]>()
|
||||
|
||||
// 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[],
|
||||
|
|
@ -247,6 +250,23 @@ function drawEdges(
|
|||
if (!first) continue
|
||||
const isDimmed = key.endsWith("|d")
|
||||
|
||||
// Draw glow pass behind version edges for visual emphasis
|
||||
// Version edges share a distinct style key, so a batch is all-version or none
|
||||
const isVersionBatch = first.isVersion
|
||||
if (isVersionBatch && !isDimmed) {
|
||||
ctx.save()
|
||||
ctx.globalAlpha = 0.3
|
||||
ctx.strokeStyle = first.style.color
|
||||
ctx.lineWidth = first.style.width + 4
|
||||
ctx.beginPath()
|
||||
for (const e of batch) {
|
||||
ctx.moveTo(e.startX, e.startY)
|
||||
ctx.lineTo(e.endX, e.endY)
|
||||
}
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.globalAlpha = isDimmed ? 1 - state.dimProgress * 0.8 : 1
|
||||
ctx.strokeStyle = first.style.color
|
||||
ctx.lineWidth = first.style.width
|
||||
|
|
@ -258,10 +278,9 @@ function drawEdges(
|
|||
}
|
||||
ctx.stroke()
|
||||
|
||||
const versionEdges = batch.filter((e) => e.isVersion)
|
||||
if (versionEdges.length > 0) {
|
||||
if (isVersionBatch) {
|
||||
ctx.fillStyle = first.style.color
|
||||
for (const e of versionEdges) {
|
||||
for (const e of batch) {
|
||||
drawArrowHead(ctx, e.startX, e.startY, e.endX, e.endY, e.arrowSize)
|
||||
}
|
||||
}
|
||||
|
|
@ -377,8 +396,16 @@ function drawNodes(
|
|||
)
|
||||
}
|
||||
|
||||
if (isSelected || isHighlighted) {
|
||||
drawGlow(ctx, screen.x, screen.y, screenSize, node.type, colors)
|
||||
if (isSelected || isHighlighted || isHovered) {
|
||||
drawGlow(
|
||||
ctx,
|
||||
screen.x,
|
||||
screen.y,
|
||||
screenSize,
|
||||
node.type,
|
||||
colors,
|
||||
isHovered && !isSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -437,7 +464,7 @@ function drawNodes(
|
|||
|
||||
// Draw dimmed (superseded) memory dots at reduced opacity
|
||||
if (dimmedDots.length > 0) {
|
||||
ctx.globalAlpha = dimAlpha * 0.35
|
||||
ctx.globalAlpha = dimAlpha * 0.3
|
||||
ctx.fillStyle = colors.memFill
|
||||
ctx.beginPath()
|
||||
for (const d of dimmedDots) {
|
||||
|
|
@ -485,18 +512,32 @@ function drawDocumentNode(
|
|||
const half = size * 0.5
|
||||
const cornerR = 8 * (size / 50)
|
||||
|
||||
ctx.fillStyle = colors.docFill
|
||||
// Drop shadow for selected/hovered nodes
|
||||
if (isSelected || isHovered) {
|
||||
ctx.save()
|
||||
ctx.shadowColor = colors.accent
|
||||
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, colors.docFill)
|
||||
grad.addColorStop(1, lightenColor(colors.docFill, 0.08))
|
||||
ctx.fillStyle = grad
|
||||
|
||||
ctx.strokeStyle =
|
||||
isSelected || isHighlighted
|
||||
? colors.accent
|
||||
: isHovered
|
||||
? colors.accent
|
||||
: colors.docStroke
|
||||
ctx.lineWidth = isSelected || isHighlighted ? 2 : 1
|
||||
isSelected || isHighlighted || isHovered ? colors.accent : colors.docStroke
|
||||
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)
|
||||
|
|
@ -523,12 +564,13 @@ function drawMemoryNode(
|
|||
): void {
|
||||
const memData = node.data as MemoryNodeData
|
||||
const isSuperseded = memData.isLatest === false
|
||||
const isForgotten = memData.isForgotten
|
||||
const radius = size * 0.5
|
||||
|
||||
// Dim superseded (non-latest) memory nodes
|
||||
// Dim superseded (non-latest) memory nodes with strikethrough effect
|
||||
if (isSuperseded && !isSelected && !isHovered) {
|
||||
const prevAlpha = ctx.globalAlpha
|
||||
ctx.globalAlpha = prevAlpha * 0.35
|
||||
ctx.globalAlpha = prevAlpha * 0.3
|
||||
ctx.fillStyle = colors.memFill
|
||||
drawHexagon(ctx, sx, sy, radius)
|
||||
ctx.fill()
|
||||
|
|
@ -537,18 +579,59 @@ function drawMemoryNode(
|
|||
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()
|
||||
|
||||
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 = isHovered ? colors.memFillHover : colors.memFill
|
||||
drawHexagon(ctx, sx, sy, radius)
|
||||
ctx.fill()
|
||||
|
||||
const borderColor = node.borderColor || colors.memStrokeDefault
|
||||
ctx.strokeStyle = isSelected ? colors.accent : borderColor
|
||||
ctx.lineWidth = isHovered ? 2 : 1.5
|
||||
ctx.lineWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5
|
||||
ctx.stroke()
|
||||
|
||||
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 drawGlow(
|
||||
|
|
@ -558,19 +641,22 @@ function drawGlow(
|
|||
size: number,
|
||||
nodeType: "document" | "memory",
|
||||
colors: GraphThemeColors,
|
||||
isHoverOnly = false,
|
||||
): void {
|
||||
ctx.strokeStyle = colors.glowColor
|
||||
ctx.lineWidth = 2
|
||||
ctx.setLineDash([3, 3])
|
||||
ctx.globalAlpha = 0.8
|
||||
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 * 1.15
|
||||
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 * 1.15)
|
||||
drawHexagon(ctx, sx, sy, size * 0.5 * scale)
|
||||
}
|
||||
|
||||
ctx.stroke()
|
||||
|
|
@ -767,3 +853,19 @@ function drawDocOutline(
|
|||
ctx.lineTo(x + lw / 2, y + sp)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue