feat: entity graph app with theme-aware colors, icons, filters, and blast radius
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:
@@ -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<string, string> = {
|
||||
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<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',
|
||||
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<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> = {
|
||||
infrastructure: 'Infrastructure', governance: 'Governance',
|
||||
cognition: 'Cognition', meta: 'Meta'
|
||||
}
|
||||
const REL_COLORS: Record<string, string> = {
|
||||
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<Palette>(getTheme() === 'light' ? LIGHT : DARK)
|
||||
|
||||
type ColorMode = 'health' | 'type'
|
||||
|
||||
// ── Lucide icon rendering ───────────────────────────────
|
||||
// ── Lucide icon rendering (synchronous via Path2D) ──────
|
||||
type IconNode = [string, Record<string, string>]
|
||||
function lucideIcon(node: IconNode[]): 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
|
||||
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<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()
|
||||
}
|
||||
const IC = (n: IconNode[]) => lucideIcon(n)
|
||||
const ICONS: Record<string, string> = {
|
||||
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<string, string>
|
||||
})
|
||||
function iconForType(type: string): string {
|
||||
return ICONS[type] ?? ICONS[type.split(':')[0]] ?? ICONS.entity
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────
|
||||
let container = $state<HTMLDivElement | null>(null)
|
||||
let tooltipEl = $state<HTMLDivElement | null>(null)
|
||||
let loading = $state(true)
|
||||
let loadError = $state<string | null>(null)
|
||||
let search = $state('')
|
||||
@@ -110,7 +176,7 @@
|
||||
let relFilters = $state<Record<string, boolean>>({})
|
||||
let selectedNode = $state<string | null>(null)
|
||||
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 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<string, string>) {
|
||||
@@ -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 @@
|
||||
<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>
|
||||
|
||||
{#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}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-card">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
@@ -513,12 +572,12 @@
|
||||
{#if cnt > 0}
|
||||
{@const active = healthFilter.size === 0 || healthFilter.has(h)}
|
||||
<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}"
|
||||
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
|
||||
}}
|
||||
class="h-full min-w-[2px] transition-opacity hover:opacity-80 cursor-pointer"></button>
|
||||
{/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
|
||||
}}>
|
||||
<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>
|
||||
</button>
|
||||
{/each}
|
||||
@@ -545,7 +604,7 @@
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
<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}
|
||||
</Collapsible.Trigger>
|
||||
<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">
|
||||
{#each types as t}
|
||||
{@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">
|
||||
<input type="checkbox" checked={typeFilters[t.name] ?? true}
|
||||
onchange={() => { typeFilters = { ...typeFilters, [t.name]: !typeFilters[t.name] } }}
|
||||
@@ -626,7 +685,7 @@
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
{#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">
|
||||
<input type="checkbox" checked={relFilters[rt] ?? true}
|
||||
onchange={() => { relFilters = { ...relFilters, [rt]: !relFilters[rt] } }}
|
||||
@@ -641,8 +700,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer hint -->
|
||||
<div class="border-t px-2.5 py-1.5 text-[9px] leading-relaxed text-muted-foreground/60">
|
||||
Hover to trace. Click to select + blast radius. Double-click to open.
|
||||
<div class="flex items-center justify-between border-t px-2.5 py-1.5">
|
||||
<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>
|
||||
</aside>
|
||||
</div>
|
||||
Reference in New Issue
Block a user