feat(ui): where the graph stops — the Flow strip's dynamic-dispatch end cap (CG-51)
A flow that does not reach what it was asked about now ends in a cap instead of in silence: the dispatch form that ended it, the line, the static key when the source spells one out, the candidate runtime targets as clickable rows, and the name-only matches under 0.6 the search refused to follow. A flow that does reach its destination never shows one. The verdict is lifted out of `ToolHandler` into `src/graph/dynamic-boundary-report.ts` and both callers render it — `codegraph_explore`'s prose and `/api/flow`'s `WireFlowBoundary` — the same move `named-symbol-flow.ts` made for the path finder, and for the same reason: a reader holding the strip and the MCP answer must not be told two different things. The explore prose is unchanged, byte for byte. When nothing connects at all and a dispatch site explains why, the strip is that site: one card opened at the line where the static path ends, plus the cap. When nothing explains it, no stopping point is invented.
This commit is contained in:
+23
-1
@@ -43,7 +43,7 @@ src/
|
||||
lib/trail.svelte.ts the walked path; mirrored into the `t` query param
|
||||
lib/kinds.ts kind glyph letters
|
||||
lib/map-model.ts the Map's deterministic layered layout (pure)
|
||||
lib/flow-model.ts the Flow strip's card/link geometry — a DAG (pure)
|
||||
lib/flow-model.ts the Flow strip's card/link geometry + the end cap — a DAG (pure)
|
||||
lib/filecode-model.ts the whole-file view: fixed line height, arcs, paging (pure)
|
||||
lib/live.svelte.ts /api/events: two counters every screen refreshes from
|
||||
lib/toast.svelte.ts the one transient note ("Index updated · reloaded")
|
||||
@@ -68,6 +68,28 @@ announce the project to a font CDN.
|
||||
| `#/flow?symbols=a,b,c` | flow strip — `codegraph_explore`'s own question |
|
||||
| `#/flow?t=<trail>` | flow strip — the trail you walked, read as a flow |
|
||||
|
||||
## Where the graph stops
|
||||
|
||||
A flow that does not reach everything it was asked about carries a
|
||||
`boundary` on the wire, and `buildFlowLayout` turns it into an extra 240px node
|
||||
one column past the symbol the path stopped at, joined by a dotted `2 4` link
|
||||
labelled "end of static path" that deliberately has **no arrowhead** — an arrow
|
||||
would point at a continuation, and the absence of one is the finding.
|
||||
|
||||
Two rules hold it together:
|
||||
|
||||
- **The cap's height is arithmetic, like a card's.** `endCapText()` builds every
|
||||
sentence the cap shows and `endCapHeight()` measures them; the component then
|
||||
renders exactly what was measured. Change the wording in one and the other
|
||||
moves with it — they are the same function read twice.
|
||||
- **One cap per stopping symbol, not per flow.** Two paths that run out at the
|
||||
same place ran out for the same reason, and two caps side by side would read
|
||||
as two different findings.
|
||||
|
||||
The verdict itself is not computed here or in the server: it is
|
||||
`findDynamicBoundaries` in `src/graph/dynamic-boundary-report.ts`, the same
|
||||
detector `codegraph_explore` announces boundaries with.
|
||||
|
||||
## Live updates
|
||||
|
||||
The viewer never polls. `lib/live.svelte.ts` holds one `EventSource` on
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
const claimed = assignRefs(lineTokens, refs.get(n) ?? []);
|
||||
return {
|
||||
n,
|
||||
call: n === hop.callRef?.line,
|
||||
call: n === hop.callRef?.line || n === card.stopLine,
|
||||
parts: lineTokens.map((token, index): Part => {
|
||||
const ref = claimed.get(index) ?? null;
|
||||
return { text: token.text, cls: ref ? null : tokenClass(token.cls), ref };
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<!--
|
||||
Where the graph stops (design spec §3.5).
|
||||
|
||||
The last thing on a strip that did not reach what it was asked about. It is
|
||||
not an error state and not an apology: a flow running through a computed
|
||||
member call, a string-keyed bus or a reflective invoke genuinely has no static
|
||||
edge to follow, and the useful answer is the dispatch site itself — the form,
|
||||
the key when the source makes it visible, the line, and the symbols that could
|
||||
plausibly be on the other side.
|
||||
|
||||
Every claim on it comes from the server, which builds it with the same
|
||||
detector `codegraph_explore` announces boundaries with. Nothing here guesses:
|
||||
a candidate row is a shortlist, and it says so by being under a heading that
|
||||
counts it rather than under an arrow that asserts it.
|
||||
|
||||
The last block is the one that matters most. A name-only match under 0.6
|
||||
confidence is a continuation the search deliberately refused to follow, and
|
||||
leaving it invisible would read as "there is nothing here" — which is the one
|
||||
thing it does not mean.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Handle, Position } from '@xyflow/svelte';
|
||||
import { endCapText, type FlowEndCapLayout } from '../../lib/flow-model';
|
||||
import { basename } from '../../lib/symbol-model';
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
cap: FlowEndCapLayout;
|
||||
dimmed: boolean;
|
||||
onOpen: (nodeId: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
let cap = $derived(data.cap);
|
||||
let text = $derived(endCapText(cap.boundary));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="endcap"
|
||||
class:dim={data.dimmed}
|
||||
style={`width:${cap.width}px;min-height:${cap.height}px`}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} id="in" isConnectable={false} />
|
||||
|
||||
<p class="lead"><b>Where the graph stops.</b> {text.intro}</p>
|
||||
|
||||
{#each text.sites as site, i (i)}
|
||||
<div class="site">
|
||||
<p class="form">{site.headline}</p>
|
||||
{#if site.key !== null}
|
||||
<p class="key">key <span class="mono">{site.key}</span></p>
|
||||
{/if}
|
||||
{#each site.notes as note (note)}
|
||||
<p class="soft">{note}</p>
|
||||
{/each}
|
||||
{#if site.candidateHeading !== null}
|
||||
<p class="soft">{site.candidateHeading}</p>
|
||||
{#each site.candidates as candidate (candidate.node.id)}
|
||||
<button type="button" class="row" onclick={() => data.onOpen(candidate.node.id)}>
|
||||
<span class="nm">{candidate.display}</span>
|
||||
<span class="at">{basename(candidate.node.file)}:{candidate.node.line}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if site.candidateNote !== null}
|
||||
<p class="soft">{site.candidateNote}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if text.quiet !== null}
|
||||
<p class="soft block">{text.quiet}</p>
|
||||
{/if}
|
||||
|
||||
{#if text.uncertainHeading !== null}
|
||||
<div class="block">
|
||||
<p class="soft">{text.uncertainHeading}</p>
|
||||
{#each text.uncertain as next (next.node.id)}
|
||||
<button type="button" class="row" onclick={() => data.onOpen(next.node.id)}>
|
||||
<span class="nm unsure">{next.node.name}</span>
|
||||
<span class="at">{next.confidence === null ? '' : next.confidence.toFixed(2)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if text.further !== null}
|
||||
<p class="block">{text.further}</p>
|
||||
{/if}
|
||||
{#if text.missed !== null}
|
||||
<p class="block">{text.missed}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.endcap {
|
||||
box-sizing: border-box;
|
||||
padding: 12px;
|
||||
background: var(--paper);
|
||||
border: 1px dashed var(--rule-soft);
|
||||
color: var(--ink-2);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.endcap.dim {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lead b {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.site,
|
||||
.block {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.endcap p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.soft {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
.key .mono,
|
||||
.mono {
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: baseline;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
font: 11.5px / 18px var(--mono);
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.row:hover .nm {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nm {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* A refused match reads as refused: the dotted rule under it is the same one
|
||||
the code block draws under an uncertain call site. */
|
||||
.unsure {
|
||||
text-decoration: underline dotted var(--ink-4);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.at {
|
||||
color: var(--ink-4);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,10 @@
|
||||
Straight, not curved: two cards on the same row sit at the same height, and a
|
||||
bezier between them would be a decorative wobble. The path bends only when a
|
||||
branch puts them on different rows.
|
||||
|
||||
The link into an end cap is the exception with no edge behind it: `2 4` dots,
|
||||
no arrowhead, labelled "end of static path". An arrow would point at a
|
||||
continuation, and the whole point of the cap is that there isn't one.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { BaseEdge, type EdgeProps } from '@xyflow/svelte';
|
||||
@@ -39,7 +43,9 @@
|
||||
</script>
|
||||
|
||||
<BaseEdge {path} class={`flink${d.dimmed ? ' dimmed' : ''}`} style={dashStyle} />
|
||||
<polygon class={`fhead${d.dimmed ? ' dimmed' : ''}`} points={head} />
|
||||
{#if !d.link.cap}
|
||||
<polygon class={`fhead${d.dimmed ? ' dimmed' : ''}`} points={head} />
|
||||
{/if}
|
||||
<g class={`flabel${d.dimmed ? ' dimmed' : ''}`}>
|
||||
<title>{d.link.label}{d.link.lineLabel ? ` (${d.link.lineLabel})` : ''}</title>
|
||||
{#each above as line, i (i)}
|
||||
|
||||
@@ -459,11 +459,50 @@ export interface WireFlowHop {
|
||||
source: WireFlowSource | null;
|
||||
}
|
||||
|
||||
/** One plausible runtime target of a keyed dispatch — a clickable cap row. */
|
||||
export interface WireBoundaryCandidate {
|
||||
node: WireNodeRef;
|
||||
display: string;
|
||||
named: boolean;
|
||||
}
|
||||
|
||||
/** A dynamic-dispatch site: the form, the key when it is visible, the targets. */
|
||||
export interface WireBoundarySite {
|
||||
form: string;
|
||||
label: string;
|
||||
snippet: string;
|
||||
line: number;
|
||||
key: string | null;
|
||||
keyIsType: boolean;
|
||||
moreSites: number;
|
||||
candidates: WireBoundaryCandidate[];
|
||||
candidateNote: string | null;
|
||||
}
|
||||
|
||||
export interface WireFlowContinuation {
|
||||
node: WireNodeRef;
|
||||
line: number | null;
|
||||
confidence: number | null;
|
||||
}
|
||||
|
||||
/** Where the graph stops — the strip's end cap (design spec §3.5). */
|
||||
export interface WireFlowBoundary {
|
||||
node: WireNodeRef;
|
||||
sites: WireBoundarySite[];
|
||||
uncertain: WireList<WireFlowContinuation>;
|
||||
further: WireList<WireFlowContinuation>;
|
||||
missed: WireNodeRef[];
|
||||
}
|
||||
|
||||
export interface WireFlow {
|
||||
id: string;
|
||||
/** "execute → rowToFileRecord", for the header's flow picker. */
|
||||
label: string;
|
||||
hops: WireFlowHop[];
|
||||
/** Null on a flow that reaches everything it was asked about. */
|
||||
boundary: WireFlowBoundary | null;
|
||||
/** One card at the dispatch site, not a path: the answer ran out here. */
|
||||
partial: boolean;
|
||||
}
|
||||
|
||||
export interface WireFlowAmbiguity {
|
||||
|
||||
+310
-40
@@ -23,7 +23,14 @@
|
||||
* Tested in `__tests__/ui-flow-model.test.ts`.
|
||||
*/
|
||||
|
||||
import type { WireFlow, WireFlowEdge, WireFlowHop } from './api';
|
||||
import type {
|
||||
WireBoundaryCandidate,
|
||||
WireFlow,
|
||||
WireFlowBoundary,
|
||||
WireFlowContinuation,
|
||||
WireFlowEdge,
|
||||
WireFlowHop,
|
||||
} from './api';
|
||||
|
||||
/* ------------------------------------------------------------ dimensions -- */
|
||||
|
||||
@@ -67,6 +74,143 @@ export function cardHeight(hop: WireFlowHop): number {
|
||||
return HEADER_HEIGHT + body;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- end cap -- */
|
||||
|
||||
/** End-cap width (design spec §3.5). */
|
||||
export const END_CAP_WIDTH = 240;
|
||||
/** Padding inside the cap, all four sides. */
|
||||
export const END_CAP_PADDING = 12;
|
||||
/** 12px text at 1.45 — the cap's own line box. */
|
||||
export const END_CAP_LINE = 17.4;
|
||||
/** One mono row: a candidate target, an uncertain continuation. */
|
||||
export const END_CAP_ROW = 18;
|
||||
/** Space between two blocks inside the cap. */
|
||||
export const END_CAP_GAP = 8;
|
||||
|
||||
/**
|
||||
* Characters of 12px Archivo that fit across the cap's 216px of content.
|
||||
*
|
||||
* The cap's height has to be known before it renders, for the same reason a
|
||||
* card's does — the layout packs columns with it. So the text is built here
|
||||
* (see {@link endCapText}), measured with this constant, and the component
|
||||
* renders exactly what was measured. Deliberately a little pessimistic: a cap
|
||||
* estimated too tall leaves white space, a cap estimated too short would put
|
||||
* its last row under the next one.
|
||||
*/
|
||||
const END_CAP_CHARS = 32;
|
||||
|
||||
function wrappedLines(text: string): number {
|
||||
return Math.max(1, Math.ceil(text.length / END_CAP_CHARS));
|
||||
}
|
||||
|
||||
/** One dispatch site as the cap words it. */
|
||||
export interface EndCapSite {
|
||||
/** "computed member call at line 61". */
|
||||
headline: string;
|
||||
/** The statically visible key, set in mono. Null when it is a runtime value. */
|
||||
key: string | null;
|
||||
/** "key is a runtime value", or the "+N more such sites" tail. */
|
||||
notes: string[];
|
||||
candidates: WireBoundaryCandidate[];
|
||||
/** "N candidate targets", the heading over the rows. Null when there are none. */
|
||||
candidateHeading: string | null;
|
||||
/** Why there is no shortlist, when a key was visible but too generic. */
|
||||
candidateNote: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the end cap says, as strings.
|
||||
*
|
||||
* Built here rather than in the component so the layout can measure the cap
|
||||
* before it exists — and so the wording is testable without a browser.
|
||||
*/
|
||||
export interface EndCapText {
|
||||
/** The sentence after the bold "Where the graph stops." lead. */
|
||||
intro: string;
|
||||
sites: EndCapSite[];
|
||||
/** "No dynamic-dispatch site …", when the detector found nothing. */
|
||||
quiet: string | null;
|
||||
uncertainHeading: string | null;
|
||||
uncertain: WireFlowContinuation[];
|
||||
further: string | null;
|
||||
missed: string | null;
|
||||
}
|
||||
|
||||
const plural = (n: number, one: string, many: string): string => (n === 1 ? one : many);
|
||||
|
||||
export function endCapText(boundary: WireFlowBoundary): EndCapText {
|
||||
const sites: EndCapSite[] = boundary.sites.map((site) => {
|
||||
const notes: string[] = [];
|
||||
if (site.key === null) notes.push('the key is a runtime value');
|
||||
if (site.moreSites > 0) {
|
||||
notes.push(`+${site.moreSites} more such ${plural(site.moreSites, 'site', 'sites')} here`);
|
||||
}
|
||||
return {
|
||||
headline: `${site.label} at line ${site.line}`,
|
||||
key: site.key,
|
||||
notes,
|
||||
candidates: site.candidates,
|
||||
candidateHeading:
|
||||
site.candidates.length > 0
|
||||
? `${site.candidates.length} candidate ${plural(site.candidates.length, 'target', 'targets')} \u203a`
|
||||
: null,
|
||||
candidateNote: site.candidateNote,
|
||||
};
|
||||
});
|
||||
|
||||
const missedNames = boundary.missed.map((m) => m.name);
|
||||
return {
|
||||
intro:
|
||||
boundary.sites.length > 0
|
||||
? `${boundary.node.name} chooses its next call at runtime.`
|
||||
: `${boundary.node.name} is the last symbol on this path.`,
|
||||
sites,
|
||||
quiet:
|
||||
boundary.sites.length > 0
|
||||
? null
|
||||
: 'No dynamic-dispatch site was detected in its body, so nothing here explains the break.',
|
||||
uncertainHeading:
|
||||
boundary.uncertain.total > 0
|
||||
? `${boundary.uncertain.total} name-only ${plural(boundary.uncertain.total, 'match', 'matches')} not followed (confidence < 0.6)`
|
||||
: null,
|
||||
uncertain: boundary.uncertain.items,
|
||||
further:
|
||||
boundary.further.total > 0
|
||||
? `It makes ${boundary.further.total} further resolved ${plural(boundary.further.total, 'call', 'calls')} this path does not need.`
|
||||
: null,
|
||||
missed:
|
||||
missedNames.length > 0
|
||||
? `Never reaches ${missedNames.join(', ')}${boundary.missed.length < missedNames.length ? '…' : '.'}`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Exact rendered height of an end cap, which its CSS then pins as a minimum. */
|
||||
export function endCapHeight(boundary: WireFlowBoundary): number {
|
||||
const text = endCapText(boundary);
|
||||
let h = END_CAP_PADDING * 2;
|
||||
h += wrappedLines(`Where the graph stops. ${text.intro}`) * END_CAP_LINE;
|
||||
for (const site of text.sites) {
|
||||
h += END_CAP_GAP;
|
||||
h += wrappedLines(site.headline) * END_CAP_LINE;
|
||||
if (site.key !== null) h += END_CAP_ROW;
|
||||
for (const note of site.notes) h += wrappedLines(note) * END_CAP_LINE;
|
||||
if (site.candidateHeading !== null) {
|
||||
h += END_CAP_LINE + site.candidates.length * END_CAP_ROW;
|
||||
} else if (site.candidateNote !== null) {
|
||||
h += wrappedLines(site.candidateNote) * END_CAP_LINE;
|
||||
}
|
||||
}
|
||||
if (text.quiet !== null) h += END_CAP_GAP + wrappedLines(text.quiet) * END_CAP_LINE;
|
||||
if (text.uncertainHeading !== null) {
|
||||
h += END_CAP_GAP + wrappedLines(text.uncertainHeading) * END_CAP_LINE;
|
||||
h += text.uncertain.length * END_CAP_ROW;
|
||||
}
|
||||
if (text.further !== null) h += END_CAP_GAP + wrappedLines(text.further) * END_CAP_LINE;
|
||||
if (text.missed !== null) h += END_CAP_GAP + wrappedLines(text.missed) * END_CAP_LINE;
|
||||
return Math.round(h);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- model -- */
|
||||
|
||||
export interface FlowCardLayout {
|
||||
@@ -82,13 +226,22 @@ export interface FlowCardLayout {
|
||||
flows: string[];
|
||||
/** Position in the ACTIVE flow, or -1 when it is not on it. */
|
||||
step: number;
|
||||
/**
|
||||
* The dispatch line an end cap hangs off, when one does.
|
||||
*
|
||||
* The card is tinted there for the same reason a hop is tinted at its call
|
||||
* site: it is the line the next thing on screen is about. A boundary card has
|
||||
* no resolved call to link, so the tint is all the connection there is.
|
||||
*/
|
||||
stopLine: number | null;
|
||||
}
|
||||
|
||||
export interface FlowLinkLayout {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
edge: WireFlowEdge;
|
||||
/** Null on the dotted link into an end cap — no edge records a non-call. */
|
||||
edge: WireFlowEdge | null;
|
||||
/** Flows this link belongs to. */
|
||||
flows: string[];
|
||||
/** The full label, for the connector's tooltip. */
|
||||
@@ -104,10 +257,29 @@ export interface FlowLinkLayout {
|
||||
lineLabel: string | null;
|
||||
/** SVG dasharray, or null for a solid line. */
|
||||
dash: string | null;
|
||||
/** This is the dotted link into an end cap, not a recorded edge. */
|
||||
cap: boolean;
|
||||
}
|
||||
|
||||
/** An end cap, placed one column past the symbol whose path stopped. */
|
||||
export interface FlowEndCapLayout {
|
||||
/** `cap:<anchor node id>`. */
|
||||
id: string;
|
||||
/** The card the dotted link comes out of. */
|
||||
anchorId: string;
|
||||
boundary: WireFlowBoundary;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
column: number;
|
||||
/** Flows that stop here — what dims when one is picked. */
|
||||
flows: string[];
|
||||
}
|
||||
|
||||
export interface FlowLayout {
|
||||
cards: FlowCardLayout[];
|
||||
endCaps: FlowEndCapLayout[];
|
||||
links: FlowLinkLayout[];
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -124,6 +296,14 @@ export function dashFor(edge: WireFlowEdge): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The dotted link into an end cap (design spec §3.5). */
|
||||
export const END_CAP_DASH = '2 4';
|
||||
|
||||
/** The layout id of the cap hanging off `anchorId`, and the way back. */
|
||||
export const capId = (anchorId: string): string => `cap:${anchorId}`;
|
||||
export const isCapId = (id: string): boolean => id.startsWith('cap:');
|
||||
export const anchorOf = (id: string): string => (isCapId(id) ? id.slice(4) : id);
|
||||
|
||||
/** Longest a connector label line may be before it is cut. */
|
||||
export const LABEL_MAX_CHARS = 26;
|
||||
|
||||
@@ -209,7 +389,7 @@ export function buildFlowLayout(flows: readonly WireFlow[], activeId: string | n
|
||||
}
|
||||
|
||||
if (cards.size === 0) {
|
||||
return { cards: [], links: [], width: 0, height: 0, columns: 0, gaps: [] };
|
||||
return { cards: [], endCaps: [], links: [], width: 0, height: 0, columns: 0, gaps: [] };
|
||||
}
|
||||
|
||||
// ---- column = longest distance from a start -----------------------------
|
||||
@@ -241,20 +421,69 @@ export function buildFlowLayout(flows: readonly WireFlow[], activeId: string | n
|
||||
column.set(id, best);
|
||||
}
|
||||
|
||||
// ---- the end caps ------------------------------------------------------
|
||||
// One cap per stopping symbol, not per flow: two paths that run out at the
|
||||
// same place ran out for the same reason, and two caps side by side saying so
|
||||
// would read as two different findings.
|
||||
const caps = new Map<string, { boundary: WireFlowBoundary; flows: string[] }>();
|
||||
for (const flow of flows) {
|
||||
const boundary = flow.boundary;
|
||||
if (!boundary || !cards.has(boundary.node.id)) continue;
|
||||
const hit = caps.get(boundary.node.id);
|
||||
if (hit) {
|
||||
if (!hit.flows.includes(flow.id)) hit.flows.push(flow.id);
|
||||
} else {
|
||||
caps.set(boundary.node.id, { boundary, flows: [flow.id] });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pack each column, active flow first --------------------------------
|
||||
interface Member {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Cards before caps, then first-seen order. */
|
||||
rank: number;
|
||||
onActive: boolean;
|
||||
}
|
||||
const members = new Map<string, Member>();
|
||||
for (const [id, card] of cards) {
|
||||
members.set(id, {
|
||||
id,
|
||||
width: CARD_WIDTH,
|
||||
height: cardHeight(card.hop),
|
||||
rank: card.order,
|
||||
onActive: activeSteps.has(id),
|
||||
});
|
||||
}
|
||||
const capColumn = new Map<string, number>();
|
||||
let capRank = cards.size;
|
||||
for (const [anchorId, cap] of caps) {
|
||||
const id = capId(anchorId);
|
||||
capColumn.set(id, (column.get(anchorId) ?? 0) + 1);
|
||||
members.set(id, {
|
||||
id,
|
||||
width: END_CAP_WIDTH,
|
||||
height: endCapHeight(cap.boundary),
|
||||
rank: capRank++,
|
||||
onActive: activeSteps.has(anchorId),
|
||||
});
|
||||
}
|
||||
const columnOf = (id: string): number => capColumn.get(id) ?? column.get(id) ?? 0;
|
||||
|
||||
const byColumn = new Map<number, string[]>();
|
||||
for (const id of cards.keys()) {
|
||||
const c = column.get(id) ?? 0;
|
||||
for (const id of members.keys()) {
|
||||
const c = columnOf(id);
|
||||
const list = byColumn.get(c);
|
||||
if (list) list.push(id);
|
||||
else byColumn.set(c, [id]);
|
||||
}
|
||||
for (const list of byColumn.values()) {
|
||||
list.sort((a, b) => {
|
||||
const onA = activeSteps.has(a) ? 0 : 1;
|
||||
const onB = activeSteps.has(b) ? 0 : 1;
|
||||
const onA = (members.get(a) as Member).onActive ? 0 : 1;
|
||||
const onB = (members.get(b) as Member).onActive ? 0 : 1;
|
||||
if (onA !== onB) return onA - onB;
|
||||
return (cards.get(a)?.order ?? 0) - (cards.get(b)?.order ?? 0);
|
||||
return (members.get(a) as Member).rank - (members.get(b) as Member).rank;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -262,7 +491,8 @@ export function buildFlowLayout(flows: readonly WireFlow[], activeId: string | n
|
||||
|
||||
// Each gap is wide enough for the widest label that crosses it. Labels are
|
||||
// built here rather than in the render pass because the geometry depends on
|
||||
// them — see LABEL_CHAR_WIDTH.
|
||||
// them — see LABEL_CHAR_WIDTH. A cap's dotted link keeps the spec's 86px: its
|
||||
// label is fixed and stacks into two short lines.
|
||||
const labelled = [...links.entries()].map(([key, link]) => ({
|
||||
key,
|
||||
link,
|
||||
@@ -276,60 +506,100 @@ export function buildFlowLayout(flows: readonly WireFlow[], activeId: string | n
|
||||
const widest = Math.max(0, ...lines.map((l) => l.length), lineLabel?.length ?? 0);
|
||||
gaps[from] = Math.max(gaps[from] as number, Math.ceil(widest * LABEL_CHAR_WIDTH) + LABEL_PAD);
|
||||
}
|
||||
|
||||
// A column is as wide as its widest member, so a cap sharing a column with a
|
||||
// card does not push the card's neighbours out of line.
|
||||
const columnWidth = Array.from({ length: columns }, () => 0);
|
||||
for (const [c, list] of byColumn) {
|
||||
columnWidth[c] = Math.max(...list.map((id) => (members.get(id) as Member).width));
|
||||
}
|
||||
const columnX: number[] = [PADDING];
|
||||
for (let c = 1; c < columns; c++) {
|
||||
columnX[c] = (columnX[c - 1] as number) + CARD_WIDTH + (gaps[c - 1] as number);
|
||||
columnX[c] = (columnX[c - 1] as number) + (columnWidth[c - 1] as number) + (gaps[c - 1] as number);
|
||||
}
|
||||
|
||||
const heights = new Map<string, number>();
|
||||
for (const [id, card] of cards) heights.set(id, cardHeight(card.hop));
|
||||
|
||||
// Rows are centred on the tallest column, so a one-card column sits opposite
|
||||
// the middle of a two-card one instead of hugging the top of the canvas.
|
||||
const columnHeights = new Map<number, number>();
|
||||
for (const [c, list] of byColumn) {
|
||||
columnHeights.set(
|
||||
c,
|
||||
list.reduce((sum, id) => sum + (heights.get(id) ?? 0), 0) + ROW_GAP * (list.length - 1)
|
||||
list.reduce((sum, id) => sum + (members.get(id) as Member).height, 0) +
|
||||
ROW_GAP * (list.length - 1)
|
||||
);
|
||||
}
|
||||
const tallest = Math.max(...columnHeights.values());
|
||||
|
||||
const laidOut = new Map<string, FlowCardLayout>();
|
||||
const endCaps: FlowEndCapLayout[] = [];
|
||||
for (const [c, list] of byColumn) {
|
||||
let y = PADDING + (tallest - (columnHeights.get(c) ?? 0)) / 2;
|
||||
for (const id of list) {
|
||||
const card = cards.get(id) as { hop: WireFlowHop; flows: string[]; order: number };
|
||||
const height = heights.get(id) ?? 0;
|
||||
laidOut.set(id, {
|
||||
id,
|
||||
hop: card.hop,
|
||||
x: columnX[c] as number,
|
||||
y,
|
||||
width: CARD_WIDTH,
|
||||
height,
|
||||
column: c,
|
||||
flows: card.flows,
|
||||
step: activeSteps.get(id) ?? -1,
|
||||
});
|
||||
y += height + ROW_GAP;
|
||||
const member = members.get(id) as Member;
|
||||
const cap = caps.get(anchorOf(id));
|
||||
if (cap && isCapId(id)) {
|
||||
endCaps.push({
|
||||
id,
|
||||
anchorId: anchorOf(id),
|
||||
boundary: cap.boundary,
|
||||
x: columnX[c] as number,
|
||||
y,
|
||||
width: member.width,
|
||||
height: member.height,
|
||||
column: c,
|
||||
flows: cap.flows,
|
||||
});
|
||||
} else {
|
||||
const card = cards.get(id) as { hop: WireFlowHop; flows: string[]; order: number };
|
||||
laidOut.set(id, {
|
||||
id,
|
||||
hop: card.hop,
|
||||
x: columnX[c] as number,
|
||||
y,
|
||||
width: member.width,
|
||||
height: member.height,
|
||||
column: c,
|
||||
flows: card.flows,
|
||||
step: activeSteps.get(id) ?? -1,
|
||||
stopLine: caps.get(id)?.boundary.sites[0]?.line ?? null,
|
||||
});
|
||||
}
|
||||
y += member.height + ROW_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
const linkLayouts: FlowLinkLayout[] = labelled.map(({ link, lines, lineLabel }) => ({
|
||||
id: `${link.source}->${link.target}`,
|
||||
source: link.source,
|
||||
target: link.target,
|
||||
edge: link.edge,
|
||||
flows: link.flows,
|
||||
label: link.edge.label,
|
||||
labelLines: lines,
|
||||
lineLabel,
|
||||
dash: dashFor(link.edge),
|
||||
cap: false,
|
||||
}));
|
||||
for (const cap of endCaps) {
|
||||
linkLayouts.push({
|
||||
id: `${cap.anchorId}->${cap.id}`,
|
||||
source: cap.anchorId,
|
||||
target: cap.id,
|
||||
edge: null,
|
||||
flows: cap.flows,
|
||||
label: 'end of static path',
|
||||
labelLines: ['end of', 'static path'],
|
||||
lineLabel: null,
|
||||
dash: END_CAP_DASH,
|
||||
cap: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
cards: [...laidOut.values()].sort((a, b) => a.column - b.column || a.y - b.y),
|
||||
links: labelled.map(({ link, lines, lineLabel }) => ({
|
||||
id: `${link.source}->${link.target}`,
|
||||
source: link.source,
|
||||
target: link.target,
|
||||
edge: link.edge,
|
||||
flows: link.flows,
|
||||
label: link.edge.label,
|
||||
labelLines: lines,
|
||||
lineLabel,
|
||||
dash: dashFor(link.edge),
|
||||
})),
|
||||
width: (columnX[columns - 1] as number) + CARD_WIDTH + PADDING,
|
||||
endCaps: endCaps.sort((a, b) => a.column - b.column || a.y - b.y),
|
||||
links: linkLayouts,
|
||||
width: (columnX[columns - 1] as number) + (columnWidth[columns - 1] as number) + PADDING,
|
||||
height: PADDING * 2 + tallest,
|
||||
columns,
|
||||
gaps,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import '@xyflow/svelte/dist/style.css';
|
||||
import FlowCard from '../components/flow/FlowCard.svelte';
|
||||
import FlowLink from '../components/flow/FlowLink.svelte';
|
||||
import FlowEndCap from '../components/flow/FlowEndCap.svelte';
|
||||
import { fetchFlow, type WireFlow, type WireFlowPayload } from '../lib/api';
|
||||
import { live } from '../lib/live.svelte';
|
||||
import { navigate, symbolHref } from '../lib/router.svelte';
|
||||
@@ -55,7 +56,7 @@
|
||||
* there for anyone who wants the shape rather than the code.
|
||||
*/
|
||||
const START_VIEWPORT = { x: 0, y: 0, zoom: 1 };
|
||||
const nodeTypes = { flow: FlowCard };
|
||||
const nodeTypes = { flow: FlowCard, cap: FlowEndCap };
|
||||
const edgeTypes = { flow: FlowLink };
|
||||
|
||||
/** The hops the trail form asks for, as `<dir><id>` — the wire's own spelling. */
|
||||
@@ -107,24 +108,41 @@
|
||||
|
||||
const nodes = $derived.by<Node[]>(() => {
|
||||
if (layout === null) return [];
|
||||
return layout.cards.map((card) => ({
|
||||
id: card.id,
|
||||
type: 'flow',
|
||||
position: { x: card.x, y: card.y },
|
||||
const caps: Node[] = layout.endCaps.map((cap) => ({
|
||||
id: cap.id,
|
||||
type: 'cap',
|
||||
position: { x: cap.x, y: cap.y },
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
connectable: false,
|
||||
data: {
|
||||
// The accent border marks the picked path, and only means something
|
||||
// when there is more than one on screen. A single flow whose every
|
||||
// card is accented has said nothing.
|
||||
card,
|
||||
current: showAll && card.step >= 0,
|
||||
dimmed: showAll && card.step < 0,
|
||||
onOpen: openCard,
|
||||
onFollow: followCard,
|
||||
cap,
|
||||
dimmed: showAll && picked !== null && !cap.flows.includes(picked),
|
||||
onOpen: openNode,
|
||||
},
|
||||
}));
|
||||
// Caps first, so a card that overlaps one paints on top of it.
|
||||
return [
|
||||
...caps,
|
||||
...layout.cards.map((card) => ({
|
||||
id: card.id,
|
||||
type: 'flow',
|
||||
position: { x: card.x, y: card.y },
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
connectable: false,
|
||||
data: {
|
||||
// The accent border marks the picked path, and only means something
|
||||
// when there is more than one on screen. A single flow whose every
|
||||
// card is accented has said nothing.
|
||||
card,
|
||||
current: showAll && card.step >= 0,
|
||||
dimmed: showAll && card.step < 0,
|
||||
onOpen: openCard,
|
||||
onFollow: followCard,
|
||||
},
|
||||
})),
|
||||
];
|
||||
});
|
||||
|
||||
const edges = $derived.by<Edge[]>(() => {
|
||||
@@ -172,6 +190,19 @@
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A row on the end cap: a candidate runtime target, or a continuation the
|
||||
* search refused to follow.
|
||||
*
|
||||
* It opens as a fresh start rather than as another hop, because neither is a
|
||||
* call the graph recorded — pushing one onto the trail would draw a step
|
||||
* nobody took. That is the whole reason the cap exists.
|
||||
*/
|
||||
function openNode(nodeId: string): void {
|
||||
trail.clear();
|
||||
navigate(symbolHref(nodeId));
|
||||
}
|
||||
|
||||
/** The accent link inside a card: step to the symbol it names. */
|
||||
function followCard(card: FlowCardLayout): void {
|
||||
const target = card.hop.callRef?.targetId;
|
||||
@@ -184,6 +215,9 @@
|
||||
if (p.query.kind === 'trail') {
|
||||
return 'Your trail, read as a flow: each card is opened at the line that carried you to the next one.';
|
||||
}
|
||||
if (p.flows.some((f) => f.partial)) {
|
||||
return 'No static path connects them. The card is where the looking stopped — a call whose target is chosen at runtime — and the cap names the form, the key and who could be on the other side.';
|
||||
}
|
||||
if (p.query.kind === 'directed') {
|
||||
return 'Every card is a call the graph recorded. A dashed link is a hop no one can see in the source — a callback, an interface, a re-render — and it names where it was wired.';
|
||||
}
|
||||
@@ -205,7 +239,9 @@
|
||||
}}
|
||||
>
|
||||
{#each flows as flow (flow.id)}
|
||||
<option value={flow.id}>{flow.label} · {flow.hops.length} hops</option>
|
||||
<option value={flow.id}
|
||||
>{flow.label}{flow.hops.length > 1 ? ` · ${flow.hops.length} hops` : ''}</option
|
||||
>
|
||||
{/each}
|
||||
{#if flows.length > 1}
|
||||
<option value={ALL}>All {flows.length} paths</option>
|
||||
@@ -265,8 +301,11 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if payload && (payload.ambiguous.length > 0 || payload.unresolved.length > 0)}
|
||||
{#if payload && (payload.reason !== null || payload.ambiguous.length > 0 || payload.unresolved.length > 0) && layout !== null}
|
||||
<footer class="fnote">
|
||||
{#if payload.reason !== null}
|
||||
<p>{payload.reason}</p>
|
||||
{/if}
|
||||
{#each payload.ambiguous as amb (amb.token)}
|
||||
<p>
|
||||
<span class="mono">{amb.token}</span> names {amb.others.length + 1} definitions.
|
||||
|
||||
Reference in New Issue
Block a user