mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 03:08:45 +08:00
3ed73bc127
* fix(ui): a screen's steps read as clusters, and a link too far to follow is said in words The mobile app's /capture came back as a web: 100 boxes in a 1,227x5,588 ribbon, 113 lines drawn at rest crossing each other 652 times, each one running over about five other boxes' names. Measured, not guessed — three separate causes, very unequal. The region grouping had nothing to divide there (98 of 100 boxes take their region from one memoized component), so the picture fell back to a single 719px column. But the region dimension was not the lever. The lever was that `packRegions` packed every step of one distance onto shared rows and wrapped those rows at a fixed 720px, so a box and the thing it fires landed seven lines apart: 70 of the 113 lines joined boxes ONE step apart. That is what the crossings were made of. So a region is now packed as CLUSTERS — a step, then the steps it sets in motion on the line under it, stepped in — while the starting points that fire nothing still share a line, because a screen's handlers are siblings and giving each its own line turned a flat region into a column. A region's line width is earned rather than fixed (sqrt(total * pitch), clamped 720..2600), so a big screen comes out about as wide as it is tall. Clustering makes most links local but not all: a step reached from two places is drawn under whichever reached it first, so the other way in still crosses the picture. Those are now said in WORDS at both ends — `-> resumeInference` under the box that leads there, `<- CaptureView` under the box it arrives at, capped at three with `+N more` — rather than drawn. This is not a hiding: the link is stated, which says more than a line vanishing off the edge of the screen does, and selecting the box draws every one of its real lines exactly as before. It is the one at-rest cut that does not produce the "box that leads somewhere and draws nothing" every earlier cut produced. Also fixed while here, and predicted by the earlier region work: the in-region row relaxation had no cycle guard, so a region holding one loop pushed 65 of its boxes to rows 294-301 while the rest sat at 0-2. `forwardLinks` sets cycle-closing links aside first, as the order reading's `withoutBackEdges` already did. /capture: 1,227x5,588 -> 2,279x4,356, at-rest crossings 652 -> 1, lines-over-boxes 553 -> 26, with half the links still drawn as real lines and every quiet box still one the screen itself fires directly. The order reading is untouched (it keeps every line). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD * fix(ui): a stub names its box without the mark the box wears for its kind `← ⇠ onCaptureProgress +2` reads as two arrows arguing: the stub already leads with a direction, and the box's own kind mark was competing with it. Verified in the live canvas. The box keeps its mark, where nothing competes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD * fix(ui): the screen's line into each of its parts stops sweeping the picture Audited all 51 screens of the mobile app on this branch. 96 lines — the screen's own stand-in line into each region — were 17% of everything drawn and caused 79% of every crossing left. A screen with ten regions tiles them into bands, so the line into a region two bands down travelled the height of the whole picture. Two causes, both fixed. The entry the line lands on was the walk's first member of the region; clustering moves a step that fires something BELOW the ones that fire nothing, so that box could sit lines down inside the region and the line had to reach past everything above it. It now lands on the box nearest the region's top-left that the screen actually leads to. And the stand-in line is no longer exempt from the stub rule — when the region is still too far to follow, the link is said in words like any other. The rest of the anchor's fan stays quiet as before: it is already stood in for. Across the 51 screens: crossings 47 -> 10, no screen above 10 (worst was 19, now 2); lines-over-boxes 236 -> 182; boxes with neither a line nor a word 150 -> 135. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD * fix(ui): a screen's parts fill the canvas instead of squaring off into rows Audited the app's 28 regioned screens: the median canvas was 55% region and 45% nothing, and /home was 44% — 4,860px tall to hold about 2,160px of picture. The cause is that regions were tiled a row at a time with each row as tall as its tallest member, so one short region beside a tall one left the rest of that row blank, and a reader scrolls through the blank. Each region now goes as high as it can and then as far left as it can, over a skyline of what is already placed. Reading order is untouched: regions are still walked in the screen's own source order, so an earlier one is never pushed below a later one — a short one just tucks under another short one rather than waiting for the tall one beside it. Layering now comes from the finished geometry rather than a band counter, since once regions drop independently what a reader sees as one row IS one row. /home 4,860px -> 3,584px, aspect 0.59 -> 0.94. Tallest screen in the app 4,860 -> 4,356. Crossings 10 -> 13 across all 51 screens, still none above 10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD * fix(ui): the width a picture wraps at is tried, not estimated A region's line width came from sqrt(total * pitch) — the width at which total/width lines come out square. That estimate is wrong for how these pictures are drawn: a cluster spends lines on its own structure (a hub gets a line to itself, and what it fires starts another), so it undercounts a region's lines badly and wrapped /capture's 98 boxes into a 4,356px column. Laying a picture out is cheap and exact, so the widths are tried instead: layoutAt runs the whole pack at each of eight widths and the best finished canvas wins (~2ms for the model, all eight included). It has to be scored on the CANVAS, not per region — squaring each region off individually leaves fewer of them side by side, which took /home from 3,584px to 5,624px while every region looked better on its own. Also measured and rejected while here: dropping a region's CLUSTERS side by side the way the regions drop onto the canvas. Total height 42,084 -> 39,756px (-6%), but lines-over-boxes 120 -> 134 and crossings 5 -> 8, because two clusters side by side put each one's lines through the other. Height is cheap to scroll; a crossed line is what made this picture unreadable. The reasoning is recorded in the code so it is not re-tried blindly. Regions differ — they sit far enough apart that few lines run between them. Across the app's 51 screens: tallest picture 4,356 -> 3,796px, total height 45,744 -> 42,084px, lines-over-boxes 184 -> 120, crossings 13 -> 5, and no screen is a tall ribbon any more. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD * stuff --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1202 lines
49 KiB
TypeScript
1202 lines
49 KiB
TypeScript
/**
|
|
* The Steps view's model — what happens from an anchor, as typed steps laid
|
|
* out so that a step sits above the steps it sets in motion.
|
|
*
|
|
* Everything geometric is the Screens view's (`screens-model.ts`): the Map's
|
|
* layout with directional ports, a curve per edge on a track of its own, the
|
|
* pills that label a selected step's links at the far end of each line, and
|
|
* the nearest-line pointer. What is this file's own is small: the row a step
|
|
* sits on is its distance from the anchor, which the server already counted
|
|
* (`WireStep.depth`), so the layering is a lookup rather than a search; the
|
|
* words in a box come from the step's kind; and the side panel's two lists
|
|
* are the links into and out of the selected step.
|
|
*/
|
|
|
|
import { whenWords } from './conditions';
|
|
import type { WireMapLink, WireMapModule, WireStep, WireStepDecision, WireStepLink, WireStepTrigger, WireStepsPayload } from './wire';
|
|
import {
|
|
buildMapLayout,
|
|
linkId,
|
|
nodeWidth,
|
|
strokeWidthFor,
|
|
NODE_GAP,
|
|
NODE_HEIGHT,
|
|
PADDING,
|
|
PORT_PITCH,
|
|
type EdgeRoute,
|
|
type MapEdgeLayout,
|
|
type MapLayout,
|
|
type MapNodeLayout,
|
|
type PortRef,
|
|
} from './map-model';
|
|
import {
|
|
edgeLabel,
|
|
samplePolyline,
|
|
trackedCurves,
|
|
SCREEN_LAYER_GAP,
|
|
type Curve,
|
|
type Picture,
|
|
type Point,
|
|
} from './screens-model';
|
|
|
|
export interface StepNodeInfo {
|
|
id: string;
|
|
step: WireStep;
|
|
/** What the box prints on its first line. */
|
|
label: string;
|
|
/** …and on its second. */
|
|
sub: string;
|
|
}
|
|
|
|
export interface StepEdgeInfo {
|
|
id: string;
|
|
from: string;
|
|
to: string;
|
|
/** Every link between the pair — one connector, several stories. */
|
|
links: WireStepLink[];
|
|
/** The connector's short label: the innermost condition, or how many links. */
|
|
label: string;
|
|
/** Every link behind it was synthesized (a dynamic-dispatch bridge). */
|
|
synthesized: boolean;
|
|
/** The kind the links agree on, or `calls` when they differ. */
|
|
kind: WireStepLink['kind'];
|
|
/**
|
|
* The one way of a decision this connector is — `yes`, `no`, a case's
|
|
* value — when it and a sibling out of the same box are arms of one fork.
|
|
* The condition itself is said once, under the box ({@link StepDecision}).
|
|
*/
|
|
arm?: string;
|
|
}
|
|
|
|
/**
|
|
* A decision drawn where it is made: the condition said ONCE, under the box
|
|
* that decides it, while each line out of that box says only which way it is.
|
|
*/
|
|
export interface StepDecision {
|
|
id: string;
|
|
/** The condition as a reader says it, asking: `await hasSeenWelcome(…)?`. */
|
|
label: string;
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
}
|
|
|
|
export interface StepsModel extends Picture {
|
|
layout: MapLayout;
|
|
nodes: Map<string, StepNodeInfo>;
|
|
edges: Map<string, StepEdgeInfo>;
|
|
layerGap: number;
|
|
curves: Map<string, Curve>;
|
|
polylines: Map<string, Point[]>;
|
|
/** Steps per kind, for the panel's summary. */
|
|
counts: Record<WireStep['kind'], number>;
|
|
/**
|
|
* A screen's picture only: the regions its boxes are laid out by, for the
|
|
* captions. Null when the steps carry no regions — an endpoint's or a
|
|
* function's picture, and the order reading — and the rows are distance.
|
|
*/
|
|
regions: StepRegionZone[] | null;
|
|
/** The first box of each region — where the anchor's at-rest line arrives. */
|
|
regionEntries: ReadonlySet<string> | null;
|
|
/**
|
|
* The order reading only: its decisions, each drawn as a point of its own
|
|
* where the arms diverge (`fork:N` in the layout). Null on the tree, whose
|
|
* decisions are made INSIDE a box and drawn under it ({@link decisions}).
|
|
*/
|
|
forks: Map<string, StepForkInfo> | null;
|
|
/**
|
|
* The tree reading's decisions: a condition said once under the box that
|
|
* decides it, its arms labelled on the lines out. Empty when the picture
|
|
* holds none.
|
|
*/
|
|
decisions: StepDecision[];
|
|
/**
|
|
* Per box, the links it does NOT draw a line for at rest, said in words on
|
|
* it instead ({@link StepStub}). Empty for a picture whose every line is
|
|
* local.
|
|
*/
|
|
stubs: Map<string, StepStub[]>;
|
|
/** The edges drawn as stubs rather than lines — what {@link stepEdgeVisible} keeps back. */
|
|
stubbed: ReadonlySet<string>;
|
|
}
|
|
|
|
/**
|
|
* A decision on the order reading's canvas — a fork of the code with two or
|
|
* more arms that lead somewhere. The condition is said ONCE, on the point,
|
|
* and each line out answers it (`yes`, `no`, a case's value): two lines that
|
|
* each carried the whole predicate, one of them negated, never said they were
|
|
* the same choice.
|
|
*/
|
|
export interface StepForkInfo {
|
|
id: string;
|
|
/** The condition in positive words — a switch's subject; '' when the arms share none. */
|
|
on: string;
|
|
form: 'if' | 'switch' | 'ternary' | 'try';
|
|
/** The point's words: the condition, asked — `user AND (await …)?`. */
|
|
label: string;
|
|
}
|
|
|
|
/**
|
|
* One end of a link said in WORDS on its box instead of drawn as a line across
|
|
* the canvas.
|
|
*
|
|
* A line is a good drawing of a hop between two boxes a reader can see at
|
|
* once. It is a bad drawing of a hop across two thousand pixels: on one real
|
|
* screen the hundred and thirteen lines drawn at rest crossed each other six
|
|
* hundred and fifty-two times and each ran over five other boxes' names, so
|
|
* no single line could be followed and the boxes could not be read either.
|
|
* The link is not dropped — it is stated at BOTH ends, `→ resumeInference` on
|
|
* the box that leads there and `← CaptureView` on the box it arrives at, which
|
|
* says more than a line disappearing off the edge of the screen does. Select
|
|
* the box and every one of its real lines draws, exactly as before.
|
|
*/
|
|
export interface StepStub {
|
|
/** The layout edge this stands for, so selecting draws the real line. */
|
|
edge: string;
|
|
/** The box at the other end. */
|
|
other: string;
|
|
/** What that box calls itself. */
|
|
label: string;
|
|
/** `out` — this box leads there; `in` — it arrives from there. */
|
|
dir: 'out' | 'in';
|
|
}
|
|
|
|
/** One region of a screen's picture: its caption, and the space its boxes hold. */
|
|
export interface StepRegionZone {
|
|
id: string;
|
|
label: string;
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
/** The region's first box, in the walk's order. */
|
|
entry: string;
|
|
}
|
|
|
|
/** Points a curve is sampled at for hit-testing (as the Screens view's). */
|
|
const HIT_SAMPLES = 24;
|
|
|
|
/* ---------------------------------------------------------------- words -- */
|
|
|
|
/** What the index is a picture of; the server decides it from the routes (`WireStepsPayload.project`). */
|
|
export type ProjectKind = WireStepsPayload['project'];
|
|
|
|
/**
|
|
* A short word for a step's kind, as the panel and the legend say it — in the
|
|
* project's own vocabulary. The same box is a screen in an app, a page in a
|
|
* web app and an endpoint in an API; a route that leads with an HTTP verb is
|
|
* an endpoint wherever it is. One place decides, so the legend, the panel
|
|
* and the tooltip never disagree.
|
|
*/
|
|
export function kindWord(kind: WireStep['kind'], project: ProjectKind = 'app', step?: WireStep): string {
|
|
return kindWords(kind, project, step)[0];
|
|
}
|
|
|
|
/** The singular and the plural, for counts: `1 endpoint`, `3 outside the index`. */
|
|
export function kindWords(kind: WireStep['kind'], project: ProjectKind = 'app', step?: WireStep): [string, string] {
|
|
switch (kind) {
|
|
case 'screen':
|
|
if (step?.screen?.endpoint) return ['endpoint', 'endpoints'];
|
|
return project === 'api' ? ['endpoint', 'endpoints'] : project === 'web' ? ['page', 'pages'] : ['screen', 'screens'];
|
|
case 'trigger':
|
|
return ['handler', 'handlers'];
|
|
case 'bridge':
|
|
// An endpoint reached across a tier is a call to the server wherever it is.
|
|
if (step?.screen?.endpoint) return ['call to the server', 'calls to the server'];
|
|
return project === 'app' ? ['native call', 'native calls'] : project === 'web' ? ['call to the server', 'calls to the server'] : ['call to another tier', 'calls to another tier'];
|
|
case 'event':
|
|
return project === 'app' ? ['native event', 'native events'] : project === 'web' ? ['arrives from the server', 'arrive from the server'] : ['arrives from a queue or bus', 'arrive from a queue or bus'];
|
|
case 'store':
|
|
return project === 'api' ? ['data call', 'data calls'] : ['store action', 'store actions'];
|
|
case 'effect':
|
|
return ['outside the index', 'outside the index'];
|
|
default:
|
|
return ['start', 'start'];
|
|
}
|
|
}
|
|
|
|
/** `3 handlers`, `1 endpoint`, `11 outside the index`. */
|
|
export function countWords(n: number, kind: WireStep['kind'], project: ProjectKind = 'app'): string {
|
|
const [one, many] = kindWords(kind, project);
|
|
return `${n} ${n === 1 ? one : many}`;
|
|
}
|
|
|
|
/**
|
|
* What fires something, in a few characters: `onPress · <Button>`,
|
|
* `onSubmit · useFormik(…)`, `addListener('onZipComplete')`, `useEffect`;
|
|
* for a server, `POST /users · after authenticate, validate(…)`,
|
|
* `@Process('email')`, `page load · /blog/[slug]`.
|
|
*/
|
|
export function triggerWords(t: WireStepTrigger): string {
|
|
const after = t.after && t.after.length > 0 ? ` · after ${t.after.join(', ')}` : '';
|
|
switch (t.kind) {
|
|
case 'prop':
|
|
return t.of ? `${t.name} · <${t.of}>` : t.name;
|
|
case 'option':
|
|
return t.of ? `${t.name} · ${t.of}(…)` : t.name;
|
|
case 'request':
|
|
return `${t.name} ${t.of ?? ''}`.trim() + after;
|
|
case 'decorator':
|
|
return `@${t.name}(${t.of ?? ''})` + after;
|
|
case 'load':
|
|
return `page load · ${t.of ?? t.name}` + after;
|
|
default:
|
|
return t.of ? `${t.name}(${t.of})` : t.name;
|
|
}
|
|
}
|
|
|
|
/** The first line of a step's box. Boundary crossings carry an arrow for which way the code goes. */
|
|
export function stepLabel(step: WireStep): string {
|
|
switch (step.kind) {
|
|
case 'bridge':
|
|
return `⇢ ${step.label}`;
|
|
case 'event': {
|
|
const events = step.events ?? (step.event ? [step.event] : []);
|
|
if (events.length === 0) return `⇠ ${step.label}`;
|
|
return events.length === 1 ? `⇠ ${events[0]}` : `⇠ ${events[0]} +${events.length - 1}`;
|
|
}
|
|
default:
|
|
return step.label;
|
|
}
|
|
}
|
|
|
|
/** The second line: what the step is, then where it is. */
|
|
export function stepSub(step: WireStep, project: ProjectKind = 'app'): string {
|
|
const file = step.node ? step.node.file.slice(step.node.file.lastIndexOf('/') + 1) : '';
|
|
switch (step.kind) {
|
|
case 'screen':
|
|
return step.sub;
|
|
case 'trigger':
|
|
// The event before the file: `onPress · <Button> · index.tsx`.
|
|
return step.trigger ? `${triggerWords(step.trigger)} · ${file}` : `handler · ${file}`;
|
|
case 'bridge':
|
|
// An endpoint the code crosses to says its handler, as an endpoint box does.
|
|
if (step.screen) return step.sub;
|
|
return `${project === 'app' ? 'native' : project === 'web' ? 'server' : 'another tier'} · ${file}`;
|
|
case 'event':
|
|
return `${step.label} · ${file}`;
|
|
case 'store':
|
|
return `${project === 'api' ? 'data' : 'store'} · ${file}`;
|
|
case 'effect':
|
|
return step.sub;
|
|
default:
|
|
// The anchor: its file, at the size of a box; the panel prints the whole path.
|
|
return step.node && step.sub === step.node.file ? file : step.sub;
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------- decisions -- */
|
|
|
|
/** A case value longer than this is cut on the line; the whole condition is a hover away. */
|
|
const ARM_WORD_MAX = 24;
|
|
/** Room for one line of a decision's caption under its box. */
|
|
const DECISION_LINE = 15;
|
|
/** Advance of the caption's 10.5px mono, and the room it may take past its box. */
|
|
const DECISION_CHAR = 6.3;
|
|
const DECISION_MAX_WIDTH = 320;
|
|
|
|
/**
|
|
* The word a line out of a decision says — the ONE place that decides it, so
|
|
* the two readings can never word an arm differently. `yes` / `no` for an
|
|
* `if` or a ternary; a case's own value for a switch, with the subject the
|
|
* decision already asks stripped off (`status === 'expired'` → `'expired'`),
|
|
* and `else` for its default; a `try`'s arms keep their own words.
|
|
*/
|
|
export function armWords(d: { on: string; arm: string; form: 'if' | 'switch' | 'ternary' | 'try'; not?: true }): string {
|
|
if (d.form === 'if' || d.form === 'ternary') return d.not ? 'no' : 'yes';
|
|
if (d.not) return 'else';
|
|
let text = d.arm;
|
|
if (d.on && text.startsWith(d.on)) text = text.slice(d.on.length).trim().replace(/^===?\s*/, '');
|
|
if (!text) return 'yes';
|
|
return text.length > ARM_WORD_MAX ? `${text.slice(0, ARM_WORD_MAX - 1)}…` : text;
|
|
}
|
|
|
|
/**
|
|
* The one arm of one decision a connector is, when EVERY site behind it
|
|
* agrees. A connector with a site that runs under no condition is not
|
|
* exclusively an arm — the step happens either way — and one whose sites
|
|
* disagree is several stories; both stay plain lines rather than claim a side.
|
|
*/
|
|
function edgeArm(info: StepEdgeInfo): WireStepDecision | null {
|
|
let found: WireStepDecision | null = null;
|
|
for (const link of info.links) {
|
|
for (const site of link.sites) {
|
|
if (!site.decision) return null;
|
|
if (found === null) found = site.decision;
|
|
else if (found.branch !== site.decision.branch || found.arm !== site.decision.arm) return null;
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* Sibling connectors out of one box that are arms of ONE fork, marked as the
|
|
* choice they are: each line says only which way it is, and the condition is
|
|
* said once under the box that decides it. Two lines that each carried the
|
|
* whole predicate — one of them the other's negation, both truncated to the
|
|
* same forty characters — never said they were the same choice, and at rest
|
|
* the tree drew them with no label at all.
|
|
*
|
|
* A fork with ONE drawn arm is a guard clause, not a choice, and keeps its
|
|
* condition on the line: the decision has to have at least two ways drawn
|
|
* before it is worth a caption.
|
|
*/
|
|
function markDecisions(edges: Map<string, StepEdgeInfo>, layout: MapLayout): StepDecision[] {
|
|
const groups = new Map<string, Array<{ info: StepEdgeInfo; decision: WireStepDecision }>>();
|
|
for (const info of edges.values()) {
|
|
const decision = edgeArm(info);
|
|
if (decision === null) continue;
|
|
const key = `${info.from} |