feat(expo-router): add Expo Router support for Screens and navigations
Introduce Expo Router integration by adding a new Screens view and API to surface screens and their transitions. Implement a map-based layout with directional ports, extended layering and port pitch to accommodate edge labels, and a pill-based labeling system for transition conditions. Include tests for the new map/screens models, updates to the UI components, and design/docs changes reflecting the Screens design. Merge CodeGraph UI viewer changes to render and interact with Expo Router-based screen graphs. This enables CodeGraph UI to surface screens and navigations from Expo Router apps.
This commit is contained in:
+61
-1
@@ -186,11 +186,12 @@ src/
|
||||
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/entry-model.ts the entry-points panel: rows, file groups, flow arming (pure)
|
||||
lib/screens-model.ts the Screens view: layering by distance from the entry, edge labels, pill lanes (pure)
|
||||
lib/export-svg.ts the Flow strip and the Map as a standalone SVG (pure)
|
||||
lib/export-image.ts rasterising that SVG to PNG, clipboard and download
|
||||
lib/live.svelte.ts /api/events: two counters every screen refreshes from
|
||||
lib/toast.svelte.ts the one transient note ("Index updated · reloaded")
|
||||
components/ TopBar, TrailBar, SavedTrails, KindGlyph, DriftBanner, Toast, ExportButtons, map/, flow/, symbol/, file/, entry/
|
||||
components/ TopBar, TrailBar, SavedTrails, KindGlyph, DriftBanner, Toast, ExportButtons, map/, flow/, symbol/, file/, entry/, screens/
|
||||
views/ one component per route
|
||||
```
|
||||
|
||||
@@ -232,6 +233,7 @@ Mono, so the code grid survives and only the letterforms change.
|
||||
| `#/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 |
|
||||
| `#/entry` | entry points — routes, files that run something, tests, hubs |
|
||||
| `#/screens` | screens — the app's screens and the transitions between them |
|
||||
|
||||
## Entry points
|
||||
|
||||
@@ -255,6 +257,64 @@ not accidents:
|
||||
`buildEntryPanel` is pure and keeps `panel.rows` exactly equal to the sections it
|
||||
draws, the same identity the search palette rests its keyboard on.
|
||||
|
||||
## Screens
|
||||
|
||||
`#/screens` draws `/api/screens` — the app as its user meets it: one box per
|
||||
screen, an arrow for every way of getting from one to another, and on each
|
||||
arrow the condition under which it happens. The canvas is the Map's layout
|
||||
engine (`buildMapLayout`) driven by `screens-model.ts` with three options the
|
||||
Map never sets, because a screens graph differs from a module graph in one way
|
||||
that shapes the whole picture: it is full of cycles. Every screen returns to
|
||||
Home.
|
||||
|
||||
- **Layering is distance from the entry screen**, measured over every
|
||||
transition — not longest path over a two-cycle-broken set. Shared chrome (a
|
||||
top bar rendered on ten screens) hangs one row above the shallowest screen it
|
||||
opens, so what it opens is placed by the entry and never dragged up beside
|
||||
Home; what only chrome reaches is seeded from the chrome. Whatever nothing
|
||||
reaches sits in a band at the bottom, one empty row below the rest.
|
||||
- **Ports are directional.** A transition down the picture leaves the bottom
|
||||
of its box and arrives at the top of the other, as on the Map; a return
|
||||
leaves the **top** of its source and arrives at the **bottom** of its target,
|
||||
so it is drawn around the boxes rather than through them; a transition
|
||||
between two screens on one row arches over the row, top to top. A hub widens
|
||||
so its ports are at least 12px apart (`portPitch`), and rows are 116px apart
|
||||
instead of the Map's 74 (`layerGap`), because the edges here carry labels.
|
||||
- **Lines fan out instead of stacking.** Drawn through one midpoint, every
|
||||
line between two rows crosses that height at its middle, and a line to a
|
||||
screen far to the side is nearly horizontal there — a hub's lines run stacked
|
||||
within a few pixels for hundreds, and no pointer can pick one.
|
||||
`trackedCurves` gives each line in a fan (the lines leaving one side of one
|
||||
box towards one side) a track of its own: the farthest-reaching runs nearest
|
||||
the hub's row, the next a track further out, nested in port order so no two
|
||||
lines of a fan cross; a line spanning several rows keeps its track beside
|
||||
its fan and drops the rest of the way vertically; a wider level arch rises
|
||||
higher than a narrower one. There are no hit paths: hovering the canvas
|
||||
means the line **nearest** the pointer (`nearestEdge`, within 10 screen
|
||||
pixels), so moving a few pixels moves to the next line, predictably. Zoom
|
||||
runs to 3× — the honest spacing control, since it scales lines and text
|
||||
together.
|
||||
- **Labels are placed by the model, at the far end.** At rest the picture is
|
||||
boxes and lines. Selecting a screen labels each of its transitions with the
|
||||
innermost condition — the clause decided at the navigation call
|
||||
(`…guide.dontShowAgain.captureGuide`); the first thirty characters of the
|
||||
whole chain are usually shared with a sibling — prefixed `→` leaving the
|
||||
selected screen or `←` arriving at it. `placeLabels` puts every pill beside
|
||||
the screen at the *other* end of its line, where the lines are apart (beside
|
||||
the selected screen fifteen of them share one box's width), in the first of
|
||||
five lanes walking away from that box in which it overlaps nothing, centred
|
||||
on its own curve at that height. A pill that fits nowhere is counted and the
|
||||
panel says so. Pills are HTML in Svelte Flow's edge-label layer, above every
|
||||
stroke; the selection alone decides where they go, so hovering never reflows
|
||||
them.
|
||||
- **The panel and the picture point at each other.** A row under the pointer
|
||||
lights its line and prints the whole condition on it; a line under the
|
||||
pointer tints its row.
|
||||
|
||||
The geometry — ports, curves, pill lanes — is arithmetic in `screens-model.ts`
|
||||
and `map-model.ts`, tested without a browser in
|
||||
`__tests__/ui-screens-model.test.ts`.
|
||||
|
||||
## Where the graph stops
|
||||
|
||||
A flow that does not reach everything it was asked about carries a
|
||||
|
||||
@@ -1,68 +1,73 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* One transition on the Screens view — the Map's cubic, plus a label at the
|
||||
* midpoint saying under what condition it happens. A pair with several
|
||||
* transitions draws once and counts them; the tooltip and the side panel
|
||||
* tell them apart. Dashed when every transition behind it rides a
|
||||
* One transition on the Screens view — the curve the model chose for it
|
||||
* (`trackedCurves`: the Map's cubic on a track of its own for a transition
|
||||
* down or up the picture, an arch for one along a row) and, when the model
|
||||
* placed one, a pill saying under what condition it happens. A pair with
|
||||
* several transitions draws once and counts them; the tooltip and the side
|
||||
* panel tell them apart. Dashed when every transition behind it rides a
|
||||
* synthesized hop (a helper's return value); accent-dashed when it points
|
||||
* back up the layering (Capture → Home).
|
||||
*
|
||||
* The path is drawn from the model's curve, not from the endpoints Svelte
|
||||
* Flow measured, so the line on screen is the line the labels were placed
|
||||
* on and the line the pointer is tested against. There is no hit path: the
|
||||
* view finds the line nearest the pointer (`nearestEdge`), which in a fan
|
||||
* of lines a few pixels apart is the only way to mean one of them.
|
||||
*
|
||||
* The pill is HTML in Svelte Flow's edge-label layer rather than SVG inside
|
||||
* this edge's own group. The layer sits above every edge path, so a pill is
|
||||
* never drawn under the next edge's stroke — the fate of a label that lives
|
||||
* inside one edge among thirty.
|
||||
*/
|
||||
import { BaseEdge, type EdgeProps } from '@xyflow/svelte';
|
||||
import { BaseEdge, EdgeLabel, type EdgeProps } from '@xyflow/svelte';
|
||||
import type { MapEdgeLayout } from '../../lib/map-model';
|
||||
import type { ScreenEdgeInfo } from '../../lib/screens-model';
|
||||
import { pathOf, type Curve, type PillPlacement, type ScreenEdgeInfo } from '../../lib/screens-model';
|
||||
|
||||
let { sourceX, sourceY, targetX, targetY, data }: EdgeProps = $props();
|
||||
let { data }: EdgeProps = $props();
|
||||
|
||||
const d = $derived(
|
||||
data as unknown as {
|
||||
edge: MapEdgeLayout;
|
||||
info: ScreenEdgeInfo;
|
||||
curve: Curve;
|
||||
/** One of the selected screen's, or under the pointer. */
|
||||
hot: boolean;
|
||||
/** Hot, but another of the selected screen's edges is the one in focus. */
|
||||
soft: boolean;
|
||||
/** Under the pointer, or its row in the panel is. */
|
||||
focus: boolean;
|
||||
dimmed: boolean;
|
||||
/** Show the label; only the selected screen's edges and the hovered one do. */
|
||||
labelled: boolean;
|
||||
/**
|
||||
* Where along the curve the label sits, 0 = source end, 1 = target end.
|
||||
* Close to the selected screen, where its lines are still apart, so a
|
||||
* label sits beside the one line it belongs to.
|
||||
*/
|
||||
labelAt: number;
|
||||
/** Where the model put the label; null draws none. */
|
||||
pill: PillPlacement | null;
|
||||
/** The whole condition, when the panel's row is hovered; replaces the pill's words. */
|
||||
full: string | null;
|
||||
onHover: (edge: MapEdgeLayout | null, event: MouseEvent | null) => void;
|
||||
}
|
||||
);
|
||||
|
||||
const midY = $derived((sourceY + targetY) / 2);
|
||||
const path = $derived(`M${sourceX},${sourceY} C${sourceX},${midY} ${targetX},${midY} ${targetX},${targetY}`);
|
||||
|
||||
/** The point at `t` on the same cubic the path draws. */
|
||||
const label = $derived.by(() => {
|
||||
const t = d.labelAt;
|
||||
const u = 1 - t;
|
||||
const x = u * u * u * sourceX + 3 * u * u * t * sourceX + 3 * u * t * t * targetX + t * t * t * targetX;
|
||||
const y = u * u * u * sourceY + 3 * u * u * t * midY + 3 * u * t * t * midY + t * t * t * targetY;
|
||||
return { x, y };
|
||||
});
|
||||
/** IBM Plex Mono at 10.5px: ~6.3px per character, plus the pill's padding. */
|
||||
const pillWidth = $derived(d.info.label.length * 6.3 + 12);
|
||||
const path = $derived(pathOf(d.curve));
|
||||
const classes = $derived(
|
||||
`sedge${d.edge.back ? ' back' : ''}${d.info.synthesized ? ' synth' : ''}${d.hot ? ' hot' : ''}${
|
||||
d.soft ? ' soft' : ''
|
||||
}${d.focus ? ' focus' : ''}${d.dimmed ? ' dimmed' : ''}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<BaseEdge
|
||||
{path}
|
||||
class={`sedge${d.edge.back ? ' back' : ''}${d.hot ? ' hot' : ''}${d.dimmed ? ' dimmed' : ''}${d.info.synthesized ? ' synth' : ''}`}
|
||||
style={`stroke-width:${Math.min(3, d.edge.width)}px`}
|
||||
/>
|
||||
<path
|
||||
class="hit"
|
||||
d={path}
|
||||
role="presentation"
|
||||
onmousemove={(event) => d.onHover(d.edge, event)}
|
||||
onmouseleave={() => d.onHover(null, null)}
|
||||
/>
|
||||
{#if d.info.label && d.labelled}
|
||||
<g class="epill" class:hot={d.hot}>
|
||||
<rect x={label.x - pillWidth / 2} y={label.y - 9} width={pillWidth} height={17} rx="2" />
|
||||
<text x={label.x} y={label.y + 3.5} text-anchor="middle">{d.info.label}</text>
|
||||
</g>
|
||||
<BaseEdge {path} class={classes} style={`stroke-width:${Math.min(3, d.edge.width)}px`} />
|
||||
{#if d.pill !== null}
|
||||
<EdgeLabel
|
||||
x={d.pill.x}
|
||||
y={d.pill.y}
|
||||
transparent
|
||||
class="spill-anchor"
|
||||
onmousemove={(event) => d.onHover(d.edge, event)}
|
||||
onmouseleave={() => d.onHover(null, null)}
|
||||
>
|
||||
<span class="spill" class:hot={d.hot} class:focus={d.focus} class:full={d.full !== null}>
|
||||
{d.full ?? d.pill.text}
|
||||
</span>
|
||||
</EdgeLabel>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@@ -71,12 +76,6 @@
|
||||
stroke-opacity: 0.32;
|
||||
fill: none;
|
||||
}
|
||||
:global(.svelte-flow__edge-path.sedge.hot) {
|
||||
stroke-opacity: 0.95;
|
||||
}
|
||||
:global(.svelte-flow__edge-path.sedge.dimmed) {
|
||||
stroke-opacity: 0.06;
|
||||
}
|
||||
:global(.svelte-flow__edge-path.sedge.synth) {
|
||||
stroke-dasharray: 5 3;
|
||||
}
|
||||
@@ -85,29 +84,60 @@
|
||||
stroke-opacity: 0.6;
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
.hit {
|
||||
stroke: transparent;
|
||||
stroke-width: 12;
|
||||
fill: none;
|
||||
pointer-events: stroke;
|
||||
:global(.svelte-flow__edge-path.sedge.hot) {
|
||||
stroke-opacity: 0.95;
|
||||
}
|
||||
:global(.svelte-flow__edge-path.sedge.back.hot) {
|
||||
stroke-opacity: 0.85;
|
||||
}
|
||||
/* Another of the selected screen's lines is in focus: this one recedes,
|
||||
without going, so the reader can still count them. */
|
||||
:global(.svelte-flow__edge-path.sedge.hot.soft) {
|
||||
stroke-opacity: 0.38;
|
||||
}
|
||||
:global(.svelte-flow__edge-path.sedge.focus) {
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
:global(.svelte-flow__edge-path.sedge.dimmed) {
|
||||
stroke-opacity: 0.06;
|
||||
}
|
||||
/* The label layer's own box: no padding of its own, so the pill's
|
||||
rectangle is the one the lane arithmetic reserved. */
|
||||
:global(.svelte-flow__edge-label.spill-anchor) {
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
font-size: 0;
|
||||
}
|
||||
.spill {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
height: 17px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--rule);
|
||||
background: var(--paper);
|
||||
color: var(--ink-2);
|
||||
font: 400 10.5px var(--mono);
|
||||
line-height: 15px;
|
||||
white-space: nowrap;
|
||||
cursor: crosshair;
|
||||
}
|
||||
.epill {
|
||||
pointer-events: none;
|
||||
.spill.hot {
|
||||
border-color: var(--ink-3);
|
||||
color: var(--ink);
|
||||
}
|
||||
.epill rect {
|
||||
fill: var(--paper);
|
||||
stroke: var(--rule);
|
||||
stroke-width: 1px;
|
||||
.spill.focus {
|
||||
border-color: var(--ink);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.epill text {
|
||||
font: 400 10.5px var(--mono);
|
||||
fill: var(--ink-2);
|
||||
}
|
||||
.epill.hot rect {
|
||||
stroke: var(--ink-3);
|
||||
}
|
||||
.epill.hot text {
|
||||
fill: var(--ink);
|
||||
/* The whole condition, for the row under the pointer in the panel: it may
|
||||
wrap, and it may cover its neighbours — it is on top, and transient. */
|
||||
.spill.full {
|
||||
height: auto;
|
||||
max-width: 360px;
|
||||
padding: 2px 6px;
|
||||
line-height: 13px;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
* store action after login) — draws dashed, so it reads as a trigger rather
|
||||
* than a place. The entry screen (`/`) carries a mark.
|
||||
*
|
||||
* Hidden handles along the top and bottom, one per link, exactly as the
|
||||
* Map's module box does: the layout decided the ports, this only draws them.
|
||||
* Hidden handles along the top and bottom, one per port, exactly as the
|
||||
* Map's module box does — except that here a side may hold both kinds. The
|
||||
* layout routed every transition (`directional` ports): a return trip
|
||||
* leaves the TOP of this box and arrives at the BOTTOM of the screen it
|
||||
* returns to, so the line runs around the boxes instead of through them.
|
||||
* This only draws the ports the layout decided.
|
||||
*/
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/svelte';
|
||||
import type { MapNodeLayout } from '../../lib/map-model';
|
||||
@@ -31,12 +35,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each layout.targetHandles as handle, i (handle)}
|
||||
{#each layout.ports.top as port, i (`${port.type}:${port.id}`)}
|
||||
<Handle
|
||||
type="target"
|
||||
id={`t:${handle}`}
|
||||
type={port.type}
|
||||
id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
|
||||
position={Position.Top}
|
||||
style={portStyle(i, layout.targetHandles.length)}
|
||||
style={portStyle(i, layout.ports.top.length)}
|
||||
isConnectable={false}
|
||||
/>
|
||||
{/each}
|
||||
@@ -61,12 +65,12 @@
|
||||
<span class="sub">{info.sub}</span>
|
||||
</button>
|
||||
|
||||
{#each layout.sourceHandles as handle, i (handle)}
|
||||
{#each layout.ports.bottom as port, i (`${port.type}:${port.id}`)}
|
||||
<Handle
|
||||
type="source"
|
||||
id={`s:${handle}`}
|
||||
type={port.type}
|
||||
id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
|
||||
position={Position.Bottom}
|
||||
style={portStyle(i, layout.sourceHandles.length)}
|
||||
style={portStyle(i, layout.ports.bottom.length)}
|
||||
isConnectable={false}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
+146
-22
@@ -32,6 +32,14 @@
|
||||
* no declared edge behind it — is marked `back` and drawn only when a module it
|
||||
* touches is selected. Drawing it downward would be a lie about the direction
|
||||
* of the dependency; hiding it entirely would be a lie about its existence.
|
||||
*
|
||||
* The Screens view runs the same pipeline with three options the Map leaves at
|
||||
* their defaults: its own layering (distance from the entry screen), a wider
|
||||
* layer gap (its edges carry labels), and `directional` ports — a link that
|
||||
* points up the layering leaves the TOP of its source and arrives at the
|
||||
* BOTTOM of its target, so a return trip is drawn around the boxes instead of
|
||||
* through them. In a screens graph a cycle is the normal case, not the
|
||||
* exception the Map hides at rest.
|
||||
*/
|
||||
|
||||
import type { WireMapLink, WireMapModule, WireMapPayload } from './api';
|
||||
@@ -43,6 +51,12 @@ export const NODE_GAP = 34;
|
||||
export const PADDING = 44;
|
||||
/** Least horizontal room a layer gets per module, so a sparse row still spreads. */
|
||||
const MIN_SLOT = 230;
|
||||
/**
|
||||
* Room between two ports on one side of a box, when a view asks for it
|
||||
* (`portPitch`). Fifteen lines leaving a 110px box are 7px apart and read as
|
||||
* one; at 12px they are a fan a reader can follow back to its box.
|
||||
*/
|
||||
export const PORT_PITCH = 12;
|
||||
const MIN_NODE_WIDTH = 110;
|
||||
/**
|
||||
* IBM Plex Mono's real advance at 13px (0.6em), not the spec's 7.3 estimate.
|
||||
@@ -111,6 +125,12 @@ export function moduleMetaLabel(module: WireMapModule, island = false): string {
|
||||
return `${symbols} · ${files}`;
|
||||
}
|
||||
|
||||
/** One port on a box's edge: the link it belongs to, and which end of it this is. */
|
||||
export interface PortRef {
|
||||
id: string;
|
||||
type: 'source' | 'target';
|
||||
}
|
||||
|
||||
export interface MapNodeLayout {
|
||||
id: string;
|
||||
module: WireMapModule;
|
||||
@@ -129,10 +149,19 @@ export interface MapNodeLayout {
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Link ids leaving this node, left to right — one hidden handle each. */
|
||||
/** Link ids leaving from the BOTTOM of this node, left to right — one hidden handle each. */
|
||||
sourceHandles: string[];
|
||||
/** Link ids arriving at this node, left to right. */
|
||||
/** Link ids arriving at the TOP of this node, left to right. */
|
||||
targetHandles: string[];
|
||||
/**
|
||||
* Every port on the box, by side, left to right — what a node component
|
||||
* draws its handles from. Under the Map's `layered` ports this is exactly
|
||||
* `targetHandles` on top and `sourceHandles` below. Under `directional`
|
||||
* ports a side mixes the two: an edge routed `up` leaves the top of its
|
||||
* source and arrives at the bottom of its target, and a `level` edge leaves
|
||||
* and arrives at the top, arching over the row.
|
||||
*/
|
||||
ports: { top: PortRef[]; bottom: PortRef[] };
|
||||
}
|
||||
|
||||
export interface MapEdgeLayout {
|
||||
@@ -148,8 +177,16 @@ export interface MapEdgeLayout {
|
||||
back: boolean;
|
||||
/** Below the weight threshold — drawn only when a touching module is selected. */
|
||||
thin: boolean;
|
||||
/**
|
||||
* Which way the link runs through the layering: `down` to a lower layer,
|
||||
* `up` to a higher one, `level` along its own. Under `directional` ports
|
||||
* this decides the sides the edge uses and the curve it draws.
|
||||
*/
|
||||
route: EdgeRoute;
|
||||
}
|
||||
|
||||
export type EdgeRoute = 'down' | 'up' | 'level';
|
||||
|
||||
export interface MapLayerLayout {
|
||||
index: number;
|
||||
y: number;
|
||||
@@ -202,6 +239,22 @@ export interface MapLayoutOptions {
|
||||
* longest chain of screens above the login page.
|
||||
*/
|
||||
layering?: (ids: string[], links: ReadonlyArray<{ source: string; target: string }>) => Map<string, number>;
|
||||
/**
|
||||
* Vertical room between two layers; {@link LAYER_GAP} unless a view says
|
||||
* otherwise. The Screens view widens it because its edges carry labels, and
|
||||
* a label needs a lane the Map's hairlines never did.
|
||||
*/
|
||||
layerGap?: number;
|
||||
/**
|
||||
* Least distance between two ports on one side of a box; a box widens to
|
||||
* keep it. 0 (the default) sizes a box by its text alone.
|
||||
*/
|
||||
portPitch?: number;
|
||||
/**
|
||||
* `layered` (the default): every link leaves a bottom and arrives at a top,
|
||||
* whichever way it points. `directional`: see {@link MapNodeLayout.ports}.
|
||||
*/
|
||||
ports?: 'layered' | 'directional';
|
||||
}
|
||||
|
||||
export function strokeWidthFor(count: number): number {
|
||||
@@ -229,6 +282,9 @@ export function buildMapLayout(
|
||||
const depended = new Set(payload.links.map((l) => l.target));
|
||||
const links = payload.links.filter((l) => present.has(l.source) && present.has(l.target));
|
||||
const minWeight = options.minWeight ?? (options.includeTests ? MIN_WEIGHT_WITH_TESTS : MIN_WEIGHT);
|
||||
const layerGap = options.layerGap ?? LAYER_GAP;
|
||||
const portPitch = options.portPitch ?? 0;
|
||||
const directional = options.ports === 'directional';
|
||||
|
||||
const declaredLinks = links.filter((l) => l.declared > 0);
|
||||
const useDeclared =
|
||||
@@ -301,12 +357,37 @@ export function buildMapLayout(
|
||||
}
|
||||
|
||||
// --- placement -----------------------------------------------------------
|
||||
// Which side of each box a link's two ports land on is settled by the
|
||||
// layers alone, so it is known before any box has a width — and a view
|
||||
// that asked for a port pitch needs it now: a hub with nineteen lines
|
||||
// leaving its bottom edge is widened to hold them.
|
||||
const routeOf = (link: { source: string; target: string }): EdgeRoute => {
|
||||
const from = layer.get(link.source) ?? 0;
|
||||
const to = layer.get(link.target) ?? 0;
|
||||
return from > to ? 'down' : from < to ? 'up' : 'level';
|
||||
};
|
||||
const sidesOf = (route: EdgeRoute): { source: 'top' | 'bottom'; target: 'top' | 'bottom' } => {
|
||||
if (!directional || route === 'down') return { source: 'bottom', target: 'top' };
|
||||
if (route === 'up') return { source: 'top', target: 'bottom' };
|
||||
return { source: 'top', target: 'top' };
|
||||
};
|
||||
const portCount = new Map<string, { top: number; bottom: number }>(
|
||||
modules.map((m) => [m.id, { top: 0, bottom: 0 }])
|
||||
);
|
||||
for (const link of links) {
|
||||
const sides = sidesOf(routeOf(link));
|
||||
portCount.get(link.source)![sides.source] += 1;
|
||||
portCount.get(link.target)![sides.target] += 1;
|
||||
}
|
||||
|
||||
const islands = new Set(modules.filter((m) => !depended.has(m.id)).map((m) => m.id));
|
||||
const widths = new Map(
|
||||
modules.map((m) => {
|
||||
const island = islands.has(m.id);
|
||||
const lines = options.sizing?.(m, island) ?? { label: m.id, meta: moduleMetaLabel(m, island) };
|
||||
return [m.id, nodeWidth(lines.label, lines.meta)];
|
||||
const count = portCount.get(m.id) ?? { top: 0, bottom: 0 };
|
||||
const forPorts = (Math.max(count.top, count.bottom) + 1) * portPitch;
|
||||
return [m.id, Math.max(nodeWidth(lines.label, lines.meta), forPorts)];
|
||||
})
|
||||
);
|
||||
const rowSums = rows.map((row) => row.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0));
|
||||
@@ -322,7 +403,7 @@ export function buildMapLayout(
|
||||
Math.min(contentWidth, Math.max(naturalSpans[i] ?? 0, row.length * MIN_SLOT))
|
||||
);
|
||||
const width = contentWidth + PADDING * 2;
|
||||
const height = layerCount * (NODE_HEIGHT + LAYER_GAP) - LAYER_GAP + PADDING * 2;
|
||||
const height = layerCount * (NODE_HEIGHT + layerGap) - layerGap + PADDING * 2;
|
||||
|
||||
const nodesById = new Map<string, MapNodeLayout>();
|
||||
const byId = new Map(modules.map((m) => [m.id, m]));
|
||||
@@ -333,7 +414,7 @@ export function buildMapLayout(
|
||||
// A single box centres in the content width instead of clinging to the
|
||||
// left edge — the common case for the entry point at the top.
|
||||
let x = PADDING + (contentWidth - span) / 2 + (row.length === 1 ? (span - sum) / 2 : 0);
|
||||
const y = PADDING + (layerCount - 1 - index) * (NODE_HEIGHT + LAYER_GAP);
|
||||
const y = PADDING + (layerCount - 1 - index) * (NODE_HEIGHT + layerGap);
|
||||
for (const id of row) {
|
||||
const w = widths.get(id) ?? MIN_NODE_WIDTH;
|
||||
const module = byId.get(id)!;
|
||||
@@ -351,6 +432,7 @@ export function buildMapLayout(
|
||||
height: NODE_HEIGHT,
|
||||
sourceHandles: [],
|
||||
targetHandles: [],
|
||||
ports: { top: [], bottom: [] },
|
||||
});
|
||||
x += w + gap;
|
||||
}
|
||||
@@ -361,13 +443,14 @@ export function buildMapLayout(
|
||||
// that survives the filter exists in the code, and the map's job is to say
|
||||
// where it goes, not to pretend it is absent.
|
||||
const edges: MapEdgeLayout[] = [];
|
||||
const outgoing = new Map<string, MapEdgeLayout[]>();
|
||||
const incoming = new Map<string, MapEdgeLayout[]>();
|
||||
// Every port, by box and side, with the x of the link's other end.
|
||||
const sidePorts = new Map<string, { top: SidePort[]; bottom: SidePort[] }>();
|
||||
for (const link of links) {
|
||||
const from = nodesById.get(link.source);
|
||||
const to = nodesById.get(link.target);
|
||||
if (!from || !to) continue;
|
||||
const id = linkId(link);
|
||||
const route = routeOf(link);
|
||||
const edge: MapEdgeLayout = {
|
||||
id,
|
||||
source: link.source,
|
||||
@@ -378,27 +461,40 @@ export function buildMapLayout(
|
||||
width: strokeWidthFor(link.count),
|
||||
back: from.layer <= to.layer,
|
||||
thin: link.count < minWeight,
|
||||
route,
|
||||
};
|
||||
edges.push(edge);
|
||||
(outgoing.get(link.source) ?? setDefault(outgoing, link.source)).push(edge);
|
||||
(incoming.get(link.target) ?? setDefault(incoming, link.target)).push(edge);
|
||||
const sides = sidesOf(route);
|
||||
(sidePorts.get(link.source) ?? setDefault(sidePorts, link.source))[sides.source].push({
|
||||
id,
|
||||
type: 'source',
|
||||
other: xOf(nodesById, link.target),
|
||||
});
|
||||
(sidePorts.get(link.target) ?? setDefault(sidePorts, link.target))[sides.target].push({
|
||||
id,
|
||||
type: 'target',
|
||||
other: xOf(nodesById, link.source),
|
||||
});
|
||||
}
|
||||
// Ports spread in the order the other end appears left-to-right, so bundles
|
||||
// between two layers stay untangled instead of crossing inside the gap.
|
||||
for (const [id, list] of outgoing) {
|
||||
list.sort((a, b) => xOf(nodesById, a.target) - xOf(nodesById, b.target) || a.id.localeCompare(b.id));
|
||||
const byOther = (a: SidePort, b: SidePort) => a.other - b.other || a.id.localeCompare(b.id);
|
||||
for (const [id, sides] of sidePorts) {
|
||||
const node = nodesById.get(id);
|
||||
if (node) node.sourceHandles = list.map((e) => e.id);
|
||||
}
|
||||
for (const [id, list] of incoming) {
|
||||
list.sort((a, b) => xOf(nodesById, a.source) - xOf(nodesById, b.source) || a.id.localeCompare(b.id));
|
||||
const node = nodesById.get(id);
|
||||
if (node) node.targetHandles = list.map((e) => e.id);
|
||||
if (!node) continue;
|
||||
sides.top.sort(byOther);
|
||||
sides.bottom.sort(byOther);
|
||||
node.ports = {
|
||||
top: sides.top.map((p) => ({ id: p.id, type: p.type })),
|
||||
bottom: sides.bottom.map((p) => ({ id: p.id, type: p.type })),
|
||||
};
|
||||
node.sourceHandles = sides.bottom.filter((p) => p.type === 'source').map((p) => p.id);
|
||||
node.targetHandles = sides.top.filter((p) => p.type === 'target').map((p) => p.id);
|
||||
}
|
||||
|
||||
const layers: MapLayerLayout[] = rows.map((_, index) => ({
|
||||
index,
|
||||
y: PADDING + (layerCount - 1 - index) * (NODE_HEIGHT + LAYER_GAP) + NODE_HEIGHT / 2,
|
||||
y: PADDING + (layerCount - 1 - index) * (NODE_HEIGHT + layerGap) + NODE_HEIGHT / 2,
|
||||
label:
|
||||
layerCount === 1
|
||||
? null
|
||||
@@ -439,10 +535,38 @@ export function isEdgeVisible(edge: MapEdgeLayout, selected: string | null): boo
|
||||
return !edge.thin && !edge.back;
|
||||
}
|
||||
|
||||
function setDefault(map: Map<string, MapEdgeLayout[]>, key: string): MapEdgeLayout[] {
|
||||
const list: MapEdgeLayout[] = [];
|
||||
map.set(key, list);
|
||||
return list;
|
||||
/**
|
||||
* Where a link's port sits on a box: `x = left + width x (i+1)/(n+1)` along
|
||||
* the side that holds it, at the top or bottom edge. The same arithmetic the
|
||||
* node components place their hidden handles with, so a view that needs the
|
||||
* point before anything is rendered — to put a label on the curve — gets the
|
||||
* one the browser will measure.
|
||||
*/
|
||||
export function portPoint(node: MapNodeLayout, id: string, type: 'source' | 'target'): { x: number; y: number } {
|
||||
const top = node.ports.top.findIndex((p) => p.id === id && p.type === type);
|
||||
if (top >= 0) return { x: node.x + (node.width * (top + 1)) / (node.ports.top.length + 1), y: node.y };
|
||||
const bottom = node.ports.bottom.findIndex((p) => p.id === id && p.type === type);
|
||||
if (bottom >= 0) {
|
||||
return {
|
||||
x: node.x + (node.width * (bottom + 1)) / (node.ports.bottom.length + 1),
|
||||
y: node.y + node.height,
|
||||
};
|
||||
}
|
||||
return { x: node.x + node.width / 2, y: type === 'source' ? node.y + node.height : node.y };
|
||||
}
|
||||
|
||||
interface SidePort extends PortRef {
|
||||
/** Centre x of the link's other end — the sort key along the side. */
|
||||
other: number;
|
||||
}
|
||||
|
||||
function setDefault(
|
||||
map: Map<string, { top: SidePort[]; bottom: SidePort[] }>,
|
||||
key: string
|
||||
): { top: SidePort[]; bottom: SidePort[] } {
|
||||
const sides = { top: [], bottom: [] };
|
||||
map.set(key, sides);
|
||||
return sides;
|
||||
}
|
||||
|
||||
function xOf(nodes: Map<string, MapNodeLayout>, id: string): number {
|
||||
|
||||
+627
-50
@@ -2,25 +2,81 @@
|
||||
* The Screens view's model — the app's screens and the transitions between
|
||||
* them, laid out so that a screen sits above the screens it opens.
|
||||
*
|
||||
* The layout is the Map's (`buildMapLayout`): the same longest-path layering,
|
||||
* the same barycenter ordering, the same ports, the same determinism. A
|
||||
* screen graph is a module graph with different words — nodes with names,
|
||||
* weighted links that mostly point one way, a few cycles (Home ↔ Capture)
|
||||
* that become dashed back-edges rather than being straightened into a lie.
|
||||
* Reusing it means a reader who learned the Map reads this without learning
|
||||
* anything new, and means this file is mostly translation, not geometry.
|
||||
* The layout is the Map's (`buildMapLayout`): the same barycenter ordering,
|
||||
* the same ports, the same determinism. A screen graph is a module graph with
|
||||
* different words — nodes with names, weighted links that mostly point one
|
||||
* way — but it differs from a module graph in one thing that shapes the
|
||||
* picture: it is full of cycles. Every screen returns to Home. So this file
|
||||
* asks the layout for three things the Map leaves alone:
|
||||
*
|
||||
* - **layering by distance from the entry screen** (`entryLayering`), where
|
||||
* "one layer above what it depends on" would put the head of the longest
|
||||
* chain of screens above the login page;
|
||||
* - **directional ports**, so a return trip leaves the top of its source and
|
||||
* arrives at the bottom of its target — drawn around the boxes, not through
|
||||
* them — and a transition between two screens on one row arches over it;
|
||||
* - **room**: a wider layer gap, because the edges here carry labels, and a
|
||||
* port pitch, because a hub with nineteen lines leaving it needs to be wide
|
||||
* enough for a reader to follow one back.
|
||||
*
|
||||
* What is this file's own: which links share a pair (several transitions from
|
||||
* Home to Capture, each with its own condition, draw as ONE edge whose label
|
||||
* counts them), the words on that edge, and the two lists the side panel
|
||||
* shows for a selected screen.
|
||||
* counts them), the words on that edge, where on the canvas those words sit
|
||||
* (`placeLabels`), the curve every edge draws — each with its own height
|
||||
* through the gap, so a hub's lines fan out instead of stacking
|
||||
* (`trackedCurves`) — which line is under the pointer (`nearestEdge`), and
|
||||
* the two lists the side panel shows for a selected screen.
|
||||
*/
|
||||
|
||||
import type { WireMapLink, WireMapModule, WireScreen, WireScreenLink, WireScreensPayload } from './wire';
|
||||
import { buildMapLayout, linkId, type MapLayout } from './map-model';
|
||||
import {
|
||||
buildMapLayout,
|
||||
linkId,
|
||||
portPoint,
|
||||
PORT_PITCH,
|
||||
type EdgeRoute,
|
||||
type MapEdgeLayout,
|
||||
type MapLayout,
|
||||
type MapNodeLayout,
|
||||
} from './map-model';
|
||||
|
||||
/** The longest `when` a connector prints before an ellipsis; the tooltip has the rest. */
|
||||
const EDGE_LABEL_MAX = 30;
|
||||
/* ------------------------------------------------------------- geometry -- */
|
||||
|
||||
/**
|
||||
* Vertical room between two rows of screens. The Map's 74px holds hairlines;
|
||||
* this holds labels — five lanes of them (see {@link laneCount}) with their
|
||||
* margins.
|
||||
*/
|
||||
export const SCREEN_LAYER_GAP = 116;
|
||||
|
||||
/** The widest `level` arch in a fan rises this fraction of the layer gap above its row… */
|
||||
const LEVEL_RISE = 0.66;
|
||||
/** …and the narrowest this much less, so nested arches stay apart. */
|
||||
const LEVEL_NEST = 0.26;
|
||||
/** Points a curve is sampled at for hit-testing; at 116px tall, under a pixel off. */
|
||||
const HIT_SAMPLES = 24;
|
||||
|
||||
/** IBM Plex Mono at 10.5px advances ~6.3px per character; the pill adds 6px each side. */
|
||||
export const PILL_CHAR_WIDTH = 6.3;
|
||||
export const PILL_PADDING = 12;
|
||||
export const PILL_HEIGHT = 17;
|
||||
/** From a box's edge to the centre of the first lane of pills beside it. */
|
||||
export const PILL_OFFSET = 13;
|
||||
/** From one lane to the next: a pill and 4px of paper. */
|
||||
export const LANE_STEP = PILL_HEIGHT + 4;
|
||||
/** Two pills on one lane keep this much paper between them. */
|
||||
const PILL_GAP_X = 4;
|
||||
/** The last lane keeps this much clear of the neighbouring row's boxes. */
|
||||
const BAND_MARGIN = 2;
|
||||
|
||||
/**
|
||||
* The longest label a pill prints before an ellipsis; the tooltip and the
|
||||
* panel have the rest. Sized so the innermost clause of a typical guard
|
||||
* (`guide.dontShowAgain.captureGuide`, 32 characters) fits whole.
|
||||
*/
|
||||
export const EDGE_LABEL_MAX = 36;
|
||||
|
||||
/* ---------------------------------------------------------------- model -- */
|
||||
|
||||
export interface ScreenNodeInfo {
|
||||
id: string;
|
||||
@@ -42,7 +98,7 @@ export interface ScreenEdgeInfo {
|
||||
to: string;
|
||||
/** Every transition between the pair — one connector, several stories. */
|
||||
links: WireScreenLink[];
|
||||
/** The connector's short label: the condition, or how many transitions. */
|
||||
/** The connector's short label: the innermost condition, or how many transitions. */
|
||||
label: string;
|
||||
synthesized: boolean;
|
||||
}
|
||||
@@ -54,52 +110,100 @@ export interface ScreensModel {
|
||||
edges: Map<string, ScreenEdgeInfo>;
|
||||
/** Screens no chain of transitions reaches from the entry. */
|
||||
unreached: number;
|
||||
/** The vertical room between rows the layout was built with. */
|
||||
layerGap: number;
|
||||
/** Every edge's curve, keyed by edge id — see {@link trackedCurves}. */
|
||||
curves: Map<string, Curve>;
|
||||
/** The same curves sampled for hit-testing — see {@link nearestEdge}. */
|
||||
polylines: Map<string, Point[]>;
|
||||
}
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- layering -- */
|
||||
|
||||
/**
|
||||
* Layer = distance from the entry screen: the entry on top, each row down one
|
||||
* more transition away. Origins (chrome, triggers outside any screen) count
|
||||
* as reachable seeds too, so what they open is placed below them. Whatever
|
||||
* nothing reaches sits in a band at the bottom, layered among itself by the
|
||||
* same rule from its own sources — a screen the graph cannot see anyone open
|
||||
* is a fact worth a place, not a crash.
|
||||
* more transition away, measured over EVERY transition — the two-cycle break
|
||||
* the Map performs for its own layering is irrelevant to a distance, so the
|
||||
* links come in through the closure rather than through the argument the
|
||||
* layout hands over.
|
||||
*
|
||||
* Origins (shared chrome, a store action after login) are not screens and
|
||||
* have no distance of their own. Each hangs one row above the shallowest
|
||||
* screen it opens, so what it opens is below it and what it opens is placed
|
||||
* by the entry, not by the chrome: a top bar rendered on ten screens must not
|
||||
* drag `/settings` up beside the home screen. An origin whose targets nothing
|
||||
* else reaches seeds them from wherever it sits, so they are still placed.
|
||||
*
|
||||
* Whatever nothing reaches sits in a band at the bottom, layered among itself
|
||||
* by the same rule from its own sources — a screen the graph cannot see
|
||||
* anyone open is a fact worth a place, not a crash.
|
||||
*/
|
||||
export function entryLayering(entry: string | null, seeds: readonly string[]) {
|
||||
return (ids: string[], links: ReadonlyArray<{ source: string; target: string }>): Map<string, number> => {
|
||||
export function entryLayering(
|
||||
entry: string | null,
|
||||
origins: readonly string[],
|
||||
links: ReadonlyArray<{ source: string; target: string }>
|
||||
) {
|
||||
return (ids: string[]): Map<string, number> => {
|
||||
const present = new Set(ids);
|
||||
const out = new Map<string, string[]>(ids.map((id) => [id, []]));
|
||||
const indeg = new Map<string, number>(ids.map((id) => [id, 0]));
|
||||
for (const l of links) {
|
||||
out.get(l.source)?.push(l.target);
|
||||
if (!present.has(l.source) || !present.has(l.target) || l.source === l.target) continue;
|
||||
out.get(l.source)!.push(l.target);
|
||||
indeg.set(l.target, (indeg.get(l.target) ?? 0) + 1);
|
||||
}
|
||||
for (const list of out.values()) list.sort();
|
||||
|
||||
const depth = new Map<string, number>();
|
||||
const bfs = (starts: string[]) => {
|
||||
let frontier = starts.filter((s) => !depth.has(s));
|
||||
for (const s of frontier) depth.set(s, 0);
|
||||
let d = 0;
|
||||
while (frontier.length > 0) {
|
||||
d++;
|
||||
const next: string[] = [];
|
||||
for (const id of frontier) {
|
||||
for (const t of out.get(id) ?? []) {
|
||||
if (depth.has(t)) continue;
|
||||
depth.set(t, d);
|
||||
next.push(t);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
// Multi-source BFS whose sources may start at different depths: buckets
|
||||
// processed in ascending order, first assignment wins, so a node's depth
|
||||
// is the least over every source — and a depth already set is never
|
||||
// lowered by a later phase.
|
||||
const walk = (starts: ReadonlyArray<[string, number]>): void => {
|
||||
const buckets = new Map<number, string[]>();
|
||||
const push = (id: string, d: number): void => {
|
||||
if (depth.has(id)) return;
|
||||
depth.set(id, d);
|
||||
const list = buckets.get(d);
|
||||
if (list) list.push(id);
|
||||
else buckets.set(d, [id]);
|
||||
};
|
||||
let maxStart = 0;
|
||||
for (const [id, d] of starts) {
|
||||
push(id, d);
|
||||
maxStart = Math.max(maxStart, d);
|
||||
}
|
||||
for (let d = 0; d <= ids.length + maxStart; d++) {
|
||||
const list = buckets.get(d);
|
||||
if (!list) continue;
|
||||
for (const id of list) for (const t of out.get(id) ?? []) push(t, d + 1);
|
||||
}
|
||||
};
|
||||
const roots = [entry, ...seeds].filter((s): s is string => s !== null && ids.includes(s));
|
||||
bfs(roots);
|
||||
|
||||
// Phase 1: the screens, by distance from the entry.
|
||||
if (entry !== null && present.has(entry)) walk([[entry, 0]]);
|
||||
// Phase 2: each origin above its shallowest placed target; then whatever
|
||||
// only the origins reach, from them.
|
||||
const seeds: Array<[string, number]> = [];
|
||||
for (const origin of [...origins].filter((o) => present.has(o)).sort()) {
|
||||
const placed = (out.get(origin) ?? []).map((t) => depth.get(t)).filter((d): d is number => d !== undefined);
|
||||
seeds.push([origin, placed.length > 0 ? Math.max(0, Math.min(...placed) - 1) : 0]);
|
||||
}
|
||||
walk(seeds);
|
||||
|
||||
const reachedMax = Math.max(0, ...[...depth.values()]);
|
||||
// The unreached band: its own sources first, then whatever they open.
|
||||
const rest = ids.filter((id) => !depth.has(id));
|
||||
const restDepth = new Map<string, number>();
|
||||
if (rest.length > 0) {
|
||||
const restSet = new Set(rest);
|
||||
const restSources = rest.filter((id) => (indeg.get(id) ?? 0) === 0);
|
||||
const seedsRest = restSources.length > 0 ? restSources : [rest[0]!];
|
||||
let frontier = seedsRest;
|
||||
let frontier = restSources.length > 0 ? restSources : [rest[0]!];
|
||||
for (const s of frontier) restDepth.set(s, 0);
|
||||
let d = 0;
|
||||
while (frontier.length > 0) {
|
||||
@@ -107,7 +211,7 @@ export function entryLayering(entry: string | null, seeds: readonly string[]) {
|
||||
const next: string[] = [];
|
||||
for (const id of frontier) {
|
||||
for (const t of out.get(id) ?? []) {
|
||||
if (restDepth.has(t) || depth.has(t)) continue;
|
||||
if (restDepth.has(t) || !restSet.has(t)) continue;
|
||||
restDepth.set(t, d);
|
||||
next.push(t);
|
||||
}
|
||||
@@ -127,25 +231,79 @@ export function entryLayering(entry: string | null, seeds: readonly string[]) {
|
||||
};
|
||||
}
|
||||
|
||||
/** What the connector says. Empty when unconditional and single. */
|
||||
/* --------------------------------------------------------------- labels -- */
|
||||
|
||||
/**
|
||||
* The top-level `&&` terms of a condition, in the order they were tested —
|
||||
* the outermost guard first, the one decided at the navigation call last.
|
||||
* Brackets and strings are respected; a condition joined by a top-level `||`
|
||||
* (two transitions between one pair that merged) has no innermost term and
|
||||
* comes back whole.
|
||||
*/
|
||||
export function clauses(when: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
let start = 0;
|
||||
for (let i = 0; i < when.length; i++) {
|
||||
const ch = when[i]!;
|
||||
if (quote !== null) {
|
||||
if (ch === '\\') i++;
|
||||
else if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"' || ch === '`') {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '(' || ch === '[' || ch === '{') {
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ')' || ch === ']' || ch === '}') {
|
||||
depth = Math.max(0, depth - 1);
|
||||
continue;
|
||||
}
|
||||
if (depth !== 0) continue;
|
||||
if (when.startsWith(' || ', i)) return [when.trim()];
|
||||
if (when.startsWith(' && ', i)) {
|
||||
out.push(when.slice(start, i).trim());
|
||||
start = i + 4;
|
||||
i += 3;
|
||||
}
|
||||
}
|
||||
out.push(when.slice(start).trim());
|
||||
return out.filter((c) => c.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the connector says. Empty when unconditional and single.
|
||||
*
|
||||
* A single transition is labelled with its innermost condition — the one
|
||||
* checked right at the navigation call — with an ellipsis in front when outer
|
||||
* guards precede it. The whole chain is often seventy characters and its
|
||||
* first thirty are usually shared with a sibling (`!loading && !(!objectId
|
||||
* …` on both arms of a fork); the last clause is the one that tells the two
|
||||
* apart, and the full text is a hover away.
|
||||
*/
|
||||
export function edgeLabel(links: readonly WireScreenLink[]): string {
|
||||
if (links.length === 1) {
|
||||
const when = links[0]!.when;
|
||||
if (!when) return '';
|
||||
return when.length > EDGE_LABEL_MAX ? `${when.slice(0, EDGE_LABEL_MAX - 1)}…` : when;
|
||||
const parts = clauses(when);
|
||||
const last = parts[parts.length - 1] ?? when;
|
||||
const text = parts.length > 1 ? `…${last}` : last;
|
||||
return text.length > EDGE_LABEL_MAX ? `${text.slice(0, EDGE_LABEL_MAX - 1)}…` : text;
|
||||
}
|
||||
const conditional = links.filter((l) => l.when).length;
|
||||
return conditional > 0 ? `${links.length} ways · ${conditional} conditional` : `${links.length} ways`;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- build -- */
|
||||
|
||||
export function buildScreensModel(payload: WireScreensPayload): ScreensModel {
|
||||
const nodes = new Map<string, ScreenNodeInfo>();
|
||||
const modules: WireMapModule[] = [];
|
||||
const used = new Set<string>();
|
||||
for (const link of payload.links) {
|
||||
used.add(link.from);
|
||||
used.add(link.to);
|
||||
}
|
||||
|
||||
for (const screen of payload.screens) {
|
||||
const info: ScreenNodeInfo = {
|
||||
@@ -240,10 +398,16 @@ export function buildScreensModel(payload: WireScreensPayload): ScreensModel {
|
||||
const info = nodes.get(m.id);
|
||||
return { label: info?.label ?? m.id, meta: info?.sub ?? '' };
|
||||
},
|
||||
layering: entryLayering(payload.entry, seeds),
|
||||
layering: entryLayering(payload.entry, seeds, links),
|
||||
layerGap: SCREEN_LAYER_GAP,
|
||||
portPitch: PORT_PITCH,
|
||||
ports: 'directional',
|
||||
}
|
||||
);
|
||||
return { layout, nodes, edges, unreached };
|
||||
const curves = trackedCurves(layout, SCREEN_LAYER_GAP);
|
||||
const polylines = new Map<string, Point[]>();
|
||||
for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES));
|
||||
return { layout, nodes, edges, unreached, layerGap: SCREEN_LAYER_GAP, curves, polylines };
|
||||
}
|
||||
|
||||
function moduleFor(info: ScreenNodeInfo, symbols: number): WireMapModule {
|
||||
@@ -275,3 +439,416 @@ export function neighbourhood(
|
||||
export function viaText(link: WireScreenLink): string {
|
||||
return link.via.map((v) => v.name).join(' → ');
|
||||
}
|
||||
|
||||
/** The layout edge a transition draws as, or null when it is a self-loop. */
|
||||
export function pairId(link: WireScreenLink): string | null {
|
||||
return link.from === link.to ? null : linkId({ source: link.from, target: link.to });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- curve -- */
|
||||
|
||||
/** A cubic Bézier: the point it leaves, two controls, the point it reaches. */
|
||||
export interface Curve {
|
||||
x0: number;
|
||||
y0: number;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
x3: number;
|
||||
y3: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The curve an edge draws, from its source port to its target port.
|
||||
*
|
||||
* `down` and `up` are the Map's cubic: it leaves and arrives vertically, with
|
||||
* both control points at one height — the midpoint unless a `track` says
|
||||
* otherwise (see {@link trackedCurves}). `level` joins two boxes on one row
|
||||
* from top to top, arching over the row — the only shape that touches neither
|
||||
* box on the way; `track` is then how far the arch rises. The same arithmetic
|
||||
* places the labels and answers the pointer, so a pill sits on the line the
|
||||
* browser draws and the line under the cursor is the one that lights.
|
||||
*/
|
||||
export function screenCurve(
|
||||
route: EdgeRoute,
|
||||
sx: number,
|
||||
sy: number,
|
||||
tx: number,
|
||||
ty: number,
|
||||
layerGap = SCREEN_LAYER_GAP,
|
||||
track?: number
|
||||
): Curve {
|
||||
if (route === 'level') {
|
||||
const rise = track ?? Math.round(layerGap * LEVEL_RISE);
|
||||
return { x0: sx, y0: sy, x1: sx, y1: sy - rise, x2: tx, y2: ty - rise, x3: tx, y3: ty };
|
||||
}
|
||||
const midY = track ?? (sy + ty) / 2;
|
||||
return { x0: sx, y0: sy, x1: sx, y1: midY, x2: tx, y2: midY, x3: tx, y3: ty };
|
||||
}
|
||||
|
||||
/** The SVG path of a curve. */
|
||||
export function pathOf(c: Curve): string {
|
||||
return `M${c.x0},${c.y0} C${c.x1},${c.y1} ${c.x2},${c.y2} ${c.x3},${c.y3}`;
|
||||
}
|
||||
|
||||
/** The SVG path of {@link screenCurve}. */
|
||||
export function screenEdgePath(
|
||||
route: EdgeRoute,
|
||||
sx: number,
|
||||
sy: number,
|
||||
tx: number,
|
||||
ty: number,
|
||||
layerGap = SCREEN_LAYER_GAP
|
||||
): string {
|
||||
return pathOf(screenCurve(route, sx, sy, tx, ty, layerGap));
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- tracks -- */
|
||||
|
||||
/**
|
||||
* Every edge's curve, each with its own height through the gap.
|
||||
*
|
||||
* Drawn through one midpoint, every line between two rows crosses that height
|
||||
* at its middle, and a line to a screen far to the side is nearly horizontal
|
||||
* there — so a hub's lines run stacked within a few pixels for hundreds, and
|
||||
* no pointer can pick one. Instead each line in a fan takes a track of its
|
||||
* own. A fan is the set of lines leaving one side of one box towards one
|
||||
* side; a line belongs to the bigger of the two fans at its ends (the upper
|
||||
* one on a tie). Within a fan the line whose far end is farthest out runs on
|
||||
* the track nearest the fan's own row, the next one a track further out, and
|
||||
* so on: nested, in the same order the ports along the box are, so no line in
|
||||
* a fan crosses another. A line spanning several rows keeps its track inside
|
||||
* the gap beside its fan and drops the rest of the way vertically. Level
|
||||
* arches nest the same way — the wider arch rises higher.
|
||||
*/
|
||||
export function trackedCurves(layout: MapLayout, layerGap: number): Map<string, Curve> {
|
||||
const nodes = new Map(layout.nodes.map((n) => [n.id, n]));
|
||||
const fanSize = (node: MapNodeLayout, side: 'top' | 'bottom'): number => node.ports[side].length;
|
||||
interface Member {
|
||||
edge: MapEdgeLayout;
|
||||
s: Point;
|
||||
t: Point;
|
||||
/** How far out the far end sits from the fan's box; the nesting order. */
|
||||
reach: number;
|
||||
/** For a `down`/`up` edge: the fan is at the upper end. */
|
||||
pivotUpper: boolean;
|
||||
up: Point;
|
||||
lo: Point;
|
||||
}
|
||||
const groups = new Map<string, Member[]>();
|
||||
for (const edge of layout.edges) {
|
||||
const from = nodes.get(edge.source);
|
||||
const to = nodes.get(edge.target);
|
||||
if (!from || !to) continue;
|
||||
const s = portPoint(from, edge.id, 'source');
|
||||
const t = portPoint(to, edge.id, 'target');
|
||||
let pivot: MapNodeLayout;
|
||||
let side: 'top' | 'bottom';
|
||||
let other: Point;
|
||||
let pivotUpper = true;
|
||||
let up = s;
|
||||
let lo = t;
|
||||
if (edge.route === 'level') {
|
||||
pivot = fanSize(to, 'top') > fanSize(from, 'top') ? to : from;
|
||||
side = 'top';
|
||||
other = pivot === from ? t : s;
|
||||
} else {
|
||||
const upperIsSource = edge.route === 'down';
|
||||
const upper = upperIsSource ? from : to;
|
||||
const lower = upperIsSource ? to : from;
|
||||
up = upperIsSource ? s : t;
|
||||
lo = upperIsSource ? t : s;
|
||||
pivotUpper = fanSize(upper, 'bottom') >= fanSize(lower, 'top');
|
||||
pivot = pivotUpper ? upper : lower;
|
||||
side = pivotUpper ? 'bottom' : 'top';
|
||||
other = pivotUpper ? lo : up;
|
||||
}
|
||||
const centre = pivot.x + pivot.width / 2;
|
||||
const key = `${pivot.id}\u0000${side}\u0000${other.x < centre ? 'L' : 'R'}`;
|
||||
const list = groups.get(key) ?? [];
|
||||
list.push({ edge, s, t, reach: Math.abs(other.x - centre), pivotUpper, up, lo });
|
||||
groups.set(key, list);
|
||||
}
|
||||
|
||||
const curves = new Map<string, Curve>();
|
||||
for (const members of groups.values()) {
|
||||
members.sort((a, b) => b.reach - a.reach || a.edge.id.localeCompare(b.edge.id));
|
||||
const n = members.length;
|
||||
members.forEach((m, k) => {
|
||||
const { edge, s, t } = m;
|
||||
if (edge.route === 'level') {
|
||||
const rise = Math.round(layerGap * (LEVEL_RISE - (LEVEL_NEST * k) / Math.max(1, n - 1)));
|
||||
curves.set(edge.id, screenCurve('level', s.x, s.y, t.x, t.y, layerGap, rise));
|
||||
return;
|
||||
}
|
||||
const f = (k + 1) / (n + 1);
|
||||
const span = Math.min(m.lo.y - m.up.y, layerGap);
|
||||
const track = m.pivotUpper ? m.up.y + span * f : m.lo.y - span * f;
|
||||
curves.set(edge.id, screenCurve(edge.route, s.x, s.y, t.x, t.y, layerGap, track));
|
||||
});
|
||||
}
|
||||
return curves;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ pointing -- */
|
||||
|
||||
/** The curve as `count` points from source to target, for distance tests. */
|
||||
export function samplePolyline(c: Curve, count = HIT_SAMPLES): Point[] {
|
||||
const out: Point[] = [];
|
||||
for (let i = 0; i < count; i++) out.push(pointAt(c, i / (count - 1)));
|
||||
return out;
|
||||
}
|
||||
|
||||
function distanceToSegment(p: Point, a: Point, b: Point): number {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2));
|
||||
const x = a.x + t * dx - p.x;
|
||||
const y = a.y + t * dy - p.y;
|
||||
return Math.sqrt(x * x + y * y);
|
||||
}
|
||||
|
||||
export function distanceToPolyline(p: Point, line: readonly Point[]): number {
|
||||
let best = Infinity;
|
||||
for (let i = 1; i < line.length; i++) best = Math.min(best, distanceToSegment(p, line[i - 1]!, line[i]!));
|
||||
return best;
|
||||
}
|
||||
|
||||
export interface EdgeHit {
|
||||
id: string;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The edge nearest `point` within `reach`, among the ids given (every edge
|
||||
* when null). This is what hovering means on the canvas: not "the topmost hit
|
||||
* path under the pointer", which in a bundle of eight lines four pixels apart
|
||||
* is whichever one the DOM drew last, but the line the pointer is closest to
|
||||
* — so moving three pixels moves to the next line, predictably. Ties go to
|
||||
* the smaller id, so two visits agree.
|
||||
*/
|
||||
export function nearestEdge(
|
||||
model: ScreensModel,
|
||||
point: Point,
|
||||
among: ReadonlySet<string> | null,
|
||||
reach: number
|
||||
): EdgeHit | null {
|
||||
let best: EdgeHit | null = null;
|
||||
for (const [id, line] of model.polylines) {
|
||||
if (among !== null && !among.has(id)) continue;
|
||||
const distance = distanceToPolyline(point, line);
|
||||
if (distance > reach) continue;
|
||||
if (best === null || distance < best.distance || (distance === best.distance && id < best.id)) {
|
||||
best = { id, distance };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The point at `t` on the curve, 0 = source, 1 = target. */
|
||||
export function pointAt(c: Curve, t: number): { x: number; y: number } {
|
||||
const u = 1 - t;
|
||||
const a = u * u * u;
|
||||
const b = 3 * u * u * t;
|
||||
const d = 3 * u * t * t;
|
||||
const e = t * t * t;
|
||||
return { x: a * c.x0 + b * c.x1 + d * c.x2 + e * c.x3, y: a * c.y0 + b * c.y1 + d * c.y2 + e * c.y3 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The parameter at which the curve passes height `y`, or null when it never
|
||||
* does. A `down`/`up` curve is monotonic in y end to end; a `level` arch
|
||||
* rises and falls, so it is searched on the half nearest `end`.
|
||||
*/
|
||||
export function tAtY(c: Curve, y: number, end: 'source' | 'target'): number | null {
|
||||
const arch = c.y0 === c.y3;
|
||||
let lo = arch && end === 'target' ? 0.5 : 0;
|
||||
let hi = arch && end === 'source' ? 0.5 : 1;
|
||||
const yLo = pointAt(c, lo).y;
|
||||
const yHi = pointAt(c, hi).y;
|
||||
if (y < Math.min(yLo, yHi) - 1e-6 || y > Math.max(yLo, yHi) + 1e-6) return null;
|
||||
const rising = yHi > yLo;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
if (pointAt(c, mid).y < y === rising) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
return (lo + hi) / 2;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- pills -- */
|
||||
|
||||
export interface PillPlacement {
|
||||
edge: string;
|
||||
text: string;
|
||||
/** Centre of the pill, in canvas coordinates. */
|
||||
x: number;
|
||||
y: number;
|
||||
/** Estimated from the text; what the lane arithmetic reserved. */
|
||||
width: number;
|
||||
lane: number;
|
||||
/** The end of the edge the pill sits at. */
|
||||
end: 'source' | 'target';
|
||||
}
|
||||
|
||||
export interface PillLayout {
|
||||
pills: Map<string, PillPlacement>;
|
||||
/** Labels that found no lane; the panel says so. */
|
||||
hidden: number;
|
||||
}
|
||||
|
||||
export function pillWidth(text: string): number {
|
||||
return text.length * PILL_CHAR_WIDTH + PILL_PADDING;
|
||||
}
|
||||
|
||||
/** How many lanes of pills fit between two rows `layerGap` apart. */
|
||||
export function laneCount(layerGap: number): number {
|
||||
return Math.max(1, Math.floor((layerGap - BAND_MARGIN - PILL_HEIGHT / 2 - PILL_OFFSET) / LANE_STEP) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The words on a pill: an arrow for which way the transition runs relative to
|
||||
* the selected screen — `→` leaving it, `←` arriving — and the edge's label.
|
||||
* Empty when the edge has nothing to say (a single, unconditional transition).
|
||||
*/
|
||||
export function pillText(info: ScreenEdgeInfo, edge: MapEdgeLayout, selected: string | null): string {
|
||||
if (!info.label) return '';
|
||||
const arriving = selected !== null && edge.target === selected && edge.source !== selected;
|
||||
return `${arriving ? '←' : '→'} ${info.label}`;
|
||||
}
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
function intersects(a: Rect, b: Rect, gapX: number): boolean {
|
||||
return a.x < b.x + b.w + gapX && b.x < a.x + a.w + gapX && a.y < b.y + b.h && b.y < a.y + a.h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay a pill beside the box at `end` of the edge: the first lane whose pill
|
||||
* would overlap nothing already placed, walking away from the box one lane
|
||||
* at a time, each pill centred on its own line at that height. Null when no
|
||||
* lane is free — or when `lanes` is 1 and that lane is taken.
|
||||
*/
|
||||
function layPill(
|
||||
model: ScreensModel,
|
||||
edge: MapEdgeLayout,
|
||||
end: 'source' | 'target',
|
||||
text: string,
|
||||
nodes: Map<string, MapNodeLayout>,
|
||||
lanes: number,
|
||||
taken: Rect[],
|
||||
bounds: { width: number; height: number }
|
||||
): { pill: PillPlacement; rect: Rect } | null {
|
||||
const curve = model.curves.get(edge.id);
|
||||
const box = nodes.get(end === 'source' ? edge.source : edge.target);
|
||||
if (!curve || !box) return null;
|
||||
const port = end === 'source' ? { x: curve.x0, y: curve.y0 } : { x: curve.x3, y: curve.y3 };
|
||||
const above = port.y === box.y;
|
||||
const width = pillWidth(text);
|
||||
for (let lane = 0; lane < lanes; lane++) {
|
||||
const off = PILL_OFFSET + lane * LANE_STEP;
|
||||
const y = above ? port.y - off : port.y + off;
|
||||
const t = tAtY(curve, y, end);
|
||||
if (t === null) return null;
|
||||
const x = pointAt(curve, t).x;
|
||||
const rect = { x: x - width / 2, y: y - PILL_HEIGHT / 2, w: width, h: PILL_HEIGHT };
|
||||
if (rect.y < 0 || rect.y + rect.h > bounds.height) return null;
|
||||
if (taken.some((r) => intersects(r, rect, PILL_GAP_X))) continue;
|
||||
return { pill: { edge: edge.id, text, x, y, width, lane, end }, rect };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the selected screen's labels go.
|
||||
*
|
||||
* Every pill sits at the FAR end of its line — beside the other screen, where
|
||||
* the lines are apart — never beside the selected one, where fifteen of them
|
||||
* share a box's width and no label can belong to one of them. Pills are laid
|
||||
* left to right, each in the first free lane walking away from its box, and
|
||||
* a pill that finds no lane is not drawn but counted, so the panel can say
|
||||
* so. The boxes themselves are obstacles, so a lane in the margin above the
|
||||
* top row cannot land a pill on a screen.
|
||||
*
|
||||
* A pure function of the model and the selection, so hovering never moves a
|
||||
* pill: the pill for a hovered edge that is not the selected screen's is
|
||||
* placed separately by {@link hoverPill}.
|
||||
*/
|
||||
export function placeLabels(model: ScreensModel, selected: string | null): PillLayout {
|
||||
const pills = new Map<string, PillPlacement>();
|
||||
if (selected === null) return { pills, hidden: 0 };
|
||||
const nodes = new Map(model.layout.nodes.map((n) => [n.id, n]));
|
||||
const lanes = laneCount(model.layerGap);
|
||||
const bounds = { width: model.layout.width, height: model.layout.height };
|
||||
const taken: Rect[] = model.layout.nodes.map((n) => ({ x: n.x, y: n.y, w: n.width, h: n.height }));
|
||||
|
||||
const candidates = model.layout.edges
|
||||
.filter((e) => e.source === selected || e.target === selected)
|
||||
.map((edge) => {
|
||||
const end: 'source' | 'target' = edge.source === selected ? 'target' : 'source';
|
||||
const far = nodes.get(end === 'source' ? edge.source : edge.target);
|
||||
const anchor = far ? portPoint(far, edge.id, end) : { x: 0, y: 0 };
|
||||
return { edge, end, anchor };
|
||||
})
|
||||
.sort((a, b) => a.anchor.x - b.anchor.x || a.anchor.y - b.anchor.y || a.edge.id.localeCompare(b.edge.id));
|
||||
|
||||
let hidden = 0;
|
||||
for (const { edge, end } of candidates) {
|
||||
const info = model.edges.get(edge.id);
|
||||
if (!info) continue;
|
||||
const text = pillText(info, edge, selected);
|
||||
if (!text) continue;
|
||||
const laid = layPill(model, edge, end, text, nodes, lanes, taken, bounds);
|
||||
if (laid === null) {
|
||||
hidden++;
|
||||
continue;
|
||||
}
|
||||
pills.set(edge.id, laid.pill);
|
||||
taken.push(laid.rect);
|
||||
}
|
||||
return { pills, hidden };
|
||||
}
|
||||
|
||||
/**
|
||||
* The pill for a hovered edge that has no place in {@link placeLabels} —
|
||||
* nothing is selected, or the edge has nothing to say at rest. It sits at the
|
||||
* edge's target end (its source end when the selected screen is the target),
|
||||
* in the first lane clear of the pills in `avoid` and of every box; when
|
||||
* there is none it takes the first lane anyway, on top of whatever is there —
|
||||
* it is transient, and the line under the pointer is the one the reader is
|
||||
* asking about. `text` overrides the pill's words — the panel hovers a row
|
||||
* with the whole condition, not the connector's short label.
|
||||
*/
|
||||
export function hoverPill(
|
||||
model: ScreensModel,
|
||||
edgeId: string,
|
||||
selected: string | null,
|
||||
text?: string,
|
||||
avoid?: PillLayout
|
||||
): PillPlacement | null {
|
||||
const edge = model.layout.edges.find((e) => e.id === edgeId);
|
||||
const info = edge ? model.edges.get(edge.id) : undefined;
|
||||
if (!edge || !info) return null;
|
||||
const words = text ?? pillText(info, edge, selected);
|
||||
if (!words) return null;
|
||||
const nodes = new Map(model.layout.nodes.map((n) => [n.id, n]));
|
||||
const end: 'source' | 'target' = selected !== null && edge.target === selected && edge.source !== selected ? 'source' : 'target';
|
||||
const bounds = { width: model.layout.width, height: model.layout.height };
|
||||
const taken: Rect[] = model.layout.nodes.map((n) => ({ x: n.x, y: n.y, w: n.width, h: n.height }));
|
||||
for (const pill of avoid?.pills.values() ?? []) {
|
||||
if (pill.edge === edgeId) continue;
|
||||
taken.push({ x: pill.x - pill.width / 2, y: pill.y - PILL_HEIGHT / 2, w: pill.width, h: PILL_HEIGHT });
|
||||
}
|
||||
return (
|
||||
layPill(model, edge, end, words, nodes, laneCount(model.layerGap), taken, bounds)?.pill ??
|
||||
layPill(model, edge, end, words, nodes, 1, [], bounds)?.pill ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
+188
-39
@@ -8,9 +8,16 @@
|
||||
render/call chain to the screen a transition starts on, and branch guards
|
||||
read from the source. The canvas is the Map's machinery with different
|
||||
words (see `screens-model.ts`); the side panel is where the sentences are.
|
||||
|
||||
At rest the picture is boxes and lines. Selecting a screen labels its
|
||||
transitions — each pill at the FAR end of its line, beside the other screen,
|
||||
in a lane the model chose so that no two overlap — and lists them in the
|
||||
panel; the panel and the picture point at each other, so a row under the
|
||||
pointer lights its line and prints its whole condition on it. On the canvas
|
||||
the pointer means the line NEAREST it, not the one drawn last under it.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { SvelteFlow, Controls, type Node, type Edge } from '@xyflow/svelte';
|
||||
import { SvelteFlow, Controls, type Node, type Edge, type Viewport } from '@xyflow/svelte';
|
||||
import '@xyflow/svelte/dist/style.css';
|
||||
import ScreenNode from '../components/screens/ScreenNode.svelte';
|
||||
import ScreenEdge from '../components/screens/ScreenEdge.svelte';
|
||||
@@ -19,14 +26,29 @@
|
||||
import { live } from '../lib/live.svelte';
|
||||
import { symbolHref, fileHref } from '../lib/navigation';
|
||||
import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model';
|
||||
import { buildScreensModel, neighbourhood, viaText, type ScreensModel } from '../lib/screens-model';
|
||||
import {
|
||||
buildScreensModel,
|
||||
hoverPill,
|
||||
nearestEdge,
|
||||
neighbourhood,
|
||||
pairId,
|
||||
placeLabels,
|
||||
viaText,
|
||||
type ScreensModel,
|
||||
} from '../lib/screens-model';
|
||||
|
||||
let payload = $state<WireScreensPayload | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(true);
|
||||
let selected = $state<string | null>(null);
|
||||
let hovered = $state<{ edge: MapEdgeLayout; x: number; y: number } | null>(null);
|
||||
/** The panel row under the pointer: its edge on the canvas, and the one transition it names. */
|
||||
let panelHot = $state<{ edge: string; link: WireScreenLink } | null>(null);
|
||||
let stage = $state<HTMLDivElement | null>(null);
|
||||
/** Svelte Flow's pan and zoom, for turning a pointer position into a point on the canvas. */
|
||||
let viewport = $state<Viewport | undefined>(undefined);
|
||||
/** How close, in screen pixels, the pointer must be to a line to mean it. */
|
||||
const HOVER_REACH = 10;
|
||||
|
||||
// The key stays open until the reader closes it; the choice survives a
|
||||
// reload but is per browser — a preference, not a fact about the project.
|
||||
@@ -83,6 +105,18 @@
|
||||
return set;
|
||||
});
|
||||
|
||||
// The labels move with the selection and with nothing else: hovering a
|
||||
// line or a row must not reflow the pills the reader is looking at.
|
||||
const pills = $derived(model === null ? null : placeLabels(model, selected));
|
||||
/** The edge in focus: under the pointer on the canvas, or its row in the panel. */
|
||||
const focusId = $derived(hovered?.edge.id ?? panelHot?.edge ?? null);
|
||||
/** A pill for the focused edge when the selection gave it none. */
|
||||
const focusPill = $derived.by(() => {
|
||||
if (model === null || focusId === null || pills?.pills.has(focusId)) return null;
|
||||
const full = panelHot?.edge === focusId ? fullText(panelHot.link) : undefined;
|
||||
return hoverPill(model, focusId, selected, full, pills ?? undefined);
|
||||
});
|
||||
|
||||
const nodes = $derived.by<Node[]>(() => {
|
||||
if (model === null) return [];
|
||||
return model.layout.nodes.map((node) => ({
|
||||
@@ -100,6 +134,7 @@
|
||||
onSelect: (id: string) => {
|
||||
selected = selected === id ? null : id;
|
||||
hovered = null;
|
||||
panelHot = null;
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -107,31 +142,39 @@
|
||||
|
||||
const edges = $derived.by<Edge[]>(() => {
|
||||
if (model === null) return [];
|
||||
const focus = focusId;
|
||||
return model.layout.edges
|
||||
.filter((edge) => isEdgeVisible(edge, selected))
|
||||
.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
type: 'screen',
|
||||
selectable: false,
|
||||
deletable: false,
|
||||
data: {
|
||||
edge,
|
||||
info: model.edges.get(edge.id)!,
|
||||
hot: hovered?.edge.id === edge.id || (selected !== null && (edge.source === selected || edge.target === selected)),
|
||||
dimmed: selected !== null && edge.source !== selected && edge.target !== selected,
|
||||
// A label only where it can be read as belonging to one line: the
|
||||
// selected screen's own edges (near that screen) and the hovered
|
||||
// edge (near its source). Unselected, the picture is lines and
|
||||
// boxes; the conditions are one click away.
|
||||
labelled: hovered?.edge.id === edge.id || (selected !== null && (edge.source === selected || edge.target === selected)),
|
||||
labelAt: selected !== null && edge.target === selected && edge.source !== selected ? 0.72 : 0.28,
|
||||
onHover: onEdgeHover,
|
||||
},
|
||||
}));
|
||||
.map((edge) => {
|
||||
const touches = selected !== null && (edge.source === selected || edge.target === selected);
|
||||
const isFocus = focus === edge.id;
|
||||
const hot = isFocus || touches;
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
type: 'screen',
|
||||
selectable: false,
|
||||
deletable: false,
|
||||
// The line in focus, and its pill, over the others; the selected
|
||||
// screen's over the rest.
|
||||
zIndex: isFocus ? 3 : hot ? 2 : 1,
|
||||
data: {
|
||||
edge,
|
||||
info: model.edges.get(edge.id)!,
|
||||
curve: model.curves.get(edge.id)!,
|
||||
hot,
|
||||
soft: hot && focus !== null && !isFocus,
|
||||
focus: isFocus,
|
||||
dimmed: selected !== null && !touches,
|
||||
pill: pills?.pills.get(edge.id) ?? (isFocus ? focusPill : null),
|
||||
full: panelHot?.edge === edge.id ? fullText(panelHot.link) : null,
|
||||
onHover: onEdgeHover,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const selectedInfo = $derived(selected === null || model === null ? null : (model.nodes.get(selected) ?? null));
|
||||
@@ -140,6 +183,11 @@
|
||||
);
|
||||
const hoveredInfo = $derived(hovered === null || model === null ? null : (model.edges.get(hovered.edge.id) ?? null));
|
||||
|
||||
const edgeById = $derived(
|
||||
model === null ? new Map<string, MapEdgeLayout>() : new Map(model.layout.edges.map((e) => [e.id, e]))
|
||||
);
|
||||
const visibleIds = $derived(new Set(edges.map((e) => e.id)));
|
||||
|
||||
function onEdgeHover(edge: MapEdgeLayout | null, event: MouseEvent | null): void {
|
||||
if (edge === null || event === null || stage === null) {
|
||||
hovered = null;
|
||||
@@ -153,6 +201,62 @@
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The pointer on the canvas means the line nearest it. A pill speaks for
|
||||
* its own line; over a box, the key or the tooltip there is no line.
|
||||
*/
|
||||
function onStageMove(event: MouseEvent): void {
|
||||
if (model === null || stage === null) return;
|
||||
const target = event.target as Element | null;
|
||||
if (target?.closest('.spill')) return;
|
||||
if (target?.closest('.snode, .legend, .tip, .svelte-flow__controls')) {
|
||||
hovered = null;
|
||||
return;
|
||||
}
|
||||
const view = viewport ?? readViewport();
|
||||
if (!view) return;
|
||||
const box = stage.getBoundingClientRect();
|
||||
const point = {
|
||||
x: (event.clientX - box.left - view.x) / view.zoom,
|
||||
y: (event.clientY - box.top - view.y) / view.zoom,
|
||||
};
|
||||
const hit = nearestEdge(model, point, visibleIds, HOVER_REACH / view.zoom);
|
||||
const edge = hit === null ? undefined : edgeById.get(hit.id);
|
||||
if (!edge) {
|
||||
hovered = null;
|
||||
return;
|
||||
}
|
||||
hovered = {
|
||||
edge,
|
||||
x: Math.min(event.clientX - box.left + 14, box.width - 360),
|
||||
y: event.clientY - box.top + 14,
|
||||
};
|
||||
}
|
||||
|
||||
/** The transform Svelte Flow applied, for the moment before the binding has a value. */
|
||||
function readViewport(): Viewport | null {
|
||||
const el = stage?.querySelector<HTMLElement>('.svelte-flow__viewport');
|
||||
const m = el?.style.transform.match(/translate\(([-\d.]+)px,\s*([-\d.]+)px\)\s*scale\(([-\d.]+)\)/);
|
||||
return m ? { x: Number(m[1]), y: Number(m[2]), zoom: Number(m[3]) } : null;
|
||||
}
|
||||
|
||||
/** The row under the pointer: light its line, and say the whole condition on it. */
|
||||
function onRowHover(link: WireScreenLink | null): void {
|
||||
const edge = link === null ? null : pairId(link);
|
||||
panelHot = link === null || edge === null ? null : { edge, link };
|
||||
}
|
||||
|
||||
/** The words a panel row puts on its line: the arrow, and the whole condition. */
|
||||
function fullText(link: WireScreenLink): string {
|
||||
const arriving = selected !== null && link.to === selected && link.from !== selected;
|
||||
return `${arriving ? '←' : '→'} ${link.when || 'always'}`;
|
||||
}
|
||||
|
||||
function rowHot(link: WireScreenLink): boolean {
|
||||
if (panelHot !== null) return panelHot.link.id === link.id;
|
||||
return hovered !== null && pairId(link) === hovered.edge.id;
|
||||
}
|
||||
|
||||
function nameOf(id: string): string {
|
||||
return model?.nodes.get(id)?.label ?? id;
|
||||
}
|
||||
@@ -165,7 +269,7 @@
|
||||
</script>
|
||||
|
||||
<div class="screens">
|
||||
<div class="stage" bind:this={stage}>
|
||||
<div class="stage" bind:this={stage} role="presentation" onmousemove={onStageMove} onmouseleave={() => (hovered = null)}>
|
||||
{#if error !== null}
|
||||
<div class="state">
|
||||
<h2>The screens could not be read</h2>
|
||||
@@ -191,8 +295,9 @@
|
||||
{edgeTypes}
|
||||
fitView
|
||||
{...FIT}
|
||||
bind:viewport
|
||||
minZoom={0.2}
|
||||
maxZoom={1.6}
|
||||
maxZoom={3}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
@@ -201,6 +306,7 @@
|
||||
onpaneclick={() => {
|
||||
selected = null;
|
||||
hovered = null;
|
||||
panelHot = null;
|
||||
}}
|
||||
>
|
||||
<Controls position="bottom-right" showLock={false} />
|
||||
@@ -225,11 +331,14 @@
|
||||
</div>
|
||||
<div class="lrow">
|
||||
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-back" /></svg>
|
||||
<span>Goes back up the picture (returning)</span>
|
||||
<span>Goes back up the picture (returning) — leaves the top of its box, arrives at the bottom of the other</span>
|
||||
</div>
|
||||
<div class="lrow">
|
||||
<span class="k-label mono">when x</span>
|
||||
<span>Runs only under that condition (shown on the selected screen's lines); none = always</span>
|
||||
<span class="k-label mono">→ …x</span>
|
||||
<span>
|
||||
The last condition checked before the transition, beside the screen at the other end of
|
||||
the selected screen's line; ← when it arrives there. None = always
|
||||
</span>
|
||||
</div>
|
||||
<div class="lrow">
|
||||
<span class="k-box mono">/path</span>
|
||||
@@ -241,7 +350,7 @@
|
||||
</div>
|
||||
<div class="lrow">
|
||||
<span class="k-box k-origin mono">fn()</span>
|
||||
<span>Not a screen: shared chrome, or a trigger no screen reaches</span>
|
||||
<span>Not a screen: shared chrome, or a trigger no screen reaches — a row above what it opens</span>
|
||||
</div>
|
||||
<div class="lrow">
|
||||
<span class="k-box k-unreached mono">/path</span>
|
||||
@@ -286,6 +395,12 @@
|
||||
</div>
|
||||
<button class="clear" onclick={() => (selected = null)}>clear</button>
|
||||
</div>
|
||||
{#if pills !== null && pills.hidden > 0}
|
||||
<p class="dim note">
|
||||
{pills.hidden} condition{pills.hidden === 1 ? '' : 's'} not drawn on the picture for want of
|
||||
room — hover a row below to see {pills.hidden === 1 ? 'it' : 'each'} on its line.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<h4>Opens from <span class="dim">{lists.opensFrom.length}</span></h4>
|
||||
{#if lists.opensFrom.length === 0}
|
||||
@@ -294,7 +409,15 @@
|
||||
</p>
|
||||
{/if}
|
||||
{#each lists.opensFrom as link (link.id)}
|
||||
<div class="row">
|
||||
<div
|
||||
class="row"
|
||||
class:hot={rowHot(link)}
|
||||
role="presentation"
|
||||
onmouseenter={() => onRowHover(link)}
|
||||
onmouseleave={() => onRowHover(null)}
|
||||
onfocusin={() => onRowHover(link)}
|
||||
onfocusout={() => onRowHover(null)}
|
||||
>
|
||||
<button class="peer mono" onclick={() => (selected = link.from)}>{sentence(link, 'from')}</button>
|
||||
{#if link.when}<div class="when">when {link.when}</div>{/if}
|
||||
{#if link.via.length > 0}<div class="via dim">via {viaText(link)}</div>{/if}
|
||||
@@ -309,7 +432,15 @@
|
||||
<h4>Goes to <span class="dim">{lists.goesTo.length}</span></h4>
|
||||
{#if lists.goesTo.length === 0}<p class="dim">No navigation leaves this screen.</p>{/if}
|
||||
{#each lists.goesTo as link (link.id)}
|
||||
<div class="row">
|
||||
<div
|
||||
class="row"
|
||||
class:hot={rowHot(link)}
|
||||
role="presentation"
|
||||
onmouseenter={() => onRowHover(link)}
|
||||
onmouseleave={() => onRowHover(null)}
|
||||
onfocusin={() => onRowHover(link)}
|
||||
onfocusout={() => onRowHover(null)}
|
||||
>
|
||||
<button class="peer mono" onclick={() => (selected = link.to)}>{sentence(link, 'to')}</button>
|
||||
{#if link.when}<div class="when">when {link.when}</div>{/if}
|
||||
{#if link.via.length > 0}<div class="via dim">via {viaText(link)}</div>{/if}
|
||||
@@ -330,15 +461,16 @@
|
||||
</p>
|
||||
<p class="dim">
|
||||
<span class="mark">●</span> The entry screen is at the top; each row down is one more
|
||||
transition away from it. An arrow's label is the condition under which that transition
|
||||
happens, read from the code around the navigation call; hover it for the chain the call
|
||||
travels through, click a screen for everything in and out of it.
|
||||
transition away from it. Click a screen and each of its transitions is labelled at the far
|
||||
end of its line — beside the screen it leads to or comes from — with the last condition
|
||||
checked before it happens; hover the line, or its row here, for the whole chain and the
|
||||
calls it travels through.
|
||||
</p>
|
||||
<p class="dim">
|
||||
Solid: the destination is written at the call. Dashed grey: it comes back from a
|
||||
helper's return value (inferred). Dashed accent: a transition back up the picture
|
||||
(returning). Dashed box: a trigger that is not a screen — shared chrome, or code no
|
||||
screen's render chain reaches.
|
||||
(returning), drawn around the boxes rather than through them. Dashed box: a trigger that
|
||||
is not a screen — shared chrome, or code no screen's render chain reaches.
|
||||
</p>
|
||||
{#if model.unreached > 0}
|
||||
<p class="dim">
|
||||
@@ -385,6 +517,11 @@
|
||||
border: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* The label layer covers the canvas; only the pills in it take the pointer,
|
||||
never the empty paper between them — the lines underneath do. */
|
||||
.stage :global(.svelte-flow__edge-labels) {
|
||||
pointer-events: none;
|
||||
}
|
||||
.stage :global(.svelte-flow__controls-button) {
|
||||
background: var(--paper);
|
||||
border: 0;
|
||||
@@ -536,13 +673,25 @@
|
||||
padding: 1px 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.note {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
h4 {
|
||||
margin: 16px 0 6px;
|
||||
font: 600 12.5px var(--sans);
|
||||
}
|
||||
/* A row is also a pointer at its line: hovering it lights the line and
|
||||
prints the whole condition on it, and the line under the pointer on the
|
||||
canvas tints its row here. Bled to the panel's edges so the tint reads
|
||||
as a row, not a box inside one. */
|
||||
.row {
|
||||
padding: 7px 0;
|
||||
padding: 7px 8px;
|
||||
margin: 0 -8px;
|
||||
border-top: 1px solid var(--rule-soft);
|
||||
transition: background 90ms linear;
|
||||
}
|
||||
.row.hot {
|
||||
background: var(--press);
|
||||
}
|
||||
.peer {
|
||||
display: block;
|
||||
|
||||
Reference in New Issue
Block a user