feat: improve graph aesthetics - subtle edges, luminous nodes, better spacing

- Edge opacity: derives 0.18, updates 0.45, extends 0.12 for gossamer web effect
- Edge colors: sky blue (#38BDF8), soft violet (#A78BFA), teal (#2DD4BF)
- Node glow: subtle radial glow behind memory dots for luminous effect
- Force layout: stronger repulsion (-2500), wider spacing (450/200), gentler centering
- Refactor simulation to use FORCE_CONFIG constants instead of hardcoded values
- Update MCP app edge colors to match
This commit is contained in:
Vorflux AI 2026-03-28 07:41:03 +00:00
parent b43c311092
commit bcf2ff87fa
4 changed files with 91 additions and 44 deletions

View file

@ -99,14 +99,14 @@ const MEMORY_BORDER = {
const EDGE_COLORS = {
dark: {
derives: "#3B82F6",
updates: "#8B5CF6",
extends: "#14B8A6",
derives: "#38BDF8",
updates: "#A78BFA",
extends: "#2DD4BF",
},
light: {
derives: "#60A5FA",
updates: "#8B5CF6",
extends: "#2DD4BF",
derives: "#7DD3FC",
updates: "#A78BFA",
extends: "#5EEAD4",
},
}

View file

@ -41,18 +41,22 @@ export function renderFrame(
function edgeStyle(
edge: GraphEdge,
colors: GraphThemeColors,
): { color: string; width: number } {
): { color: string; width: number; opacity: number } {
if (edge.edgeType === "derives")
return { color: colors.edgeDerives, width: 1.5 }
return { color: colors.edgeDerives, width: 0.8, opacity: 0.18 }
if (edge.edgeType === "updates")
return { color: colors.edgeUpdates, width: 2 }
return { color: colors.edgeUpdates, width: 1.2, opacity: 0.45 }
if (edge.edgeType === "extends")
return { color: colors.edgeExtends, width: 1 }
return { color: colors.edgeExtends, width: 1 }
return { color: colors.edgeExtends, width: 0.6, opacity: 0.12 }
return { color: colors.edgeExtends, width: 0.6, opacity: 0.12 }
}
function batchKey(style: { color: string; width: number }): string {
return `${style.color}|${style.width}`
function batchKey(style: {
color: string
width: number
opacity: number
}): string {
return `${style.color}|${style.width}|${style.opacity}`
}
interface PreparedEdge {
@ -61,7 +65,7 @@ interface PreparedEdge {
endX: number
endY: number
connected: boolean
style: { color: string; width: number }
style: { color: string; width: number; opacity: number }
isUpdates: boolean
arrowSize: number
}
@ -161,14 +165,13 @@ function drawEdges(
if (!first) continue
const isDimmed = key.endsWith("|d")
// Draw glow pass behind updates edges for visual emphasis
// Updates edges share a distinct style key, so a batch is all-updates or none
// Draw subtle glow pass behind updates edges
const isUpdatesBatch = first.isUpdates
if (isUpdatesBatch && !isDimmed) {
ctx.save()
ctx.globalAlpha = 0.3
ctx.globalAlpha = first.style.opacity * 0.4
ctx.strokeStyle = first.style.color
ctx.lineWidth = first.style.width + 4
ctx.lineWidth = first.style.width + 2
ctx.beginPath()
for (const e of batch) {
ctx.moveTo(e.startX, e.startY)
@ -178,7 +181,10 @@ function drawEdges(
ctx.restore()
}
ctx.globalAlpha = isDimmed ? 1 - state.dimProgress * 0.8 : 1
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
@ -190,6 +196,9 @@ function drawEdges(
ctx.stroke()
if (isUpdatesBatch) {
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)
@ -343,6 +352,28 @@ function drawNodes(
const dimmedDots = memDots.filter((d) => d.dimmed)
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) {
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
ctx.fillStyle = colors.memFill
ctx.beginPath()
@ -352,6 +383,7 @@ function drawNodes(
}
ctx.fill()
// Colored border
ctx.lineWidth = 1.5
const byColor = new Map<string, typeof normalDots>()
for (const d of normalDots) {

View file

@ -1,5 +1,6 @@
import * as d3 from "d3-force"
import type { GraphEdge, GraphNode } from "../types"
import { FORCE_CONFIG } from "../constants"
export class ForceSimulation {
private sim: d3.Simulation<GraphNode, GraphEdge> | null = null
@ -10,40 +11,54 @@ export class ForceSimulation {
try {
this.sim = d3
.forceSimulation<GraphNode>(nodes)
.alphaDecay(0.04)
.alphaMin(0.001)
.velocityDecay(0.6)
.alphaDecay(FORCE_CONFIG.alphaDecay)
.alphaMin(FORCE_CONFIG.alphaMin)
.velocityDecay(FORCE_CONFIG.velocityDecay)
this.sim.force(
"link",
d3
.forceLink<GraphNode, GraphEdge>(edges)
.id((d) => d.id)
.distance((link) => (link.edgeType === "derives" ? 150 : 300))
.distance((link) =>
link.edgeType === "derives"
? FORCE_CONFIG.docMemoryDistance
: FORCE_CONFIG.linkDistance,
)
.strength((link) => {
if (link.edgeType === "derives") return 0.8
if (link.edgeType === "updates") return 1.0
if (link.edgeType === "extends") return 0.1
if (link.edgeType === "derives")
return FORCE_CONFIG.linkStrength.docMemory
if (link.edgeType === "updates")
return FORCE_CONFIG.linkStrength.version
if (link.edgeType === "extends")
return FORCE_CONFIG.linkStrength.docDocBase
return 0.3
}),
)
this.sim.force("charge", d3.forceManyBody<GraphNode>().strength(-1000))
this.sim.force(
"charge",
d3.forceManyBody<GraphNode>().strength(FORCE_CONFIG.chargeStrength),
)
this.sim.force(
"collide",
d3
.forceCollide<GraphNode>()
.radius((d) => (d.type === "document" ? 80 : 40))
.radius((d) =>
d.type === "document"
? FORCE_CONFIG.collisionRadius.document
: FORCE_CONFIG.collisionRadius.memory,
)
.strength(0.7),
)
this.sim.force("x", d3.forceX().strength(0.05))
this.sim.force("y", d3.forceY().strength(0.05))
this.sim.force("x", d3.forceX().strength(0.03))
this.sim.force("y", d3.forceY().strength(0.03))
this.sim.stop()
this.sim.alpha(1)
for (let i = 0; i < 100; i++) this.sim.tick()
for (let i = 0; i < FORCE_CONFIG.preSettleTicks; i++) this.sim.tick()
this.sim.alphaTarget(0).restart()
} catch (e) {
console.error("ForceSimulation.init failed:", e)
@ -59,7 +74,7 @@ export class ForceSimulation {
}
reheat(): void {
this.sim?.alphaTarget(0.3).restart()
this.sim?.alphaTarget(FORCE_CONFIG.alphaTarget).restart()
}
coolDown(): void {
@ -67,7 +82,7 @@ export class ForceSimulation {
}
isActive(): boolean {
return (this.sim?.alpha() ?? 0) > 0.001
return (this.sim?.alpha() ?? 0) > FORCE_CONFIG.alphaMin
}
destroy(): void {

View file

@ -9,19 +9,19 @@ export const MEMORY_BORDER_KEYS = {
export const FORCE_CONFIG = {
linkStrength: {
docMemory: 0.8,
version: 1.0,
docDocBase: 0.3,
docMemory: 0.6,
version: 0.8,
docDocBase: 0.15,
},
linkDistance: 300,
docMemoryDistance: 150,
chargeStrength: -1000,
collisionRadius: { document: 80, memory: 40 },
linkDistance: 450,
docMemoryDistance: 200,
chargeStrength: -2500,
collisionRadius: { document: 90, memory: 45 },
alphaDecay: 0.04,
alphaMin: 0.001,
velocityDecay: 0.6,
alphaTarget: 0.3,
preSettleTicks: 100,
preSettleTicks: 120,
}
export const GRAPH_SETTINGS = {
@ -45,9 +45,9 @@ export const DEFAULT_COLORS: GraphThemeColors = {
textPrimary: "#ffffff",
textSecondary: "#e2e8f0",
textMuted: "#94a3b8",
edgeDerives: "#3B82F6",
edgeUpdates: "#8B5CF6",
edgeExtends: "#14B8A6",
edgeDerives: "#38BDF8",
edgeUpdates: "#A78BFA",
edgeExtends: "#2DD4BF",
memBorderForgotten: "#EF4444",
memBorderExpiring: "#F59E0B",
memBorderRecent: "#10B981",