feat(web): curved edges + unique SVG ids for concurrent graph views

Quadratic-bezier edges instead of straight lines, and drop the
auto-refit-on-load that caused a jarring zoom/pan snap once the force
simulation settled. Also namespace each graph's dot-grid pattern id
with a per-instance uuid — multiple SessionGraph instances can now be
mounted at once (one per open task window), and duplicate SVG ids
silently blanked out every graph's background but the first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 16:59:23 +02:00
parent 544afae77f
commit 6051fb4845
2 changed files with 37 additions and 15 deletions

View File

@@ -198,8 +198,14 @@
const z = (s.z + tg.z) / 2
const a = project(s.x, s.y!, z)
const b = project(tg.x, tg.y!, z)
const dx = b.x - a.x
const dy = b.y - a.y
const len = Math.max(Math.hypot(dx, dy), 1)
const curve = Math.min(len * 0.15, 40)
const mx = (a.x + b.x) / 2 - (dy / len) * curve
const my = (a.y + b.y) / 2 + (dx / len) * curve
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
ctx.quadraticCurveTo(mx, my, b.x, b.y)
}
ctx.stroke()

View File

@@ -11,10 +11,22 @@
type Simulation
} from 'd3-force'
import { fetchGraph, type Entity } from '$lib/api'
import { messages } from '$lib/stores/chat'
import { touched, healthDiffs } from '$lib/stores/workspace'
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
@@ -75,7 +87,7 @@
// 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) {
for (const m of messages) {
collectSlugs(m.text, out)
for (const t of m.tools) collectSlugs(t.args, out)
}
@@ -234,15 +246,15 @@
// 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
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
for (const d of healthDiffs) if (!(d.slug in m)) m[d.slug] = d
return m
})
const nowTouching = $derived($touched[0] ?? null)
const nowTouching = $derived(touched[0] ?? null)
function endpoint(end: string | Node): Node | undefined {
return typeof end === 'object' ? end : nodes.find((n) => n.slug === end)
@@ -348,28 +360,32 @@
onpointercancel={onUp}
>
<defs>
<pattern id="dot-grid" width="12" height="12" patternUnits="userSpaceOnUse">
<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(#dot-grid)" />
<rect width={cw} height={ch} fill="url(#{dotGridId})" />
<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)}
<line
x1={s.x}
y1={s.y}
x2={t.x}
y2={t.y}
{@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>
</line>
</path>
{/if}
{/each}
</g>