Backend: - Add isThinking flag to agentEvent for text before tool calls - Separate thinking from response text in runChatTurn and continue.go - Persist thinking in a dedicated field in message content Frontend: - Add thinking field to MessageContent, ChatMessage, ChatTextEvent types - Create ThinkingBlock.svelte — collapsible block with brain icon - SSE handler moves text_delta content to thinking on isThinking flag - Render thinking block between tools and response in ChatThread - Fix chat window scroll reset on focus change (stable windowKeys order) - Remove redundant #key id wrapper in WindowLayer - Enlarge sidebar rail (24→32 default, 40→60 max) - Remove glyph from sidebar, square graph at top - Replace AgentTrace/ToolCallCard/UnifiedTimeline with TurnTrace/ToolLine
651 lines
22 KiB
Svelte
651 lines
22 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy, untrack } from 'svelte'
|
|
import {
|
|
forceSimulation,
|
|
forceLink,
|
|
forceManyBody,
|
|
forceCenter,
|
|
forceCollide,
|
|
forceX,
|
|
forceY,
|
|
type Simulation
|
|
} from 'd3-force'
|
|
import { fetchGraph, type Entity } from '$lib/api'
|
|
import type { ChatMessage } from '$lib/stores/chat'
|
|
import type { TouchedEntity, HealthDiff } from '$lib/stores/workspace'
|
|
import { openEntityWindow, wmState } from '$lib/stores/windows'
|
|
|
|
// Prop-driven (not store-imported) so this can render either the main
|
|
// page's global "current session" data or a floating task window's own
|
|
// per-session data — see TaskContextPanel.svelte, which supplies both.
|
|
let {
|
|
messages,
|
|
touched,
|
|
healthDiffs
|
|
}: { messages: ChatMessage[]; touched: TouchedEntity[]; healthDiffs: HealthDiff[] } = $props()
|
|
|
|
// SVG ids are document-global, not scoped to this <svg> — several task
|
|
// windows can each have their own Scope graph open at once, and without a
|
|
// per-instance suffix every one of them would define (and reference)
|
|
// <pattern id="dot-grid">, so only the first in the document would ever
|
|
// actually paint (the rest resolve to nothing, background reads blank).
|
|
const dotGridId = `dot-grid-${crypto.randomUUID().slice(0, 8)}`
|
|
|
|
interface Node extends Entity {
|
|
x?: number
|
|
y?: number
|
|
vx?: number
|
|
vy?: number
|
|
fx?: number | null
|
|
fy?: number | null
|
|
degree: number
|
|
}
|
|
interface Edge {
|
|
source: string | Node
|
|
target: string | Node
|
|
type: string
|
|
}
|
|
|
|
// Probe/bookkeeping entity types are excluded — a health conversation
|
|
// mentions dozens of check:… slugs that would swamp the fleet topology.
|
|
const EXCLUDED = new Set(['check', 'execution'])
|
|
|
|
// Slug shape: lowercase type prefix, then one or more colon-separated
|
|
// segments (host:hubris, check:ping:8cf, lxc:caddy, investigation:foo/bar).
|
|
const SLUG_RE = /\b[a-z][a-z-]*:[a-z0-9][a-z0-9._/-]*(?::[a-z0-9._/-]+)*/g
|
|
|
|
let nodes = $state<Node[]>([])
|
|
let links = $state<Edge[]>([])
|
|
let selected = $state<Node | null>(null)
|
|
|
|
let sim: Simulation<Node, Edge> | null = null
|
|
|
|
// Non-reactive caches (persist across message deltas). resolvedVersion is a
|
|
// reactive counter bumped when async resolution finishes, so the reconcile
|
|
// effect re-runs once entities come back.
|
|
const resolvedCache = new Map<string, Node | null>()
|
|
const edgeCache: { source: string; target: string; type: string }[] = []
|
|
const edgeKeys = new Set<string>()
|
|
const resolving = new Set<string>()
|
|
let resolvedVersion = $state(0)
|
|
|
|
// container size drives the simulation coordinate space (1:1 with pixels so
|
|
// node dragging maps cleanly regardless of the resizable panel width).
|
|
let container = $state<HTMLDivElement | null>(null)
|
|
let cw = $state(300)
|
|
let ch = $state(300)
|
|
|
|
// View transform (zoom-to-fit + drag-pan). The force simulation runs in its
|
|
// own graph coordinate space; this maps graph→screen so every entity stays
|
|
// visible regardless of how far the layout spreads or how narrow the panel
|
|
// is. tx/ty are screen px; scale is unitless. `userPanned` pauses auto-fit
|
|
// once the operator drags the background, until the entity set changes or
|
|
// they double-click to reset.
|
|
let tx = $state(0)
|
|
let ty = $state(0)
|
|
let scale = $state(1)
|
|
let userPanned = $state(false)
|
|
const viewTransform = $derived(`translate(${tx},${ty}) scale(${scale})`)
|
|
|
|
function collectSlugs(value: unknown, out: Set<string>) {
|
|
if (typeof value === 'string') {
|
|
const m = value.match(SLUG_RE)
|
|
if (m) for (const s of m) out.add(s.replace(/[.,;)\]]+$/, ''))
|
|
} else if (Array.isArray(value)) {
|
|
for (const v of value) collectSlugs(v, out)
|
|
} else if (value && typeof value === 'object') {
|
|
for (const v of Object.values(value)) collectSlugs(v, out)
|
|
}
|
|
}
|
|
|
|
// Only pull from what the conversation is *about*: message text and the
|
|
// arguments the agent passed to tools — never bulk result rows (a single
|
|
// get_health_summary would otherwise dump all 168 entities into the graph).
|
|
const candidateSlugs = $derived.by(() => {
|
|
const out = new Set<string>()
|
|
for (const m of messages) {
|
|
collectSlugs(m.text, out)
|
|
for (const t of m.tools) collectSlugs(t.args, out)
|
|
}
|
|
return out
|
|
})
|
|
|
|
async function resolveSlugs(slugs: string[]) {
|
|
const todo = slugs.filter((s) => !resolvedCache.has(s) && !resolving.has(s))
|
|
if (!todo.length) return
|
|
for (const s of todo) resolving.add(s)
|
|
await Promise.all(
|
|
todo.map(async (s) => {
|
|
try {
|
|
const g = await fetchGraph({ root: s, depth: 1 })
|
|
const root = g?.nodes.find((n) => n.slug === s) ?? null
|
|
resolvedCache.set(s, root ? { ...root, degree: 0 } : null)
|
|
if (g && root) {
|
|
for (const e of g.edges) {
|
|
const k = `${e.source}|${e.target}|${e.type}`
|
|
if (!edgeKeys.has(k)) {
|
|
edgeKeys.add(k)
|
|
edgeCache.push({ source: e.source, target: e.target, type: e.type })
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
resolvedCache.set(s, null)
|
|
} finally {
|
|
resolving.delete(s)
|
|
}
|
|
})
|
|
)
|
|
resolvedVersion++
|
|
}
|
|
|
|
function reconcile(cands: Set<string>) {
|
|
const desired: Node[] = []
|
|
const seen = new Set<string>()
|
|
for (const s of cands) {
|
|
const e = resolvedCache.get(s)
|
|
if (e && !EXCLUDED.has(e.type) && !seen.has(e.slug)) {
|
|
seen.add(e.slug)
|
|
desired.push(e)
|
|
}
|
|
}
|
|
const desiredSlugs = new Set(desired.map((e) => e.slug))
|
|
const current = nodes
|
|
const curSlugs = new Set(current.map((n) => n.slug))
|
|
|
|
let changed = desiredSlugs.size !== curSlugs.size
|
|
if (!changed)
|
|
for (const s of desiredSlugs)
|
|
if (!curSlugs.has(s)) {
|
|
changed = true
|
|
break
|
|
}
|
|
if (!changed) return
|
|
|
|
const bySlug = new Map(current.map((n) => [n.slug, n]))
|
|
const ls = edgeCache
|
|
.filter((e) => desiredSlugs.has(e.source) && desiredSlugs.has(e.target))
|
|
.map((e) => ({ ...e }))
|
|
|
|
const deg = new Map<string, number>()
|
|
for (const l of ls) {
|
|
deg.set(l.source as string, (deg.get(l.source as string) ?? 0) + 1)
|
|
deg.set(l.target as string, (deg.get(l.target as string) ?? 0) + 1)
|
|
}
|
|
|
|
const next = desired.map((e) => {
|
|
const p = bySlug.get(e.slug)
|
|
return { ...e, x: p?.x, y: p?.y, vx: p?.vx, vy: p?.vy, degree: deg.get(e.slug) ?? 0 }
|
|
})
|
|
|
|
nodes = next
|
|
links = ls
|
|
if (selected && !desiredSlugs.has(selected.slug)) selected = null
|
|
buildSim()
|
|
}
|
|
|
|
$effect(() => {
|
|
const cands = candidateSlugs
|
|
void resolvedVersion
|
|
const missing = [...cands].filter((s) => !resolvedCache.has(s))
|
|
if (missing.length) resolveSlugs(missing)
|
|
untrack(() => reconcile(cands))
|
|
})
|
|
|
|
function buildSim() {
|
|
sim?.stop()
|
|
if (!nodes.length) {
|
|
sim = null
|
|
return
|
|
}
|
|
sim = forceSimulation(nodes)
|
|
.force(
|
|
'link',
|
|
forceLink<Node, Edge>(links)
|
|
.id((n) => n.slug)
|
|
.distance(48)
|
|
.strength(0.5)
|
|
)
|
|
.force('charge', forceManyBody().strength(-150).distanceMax(240))
|
|
.force('center', forceCenter(cw / 2, ch / 2))
|
|
.force(
|
|
'collide',
|
|
forceCollide<Node>((n) => nodeRadius(n) + 6)
|
|
)
|
|
.force('x', forceX(cw / 2).strength(0.06))
|
|
.force('y', forceY(ch / 2).strength(0.06))
|
|
.velocityDecay(0.34)
|
|
.alphaDecay(0.045)
|
|
.on('tick', () => {
|
|
nodes = [...nodes]
|
|
if (!userPanned) fitView()
|
|
})
|
|
}
|
|
|
|
// keep the layout centred as the panel resizes
|
|
$effect(() => {
|
|
const w = cw
|
|
const h = ch
|
|
if (sim) {
|
|
sim.force('center', forceCenter(w / 2, h / 2))
|
|
sim.force('x', forceX(w / 2).strength(0.06))
|
|
sim.force('y', forceY(h / 2).strength(0.06))
|
|
sim.alpha(0.3).restart()
|
|
}
|
|
})
|
|
|
|
$effect(() => {
|
|
if (!container) return
|
|
const ro = new ResizeObserver((entries) => {
|
|
const r = entries[0].contentRect
|
|
cw = Math.max(r.width, 1)
|
|
ch = Math.max(r.height, 1)
|
|
})
|
|
ro.observe(container)
|
|
return () => ro.disconnect()
|
|
})
|
|
|
|
// The highlight ring/dim styling below is tied to the node whose window
|
|
// was last opened — once that window is closed (from WindowLayer, not
|
|
// necessarily from here), the ring should go with it rather than pointing
|
|
// at a window that no longer exists.
|
|
$effect(() => {
|
|
if (selected && !$wmState.windows[selected.slug]) selected = null
|
|
})
|
|
|
|
onDestroy(() => sim?.stop())
|
|
|
|
const healthColor: Record<string, string> = {
|
|
healthy: 'var(--success)',
|
|
degraded: 'var(--warning)',
|
|
down: 'var(--destructive)',
|
|
stale: 'var(--warning)',
|
|
unknown: 'var(--muted-foreground)'
|
|
}
|
|
function nodeColor(n: Node): string {
|
|
return n.health
|
|
? (healthColor[n.health] ?? 'var(--muted-foreground)')
|
|
: 'var(--muted-foreground)'
|
|
}
|
|
function nodeRadius(n: Node): number {
|
|
return 6 + Math.min(Math.sqrt(n.degree) * 1.5, 6)
|
|
}
|
|
function shortName(slug: string): string {
|
|
return slug.split(':').pop() ?? slug
|
|
}
|
|
|
|
// Compute the view transform that fits every node (with label clearance)
|
|
// inside the panel, clamped so a single node doesn't fill it and a huge
|
|
// graph stays legible. No-op until the layout has positions / a size.
|
|
function fitView() {
|
|
if (!nodes.length || cw <= 1 || ch <= 1) return
|
|
let minX = Infinity
|
|
let minY = Infinity
|
|
let maxX = -Infinity
|
|
let maxY = -Infinity
|
|
for (const n of nodes) {
|
|
if (n.x == null || n.y == null) continue
|
|
const r = nodeRadius(n) + 12 // node + label clearance
|
|
minX = Math.min(minX, n.x - r)
|
|
minY = Math.min(minY, n.y - r)
|
|
maxX = Math.max(maxX, n.x + r)
|
|
maxY = Math.max(maxY, n.y + r)
|
|
}
|
|
if (!Number.isFinite(minX)) return
|
|
const pad = 16
|
|
const w = Math.max(maxX - minX, 1)
|
|
const h = Math.max(maxY - minY, 1)
|
|
const s = Math.min((cw - pad * 2) / w, (ch - pad * 2) / h)
|
|
const clamped = Math.max(0.2, Math.min(2.5, Number.isFinite(s) ? s : 1))
|
|
scale = clamped
|
|
tx = (cw - w * clamped) / 2 - minX * clamped
|
|
ty = (ch - h * clamped) / 2 - minY * clamped
|
|
}
|
|
|
|
// When the entity SET changes (a new node added/removed), re-engage auto-fit
|
|
// so the new entity is brought into view. Same-slug re-renders (every sim
|
|
// tick) leave the signature unchanged and don't reset.
|
|
let lastMembership = ''
|
|
$effect(() => {
|
|
const sig = nodes
|
|
.map((n) => n.slug)
|
|
.sort()
|
|
.join('|')
|
|
if (sig !== lastMembership) {
|
|
lastMembership = sig
|
|
userPanned = false
|
|
}
|
|
})
|
|
|
|
// Live touch/health-diff lookups, keyed by slug for O(1) per-node checks
|
|
// during render. Kept as plain objects (not Maps) since Svelte 5 runes track
|
|
// object identity fine and this is small (≤12 touched, ≤8 diffs).
|
|
const touchedBySlug = $derived.by(() => {
|
|
const m: Record<string, true> = {}
|
|
for (const t of touched) m[t.slug] = true
|
|
return m
|
|
})
|
|
const diffBySlug = $derived.by(() => {
|
|
const m: Record<string, { from: string; to: string }> = {}
|
|
for (const d of healthDiffs) if (!(d.slug in m)) m[d.slug] = d
|
|
return m
|
|
})
|
|
const nowTouching = $derived(touched[0] ?? null)
|
|
|
|
function endpoint(end: string | Node): Node | undefined {
|
|
return typeof end === 'object' ? end : nodes.find((n) => n.slug === end)
|
|
}
|
|
function endpointSlug(end: string | Node): string {
|
|
return typeof end === 'object' ? end.slug : end
|
|
}
|
|
|
|
// ─── drag / select / pan ─────────────────────────────────────────────
|
|
// A click (pointerdown+up with no movement in between) opens the entity
|
|
// straight in its own floating window (WindowLayer); `selected` only drives
|
|
// the highlight/dim styling. Node drag pins the node in GRAPH coords
|
|
// (screen→graph via the inverse view transform). Background drag pans the
|
|
// view and sets userPanned so auto-fit pauses. Double-click background
|
|
// re-fits all entities.
|
|
let dragState: { node: Node; moved: boolean } | null = null
|
|
let panState: { x: number; y: number } | null = null
|
|
|
|
function toGraph(clientX: number, clientY: number) {
|
|
const rect = container!.getBoundingClientRect()
|
|
return {
|
|
x: (clientX - rect.left - tx) / scale,
|
|
y: (clientY - rect.top - ty) / scale
|
|
}
|
|
}
|
|
|
|
function onNodeDown(e: PointerEvent, node: Node) {
|
|
e.stopPropagation()
|
|
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
|
dragState = { node, moved: false }
|
|
sim?.alphaTarget(0.2).restart()
|
|
}
|
|
function onBgDown(e: PointerEvent) {
|
|
panState = { x: e.clientX - tx, y: e.clientY - ty }
|
|
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
|
}
|
|
function onMove(e: PointerEvent) {
|
|
if (dragState) {
|
|
const p = toGraph(e.clientX, e.clientY)
|
|
dragState.node.fx = p.x
|
|
dragState.node.fy = p.y
|
|
dragState.moved = true
|
|
nodes = [...nodes]
|
|
return
|
|
}
|
|
if (panState) {
|
|
tx = e.clientX - panState.x
|
|
ty = e.clientY - panState.y
|
|
userPanned = true
|
|
}
|
|
}
|
|
function selectAndOpen(node: Node) {
|
|
selected = node
|
|
openEntityWindow(node.slug)
|
|
}
|
|
function onUp() {
|
|
if (dragState) {
|
|
const { node, moved } = dragState
|
|
node.fx = null
|
|
node.fy = null
|
|
sim?.alphaTarget(0)
|
|
dragState = null
|
|
if (!moved) selectAndOpen(node)
|
|
return
|
|
}
|
|
panState = null
|
|
}
|
|
function refit() {
|
|
userPanned = false
|
|
fitView()
|
|
}
|
|
|
|
const selectedRelations = $derived(
|
|
selected
|
|
? links
|
|
.filter(
|
|
(l) =>
|
|
endpointSlug(l.source) === selected!.slug || endpointSlug(l.target) === selected!.slug
|
|
)
|
|
.map((l) => {
|
|
const outgoing = endpointSlug(l.source) === selected!.slug
|
|
return {
|
|
dir: outgoing ? '→' : '←',
|
|
type: l.type,
|
|
other: outgoing ? endpointSlug(l.target) : endpointSlug(l.source)
|
|
}
|
|
})
|
|
: []
|
|
)
|
|
</script>
|
|
|
|
<aside class="flex h-full min-h-0 flex-col bg-card">
|
|
{#if nowTouching}
|
|
<div
|
|
class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary"
|
|
>
|
|
<span class="size-1.5 animate-pulse rounded-full bg-primary"></span>
|
|
Now touching <code class="font-mono">{nowTouching.slug}</code>
|
|
</div>
|
|
{/if}
|
|
|
|
<div bind:this={container} class="relative min-h-0 flex-1 overflow-hidden">
|
|
{#if nodes.length === 0}
|
|
<div
|
|
class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 text-center"
|
|
>
|
|
<svg viewBox="0 0 120 120" class="size-24 text-muted-foreground/40" fill="none">
|
|
<circle cx="60" cy="60" r="6" fill="currentColor">
|
|
<animate
|
|
attributeName="opacity"
|
|
values="0.4;1;0.4"
|
|
dur="2.4s"
|
|
repeatCount="indefinite"
|
|
/>
|
|
</circle>
|
|
<g stroke="currentColor" stroke-width="1" opacity="0.5">
|
|
<line x1="60" y1="60" x2="26" y2="34"
|
|
><animate
|
|
attributeName="opacity"
|
|
values="0.1;0.5;0.1"
|
|
dur="3s"
|
|
repeatCount="indefinite"
|
|
/></line
|
|
>
|
|
<line x1="60" y1="60" x2="96" y2="40"
|
|
><animate
|
|
attributeName="opacity"
|
|
values="0.1;0.5;0.1"
|
|
dur="3.4s"
|
|
repeatCount="indefinite"
|
|
/></line
|
|
>
|
|
<line x1="60" y1="60" x2="34" y2="92"
|
|
><animate
|
|
attributeName="opacity"
|
|
values="0.1;0.5;0.1"
|
|
dur="2.8s"
|
|
repeatCount="indefinite"
|
|
/></line
|
|
>
|
|
<line x1="60" y1="60" x2="92" y2="90"
|
|
><animate
|
|
attributeName="opacity"
|
|
values="0.1;0.5;0.1"
|
|
dur="3.1s"
|
|
repeatCount="indefinite"
|
|
/></line
|
|
>
|
|
</g>
|
|
<g fill="currentColor">
|
|
<circle cx="26" cy="34" r="3.5"
|
|
><animate
|
|
attributeName="opacity"
|
|
values="0.2;0.7;0.2"
|
|
dur="3s"
|
|
repeatCount="indefinite"
|
|
/></circle
|
|
>
|
|
<circle cx="96" cy="40" r="3.5"
|
|
><animate
|
|
attributeName="opacity"
|
|
values="0.2;0.7;0.2"
|
|
dur="3.4s"
|
|
repeatCount="indefinite"
|
|
/></circle
|
|
>
|
|
<circle cx="34" cy="92" r="3.5"
|
|
><animate
|
|
attributeName="opacity"
|
|
values="0.2;0.7;0.2"
|
|
dur="2.8s"
|
|
repeatCount="indefinite"
|
|
/></circle
|
|
>
|
|
<circle cx="92" cy="90" r="3.5"
|
|
><animate
|
|
attributeName="opacity"
|
|
values="0.2;0.7;0.2"
|
|
dur="3.1s"
|
|
repeatCount="indefinite"
|
|
/></circle
|
|
>
|
|
</g>
|
|
</svg>
|
|
<p class="max-w-[16rem] text-xs leading-relaxed text-muted-foreground">
|
|
Entities Nomos explores in this conversation appear here, wired up by their relationships.
|
|
</p>
|
|
</div>
|
|
{:else}
|
|
<svg
|
|
width={cw}
|
|
height={ch}
|
|
viewBox="0 0 {cw} {ch}"
|
|
class="h-full w-full touch-none select-none"
|
|
role="application"
|
|
aria-label="Session entity graph"
|
|
onpointerdown={onBgDown}
|
|
onpointermove={onMove}
|
|
onpointerup={onUp}
|
|
onpointercancel={onUp}
|
|
ondblclick={refit}
|
|
>
|
|
<defs>
|
|
<pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
|
|
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
|
|
</pattern>
|
|
</defs>
|
|
<rect width={cw} height={ch} fill="url(#{dotGridId})" />
|
|
<g transform={viewTransform}>
|
|
<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}
|
|
{@const focus = selected && (s.slug === selected.slug || t.slug === selected.slug)}
|
|
{@const dx = t.x - s.x}
|
|
{@const dy = t.y - s.y}
|
|
{@const len = Math.max(Math.hypot(dx, dy), 1)}
|
|
{@const curve = Math.min(len * 0.15, 40)}
|
|
{@const cx = (s.x + t.x) / 2 - (dy / len) * curve}
|
|
{@const cy = (s.y + t.y) / 2 + (dx / len) * curve}
|
|
<path
|
|
d="M {s.x},{s.y} Q {cx},{cy} {t.x},{t.y}"
|
|
fill="none"
|
|
stroke="var(--muted-foreground)"
|
|
stroke-width={focus ? 1.6 : 1}
|
|
opacity={selected ? (focus ? 0.7 : 0.12) : 0.35}
|
|
>
|
|
<title>{link.type}</title>
|
|
</path>
|
|
{/if}
|
|
{/each}
|
|
</g>
|
|
<g>
|
|
{#each nodes as node (node.slug)}
|
|
{#if node.x != null && node.y != null}
|
|
{@const r = nodeRadius(node)}
|
|
{@const isSel = selected?.slug === node.slug}
|
|
{@const dim =
|
|
selected && !isSel && !selectedRelations.some((rel) => rel.other === node.slug)}
|
|
{@const isTouched = node.slug in touchedBySlug}
|
|
{@const diff = diffBySlug[node.slug]}
|
|
<g
|
|
transform="translate({node.x},{node.y})"
|
|
class="cursor-pointer"
|
|
opacity={dim ? 0.35 : 1}
|
|
role="button"
|
|
tabindex="0"
|
|
onpointerdown={(e) => onNodeDown(e, node)}
|
|
onkeydown={(e) => e.key === 'Enter' && selectAndOpen(node)}
|
|
>
|
|
{#if isSel}
|
|
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
|
|
{/if}
|
|
{#if isTouched}
|
|
<circle
|
|
r={r + 4}
|
|
fill="none"
|
|
stroke="var(--primary)"
|
|
stroke-width="1.5"
|
|
opacity="0.8"
|
|
>
|
|
<animate
|
|
attributeName="r"
|
|
values="{r + 3};{r + 8};{r + 3}"
|
|
dur="1.6s"
|
|
repeatCount="indefinite"
|
|
/>
|
|
<animate
|
|
attributeName="opacity"
|
|
values="0.8;0.1;0.8"
|
|
dur="1.6s"
|
|
repeatCount="indefinite"
|
|
/>
|
|
</circle>
|
|
{/if}
|
|
<circle
|
|
{r}
|
|
fill={nodeColor(node)}
|
|
stroke={isSel ? 'var(--foreground)' : 'var(--background)'}
|
|
stroke-width={isSel ? 2 : 1.5}
|
|
/>
|
|
<text
|
|
y={r + 10}
|
|
text-anchor="middle"
|
|
font-size="9"
|
|
fill={isSel ? 'var(--foreground)' : 'var(--muted-foreground)'}
|
|
paint-order="stroke"
|
|
stroke="var(--background)"
|
|
stroke-width="2.5"
|
|
class="pointer-events-none"
|
|
>
|
|
{shortName(node.slug)}
|
|
</text>
|
|
{#if diff}
|
|
<text
|
|
y={-r - 6}
|
|
text-anchor="middle"
|
|
font-size="8"
|
|
fill="var(--warning)"
|
|
paint-order="stroke"
|
|
stroke="var(--background)"
|
|
stroke-width="2.5"
|
|
class="pointer-events-none"
|
|
>
|
|
{diff.from} → {diff.to}
|
|
</text>
|
|
{/if}
|
|
</g>
|
|
{/if}
|
|
{/each}
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
{/if}
|
|
</div>
|
|
</aside>
|