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:
@@ -198,8 +198,14 @@
|
|||||||
const z = (s.z + tg.z) / 2
|
const z = (s.z + tg.z) / 2
|
||||||
const a = project(s.x, s.y!, z)
|
const a = project(s.x, s.y!, z)
|
||||||
const b = project(tg.x, tg.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.moveTo(a.x, a.y)
|
||||||
ctx.lineTo(b.x, b.y)
|
ctx.quadraticCurveTo(mx, my, b.x, b.y)
|
||||||
}
|
}
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,22 @@
|
|||||||
type Simulation
|
type Simulation
|
||||||
} from 'd3-force'
|
} from 'd3-force'
|
||||||
import { fetchGraph, type Entity } from '$lib/api'
|
import { fetchGraph, type Entity } from '$lib/api'
|
||||||
import { messages } from '$lib/stores/chat'
|
import type { ChatMessage } from '$lib/stores/chat'
|
||||||
import { touched, healthDiffs } from '$lib/stores/workspace'
|
import type { TouchedEntity, HealthDiff } from '$lib/stores/workspace'
|
||||||
import { openEntityWindow, wmState } from '$lib/stores/windows'
|
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 {
|
interface Node extends Entity {
|
||||||
x?: number
|
x?: number
|
||||||
y?: number
|
y?: number
|
||||||
@@ -75,7 +87,7 @@
|
|||||||
// get_health_summary would otherwise dump all 168 entities into the graph).
|
// get_health_summary would otherwise dump all 168 entities into the graph).
|
||||||
const candidateSlugs = $derived.by(() => {
|
const candidateSlugs = $derived.by(() => {
|
||||||
const out = new Set<string>()
|
const out = new Set<string>()
|
||||||
for (const m of $messages) {
|
for (const m of messages) {
|
||||||
collectSlugs(m.text, out)
|
collectSlugs(m.text, out)
|
||||||
for (const t of m.tools) collectSlugs(t.args, 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).
|
// object identity fine and this is small (≤12 touched, ≤8 diffs).
|
||||||
const touchedBySlug = $derived.by(() => {
|
const touchedBySlug = $derived.by(() => {
|
||||||
const m: Record<string, true> = {}
|
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
|
return m
|
||||||
})
|
})
|
||||||
const diffBySlug = $derived.by(() => {
|
const diffBySlug = $derived.by(() => {
|
||||||
const m: Record<string, { from: string; to: string }> = {}
|
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
|
return m
|
||||||
})
|
})
|
||||||
const nowTouching = $derived($touched[0] ?? null)
|
const nowTouching = $derived(touched[0] ?? null)
|
||||||
|
|
||||||
function endpoint(end: string | Node): Node | undefined {
|
function endpoint(end: string | Node): Node | undefined {
|
||||||
return typeof end === 'object' ? end : nodes.find((n) => n.slug === end)
|
return typeof end === 'object' ? end : nodes.find((n) => n.slug === end)
|
||||||
@@ -348,28 +360,32 @@
|
|||||||
onpointercancel={onUp}
|
onpointercancel={onUp}
|
||||||
>
|
>
|
||||||
<defs>
|
<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" />
|
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
|
||||||
</pattern>
|
</pattern>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width={cw} height={ch} fill="url(#dot-grid)" />
|
<rect width={cw} height={ch} fill="url(#{dotGridId})" />
|
||||||
<g>
|
<g>
|
||||||
{#each links as link}
|
{#each links as link}
|
||||||
{@const s = endpoint(link.source)}
|
{@const s = endpoint(link.source)}
|
||||||
{@const t = endpoint(link.target)}
|
{@const t = endpoint(link.target)}
|
||||||
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null}
|
{#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 focus = selected && (s.slug === selected.slug || t.slug === selected.slug)}
|
||||||
<line
|
{@const dx = t.x - s.x}
|
||||||
x1={s.x}
|
{@const dy = t.y - s.y}
|
||||||
y1={s.y}
|
{@const len = Math.max(Math.hypot(dx, dy), 1)}
|
||||||
x2={t.x}
|
{@const curve = Math.min(len * 0.15, 40)}
|
||||||
y2={t.y}
|
{@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="var(--muted-foreground)"
|
||||||
stroke-width={focus ? 1.6 : 1}
|
stroke-width={focus ? 1.6 : 1}
|
||||||
opacity={selected ? (focus ? 0.7 : 0.12) : 0.35}
|
opacity={selected ? (focus ? 0.7 : 0.12) : 0.35}
|
||||||
>
|
>
|
||||||
<title>{link.type}</title>
|
<title>{link.type}</title>
|
||||||
</line>
|
</path>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</g>
|
</g>
|
||||||
|
|||||||
Reference in New Issue
Block a user