feat(web): merge Entities + Graph into a single Knowledge Base page
Replaces the separate Entities/Graph nav items with one Knowledge Base page that browses all entities as either a table or a force-graph, scoped by ontology layer (Infrastructure/Governance/Cognition), with a resizable browse/detail split instead of a slide-over sheet. - New KnowledgeBase.svelte: layer tabs, view toggle, resizable browse/detail split (pattern from Chat.svelte's rail). - EntityTable/EntityGraph extracted as presentational sub-components; their search/filter/root/depth toolbars live in the shared page toolbar (not the resizable pane) so they don't truncate when the divider is dragged narrow, and both views start flush with the detail pane for consistent height. - EntityTable columns are sortable (slug/type/name/state/health). - EntityDetailContent redesigned as a single-column list of collapsible sections (DetailSection.svelte), collapsed by default when empty; relation entries are clickable and select the entity in the browse pane + detail pane (and drill in-place in EntitySheet wherever it's used elsewhere in the app). - api.ts: add layer filter to fetchEntities, add fetchEntityTypes for client-side graph layer scoping (the graph endpoint has no layer param). Old hash routes (#/entities, #/graph) redirect to #/kb. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
476
web/src/lib/components/EntityGraph.svelte
Normal file
476
web/src/lib/components/EntityGraph.svelte
Normal file
@@ -0,0 +1,476 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
|
||||
import { fetchGraph, fetchEntityTypes, type GraphView, type Entity, type Health } from '$lib/api'
|
||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
|
||||
export interface GraphInfo {
|
||||
allNodeTypes: string[]
|
||||
allRelTypes: string[]
|
||||
relColors: Map<string, string>
|
||||
visibleCount: number
|
||||
truncated: boolean
|
||||
zoomPct: number
|
||||
}
|
||||
|
||||
let {
|
||||
layer,
|
||||
selectedSlug = null,
|
||||
onSelect,
|
||||
root = $bindable(''),
|
||||
depth,
|
||||
search,
|
||||
reloadToken,
|
||||
resetToken,
|
||||
activeNodeTypes = $bindable(new Set<string>()),
|
||||
activeRelTypes = $bindable(new Set<string>()),
|
||||
info = $bindable<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
|
||||
}: {
|
||||
layer: string
|
||||
selectedSlug?: string | null
|
||||
onSelect: (slug: string) => void
|
||||
root?: string
|
||||
depth: number
|
||||
search: string
|
||||
// Bumped by the parent toolbar to request a data reload / view reset —
|
||||
// these controls live in the shared page toolbar (not squeezed inside
|
||||
// this resizable pane), so they can't call load()/resetView() directly.
|
||||
reloadToken: number
|
||||
resetToken: number
|
||||
activeNodeTypes?: Set<string>
|
||||
activeRelTypes?: Set<string>
|
||||
info?: GraphInfo
|
||||
} = $props()
|
||||
|
||||
interface Node extends Entity {
|
||||
x?: number
|
||||
y?: number
|
||||
vx?: number
|
||||
vy?: number
|
||||
fx?: number | null
|
||||
fy?: number | null
|
||||
degree: number
|
||||
}
|
||||
interface Link {
|
||||
source: string | Node
|
||||
target: string | Node
|
||||
type: string
|
||||
}
|
||||
|
||||
let graph = $state<GraphView | null>(null)
|
||||
let loading = $state(true)
|
||||
let nodes = $state<Node[]>([])
|
||||
let links = $state<Link[]>([])
|
||||
let sim: Simulation<Node, Link> | null = null
|
||||
|
||||
// type → ontology layer, so the graph can be scoped client-side (the graph
|
||||
// endpoint itself has no layer param).
|
||||
let typeLayer = $state<Map<string, string>>(new Map())
|
||||
|
||||
let hoveredId = $state<string | null>(null)
|
||||
|
||||
// viewport transform: translate(x, y) scale(k)
|
||||
let view = $state({ x: 0, y: 0, k: 1 })
|
||||
let svgEl = $state<SVGSVGElement | null>(null)
|
||||
|
||||
const width = 1200
|
||||
const height = 800
|
||||
|
||||
const healthColor: Record<Health, string> = {
|
||||
healthy: '#3fb950',
|
||||
degraded: '#d29922',
|
||||
down: '#f85149',
|
||||
unknown: '#8b949e'
|
||||
}
|
||||
|
||||
const relPalette = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#39c5cf', '#f0883e', '#db61a2']
|
||||
const relColorByType = $derived.by(() => {
|
||||
const map = new Map<string, string>()
|
||||
const types = Array.from(new Set(links.map((l) => l.type))).sort()
|
||||
types.forEach((t, i) => map.set(t, relPalette[i % relPalette.length]))
|
||||
return map
|
||||
})
|
||||
|
||||
function relColor(type: string): string {
|
||||
return relColorByType.get(type) ?? '#30363d'
|
||||
}
|
||||
|
||||
function markerId(type: string): string {
|
||||
return 'arrow-' + type.replace(/[^a-z0-9]/gi, '_')
|
||||
}
|
||||
|
||||
function endpoint(end: string | Node): Node | undefined {
|
||||
return typeof end === 'object' ? end : nodes.find((n) => n.id === end)
|
||||
}
|
||||
function endpointId(end: string | Node): string {
|
||||
return typeof end === 'object' ? end.id : end
|
||||
}
|
||||
|
||||
// Node belongs to the active layer? (Unknown types fall back to visible so a
|
||||
// missing ontology entry never blanks the graph.)
|
||||
function inLayer(type: string): boolean {
|
||||
const l = typeLayer.get(type)
|
||||
return l === undefined || l === layer
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true
|
||||
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
|
||||
loading = false
|
||||
if (!graph) return
|
||||
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]))
|
||||
const degree = new Map<string, number>()
|
||||
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
|
||||
for (const e of graph.edges) {
|
||||
const s = idBySlug.get(e.source) ?? e.source
|
||||
const t = idBySlug.get(e.target) ?? e.target
|
||||
degree.set(s, (degree.get(s) ?? 0) + 1)
|
||||
degree.set(t, (degree.get(t) ?? 0) + 1)
|
||||
}
|
||||
|
||||
nodes = graph.nodes.map((n) => {
|
||||
const prev = byId.get(n.id)
|
||||
return { ...n, x: prev?.x, y: prev?.y, degree: degree.get(n.id) ?? 0 }
|
||||
})
|
||||
links = graph.edges.map((e) => ({
|
||||
source: idBySlug.get(e.source) ?? e.source,
|
||||
target: idBySlug.get(e.target) ?? e.target,
|
||||
type: e.type
|
||||
}))
|
||||
|
||||
// Default the node/edge-type toggles to the types present in the active layer.
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type))
|
||||
activeRelTypes = new Set(links.map((l) => l.type))
|
||||
|
||||
sim?.stop()
|
||||
sim = forceSimulation(nodes)
|
||||
.force('link', forceLink<Node, Link>(links).id((n) => n.id).distance(70).strength(0.6))
|
||||
.force('charge', forceManyBody().strength(-240).distanceMax(400))
|
||||
.force('center', forceCenter(width / 2, height / 2))
|
||||
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 8))
|
||||
.force('x', forceX(width / 2).strength(0.04))
|
||||
.force('y', forceY(height / 2).strength(0.04))
|
||||
.velocityDecay(0.32)
|
||||
.alphaDecay(0.035)
|
||||
.on('tick', () => {
|
||||
nodes = [...nodes]
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchEntityTypes().then((types) => {
|
||||
typeLayer = new Map(types.map((t) => [t.name, t.layer]))
|
||||
// Re-derive the active node types now that layer membership is known.
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type))
|
||||
})
|
||||
load()
|
||||
const unsubscribe = subscribeEvents()
|
||||
return () => {
|
||||
unsubscribe()
|
||||
sim?.stop()
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => sim?.stop())
|
||||
|
||||
// When the layer perspective changes, reset the node-type toggles to that layer.
|
||||
$effect(() => {
|
||||
layer
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type))
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const ev = $liveEvents[0]
|
||||
if (!ev) return
|
||||
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
// Toolbar-driven reload/reset — mirrors the old onchange={load} behavior:
|
||||
// typing freely doesn't refetch, only a committed change (Enter/blur in the
|
||||
// parent's inputs, or the Reset button) bumps the token.
|
||||
let lastReloadToken = $state(0)
|
||||
$effect(() => {
|
||||
if (reloadToken !== lastReloadToken) {
|
||||
lastReloadToken = reloadToken
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
let lastResetToken = $state(0)
|
||||
$effect(() => {
|
||||
if (resetToken !== lastResetToken) {
|
||||
lastResetToken = resetToken
|
||||
view = { x: 0, y: 0, k: 1 }
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
function selectNode(node: Node) {
|
||||
onSelect(node.slug)
|
||||
}
|
||||
|
||||
function rerootTo(node: Node) {
|
||||
root = node.slug
|
||||
load()
|
||||
}
|
||||
|
||||
function nodeColor(node: Node): string {
|
||||
const h = graph?.health?.[node.id]
|
||||
return h ? healthColor[h] : '#58a6ff'
|
||||
}
|
||||
|
||||
function nodeRadius(node: Node): number {
|
||||
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
|
||||
}
|
||||
|
||||
// Only offer node-type toggles that live in the active layer.
|
||||
const allNodeTypes = $derived(Array.from(new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type))).sort())
|
||||
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
|
||||
|
||||
// Publish status/legend info up to the parent toolbar.
|
||||
$effect(() => {
|
||||
info = {
|
||||
allNodeTypes,
|
||||
allRelTypes,
|
||||
relColors: relColorByType,
|
||||
visibleCount: visibleNodeIds.size,
|
||||
truncated: !!graph?.truncated,
|
||||
zoomPct: Math.round(view.k * 100)
|
||||
}
|
||||
})
|
||||
|
||||
const matchedIds = $derived.by(() => {
|
||||
if (!search.trim()) return null
|
||||
const q = search.trim().toLowerCase()
|
||||
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
|
||||
})
|
||||
|
||||
// Visible = in the active layer AND its node-type toggle is on.
|
||||
const visibleNodeIds = $derived(new Set(nodes.filter((n) => inLayer(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id)))
|
||||
|
||||
const selectedId = $derived(nodes.find((n) => n.slug === selectedSlug)?.id ?? null)
|
||||
|
||||
const adjacency = $derived.by(() => {
|
||||
const adj = new Map<string, Set<string>>()
|
||||
for (const l of links) {
|
||||
const s = endpointId(l.source)
|
||||
const t = endpointId(l.target)
|
||||
if (!adj.has(s)) adj.set(s, new Set())
|
||||
if (!adj.has(t)) adj.set(t, new Set())
|
||||
adj.get(s)!.add(t)
|
||||
adj.get(t)!.add(s)
|
||||
}
|
||||
return adj
|
||||
})
|
||||
|
||||
const focusIds = $derived.by(() => {
|
||||
const focus = hoveredId ?? selectedId
|
||||
if (!focus) return null
|
||||
const set = new Set<string>([focus])
|
||||
for (const n of adjacency.get(focus) ?? []) set.add(n)
|
||||
return set
|
||||
})
|
||||
|
||||
function nodeOpacity(node: Node): number {
|
||||
if (matchedIds !== null) return matchedIds.has(node.id) ? 1 : 0.15
|
||||
if (focusIds !== null) return focusIds.has(node.id) ? 1 : 0.15
|
||||
return 1
|
||||
}
|
||||
|
||||
function linkVisualState(link: Link): { opacity: number; emphasized: boolean } {
|
||||
const s = endpointId(link.source)
|
||||
const t = endpointId(link.target)
|
||||
const focus = hoveredId ?? selectedId
|
||||
if (focus && (s === focus || t === focus)) return { opacity: 0.95, emphasized: true }
|
||||
if (focusIds !== null || matchedIds !== null) return { opacity: 0.08, emphasized: false }
|
||||
return { opacity: 0.45, emphasized: false }
|
||||
}
|
||||
|
||||
// ─── pan / zoom / drag ───────────────────────────────────────────────
|
||||
|
||||
function toViewBox(clientX: number, clientY: number): { x: number; y: number } {
|
||||
const rect = svgEl!.getBoundingClientRect()
|
||||
return {
|
||||
x: ((clientX - rect.left) / rect.width) * width,
|
||||
y: ((clientY - rect.top) / rect.height) * height
|
||||
}
|
||||
}
|
||||
|
||||
function toWorld(clientX: number, clientY: number): { x: number; y: number } {
|
||||
const p = toViewBox(clientX, clientY)
|
||||
return { x: (p.x - view.x) / view.k, y: (p.y - view.y) / view.k }
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
const factor = e.deltaY < 0 ? 1.18 : 1 / 1.18
|
||||
const k = Math.min(6, Math.max(0.25, view.k * factor))
|
||||
const p = toViewBox(e.clientX, e.clientY)
|
||||
const wx = (p.x - view.x) / view.k
|
||||
const wy = (p.y - view.y) / view.k
|
||||
view = { k, x: p.x - wx * k, y: p.y - wy * k }
|
||||
}
|
||||
|
||||
let panState = $state<{ startX: number; startY: number; viewX: number; viewY: number } | null>(null)
|
||||
let dragState: { node: Node; moved: boolean } | null = null
|
||||
|
||||
function onBackgroundPointerDown(e: PointerEvent) {
|
||||
if (dragState) return
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
const p = toViewBox(e.clientX, e.clientY)
|
||||
panState = { startX: p.x, startY: p.y, viewX: view.x, viewY: view.y }
|
||||
}
|
||||
|
||||
function onNodePointerDown(e: PointerEvent, node: Node) {
|
||||
e.stopPropagation()
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
dragState = { node, moved: false }
|
||||
sim?.alphaTarget(0.25).restart()
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (dragState) {
|
||||
const w = toWorld(e.clientX, e.clientY)
|
||||
dragState.node.fx = w.x
|
||||
dragState.node.fy = w.y
|
||||
dragState.moved = true
|
||||
return
|
||||
}
|
||||
if (panState) {
|
||||
const p = toViewBox(e.clientX, e.clientY)
|
||||
view = { ...view, x: panState.viewX + (p.x - panState.startX), y: panState.viewY + (p.y - panState.startY) }
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (dragState) {
|
||||
const { node, moved } = dragState
|
||||
node.fx = null
|
||||
node.fy = null
|
||||
sim?.alphaTarget(0)
|
||||
dragState = null
|
||||
if (!moved) selectNode(node)
|
||||
return
|
||||
}
|
||||
panState = null
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading && !nodes.length}
|
||||
<Skeleton class="h-full min-h-0" />
|
||||
{:else}
|
||||
<div class="relative h-full min-h-0 overflow-hidden rounded-lg border bg-[radial-gradient(ellipse_at_center,rgba(88,166,255,0.04),transparent_70%)]">
|
||||
<svg
|
||||
bind:this={svgEl}
|
||||
viewBox="0 0 {width} {height}"
|
||||
class="h-full w-full touch-none {panState ? 'cursor-grabbing' : 'cursor-grab'}"
|
||||
role="application"
|
||||
aria-label="Entity graph"
|
||||
onwheel={onWheel}
|
||||
onpointerdown={onBackgroundPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
>
|
||||
<defs>
|
||||
{#each allRelTypes as type}
|
||||
<marker id={markerId(type)} viewBox="0 -4 8 8" refX="8" refY="0" markerWidth="7" markerHeight="7" orient="auto">
|
||||
<path d="M0,-3.5L8,0L0,3.5" fill={relColor(type)} />
|
||||
</marker>
|
||||
{/each}
|
||||
</defs>
|
||||
<g transform="translate({view.x},{view.y}) scale({view.k})">
|
||||
<g>
|
||||
{#each links as link}
|
||||
{@const s = endpoint(link.source)}
|
||||
{@const t = endpoint(link.target)}
|
||||
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null && activeRelTypes.has(link.type) && visibleNodeIds.has(s.id) && visibleNodeIds.has(t.id)}
|
||||
{@const vs = linkVisualState(link)}
|
||||
{@const dx = t.x - s.x}
|
||||
{@const dy = t.y - s.y}
|
||||
{@const len = Math.max(Math.hypot(dx, dy), 1)}
|
||||
{@const tr = nodeRadius(t) + 3}
|
||||
{@const ex = t.x - (dx / len) * tr}
|
||||
{@const ey = t.y - (dy / len) * tr}
|
||||
<line
|
||||
x1={s.x}
|
||||
y1={s.y}
|
||||
x2={ex}
|
||||
y2={ey}
|
||||
stroke={relColor(link.type)}
|
||||
stroke-width={vs.emphasized ? 2 : 1.2}
|
||||
opacity={vs.opacity}
|
||||
marker-end="url(#{markerId(link.type)})"
|
||||
>
|
||||
<title>{link.type}</title>
|
||||
</line>
|
||||
{#if vs.emphasized && view.k >= 0.7}
|
||||
<text
|
||||
x={(s.x + ex) / 2}
|
||||
y={(s.y + ey) / 2 - 4}
|
||||
text-anchor="middle"
|
||||
font-size={10 / view.k}
|
||||
fill={relColor(link.type)}
|
||||
opacity="0.95"
|
||||
paint-order="stroke"
|
||||
stroke="var(--background)"
|
||||
stroke-width={3 / view.k}
|
||||
>
|
||||
{link.type}
|
||||
</text>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
<g>
|
||||
{#each nodes as node (node.id)}
|
||||
{#if node.x != null && node.y != null && visibleNodeIds.has(node.id)}
|
||||
{@const r = nodeRadius(node)}
|
||||
{@const op = nodeOpacity(node)}
|
||||
{@const isFocus = hoveredId === node.id || selectedId === node.id}
|
||||
{@const isMatch = matchedIds !== null && matchedIds.has(node.id)}
|
||||
<g
|
||||
transform="translate({node.x},{node.y})"
|
||||
opacity={op}
|
||||
class="cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onpointerdown={(e) => onNodePointerDown(e, node)}
|
||||
onpointerenter={() => (hoveredId = node.id)}
|
||||
onpointerleave={() => (hoveredId = null)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectNode(node)}
|
||||
ondblclick={() => rerootTo(node)}
|
||||
>
|
||||
{#if isFocus || isMatch}
|
||||
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
|
||||
{/if}
|
||||
<circle r={r} fill={nodeColor(node)} stroke={isFocus || isMatch ? '#e6edf3' : '#0d1117'} stroke-width={isFocus || isMatch ? 2 : 1.25} />
|
||||
{#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null}
|
||||
<text
|
||||
y={r + 12}
|
||||
text-anchor="middle"
|
||||
font-size={isFocus ? 12 / view.k : 10 / Math.max(view.k, 1)}
|
||||
fill={isFocus ? '#e6edf3' : '#8b949e'}
|
||||
paint-order="stroke"
|
||||
stroke="#0d1117"
|
||||
stroke-width={3 / view.k}
|
||||
class="pointer-events-none select-none"
|
||||
>
|
||||
{node.slug}
|
||||
</text>
|
||||
{/if}
|
||||
</g>
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="pointer-events-none absolute bottom-2 left-2 rounded bg-background/80 px-2 py-1 text-[10px] text-muted-foreground">
|
||||
scroll to zoom · drag background to pan · drag nodes · click to inspect · double-click to re-root
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user