|
@@ -2,25 +2,81 @@
|
|
|
* The Screens view's model — the app's screens and the transitions between
|
|
* 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.
|
|
* 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
|
|
* 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
|
|
* 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 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';
|
|
|
|
|
+
|
|
|
|
|
+/* ------------------------------------------------------------- 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 longest `when` a connector prints before an ellipsis; the tooltip has the rest. */
|
|
|
|
|
-const EDGE_LABEL_MAX = 30;
|
|
|
|
|
|
|
+/** 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 {
|
|
export interface ScreenNodeInfo {
|
|
|
id: string;
|
|
id: string;
|
|
@@ -42,7 +98,7 @@ export interface ScreenEdgeInfo {
|
|
|
to: string;
|
|
to: string;
|
|
|
/** Every transition between the pair — one connector, several stories. */
|
|
/** Every transition between the pair — one connector, several stories. */
|
|
|
links: WireScreenLink[];
|
|
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;
|
|
label: string;
|
|
|
synthesized: boolean;
|
|
synthesized: boolean;
|
|
|
}
|
|
}
|
|
@@ -54,52 +110,100 @@ export interface ScreensModel {
|
|
|
edges: Map<string, ScreenEdgeInfo>;
|
|
edges: Map<string, ScreenEdgeInfo>;
|
|
|
/** Screens no chain of transitions reaches from the entry. */
|
|
/** Screens no chain of transitions reaches from the entry. */
|
|
|
unreached: number;
|
|
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
|
|
* 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 out = new Map<string, string[]>(ids.map((id) => [id, []]));
|
|
|
const indeg = new Map<string, number>(ids.map((id) => [id, 0]));
|
|
const indeg = new Map<string, number>(ids.map((id) => [id, 0]));
|
|
|
for (const l of links) {
|
|
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);
|
|
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 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()]);
|
|
const reachedMax = Math.max(0, ...[...depth.values()]);
|
|
|
// The unreached band: its own sources first, then whatever they open.
|
|
// The unreached band: its own sources first, then whatever they open.
|
|
|
const rest = ids.filter((id) => !depth.has(id));
|
|
const rest = ids.filter((id) => !depth.has(id));
|
|
|
const restDepth = new Map<string, number>();
|
|
const restDepth = new Map<string, number>();
|
|
|
if (rest.length > 0) {
|
|
if (rest.length > 0) {
|
|
|
|
|
+ const restSet = new Set(rest);
|
|
|
const restSources = rest.filter((id) => (indeg.get(id) ?? 0) === 0);
|
|
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);
|
|
for (const s of frontier) restDepth.set(s, 0);
|
|
|
let d = 0;
|
|
let d = 0;
|
|
|
while (frontier.length > 0) {
|
|
while (frontier.length > 0) {
|
|
@@ -107,7 +211,7 @@ export function entryLayering(entry: string | null, seeds: readonly string[]) {
|
|
|
const next: string[] = [];
|
|
const next: string[] = [];
|
|
|
for (const id of frontier) {
|
|
for (const id of frontier) {
|
|
|
for (const t of out.get(id) ?? []) {
|
|
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);
|
|
restDepth.set(t, d);
|
|
|
next.push(t);
|
|
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 {
|
|
export function edgeLabel(links: readonly WireScreenLink[]): string {
|
|
|
if (links.length === 1) {
|
|
if (links.length === 1) {
|
|
|
const when = links[0]!.when;
|
|
const when = links[0]!.when;
|
|
|
if (!when) return '';
|
|
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;
|
|
const conditional = links.filter((l) => l.when).length;
|
|
|
return conditional > 0 ? `${links.length} ways · ${conditional} conditional` : `${links.length} ways`;
|
|
return conditional > 0 ? `${links.length} ways · ${conditional} conditional` : `${links.length} ways`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/* ---------------------------------------------------------------- build -- */
|
|
|
|
|
+
|
|
|
export function buildScreensModel(payload: WireScreensPayload): ScreensModel {
|
|
export function buildScreensModel(payload: WireScreensPayload): ScreensModel {
|
|
|
const nodes = new Map<string, ScreenNodeInfo>();
|
|
const nodes = new Map<string, ScreenNodeInfo>();
|
|
|
const modules: WireMapModule[] = [];
|
|
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) {
|
|
for (const screen of payload.screens) {
|
|
|
const info: ScreenNodeInfo = {
|
|
const info: ScreenNodeInfo = {
|
|
@@ -240,10 +398,16 @@ export function buildScreensModel(payload: WireScreensPayload): ScreensModel {
|
|
|
const info = nodes.get(m.id);
|
|
const info = nodes.get(m.id);
|
|
|
return { label: info?.label ?? m.id, meta: info?.sub ?? '' };
|
|
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 {
|
|
function moduleFor(info: ScreenNodeInfo, symbols: number): WireMapModule {
|
|
@@ -275,3 +439,416 @@ export function neighbourhood(
|
|
|
export function viaText(link: WireScreenLink): string {
|
|
export function viaText(link: WireScreenLink): string {
|
|
|
return link.via.map((v) => v.name).join(' → ');
|
|
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
|
|
|
|
|
+ );
|
|
|
|
|
+}
|