diff --git a/VERSION b/VERSION index 1a44cad..0f72177 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.30.1 +0.30.2 diff --git a/web/src/pages/EntityGraph.svelte b/web/src/pages/EntityGraph.svelte index b4b1c4e..ca670be 100644 --- a/web/src/pages/EntityGraph.svelte +++ b/web/src/pages/EntityGraph.svelte @@ -6,12 +6,12 @@ import { createNodeImageProgram } from '@sigma/node-image' import { 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' import { subscribeEvents } from '$lib/stores/events' import { applyHealthEvent } from '$lib/health' 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 SearchIcon from '@lucide/svelte/icons/search' import Maximize2Icon from '@lucide/svelte/icons/maximize-2' @@ -21,86 +21,152 @@ import ZapIcon from '@lucide/svelte/icons/zap' import CircleDotIcon from '@lucide/svelte/icons/circle-dot' - // ── Color maps (Gruvbox palette, aligned with cyberspace theme) ── - const HEALTH_COLOR: Record = { - healthy: '#b8bb26', degraded: '#fabd2f', down: '#fb4934', - unknown: '#928374', stale: '#fabd2f' + // ── Theme-aware Gruvbox palettes ──────────────────────── + // Light mode = dark/faded gruvbox on warm cream → high contrast + // Dark mode = bright gruvbox on black → high visibility + interface Palette { + health: Record + type: Record + layer: Record + rel: Record + default: string + fade: string + edgeFade: string + label: string + iconStroke: string } - const TYPE_COLORS: Record = { - 'proxmox-host': '#fe8019', 'standalone-server': '#d65d0e', workstation: '#83a598', - appliance: '#928374', vm: '#fabd2f', lxc: '#8ec07c', service: '#b8bb26', - 'service-group': '#98971a', agent: '#d3869b', task: '#83a598', execution: '#458588', - document: '#928374', runbook: '#d3869b', investigation: '#b16286', check: '#8ec07c', - signal: '#fb4934', 'config-repo': '#d79921', 'deploy-pipeline': '#83a598', - secret: '#cc241d', cluster: '#8ec07c', 'storage-pool': '#d65d0e', - 'network-device': '#689d6a', namespace: '#504945', site: '#d3869b', entity: '#928374' + + const LIGHT: Palette = { + health: { healthy: '#79740e', degraded: '#b57614', down: '#9d0006', unknown: '#7c6f64', stale: '#b57614' }, + type: { + 'proxmox-host': '#af3a03', 'standalone-server': '#af3a03', workstation: '#076678', + appliance: '#7c6f64', vm: '#b57614', lxc: '#427b58', service: '#79740e', + 'service-group': '#79740e', agent: '#8f3f71', task: '#076678', execution: '#076678', + 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 = { - 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 = { infrastructure: 'Infrastructure', governance: 'Governance', cognition: 'Cognition', meta: 'Meta' } - const REL_COLORS: Record = { - hosts: '#83a598', 'depends-on': '#fe8019', 'member-of': '#8ec07c', - 'located-at': '#d3869b', documents: '#928374', about: '#b16286', - 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)' + + // Reactive palette — switches when the user toggles the theme + const pal = $derived(getTheme() === 'light' ? LIGHT : DARK) type ColorMode = 'health' | 'type' - // ── Lucide icon rendering ─────────────────────────────── +// ── Lucide icon rendering (synchronous via Path2D) ────── type IconNode = [string, Record] - function lucideIcon(node: IconNode[]): string { - const svg = `data:image/svg+xml,${encodeURIComponent( - `${ - node.map(([tag, attrs]) => `<${tag} ${Object.entries(attrs).map(([k, v]) => `${k}="${v}"`).join(' ')}/>`).join('') - }` - )}` - const img = new Image() - img.src = svg + function lucideIcon(node: IconNode[], stroke: string): string { const c = document.createElement('canvas') c.width = 128; c.height = 128 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, 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() } - const IC = (n: IconNode[]) => lucideIcon(n) - const ICONS: Record = { - 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" }]]), - machine: IC([["rect", { width: "20", height: "8", x: "2", y: "2", rx: "2" }], ["rect", { width: "20", height: "8", x: "2", y: "14", rx: "2" }]]), - 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" }]]), - 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" }]]), - 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" }]]), - 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" }]]), - task: IC([["path", { d: "M3 3h18v18H3z" }], ["path", { d: "m9 12 2 2 4-4" }]]), - execution: IC([["polygon", { points: "6 3 20 12 6 21 6 3" }]]), - 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" }]]), - 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" }]]), - investigation: IC([["circle", { cx: "11", cy: "11", r: "8" }], ["path", { d: "m21 21-4.3-4.3" }]]), - 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" }]]), - 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" }]]), - '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" }]]), - '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" }]]), - secret: IC([["rect", { width: "18", height: "11", x: "3", y: "11", rx: "2" }], ["path", { d: "M7 11V7a5 5 0 0 1 10 0v4" }]]), - 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" }]]), - 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" }]]), - 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" }]]), - entity: IC([["circle", { cx: "12", cy: "12", r: "10" }]]) - } + // Icons regenerate when the theme switches stroke color + const ICONS = $derived.by(() => { + const mk = (n: IconNode[]) => lucideIcon(n, pal.iconStroke) + return { + host: mk([["rect", { width: "20", height: "8", x: "2", y: "2", rx: "2" }], ["rect", { width: "20", height: "8", x: "2", y: "14", rx: "2" }]]), + machine: mk([["rect", { width: "20", height: "8", x: "2", y: "2", rx: "2" }], ["rect", { width: "20", height: "8", x: "2", y: "14", rx: "2" }]]), + 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" }]]), + 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" }]]), + 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" }]]), + 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" }]]), + task: mk([["path", { d: "M3 3h18v18H3z" }], ["path", { d: "m9 12 2 2 4-4" }]]), + execution: mk([["polygon", { points: "6 3 20 12 6 21 6 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" }]]), + 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" }]]), + investigation: mk([["circle", { cx: "11", cy: "11", r: "8" }], ["path", { d: "m21 21-4.3-4.3" }]]), + 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" }]]), + 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" }]]), + '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" }]]), + '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" }]]), + secret: mk([["rect", { width: "18", height: "11", x: "3", y: "11", rx: "2" }], ["path", { d: "M7 11V7a5 5 0 0 1 10 0v4" }]]), + 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" }]]), + 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 + }) function iconForType(type: string): string { return ICONS[type] ?? ICONS[type.split(':')[0]] ?? ICONS.entity } // ── State ─────────────────────────────────────────────── let container = $state(null) - let tooltipEl = $state(null) let loading = $state(true) let loadError = $state(null) let search = $state('') @@ -110,7 +176,7 @@ let relFilters = $state>({}) let selectedNode = $state(null) let blast = $state([]) - let tooltip = $state<{ x: number; y: number; node: string } | null>(null) + let hideIsolated = $state(true) let sigma: Sigma | null = null let graph: Graph | null = null @@ -154,8 +220,8 @@ // ── Graph construction ────────────────────────────────── function nodeColor(type: string, health: string): string { - if (colorMode === 'health') return HEALTH_COLOR[health] ?? HEALTH_COLOR.unknown - return TYPE_COLORS[type] ?? LAYER_FALLBACK[typeToLayer.get(type) ?? 'meta'] ?? DEFAULT_COLOR + if (colorMode === 'health') return pal.health[health] ?? pal.health.unknown + return pal.type[type] ?? pal.layer[typeToLayer.get(type) ?? 'meta'] ?? pal.default } function buildGraph(g: GraphView, t2l: Map) { @@ -195,9 +261,9 @@ const t = idBySlug.get(edge.target) if (s && t && s !== t) { try { - const relColor = REL_COLORS[edge.type] ?? '#928374' + const relColor = pal.rel[edge.type] ?? pal.default 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 */ } } @@ -226,21 +292,18 @@ defaultNodeType: 'image', labelDensity: 0.07, labelGridCellSize: 60, labelRenderedSizeThreshold: 10, labelFont: 'JetBrains Mono, monospace', labelSize: 11, - labelColor: { color: '#000000dd' }, + labelColor: { color: pal.label }, zIndex: true, defaultEdgeType: 'arrow', minCameraRatio: 0.02, maxCameraRatio: 10, - renderEdgeLabels: false, - labelRenderedSizeThreshold: 10 + renderEdgeLabels: false }) - sig.on('enterNode', ({ node, event }) => { + sig.on('enterNode', ({ node }) => { container!.style.cursor = 'pointer' - tooltip = { x: event.x, y: event.y, node } applyHover(sig, gr, node) }) sig.on('leaveNode', () => { container!.style.cursor = '' - tooltip = null if (!selectedNode) clearHover(sig, gr) else applyHover(sig, gr, selectedNode) }) @@ -272,15 +335,15 @@ // ── Hover / selection highlight ───────────────────────── 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) => { if (gr.hasEdge(n, node) || gr.hasEdge(node, n) || 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) => { 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() } @@ -296,27 +359,43 @@ 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 hiddenHealth = healthFilter.size > 0 - ? new Set(Object.keys(HEALTH_COLOR).filter(h => !healthFilter.has(h)) - .concat(['stale'].filter(h => !healthFilter.has('degraded') && h === 'stale'))) + ? new Set(Object.keys(pal.health).filter(h => { + if (h === 'stale') return !healthFilter.has('degraded') && !healthFilter.has('stale') + return !healthFilter.has(h) + })) : null graph.forEachNode((n) => { const type = graph!.getNodeAttribute(n, 'entityType') 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, 'color', nodeColor(type, health)) }) graph.forEachEdge((e) => { 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() } + // Palette changes (theme toggle): regenerate icons + label color $effect(() => { - void [typeFilters, relFilters, healthFilter, colorMode] - if (graph) applyAllFilters() + void pal + 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 ─────────────────────────────────────── @@ -418,26 +497,6 @@
- {#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} -
-
- - {ent?.name ?? tooltip.node} -
-
- {tp} - · - {hp} - {#if dg > 0}·{dg} link{dg === 1 ? '' : 's'}{/if} -
-
- {/if} - {#if loading}
@@ -513,12 +572,12 @@ {#if cnt > 0} {@const active = healthFilter.size === 0 || healthFilter.has(h)} {/if} @@ -530,9 +589,9 @@ onclick={() => { const s = new Set(healthFilter) 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 }}> - + {h} {cnt} {/each} @@ -545,7 +604,7 @@
- + {selectedEntity.name}
{selectedEntity.slug} @@ -590,7 +649,7 @@
- + {LAYER_LABEL[layer] ?? layer}