feat: entity graph app with theme-aware colors, icons, filters, and blast radius
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

New EntityGraph.svelte app (sigma.js + graphology):
- Theme-aware Gruvbox palettes (light/dark) with reactive color switching
- Lucide icons rendered synchronously via Path2D canvas per entity type
- Color mode toggle (Health / Type) with per-type distinct colors
- Health distribution bar with clickable filters
- Entity type filters grouped by ontological layer (collapsible)
- Relationship type edge filters with color-coded swatches
- Quick presets: All / Problems / Infra
- Node selection with live blast radius from API
- Hover neighborhood highlighting with muted fade
- Isolated node hiding, edge alpha tuning, dot-grid background
- Search with camera focus on highest-degree match
This commit is contained in:
2026-08-11 22:42:20 +02:00
parent febc153b7f
commit 7ecf720166
2 changed files with 174 additions and 111 deletions

View File

@@ -1 +1 @@
0.30.1 0.30.2

View File

@@ -6,12 +6,12 @@
import { createNodeImageProgram } from '@sigma/node-image' import { createNodeImageProgram } from '@sigma/node-image'
import { import {
fetchGraph, fetchOntology, fetchAllEntities, fetchBlastRadius, fetchGraph, fetchOntology, fetchAllEntities, fetchBlastRadius,
type Entity, type EntityType, type GraphView, type BlastRadiusItem, type OntologyLayer type Entity, type EntityType, type GraphView, type BlastRadiusItem
} from '$lib/api' } from '$lib/api'
import { subscribeEvents } from '$lib/stores/events' import { subscribeEvents } from '$lib/stores/events'
import { applyHealthEvent } from '$lib/health' import { applyHealthEvent } from '$lib/health'
import { openEntityWindow } from '$lib/stores/windows' import { openEntityWindow } from '$lib/stores/windows'
import { Badge } from '$lib/components/ui/badge' import { getTheme } from '$lib/stores/theme.svelte'
import * as Collapsible from '$lib/components/ui/collapsible' import * as Collapsible from '$lib/components/ui/collapsible'
import SearchIcon from '@lucide/svelte/icons/search' import SearchIcon from '@lucide/svelte/icons/search'
import Maximize2Icon from '@lucide/svelte/icons/maximize-2' import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
@@ -21,86 +21,152 @@
import ZapIcon from '@lucide/svelte/icons/zap' import ZapIcon from '@lucide/svelte/icons/zap'
import CircleDotIcon from '@lucide/svelte/icons/circle-dot' import CircleDotIcon from '@lucide/svelte/icons/circle-dot'
// ── Color maps (Gruvbox palette, aligned with cyberspace theme) ── // ── Theme-aware Gruvbox palettes ────────────────────────
const HEALTH_COLOR: Record<string, string> = { // Light mode = dark/faded gruvbox on warm cream → high contrast
healthy: '#b8bb26', degraded: '#fabd2f', down: '#fb4934', // Dark mode = bright gruvbox on black → high visibility
unknown: '#928374', stale: '#fabd2f' interface Palette {
health: Record<string, string>
type: Record<string, string>
layer: Record<string, string>
rel: Record<string, string>
default: string
fade: string
edgeFade: string
label: string
iconStroke: string
} }
const TYPE_COLORS: Record<string, string> = {
'proxmox-host': '#fe8019', 'standalone-server': '#d65d0e', workstation: '#83a598', const LIGHT: Palette = {
appliance: '#928374', vm: '#fabd2f', lxc: '#8ec07c', service: '#b8bb26', health: { healthy: '#79740e', degraded: '#b57614', down: '#9d0006', unknown: '#7c6f64', stale: '#b57614' },
'service-group': '#98971a', agent: '#d3869b', task: '#83a598', execution: '#458588', type: {
document: '#928374', runbook: '#d3869b', investigation: '#b16286', check: '#8ec07c', 'proxmox-host': '#af3a03', 'standalone-server': '#af3a03', workstation: '#076678',
signal: '#fb4934', 'config-repo': '#d79921', 'deploy-pipeline': '#83a598', appliance: '#7c6f64', vm: '#b57614', lxc: '#427b58', service: '#79740e',
secret: '#cc241d', cluster: '#8ec07c', 'storage-pool': '#d65d0e', 'service-group': '#79740e', agent: '#8f3f71', task: '#076678', execution: '#076678',
'network-device': '#689d6a', namespace: '#504945', site: '#d3869b', entity: '#928374' document: '#7c6f64', runbook: '#8f3f71', investigation: '#8f3f71', check: '#427b58',
signal: '#9d0006', 'config-repo': '#b57614', 'deploy-pipeline': '#076678',
secret: '#9d0006', cluster: '#427b58', 'storage-pool': '#af3a03',
'network-device': '#427b58', namespace: '#504945', site: '#8f3f71', entity: '#7c6f64'
},
layer: { infrastructure: '#af3a03', governance: '#076678', cognition: '#8f3f71', meta: '#7c6f64' },
rel: {
hosts: '#076678', 'depends-on': '#af3a03', 'member-of': '#427b58',
'located-at': '#8f3f71', documents: '#7c6f64', about: '#8f3f71',
provides: '#79740e', 'backs-up-to': '#427b58', 'part-of': '#504945',
runs: '#8f3f71', uses: '#b57614'
},
default: '#7c6f64',
fade: '#bcb094',
edgeFade: '#bcb09433',
label: '#1d2021',
iconStroke: '#efe5c0'
} }
const LAYER_FALLBACK: Record<string, string> = {
infrastructure: '#fe8019', governance: '#83a598', cognition: '#d3869b', meta: '#928374' const DARK: Palette = {
health: { healthy: '#b8bb26', degraded: '#fabd2f', down: '#fb4934', unknown: '#928374', stale: '#fabd2f' },
type: {
'proxmox-host': '#fe8019', 'standalone-server': '#fe8019', workstation: '#83a598',
appliance: '#928374', vm: '#fabd2f', lxc: '#8ec07c', service: '#b8bb26',
'service-group': '#b8bb26', agent: '#d3869b', task: '#83a598', execution: '#83a598',
document: '#928374', runbook: '#d3869b', investigation: '#d3869b', check: '#8ec07c',
signal: '#fb4934', 'config-repo': '#fabd2f', 'deploy-pipeline': '#83a598',
secret: '#fb4934', cluster: '#8ec07c', 'storage-pool': '#fe8019',
'network-device': '#8ec07c', namespace: '#a89984', site: '#d3869b', entity: '#928374'
},
layer: { infrastructure: '#fe8019', governance: '#83a598', cognition: '#d3869b', meta: '#928374' },
rel: {
hosts: '#83a598', 'depends-on': '#fe8019', 'member-of': '#8ec07c',
'located-at': '#d3869b', documents: '#928374', about: '#d3869b',
provides: '#b8bb26', 'backs-up-to': '#8ec07c', 'part-of': '#a89984',
runs: '#d3869b', uses: '#fabd2f'
},
default: '#928374',
fade: '#3c3836',
edgeFade: '#3c383622',
label: '#ebdbb2',
iconStroke: '#1d2021'
} }
const LAYER_LABEL: Record<string, string> = { const LAYER_LABEL: Record<string, string> = {
infrastructure: 'Infrastructure', governance: 'Governance', infrastructure: 'Infrastructure', governance: 'Governance',
cognition: 'Cognition', meta: 'Meta' cognition: 'Cognition', meta: 'Meta'
} }
const REL_COLORS: Record<string, string> = {
hosts: '#83a598', 'depends-on': '#fe8019', 'member-of': '#8ec07c', // Reactive palette — switches when the user toggles the theme
'located-at': '#d3869b', documents: '#928374', about: '#b16286', const pal = $derived<Palette>(getTheme() === 'light' ? LIGHT : DARK)
provides: '#b8bb26', 'backs-up-to': '#689d6a', 'part-of': '#504945',
runs: '#d3869b', uses: '#fabd2f'
}
const DEFAULT_COLOR = '#928374'
const FADE_COLOR = 'rgba(146,131,116,0.3)'
const EDGE_BASE = 'rgba(131,165,152,0.35)'
const EDGE_FADE = 'rgba(146,131,116,0.06)'
type ColorMode = 'health' | 'type' type ColorMode = 'health' | 'type'
// ── Lucide icon rendering ─────────────────────────────── // ── Lucide icon rendering (synchronous via Path2D) ──────
type IconNode = [string, Record<string, string>] type IconNode = [string, Record<string, string>]
function lucideIcon(node: IconNode[]): string { function lucideIcon(node: IconNode[], stroke: string): string {
const svg = `data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${
node.map(([tag, attrs]) => `<${tag} ${Object.entries(attrs).map(([k, v]) => `${k}="${v}"`).join(' ')}/>`).join('')
}</svg>`
)}`
const img = new Image()
img.src = svg
const c = document.createElement('canvas') const c = document.createElement('canvas')
c.width = 128; c.height = 128 c.width = 128; c.height = 128
const ctx = c.getContext('2d')! const ctx = c.getContext('2d')!
ctx.drawImage(img, 0, 0, 128, 128) ctx.scale(128 / 24, 128 / 24)
ctx.strokeStyle = stroke
ctx.fillStyle = stroke
ctx.lineWidth = 2
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
const num = (a: Record<string, string>, k: string) => parseFloat(a[k] ?? '0')
for (const [tag, a] of node) {
if (tag === 'path') { ctx.stroke(new Path2D(a.d ?? '')) }
else if (tag === 'circle') { ctx.beginPath(); ctx.arc(num(a, 'cx'), num(a, 'cy'), num(a, 'r'), 0, Math.PI * 2); ctx.stroke() }
else if (tag === 'rect') {
const x = num(a, 'x'), y = num(a, 'y'), w = num(a, 'width'), h = num(a, 'height'), r = num(a, 'rx')
ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r)
ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r)
ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); ctx.stroke()
}
else if (tag === 'line') { ctx.beginPath(); ctx.moveTo(num(a, 'x1'), num(a, 'y1')); ctx.lineTo(num(a, 'x2'), num(a, 'y2')); ctx.stroke() }
else if (tag === 'polygon') {
const pts = (a.points ?? '').trim().split(/\s+/).map(p => p.split(',').map(Number))
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1])
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1])
ctx.closePath(); ctx.fill()
}
else if (tag === 'polyline') {
const pts = (a.points ?? '').trim().split(/\s+/).map(p => p.split(',').map(Number))
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1])
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1])
ctx.stroke()
}
}
return c.toDataURL() return c.toDataURL()
} }
const IC = (n: IconNode[]) => lucideIcon(n) // Icons regenerate when the theme switches stroke color
const ICONS: Record<string, string> = { const ICONS = $derived.by(() => {
host: IC([["rect", { width: "20", height: "8", x: "2", y: "2", rx: "2" }], ["rect", { width: "20", height: "8", x: "2", y: "14", rx: "2" }], ["circle", { cx: "6", cy: "6", r: "0.5", fill: "#fff" }], ["circle", { cx: "6", cy: "18", r: "0.5", fill: "#fff" }]]), const mk = (n: IconNode[]) => lucideIcon(n, pal.iconStroke)
machine: IC([["rect", { width: "20", height: "8", x: "2", y: "2", rx: "2" }], ["rect", { width: "20", height: "8", x: "2", y: "14", rx: "2" }]]), return {
lxc: IC([["path", { d: "M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z" }], ["path", { d: "M10 21.9V14L2.1 9.1" }], ["path", { d: "m10 14 11.9-6.9" }]]), host: mk([["rect", { width: "20", height: "8", x: "2", y: "2", rx: "2" }], ["rect", { width: "20", height: "8", x: "2", y: "14", rx: "2" }]]),
vm: IC([["rect", { width: "20", height: "14", x: "2", y: "3", rx: "2" }], ["line", { x1: "8", x2: "16", y1: "21", y2: "21" }], ["line", { x1: "12", x2: "12", y1: "17", y2: "21" }]]), machine: mk([["rect", { width: "20", height: "8", x: "2", y: "2", rx: "2" }], ["rect", { width: "20", height: "8", x: "2", y: "14", rx: "2" }]]),
service: IC([["path", { d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" }], ["circle", { cx: "12", cy: "12", r: "3" }]]), lxc: mk([["path", { d: "M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z" }], ["path", { d: "M10 21.9V14L2.1 9.1" }], ["path", { d: "m10 14 11.9-6.9" }]]),
agent: IC([["path", { d: "M12 8V4H8" }], ["rect", { width: "16", height: "12", x: "4", y: "8", rx: "2" }], ["path", { d: "M2 14h2" }], ["path", { d: "M20 14h2" }], ["path", { d: "M15 13v2" }], ["path", { d: "M9 13v2" }]]), vm: mk([["rect", { width: "20", height: "14", x: "2", y: "3", rx: "2" }], ["line", { x1: "8", x2: "16", y1: "21", y2: "21" }], ["line", { x1: "12", x2: "12", y1: "17", y2: "21" }]]),
task: IC([["path", { d: "M3 3h18v18H3z" }], ["path", { d: "m9 12 2 2 4-4" }]]), service: mk([["path", { d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" }], ["circle", { cx: "12", cy: "12", r: "3" }]]),
execution: IC([["polygon", { points: "6 3 20 12 6 21 6 3" }]]), agent: mk([["path", { d: "M12 8V4H8" }], ["rect", { width: "16", height: "12", x: "4", y: "8", rx: "2" }], ["path", { d: "M2 14h2" }], ["path", { d: "M20 14h2" }], ["path", { d: "M15 13v2" }], ["path", { d: "M9 13v2" }]]),
document: IC([["path", { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" }], ["polyline", { points: "14 2 14 8 20 8" }], ["line", { x1: "16", x2: "8", y1: "13", y2: "13" }], ["line", { x1: "16", x2: "8", y1: "17", y2: "17" }], ["line", { x1: "10", x2: "8", y1: "9", y2: "9" }]]), task: mk([["path", { d: "M3 3h18v18H3z" }], ["path", { d: "m9 12 2 2 4-4" }]]),
runbook: IC([["path", { d: "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" }], ["path", { d: "M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" }]]), execution: mk([["polygon", { points: "6 3 20 12 6 21 6 3" }]]),
investigation: IC([["circle", { cx: "11", cy: "11", r: "8" }], ["path", { d: "m21 21-4.3-4.3" }]]), document: mk([["path", { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" }], ["polyline", { points: "14 2 14 8 20 8" }], ["line", { x1: "16", x2: "8", y1: "13", y2: "13" }], ["line", { x1: "16", x2: "8", y1: "17", y2: "17" }], ["line", { x1: "10", x2: "8", y1: "9", y2: "9" }]]),
check: IC([["path", { d: "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" }]]), runbook: mk([["path", { d: "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" }], ["path", { d: "M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" }]]),
signal: IC([["path", { d: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" }], ["path", { d: "M10.3 21a1.94 1.94 0 0 0 3.4 0" }]]), investigation: mk([["circle", { cx: "11", cy: "11", r: "8" }], ["path", { d: "m21 21-4.3-4.3" }]]),
'config-repo': IC([["line", { x1: "6", x2: "6", y1: "3", y2: "15" }], ["circle", { cx: "18", cy: "6", r: "3" }], ["circle", { cx: "6", cy: "18", r: "3" }], ["path", { d: "M18 9a9 9 0 0 1-9 9" }]]), check: mk([["path", { d: "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" }]]),
'deploy-pipeline': IC([["circle", { cx: "18", cy: "18", r: "3" }], ["circle", { cx: "6", cy: "6", r: "3" }], ["path", { d: "M13 6h3a2 2 0 0 1 2 2v7" }], ["line", { x1: "6", x2: "9", y1: "9", y2: "6" }]]), signal: mk([["path", { d: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" }], ["path", { d: "M10.3 21a1.94 1.94 0 0 0 3.4 0" }]]),
secret: IC([["rect", { width: "18", height: "11", x: "3", y: "11", rx: "2" }], ["path", { d: "M7 11V7a5 5 0 0 1 10 0v4" }]]), 'config-repo': mk([["line", { x1: "6", x2: "6", y1: "3", y2: "15" }], ["circle", { cx: "18", cy: "6", r: "3" }], ["circle", { cx: "6", cy: "18", r: "3" }], ["path", { d: "M18 9a9 9 0 0 1-9 9" }]]),
cluster: IC([["rect", { width: "6", height: "6", x: "16", y: "16", rx: "1" }], ["rect", { width: "6", height: "6", x: "2", y: "16", rx: "1" }], ["rect", { width: "6", height: "6", x: "9", y: "2", rx: "1" }], ["path", { d: "M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" }], ["path", { d: "M12 12V8" }]]), 'deploy-pipeline': mk([["circle", { cx: "18", cy: "18", r: "3" }], ["circle", { cx: "6", cy: "6", r: "3" }], ["path", { d: "M13 6h3a2 2 0 0 1 2 2v7" }], ["line", { x1: "6", x2: "9", y1: "9", y2: "6" }]]),
namespace: IC([["path", { d: "M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" }]]), secret: mk([["rect", { width: "18", height: "11", x: "3", y: "11", rx: "2" }], ["path", { d: "M7 11V7a5 5 0 0 1 10 0v4" }]]),
site: IC([["path", { d: "M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" }], ["circle", { cx: "12", cy: "10", r: "3" }]]), cluster: mk([["rect", { width: "6", height: "6", x: "16", y: "16", rx: "1" }], ["rect", { width: "6", height: "6", x: "2", y: "16", rx: "1" }], ["rect", { width: "6", height: "6", x: "9", y: "2", rx: "1" }], ["path", { d: "M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" }], ["path", { d: "M12 12V8" }]]),
entity: IC([["circle", { cx: "12", cy: "12", r: "10" }]]) namespace: mk([["path", { d: "M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" }]]),
} site: mk([["path", { d: "M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" }], ["circle", { cx: "12", cy: "10", r: "3" }]]),
entity: mk([["circle", { cx: "12", cy: "12", r: "10" }]])
} as Record<string, string>
})
function iconForType(type: string): string { function iconForType(type: string): string {
return ICONS[type] ?? ICONS[type.split(':')[0]] ?? ICONS.entity return ICONS[type] ?? ICONS[type.split(':')[0]] ?? ICONS.entity
} }
// ── State ─────────────────────────────────────────────── // ── State ───────────────────────────────────────────────
let container = $state<HTMLDivElement | null>(null) let container = $state<HTMLDivElement | null>(null)
let tooltipEl = $state<HTMLDivElement | null>(null)
let loading = $state(true) let loading = $state(true)
let loadError = $state<string | null>(null) let loadError = $state<string | null>(null)
let search = $state('') let search = $state('')
@@ -110,7 +176,7 @@
let relFilters = $state<Record<string, boolean>>({}) let relFilters = $state<Record<string, boolean>>({})
let selectedNode = $state<string | null>(null) let selectedNode = $state<string | null>(null)
let blast = $state<BlastRadiusItem[]>([]) let blast = $state<BlastRadiusItem[]>([])
let tooltip = $state<{ x: number; y: number; node: string } | null>(null) let hideIsolated = $state(true)
let sigma: Sigma | null = null let sigma: Sigma | null = null
let graph: Graph | null = null let graph: Graph | null = null
@@ -154,8 +220,8 @@
// ── Graph construction ────────────────────────────────── // ── Graph construction ──────────────────────────────────
function nodeColor(type: string, health: string): string { function nodeColor(type: string, health: string): string {
if (colorMode === 'health') return HEALTH_COLOR[health] ?? HEALTH_COLOR.unknown if (colorMode === 'health') return pal.health[health] ?? pal.health.unknown
return TYPE_COLORS[type] ?? LAYER_FALLBACK[typeToLayer.get(type) ?? 'meta'] ?? DEFAULT_COLOR return pal.type[type] ?? pal.layer[typeToLayer.get(type) ?? 'meta'] ?? pal.default
} }
function buildGraph(g: GraphView, t2l: Map<string, string>) { function buildGraph(g: GraphView, t2l: Map<string, string>) {
@@ -195,9 +261,9 @@
const t = idBySlug.get(edge.target) const t = idBySlug.get(edge.target)
if (s && t && s !== t) { if (s && t && s !== t) {
try { try {
const relColor = REL_COLORS[edge.type] ?? '#928374' const relColor = pal.rel[edge.type] ?? pal.default
gr.addEdgeWithKey(`${s}${t}${edge.type}`, s, t, { gr.addEdgeWithKey(`${s}${t}${edge.type}`, s, t, {
size: 1.2, relType: edge.type, color: relColor + '88' size: 1, relType: edge.type, color: relColor + '99'
}) })
} catch { /* duplicate edge key */ } } catch { /* duplicate edge key */ }
} }
@@ -226,21 +292,18 @@
defaultNodeType: 'image', defaultNodeType: 'image',
labelDensity: 0.07, labelGridCellSize: 60, labelRenderedSizeThreshold: 10, labelDensity: 0.07, labelGridCellSize: 60, labelRenderedSizeThreshold: 10,
labelFont: 'JetBrains Mono, monospace', labelSize: 11, labelFont: 'JetBrains Mono, monospace', labelSize: 11,
labelColor: { color: '#000000dd' }, labelColor: { color: pal.label },
zIndex: true, defaultEdgeType: 'arrow', zIndex: true, defaultEdgeType: 'arrow',
minCameraRatio: 0.02, maxCameraRatio: 10, minCameraRatio: 0.02, maxCameraRatio: 10,
renderEdgeLabels: false, renderEdgeLabels: false
labelRenderedSizeThreshold: 10
}) })
sig.on('enterNode', ({ node, event }) => { sig.on('enterNode', ({ node }) => {
container!.style.cursor = 'pointer' container!.style.cursor = 'pointer'
tooltip = { x: event.x, y: event.y, node }
applyHover(sig, gr, node) applyHover(sig, gr, node)
}) })
sig.on('leaveNode', () => { sig.on('leaveNode', () => {
container!.style.cursor = '' container!.style.cursor = ''
tooltip = null
if (!selectedNode) clearHover(sig, gr) if (!selectedNode) clearHover(sig, gr)
else applyHover(sig, gr, selectedNode) else applyHover(sig, gr, selectedNode)
}) })
@@ -272,15 +335,15 @@
// ── Hover / selection highlight ───────────────────────── // ── Hover / selection highlight ─────────────────────────
function applyHover(sig: Sigma, gr: Graph, node: string) { function applyHover(sig: Sigma, gr: Graph, node: string) {
const hc = sig.getNodeDisplayData(node)?.color ?? DEFAULT_COLOR const hc = sig.getNodeDisplayData(node)?.color ?? pal.default
sig.setSetting('nodeReducer', (n, data) => { sig.setSetting('nodeReducer', (n, data) => {
if (gr.hasEdge(n, node) || gr.hasEdge(node, n) || n === node) if (gr.hasEdge(n, node) || gr.hasEdge(node, n) || n === node)
return { ...data, zIndex: 1, highlighted: n === node } return { ...data, zIndex: 1, highlighted: n === node }
return { ...data, zIndex: 0, label: '', color: FADE_COLOR, image: null, highlighted: false } return { ...data, zIndex: 0, label: '', color: pal.fade, image: null, highlighted: false }
}) })
sig.setSetting('edgeReducer', (edge, data) => { sig.setSetting('edgeReducer', (edge, data) => {
if (gr.hasExtremity(edge, node)) return { ...data, color: hc, size: 2.5 } if (gr.hasExtremity(edge, node)) return { ...data, color: hc, size: 2.5 }
return { ...data, color: EDGE_FADE, hidden: true } return { ...data, color: pal.edgeFade, hidden: true }
}) })
sig.refresh() sig.refresh()
} }
@@ -296,27 +359,43 @@
const hiddenTypes = new Set(Object.entries(typeFilters).filter(([, v]) => !v).map(([k]) => k)) const hiddenTypes = new Set(Object.entries(typeFilters).filter(([, v]) => !v).map(([k]) => k))
const hiddenRels = new Set(Object.entries(relFilters).filter(([, v]) => !v).map(([k]) => k)) const hiddenRels = new Set(Object.entries(relFilters).filter(([, v]) => !v).map(([k]) => k))
const hiddenHealth = healthFilter.size > 0 const hiddenHealth = healthFilter.size > 0
? new Set(Object.keys(HEALTH_COLOR).filter(h => !healthFilter.has(h)) ? new Set(Object.keys(pal.health).filter(h => {
.concat(['stale'].filter(h => !healthFilter.has('degraded') && h === 'stale'))) if (h === 'stale') return !healthFilter.has('degraded') && !healthFilter.has('stale')
return !healthFilter.has(h)
}))
: null : null
graph.forEachNode((n) => { graph.forEachNode((n) => {
const type = graph!.getNodeAttribute(n, 'entityType') as string const type = graph!.getNodeAttribute(n, 'entityType') as string
const health = graph!.getNodeAttribute(n, 'health') as string const health = graph!.getNodeAttribute(n, 'health') as string
const hide = hiddenTypes.has(type) || (hiddenHealth?.has(health) ?? false) const deg = graph!.getNodeAttribute(n, 'degree') as number
const hide = hiddenTypes.has(type) || (hiddenHealth?.has(health) ?? false) || (hideIsolated && deg === 0)
graph!.setNodeAttribute(n, 'hidden', hide) graph!.setNodeAttribute(n, 'hidden', hide)
graph!.setNodeAttribute(n, 'color', nodeColor(type, health)) graph!.setNodeAttribute(n, 'color', nodeColor(type, health))
}) })
graph.forEachEdge((e) => { graph.forEachEdge((e) => {
const rt = graph!.getEdgeAttribute(e, 'relType') as string const rt = graph!.getEdgeAttribute(e, 'relType') as string
graph!.setEdgeAttribute(e, 'hidden', hiddenRels.has(rt)) const src = graph!.source(e), tgt = graph!.target(e)
const endpointHidden = graph!.getNodeAttribute(src, 'hidden') || graph!.getNodeAttribute(tgt, 'hidden')
graph!.setEdgeAttribute(e, 'hidden', hiddenRels.has(rt) || endpointHidden)
}) })
sigma.refresh() sigma.refresh()
} }
// Palette changes (theme toggle): regenerate icons + label color
$effect(() => { $effect(() => {
void [typeFilters, relFilters, healthFilter, colorMode] void pal
if (graph) applyAllFilters() if (!graph || !sigma) return
graph.forEachNode((n) => {
graph!.setNodeAttribute(n, 'image', iconForType(graph!.getNodeAttribute(n, 'entityType') as string))
})
sigma.setSetting('labelColor', { color: pal.label })
})
// Filter changes: apply visibility + color filters
$effect(() => {
void [typeFilters, relFilters, healthFilter, colorMode, pal, hideIsolated]
if (graph && sigma) applyAllFilters()
}) })
// ── Quick presets ─────────────────────────────────────── // ── Quick presets ───────────────────────────────────────
@@ -418,26 +497,6 @@
<div class="relative flex-1"> <div class="relative flex-1">
<div class="absolute inset-0 bg-card" style="background-image: radial-gradient(circle, color-mix(in oklab, var(--foreground) 8%, transparent) 1px, transparent 1px); background-size: 24px 24px;" bind:this={container}></div> <div class="absolute inset-0 bg-card" style="background-image: radial-gradient(circle, color-mix(in oklab, var(--foreground) 8%, transparent) 1px, transparent 1px); background-size: 24px 24px;" bind:this={container}></div>
{#if tooltip && graph && sigma}
{@const data = sigma.getNodeDisplayData(tooltip.node)}
{@const ent = allEntities.find(e => e.slug === (graph!.getNodeAttribute(tooltip.node, 'slug') as string))}
{@const hp = graph.getNodeAttribute(tooltip.node, 'health') as string}
{@const tp = graph.getNodeAttribute(tooltip.node, 'entityType') as string}
{@const dg = graph.getNodeAttribute(tooltip.node, 'degree') as number}
<div class="pointer-events-none absolute z-20 max-w-[220px] rounded-md border border-border/60 bg-card px-3 py-2 shadow-md" style="left:{Math.min(tooltip.x + 14, (container?.clientWidth ?? 0) - 230)}px; top:{Math.max(tooltip.y - 10, 0)}px">
<div class="flex items-center gap-1.5">
<span class="size-2 shrink-0 rounded-full" style="background:{HEALTH_COLOR[hp] ?? '#888'}"></span>
<span class="truncate text-xs font-medium">{ent?.name ?? tooltip.node}</span>
</div>
<div class="mt-0.5 flex items-center gap-2 text-[10px] text-muted-foreground">
<span class="font-mono">{tp}</span>
<span>·</span>
<span style="color:{HEALTH_COLOR[hp]}">{hp}</span>
{#if dg > 0}<span>·</span><span>{dg} link{dg === 1 ? '' : 's'}</span>{/if}
</div>
</div>
{/if}
{#if loading} {#if loading}
<div class="absolute inset-0 flex items-center justify-center bg-card"> <div class="absolute inset-0 flex items-center justify-center bg-card">
<div class="flex flex-col items-center gap-3"> <div class="flex flex-col items-center gap-3">
@@ -513,12 +572,12 @@
{#if cnt > 0} {#if cnt > 0}
{@const active = healthFilter.size === 0 || healthFilter.has(h)} {@const active = healthFilter.size === 0 || healthFilter.has(h)}
<button <button
style="background:{HEALTH_COLOR[h]}; width:{(cnt / totalHealth) * 100}%; opacity:{active ? 1 : 0.25}" style="background:{pal.health[h]}; width:{(cnt / totalHealth) * 100}%; opacity:{active ? 1 : 0.25}"
title="{h}: {cnt}" title="{h}: {cnt}"
onclick={() => { onclick={() => {
const s = new Set(healthFilter) const s = new Set(healthFilter)
if (s.has(h)) s.delete(h); else s.add(h) if (s.has(h)) s.delete(h); else s.add(h)
healthFilter = s.size === Object.keys(HEALTH_COLOR).length ? new Set() : s healthFilter = s.size === Object.keys(pal.health).length ? new Set() : s
}} }}
class="h-full min-w-[2px] transition-opacity hover:opacity-80 cursor-pointer"></button> class="h-full min-w-[2px] transition-opacity hover:opacity-80 cursor-pointer"></button>
{/if} {/if}
@@ -530,9 +589,9 @@
onclick={() => { onclick={() => {
const s = new Set(healthFilter) const s = new Set(healthFilter)
if (s.has(h)) s.delete(h); else s.add(h) if (s.has(h)) s.delete(h); else s.add(h)
healthFilter = s.size === Object.keys(HEALTH_COLOR).length ? new Set() : s healthFilter = s.size === Object.keys(pal.health).length ? new Set() : s
}}> }}>
<span class="size-1.5 rounded-full" style="background:{HEALTH_COLOR[h]}"></span> <span class="size-1.5 rounded-full" style="background:{pal.health[h]}"></span>
{h} <span class="font-mono">{cnt}</span> {h} <span class="font-mono">{cnt}</span>
</button> </button>
{/each} {/each}
@@ -545,7 +604,7 @@
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<span class="size-2 shrink-0 rounded-full" style="background:{HEALTH_COLOR[selectedEntity.health ?? 'unknown']}"></span> <span class="size-2 shrink-0 rounded-full" style="background:{pal.health[selectedEntity.health ?? 'unknown']}"></span>
<span class="truncate text-xs font-semibold">{selectedEntity.name}</span> <span class="truncate text-xs font-semibold">{selectedEntity.name}</span>
</div> </div>
<span class="mt-0.5 block font-mono text-[10px] text-muted-foreground">{selectedEntity.slug}</span> <span class="mt-0.5 block font-mono text-[10px] text-muted-foreground">{selectedEntity.slug}</span>
@@ -590,7 +649,7 @@
<div class="flex items-center"> <div class="flex items-center">
<Collapsible.Trigger class="flex flex-1 items-center gap-1.5 py-1 text-[10px] font-medium hover:text-foreground {allOn ? 'text-muted-foreground' : 'text-foreground'}"> <Collapsible.Trigger class="flex flex-1 items-center gap-1.5 py-1 text-[10px] font-medium hover:text-foreground {allOn ? 'text-muted-foreground' : 'text-foreground'}">
<svg viewBox="0 0 24 24" class="size-3" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg> <svg viewBox="0 0 24 24" class="size-3" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
<span class="size-2 rounded-full" style="background:{LAYER_FALLBACK[layer]}"></span> <span class="size-2 rounded-full" style="background:{pal.layer[layer]}"></span>
{LAYER_LABEL[layer] ?? layer} {LAYER_LABEL[layer] ?? layer}
</Collapsible.Trigger> </Collapsible.Trigger>
<button class="text-[9px] text-muted-foreground hover:text-foreground" onclick={() => { <button class="text-[9px] text-muted-foreground hover:text-foreground" onclick={() => {
@@ -601,7 +660,7 @@
<div class="ml-4 space-y-0.5 pb-1"> <div class="ml-4 space-y-0.5 pb-1">
{#each types as t} {#each types as t}
{@const cnt = allEntities.filter(e => e.type === t.name).length} {@const cnt = allEntities.filter(e => e.type === t.name).length}
{@const color = TYPE_COLORS[t.name] ?? DEFAULT_COLOR} {@const color = pal.type[t.name] ?? pal.default}
<label class="flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 text-[11px] hover:bg-muted/50"> <label class="flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 text-[11px] hover:bg-muted/50">
<input type="checkbox" checked={typeFilters[t.name] ?? true} <input type="checkbox" checked={typeFilters[t.name] ?? true}
onchange={() => { typeFilters = { ...typeFilters, [t.name]: !typeFilters[t.name] } }} onchange={() => { typeFilters = { ...typeFilters, [t.name]: !typeFilters[t.name] } }}
@@ -626,7 +685,7 @@
</div> </div>
<div class="space-y-0.5"> <div class="space-y-0.5">
{#each relTypeList as rt} {#each relTypeList as rt}
{@const color = REL_COLORS[rt] ?? '#888'} {@const color = pal.rel[rt] ?? pal.default}
<label class="flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 text-[11px] hover:bg-muted/50"> <label class="flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 text-[11px] hover:bg-muted/50">
<input type="checkbox" checked={relFilters[rt] ?? true} <input type="checkbox" checked={relFilters[rt] ?? true}
onchange={() => { relFilters = { ...relFilters, [rt]: !relFilters[rt] } }} onchange={() => { relFilters = { ...relFilters, [rt]: !relFilters[rt] } }}
@@ -641,8 +700,12 @@
</div> </div>
<!-- Footer hint --> <!-- Footer hint -->
<div class="border-t px-2.5 py-1.5 text-[9px] leading-relaxed text-muted-foreground/60"> <div class="flex items-center justify-between border-t px-2.5 py-1.5">
Hover to trace. Click to select + blast radius. Double-click to open. <label class="flex cursor-pointer items-center gap-1.5 text-[10px] text-muted-foreground">
<input type="checkbox" bind:checked={hideIsolated} class="size-2.5" />
Hide isolated
</label>
<span class="text-[9px] leading-relaxed text-muted-foreground/60">Hover to trace · Click for blast radius · Dbl-click to open</span>
</div> </div>
</aside> </aside>
</div> </div>