diff --git a/.changes/diagram-grid.json b/.changes/diagram-grid.json new file mode 100644 index 00000000..35616a5e --- /dev/null +++ b/.changes/diagram-grid.json @@ -0,0 +1 @@ +{ "type": "user-facing", "releaseNoteId": "release:0.25.0" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37b7ec00..63f8702a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,7 +114,7 @@ jobs: # within the budget (`npm run check:e2e-shards`). To change the shard count, # change the matrix AND the `/N` in the run step; the check keeps them equal. e2e-shard: - name: e2e (shard ${{ matrix.shard }}/5) + name: e2e (shard ${{ matrix.shard }}/6) # Windows so the committed visual baselines (`*-win32.png`) match; public # repo ⇒ Actions minutes are free. runs-on: windows-latest @@ -122,7 +122,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4, 5] + shard: [1, 2, 3, 4, 5, 6] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -135,7 +135,7 @@ jobs: # the default config's `build-portable` setup project self-builds the # portable bundle on the one shard that carries the portable spec - name: E2E (dev server + portable file://) - run: node scripts/e2e-shards.mjs run ${{ matrix.shard }}/5 + run: node scripts/e2e-shards.mjs run ${{ matrix.shard }}/6 # the JSON report is the timing sample `scripts/e2e-shard-weights.mjs` # reads to refresh the weights; small, kept for two weeks, uploaded always - name: Upload the timing report diff --git a/CHANGELOG.md b/CHANGELOG.md index c08c89f0..b15640b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable Loop Studio releases, newest first. Behavioral changes are pinned in versioned spec documents (see the [README](README.md#technical-reference)); this file is the narrative history, not the contract. +## v0.25.0 — 2026-10-10 + +Diagrams are easier to read, in one migration (issue #344, Diagram Readability): nodes line up on a grid, connections run at right angles around them, and every node keeps its text inside its outline, as [`docs/diagram-layout.md`](docs/diagram-layout.md), [`docs/edge-routing.md`](docs/edge-routing.md) §ER14–§ER16 and [`docs/specs/SEMANTICS-R10.md`](docs/specs/SEMANTICS-R10.md) describe. + +- **The port row.** A node's resource ports (`in` / `out`) sit on a fixed row 28 px below its top, on its drawn outline, whatever its height, text or language. A node with a long name grows downward and its connections do not move; one-line nodes draw their ports where they always did. +- **The 16 px grid.** A node is on the grid when its left edge and its port row are multiples of 16, so two nodes on one grid row have level connections. A palette click places the new node on the nearest free grid spot from the canvas centre (no random offset any more); a drop, Insert module and an imported Parameter land on the grid. +- **Dragging snaps, with smart guides.** A dragged selection moves as one, so it keeps its shape; a frame drag snaps the frame's top-left and a resize the dragged corner. Within 6 screen px a drag is pulled to another node's port row, then to its centre or edges, then to the grid; a dashed line shows where it will land and a solid line reaches every node it lines up with. Where a node is grabbed never changes where it lands. After an arrow-key move its row and column show for a moment. The arrow keys move a node or a frame one grid step, 16 px (Shift: 64 px), instead of 5 px (20 px). The phone shows no guides. +- **Modifiers.** Alt now means a free move, off the grid, for nodes, selections, frames, drops and bend points; the guides show faint and pull nothing. Moving a frame without its contents is Ctrl (⌘ on a Mac), no longer Alt; the frame hint says so. Shift is unchanged. +- **Automatic orthogonal connections.** New connections are orthogonal: drawn at right angles and routed around every node, their own two nodes included, leaving and entering each node along a short stub. Where room is short the clearance shrinks, then the route goes around the outside of the diagram; only a port that another node covers is crossed, by that one node. Connections from the same port share only its 16 px stub and branch apart there, so none run on top of each other past it. A label takes the first free spot on its own line, clear of every node and label, and a connection that runs through other labels is routed again when that crosses fewer. A run, a selection or a zoom never moves a route. +- **Connection shapes and bend points.** A selected connection's Route offers Orthogonal, Curved (the earlier curve) and Straight. An orthogonal connection is automatic until it has a bend point: Add bend, then a click on the line, or Enter, adds one without moving the line; a bend point is dragged, moved with the arrow keys (16 px, Shift: 64 px) or removed with Delete, snaps to the grid (Alt: freely) and makes the route manual. Reset to automatic removes the bend points. Each add, move, delete, reset or shape change is one undo step; nothing changes while editing is locked. Curved and Straight connections do not avoid nodes, but their labels still take a free spot. +- **The phone shows, and does not edit.** It draws every shape, route and bend point; route editing is desktop only. +- **Text inside the outline.** The Pool, Source, Drain, Converter and Gate are drawn for their own width: their slanted sides, notch, point and waist keep a fixed depth instead of stretching, and the title, value and every other line stay at least 8 px inside the drawn outline, in every language. Ports and the Converter's conversion mark sit on the outline as drawn. +- **Older diagrams are lined up once.** An ordinary document saved by an earlier version — a graph file, Workspace, share link or autosave — is re-placed once when it opens, before its undo history starts, so the conversion is never an undo step. Nodes move to the grid; related rows a few pixels apart merge into one; nodes closer than 48 px across or 32 px down are moved apart by inserting space, so no left / right or above / below order is reversed; each frame is rebuilt around the nodes it held and the nodes that sat in it, keeping its padding (at least 24 px); waypoints snap (one that would land inside a node is dropped); and every connection without a shape becomes automatic orthogonal. The re-placement uses each node's widest size over the 18 languages, so it is the same in every language. Records are not converted (see Migration). +- **Tidy to grid.** A new button in the canvas controls (desktop) applies the same re-placement to the open diagram at any time, graph and frames together, as ONE undo step; with nothing to move it adds no step, and it is off while editing is locked. On a revision-based document it changes only the current document, like any other edit. It never changes a connection's shape. +- **The Templates.** All five bundled Templates are placed again on the grid by their widest boxes in all 18 languages, every connection automatic orthogonal with no hand-placed bend point: no connection runs through a node, no label sits on a node, no frame cuts a node and two frames keep at least 32 px apart. The coffee roastery Template opens on its operating flow at about 0.5 in a 1280 x 800 window. Early MMO opens on its first steps at about 1.04 in a 1600 x 1000 window, with Character creation, Active character, Starter encounters and Starter Lv 1–5 whole in every language, with the minimap open or collapsed. The gacha Template keeps its overview. Reset view and the minimap show the whole diagram. + +**Migration.** Files now carry `layoutVersion` (1), written on every save; a file without it reads as 0 and is re-placed once on opening. It is not part of any digest. Records keep their recorded layout and are never converted automatically: a project revision and a proposal payload — opened, opened as a document or applied — and a Project autosave based on a revision keep their positions and connection shapes, and the base revision and the proposal source stay as recorded. Their digests cover the positions; converting them would make a document differ from its own revision the moment it opens and turn every exact proposal into a non-exact one. This is intended: Review shows the real layout differences between a converted document and its recorded revision. Such a record also keeps its curved connections' labels where they were recorded until its first shape change or Tidy to grid. A position placed freely with Alt in a current document is kept. A Straight connection is stored as `route: "straight"` (`loop-revision/10`); a diagram without one has the same revision bytes and digests as before. The engine digest and simulation results are unchanged; the content digest of a re-placed diagram or Template changes with its positions and shapes. 17 strings in 18 languages (15 added, 2 revised: the connection shape and bend point controls, the connection note, Tidy to grid, the frame hint and five release-note lines), 16 of them without native review. The informational `meta.tool` string is now `loop-studio/0.25.0`. + +**Compatibility.** v0.24.0 and earlier open a v0.25.0 diagram with a Straight connection but draw that connection Curved, and refuse the project header of such a project revision, without a warning; saving it again there drops `route: "straight"`, so the Straight shape is lost. Opening a v0.25.0 document in an earlier version and saving it is not supported. + ## v0.24.0 — 2026-10-09 Playback speed changes what a step draws (issue #330, the last of three), and the phone gets a speed choice of its own, as [`docs/simulation-playback.md`](docs/simulation-playback.md) §PB6.1 and [`docs/mobile.md`](docs/mobile.md) §MV4 describe. diff --git a/README.md b/README.md index de604194..1b0d6d1d 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,24 @@ Additional feature-specific design documents (localization, mobile, module system, large-graph readability, simulation playback, edge routing, data import, …) live under [`docs/`](docs/). -## Latest — v0.24.0 +## Latest — v0.25.0 + +Diagrams are easier to read: nodes line up on a grid, connections run around them at right +angles, and every node keeps its text inside its outline. + +- **Grid-aligned editing**: nodes and frames snap to a 16 px grid, with smart guides to other + nodes' ports, centres and edges; an older ordinary document is lined up once when it opens, + and Tidy to grid realigns the open one at any time as one undo step +- **Clearer automatic routing**: orthogonal connections go around nodes, branch apart at a + shared port and put their labels on a free spot of their own line +- **Editable connection shapes**: Curved, Straight or Orthogonal, automatic or with bend + points added, moved or removed on the desktop; the phone shows them without editing +- **Text inside nodes**: Pools, Sources, Drains, Converters and Gates are drawn to their own + width, so titles and values stay inside in all 18 languages +- **Reworked Templates**: all five use the new grid and routing, with clearer spacing and + opening views + +## v0.24.0 Playback speed changes what a step draws, and the phone gets a speed choice of its own. @@ -179,21 +196,8 @@ marker. outside the focus fade with those connections; in a busy step at most 24 markers move, and every other connection that moved is highlighted instead -## v0.21.4 - -A fix release: the canvas controls keep their place while editing is locked, and the phone's -language search no longer zooms the page in. - -- **The frame buttons stay, turned off**: Group frame and Clear all frames remain in the - canvas controls while editing is locked, so the other buttons no longer move; before, the - two disappeared and every button above them shifted down -- **Locking turns the frame tool off**: a drag on the empty canvas then moves the view, as it - should while editing is locked -- **No zoom from the phone's text fields**: on an iPhone, tapping the language search used to - zoom the page in and leave it zoomed; every phone text field is now large enough that it - does not - -See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.21.3 (the edit +See [`CHANGELOG.md`](CHANGELOG.md) for the full notes of these releases, v0.21.4 (the +canvas controls keep their place while editing is locked), v0.21.3 (the edit lock is exact, and its button shows the state), v0.21.2 (values and detail rows sit inside their node), v0.21.1 (Focus mode dims the connections too), v0.21.0 (compact diff --git a/docs/ci-e2e-shards.md b/docs/ci-e2e-shards.md index 1eaec0a2..fb81a220 100644 --- a/docs/ci-e2e-shards.md +++ b/docs/ci-e2e-shards.md @@ -1,6 +1,6 @@ # The e2e shards (issue #311) -The browser suite (`npm run e2e`: the dev-server projects and the portable file) runs on CI as five concurrent jobs on `windows-latest`, each with a 20-minute limit. This page says how the suite is divided between them, what proves the division is complete, and how the division is kept honest as the suite grows. +The browser suite (`npm run e2e`: the dev-server projects and the portable file) runs on CI as six concurrent jobs on `windows-latest`, each with a 20-minute limit. This page says how the suite is divided between them, what proves the division is complete, and how the division is kept honest as the suite grows. ## Why not Playwright's `--shard` @@ -29,6 +29,8 @@ It runs in the `checks` job on every pull request and push. Predicted at the first split (weights from six runs, 2026-10-01 to 2026-10-03), before any CI run of it: with four shards the loads would be 808 / 808 / 809 / 808 s by the medians and 894 / 919 / 882 / 919 s by the slowest samples, a longest job of 17 min 19 s and only 41 s inside the budget; so the split ships with **five** shards: 648 / 647 / 647 / 646 / 646 s by the medians, 712 / 746 / 708 / 725 / 723 s by the slowest samples, a longest predicted job of 14 min 26 s, 5 min 34 s under the limit. Against each of the eleven recorded runs, with that run's own times and weights that exclude it, the longest of four time-cut shards would have been 9 to 18 % shorter than the count split that ran (848 s instead of 1,037 s on `16020f2`), and the longest of five 606 to 693 s. These are predictions from recorded samples; the measured shard times of the first CI runs are what confirms them. +Grown to **six** shards on 2026-10-10 (issue #344, v0.25.0). The weights had last been refreshed on 2026-10-03, and the suite had since grown to 2,105 tests with spec files that had no sample (estimated at the suite's median per test). On run 38032960035 (`30bf3d4`) four of the five shards ran their tests in 15.0 to 16.6 min, and shard 4 was cancelled at the 20-minute limit after 379 of its 386 tests, against a prediction of 14 min 53 s. That run was added as a sample: the four uploaded reports, and for shard 4, which uploads no report when cancelled, the durations its log printed for the 379 tests that ran plus the local full run's durations for the 7 it never reached (about 6.5 s together). With those weights five shards predict longest jobs of 19 min 10 s, 50 s under the limit and so outside the budget; six predict 14 min 46 s to 16 min 01 s by the slowest samples, 3 min 59 s to 5 min 14 s under the limit. + ## Keeping the weights current Every shard job uploads its JSON report (`test-results/e2e-report.json`) as the artifact `e2e-report-shard-`, kept for two weeks. To add a run as a sample: @@ -44,4 +46,4 @@ The newest sample goes to the front of every file it covers and the seventh samp - It does not make a shard finish in time on a runner slower than any in the sample, and it does not say why two runners differ; that variance (up to ±50 % per shard on the same tree) is recorded, not explained. - It does not touch the production-bundle and PWA jobs, which are not sharded, and it does not change the 20-minute limit. -- It does not split a spec file. The largest file (`large-graph-readability.spec.ts`, about 210 s) is well under a shard's share, so file granularity does not bind; if a single file ever approached a shard's share, the unit would have to change. +- It does not split a spec file. The largest files (`whats-new.spec.ts`, `large-graph-readability.spec.ts` and `forced-colors-edge-tell.spec.ts`, about 200 to 225 s each in the 2026-10-10 sample) are well under a shard's share, so file granularity does not bind; if a single file ever approached a shard's share, the unit would have to change. diff --git a/docs/contextual-inline-help.md b/docs/contextual-inline-help.md index 17d71c43..3bc6acff 100644 --- a/docs/contextual-inline-help.md +++ b/docs/contextual-inline-help.md @@ -265,7 +265,7 @@ to fold them in, that is its own small PR, not part of shipping new hints. | 6 | Run / simulation error explanation | Register / expression errors are already inline-localized at the point of failure (`error.M_REG_*.message`, `error.EXPR_*.message` in the Inspector) | — | — | **Backlog** — re-assess only if real user confusion shows up; today's inline error codes may already be enough | | 7 | Module-insert discovery | no natural one-shot "first encounter" moment the way MC/Review have an open event — the Insert-module menu is just always present in the toolbar | — | — | **Backlog** — revisit with real usage signal | | 8 | Frames (as their own hint) | — | — | — | **Folded into #4** — the Focus/Filter hint copy names frames as a related tool instead of adding a fourth canvas hint competing for the same `top-center` slot | -| 9 | **Frame move** (added 2026-09-20 with the frame-drag carry, §LGR6.5) | the first time a **saved** frame is **selected** on an editable desktop canvas (not mobile, not edit-locked), tour idle | canvas `top-center` `` (`hint-note`) — "Drag a frame's edge to move it together with everything inside it. Hold Alt while dragging to move the frame alone." | persisted (`frame-move`) | **Yes** — tier 1 (follows a deliberate action, like #import); it takes the slot over the tier-3 discovery hints and the auto-frame note but **yields to the import note** (`ready = tourIdle && !importHintShowing`, so it is neither rendered nor consumed while that one shows and appears once it closes if the frame is still selected); listed in `Contextual help` for re-arm. Distinct from #8: not a discovery hint, and it only appears once the user has already made a frame. | +| 9 | **Frame move** (added 2026-09-20 with the frame-drag carry, §LGR6.5) | the first time a **saved** frame is **selected** on an editable desktop canvas (not mobile, not edit-locked), tour idle | canvas `top-center` `` (`hint-note`) — "Drag a frame’s edge to move it together with everything inside it. Hold Ctrl (⌘ on a Mac) while dragging to move the frame alone, or Alt to move it freely, off the grid." (Ctrl / ⌘ since #344; Alt before) | persisted (`frame-move`) | **Yes** — tier 1 (follows a deliberate action, like #import); it takes the slot over the tier-3 discovery hints and the auto-frame note but **yields to the import note** (`ready = tourIdle && !importHintShowing`, so it is neither rendered nor consumed while that one shows and appears once it closes if the frame is still selected); listed in `Contextual help` for re-arm. Distinct from #8: not a discovery hint, and it only appears once the user has already made a frame. | **#1 empty canvas** — copy names the palette and "or start from a Template," pointing at the same two entry points the tour's steps 1 and 6 named, now diff --git a/docs/diagram-layout.md b/docs/diagram-layout.md new file mode 100644 index 00000000..63a0c662 --- /dev/null +++ b/docs/diagram-layout.md @@ -0,0 +1,98 @@ +# Diagram layout — the grid, the port row and re-placement (non-frozen design doc) + +Issue #344 (Diagram Readability). The six steps ship together as ONE migration, v0.25.0, so coordinates, routes, Templates, node outlines and the visual baselines change once: + +1. the grid, the fixed port row, snapping and the layout migration (this document, §DL1–§DL3); +2. automatic orthogonal routing (the guarded C′ router) and label placement; +3. the connection shape choice and manual route editing (§DL4); +4. all five bundled Templates re-placed; +5. node outline and text containment (#337) on this base; +6. an automated collision check over all 18 languages, then the final baselines. + +The release notes, the change declaration and the baselines are settled once, after step 6. Steps 2–6 extend this document as they land. + +## DL1. The grid and the port row + +- DL1.1 The layout grid is **16 px** in flow coordinates: the dots the canvas already draws at L2. +- DL1.2 A node's resource ports (`in` left, `out` right) sit on a fixed **port row 28 px below the node's top**, whatever its height, text or language. 28 is half the 56 px floor, so a one-line node draws its ports where it always did; a taller node grows downward and its ports stay. +- DL1.3 The routing lane is the port row; the DRAWN port is projected onto the outline on that row (a Pool's slanted side, a Drain's notch tip, a Converter's waist, a Source's point), as the fraction of the width where the outline is (`portInsetFraction`). The router starts a route at that same point (`routeMap.ts`). +- DL1.4 A position is **on the grid** when the node's left edge `x` and its port row `y + 28` are multiples of 16 (the stored `y` is `16k − 28`). Frames and waypoints are on the grid when their coordinates are multiples of 16. +- DL1.5 State ports (top / bottom centre) are unchanged. + +## DL2. Re-placing a graph + +- DL2.1 **Canonical geometry.** Re-placement never measures the DOM or a font. Each node's box is its canonical box (`canonicalBox.ts`): the widest the content can take in any supported language — an upper-bound advance per character for its script, the title wrapped at 135 px under the strictest UI-language rule (Korean keeps words whole, Japanese / Chinese break by phrase, Parameter and Register titles wrap narrower), the kind's paddings, minimum / maximum widths and height rules. For the Pool, Source, Drain, Converter and Gate (#337, drawn for their own width with fixed-depth features) each side also keeps room for the outline where it reaches furthest in over the text, plus the 8 px clearance. Checked against every bundled Template in all 18 languages: never smaller than the drawn box. +- DL2.2 **Rules** (`replace.ts`), pure and deterministic, ties broken by node id: + 1. every node: left edge and port row to the nearest grid line; + 2. rows: two related nodes (a flow connects them, or they share a direct flow neighbour) whose port rows were less than 16 px apart share one row; two unrelated neighbours 8–16 px apart never do (the lower takes the next row). A row move is made only when it reverses no order; + 3. overlaps: space is inserted — a pair closer than the clearance (48 px across, 32 px down: `LAYOUT_CLEARANCE`, the same as the Templates', §DL5) is separated along the axis its centres are further apart on, by moving every node on the far side by the same grid step, until every pair keeps it. No left/right or above/below order between two nodes is ever reversed; + 4. frames (§DL2.9): each saved frame rebuilt around the nodes it showed; + 5. waypoints: snapped; one that lands inside a node is dropped. +- DL2.3 Idempotent: re-placing a re-placed graph changes nothing. +- DL2.4 Engine digest and simulation results never change; the full content digest changes (positions are cosmetic content). + +## DL2.8. The one-time conversion and `layoutVersion` + +- The file carries `layoutVersion` (`LAYOUT_VERSION = 1`), written on every save; absent means 0, a pre-grid layout. It is not part of either digest. +- A document with an older `layoutVersion` is re-placed ONCE when it is opened — a graph file, a Workspace, a share link, the autosave record (converted by the boot module before the stores read it) — **before its history starts**: never an undo entry. The next save writes the current version, so it never runs again. +- **Records are never re-placed automatically** (Lumi, 2026-10-09). A revision or proposal payload is a record with its own digest and a comparison base, not an ordinary document: + - a revision file and a proposal payload always keep their recorded coordinates — opened, opened as a document, or applied; + - a Project autosave based on a revision (an autosave record carrying a Project header) is not converted either; + - the base revision and the proposal source stay as recorded, so Review shows the real layout differences; + - **Tidy to grid** on such a document changes only the current document, as one cosmetic change and one undo step, like any other edit. + Converting them on open would make a document differ from its own revision the moment it opens and turn every exact proposal into a non-exact one. +- Ordinary documents, files and share links are converted once, by their `layoutVersion`. +- Bundled Templates and modules are never converted: they open at their source coordinates (step 4 re-places those). +- A position placed freely with Alt in a current document is kept. + +## DL2.9. Frames in a re-placement (the one-time conversion and Tidy to grid) + +- Each node's box BEFORE the move is read first, before any node's size or place changes: the one-time conversion reads the box the document was drawn with when it was saved (`legacyCanonicalBoxBeforeOutlineContainment`, frozen — the canonical box before #337 drew the five kinds for their own width), Tidy to grid the current canonical box. +- Two sets per frame, from those boxes: + - the **held set** — the nodes fully inside: the frame's members, as before; + - the **visual coverage set** — the nodes whose centre was inside. It decides no membership; it only sizes the frame. +- The rebuilt frame covers every node of both sets at its new place and current size, keeps its own padding on each side where it had more than 24 px, at least 24 px, and is snapped outward. A frame that showed no node keeps its snapped rect. +- So no frame edge runs through a node that sat in it, and a node #337 draws wider never falls out of its frame. +- Membership afterwards (Lumi, 2026-10-10): + - before the re-placement, the held set is computed by the usual full-containment rule; + - the re-placement rebuilds each frame so it also covers the nodes that sat in it visually (the coverage set); + - membership is not stored: after the re-placement it is computed again from the final geometry, so a coverage node now wholly inside its frame takes part in later frame drags like any other node inside; + - the code that decides membership during an ordinary drag or selection is not changed. + +## DL2.10. Tidy to grid + +The Controls rail's **Tidy to grid** re-places the current document by the DL2.2 rules as ONE undo entry (graph and saved frames together). Desktop only; disabled while the canvas is locked. Nothing to move ⇒ no entry. + +## DL3. Snapping while editing + +- DL3.1 New nodes land on the grid: a palette click takes the nearest free grid spot from the canvas centre (no random jitter); a drop snaps the drop point; Insert module snaps the module's anchor; imported Parameters land on the grid (frames snapped outward). +- DL3.2 A pointer drag snaps: every dragged node moves by the ONE correction that puts the grabbed node on the grid, so a selection keeps its relative positions. A frame drag snaps the frame's top-left; a resize snaps the dragged corner. +- DL3.3 Modifiers: **Alt** — a free move (no snapping) for that drag, for nodes, selections and frames, or a free drop; releasing it mid-drag snaps again. **Ctrl / Command** — move a frame alone (it was Alt before #344). **Shift** — unchanged (selection, the large keyboard step). +- DL3.4 Arrow keys move a node or frame by one grid step (16 px), Shift + arrow by four (64 px), landing on the grid; still one undo entry per held gesture (§LGR6.7). +- DL3.5 An off-grid position in a current document stays valid and is drawn as it is. +- DL3.6 Smart guides (Hanrim / Lumi, 2026-10-10; `src/model/layout/smartGuides.ts`, `SmartGuideLayer.tsx`). The offset between the pointer and the node is kept for the whole drag; the snap reference is the node's position and its port row, never the grabbed point, so where a node is grabbed cannot change where it lands. Per axis the first of these within 6 screen px wins: another node's **port row** (vertical only), another node's **centre or left / right (top / bottom) edge**, then the **16 px grid**; ties go to the smaller distance, then the node id. A selection is referenced by its bounding box (centre and edges) and the grabbed node's port row. During the drag a dashed line runs through where it will land, on both axes, and a solid line reaches every other node it is exactly aligned with. Alt shows the guides faint and pulls nothing. After an arrow-key move the row and column it sits on show for 1.2 s. Not on the phone. Only the nodes within 1,200 flow px are compared (grid buckets built once per drag). Display and input only: no stored field, revision, digest or simulation result depends on it. + +## DL4. Connection shapes and route editing (step 3 — built; `SEMANTICS-R10.md`, `docs/edge-routing.md` §ER15–§ER16) + +- DL4.1 Shapes: **Auto orthogonal** (the C′ router) is the default for new and converted connections; **Curved** (today's Bézier) and **Straight** (a direct line) are choices; **Manual orthogonal** keeps the author's bend points. +- DL4.2 Editing an automatic route by hand (adding, moving or removing a bend point) turns the connection into Manual orthogonal; **Reset to automatic** returns it to the C′ route. +- DL4.3 Bend points snap to the 16 px grid; Alt moves one freely. Each add, move or delete is one undo step. Nothing changes a route while the canvas is locked. +- DL4.4 The shape and the bend points are saved in files and share links; they never change the engine digest or a simulation result. +- DL4.5 The phone draws every shape and route but offers no route editing. +- DL4.6 Bundled Templates use automatic routes; a manual bend point only where the router cannot resolve a case. +- DL4.7 Out of scope for v0.25.0: editing Bézier control points, and line decorations beyond today's. +- DL4.8 Stored form (Lumi, 2026-10-09): Curved = no `route` (never `"bezier"`); Straight = `route: "straight"`, no bend points; Auto = `route: "orthogonal"` with no bend points; Manual = `route: "orthogonal"` with 1 to 64. Manual is not a value of its own. An invalid value, or bend points beside Straight or Curved, is removed on reading and reported. A revision or proposal written before `loop-revision/10` keeps its bytes and its digest. +- DL4.9 Curved and Straight give up obstacle avoidance by choice, but their labels still take a free slot on their own line, by the same rule as a routed label. A revision or proposal recorded before `loop-revision/10` keeps its Curved labels where they were recorded (the Bézier middle) until its first shape edit or Tidy to grid (Lumi, 2026-10-10). +- DL4.10 A bend point is added with **Add bend** and then a click on the route (on the segment clicked) or **Enter** (the middle of the longest editable segment; the focus moves to the new handle); it is snapped along its segment, so adding it moves nothing. A drop inside a node or on the connection's own port stub puts the point back; it is never adjusted. Arrow keys move a selected bend point 16 px, Shift + arrow 64 px; Delete or Backspace removes it; Escape cancels the edit in progress or disarms Add bend. + +## DL5. The bundled Templates, placed for the grid (step 4) + +- DL5.1 Every Template goes through one function, `placeTemplate` (`src/model/layout/templatePlacement.ts`): the Coffee and MMO builders and the Gacha generator call it last; the two small Templates (Equilibrium, Deadlock) were placed by it once and their files are the source. A shipped file is always the placement of its source. +- DL5.2 Placement is `replaceOnGrid` (§DL2: order-keeping, space inserted, no left/right or above/below order between two nodes reversed), with each node sized by its WIDEST real box over the 18 languages (`templateBoxes.json`, measured on the running app, rounded up) plus a clearance `TEMPLATE_GAP` of 48 px across and 32 px down. The clearance is what keeps every language clear of a node over another's port and gives every port fan room to branch past its 16 px stub; smaller clearances still left fan overlaps past the stub in MMO (measured: 32 / 16 in es and vi, 48 / 16 in de, es, it and vi, 48 / 24 in es and ru; 0.7 to 2.4 px). +- DL5.3 A saved frame travels with the nodes it held (centre inside it before the move): it is rebuilt around them, keeping its own padding on each side and at least 24 px (§CR17), snapped outward. No frame cuts a node, by the widest boxes. Two frames that stood apart keep at least 32 px between them (`FRAME_GAP`; a frame's title sits in that gap, and two margins alone would make neighbouring frames touch): space is inserted as in §DL2 — every node beyond the nearer frame's far edge moves by the same grid step — so no order is reversed and no clearance shrinks. +- DL5.4 Every connection is Auto orthogonal. A Manual exception (bend points) is used only where placement cannot resolve a case, each recorded with its connection id and reason; none is needed: in all 18 languages no route is blocked or on the outer corridor and no fan overlaps past its stub (measured 2026-10-10). +- DL5.5 The menu-open framings (`initialView`): + - MMO puts readability before the whole start path, and the core start nodes before the zoom: its rect is sized for about 1.05 at 1600 x 1000, inside the Controls rail's reserved column, and its `keep` rect (§DL5.6) caps the zoom so every core start node opens whole in every language — Character creation, Active character, Starter encounters, Starter Lv 1–5. At 1600 x 1000 it opens at 1.040 (node text 12.5 px smallest, 14.6 px median; Thai Starter Lv 1–5, the widest, ends 8 px inside the pane, above the minimap); at 1280 x 800 at 0.725, or 0.772 with the minimap collapsed. Later paths may start off screen; Reset view and the minimap show the whole graph. + - Coffee opens on its operating flow — the five daily levers, the supply and the roasting & sales frames — at about 0.5 in a 1280 x 800 window (L1 with room, never at the 0.45 edge; floor 0.46); the forecast metrics may start off screen. + - Gacha keeps its overview: the comparison row and the Free zone, at its 0.45 floor. +- DL5.6 `keep` — the nodes that must open whole (`InitialView.keep`, `viewportForRect` in `src/components/canvasFit.ts`). An `initialView` MAY carry a `keep` rect: the core start nodes at their widest box over the 18 languages (`templateBoxes.json`), the tight union of those boxes (pinned by `src/model/templates.initialView.test.ts`). The zoom is the rect's fit, capped at the largest zoom that shows `keep` inside the pane (8 px from its right and bottom edges, clear of the Controls rail); the minimap's corner (224 x 176 px with its margin) counts only when `keep` would overlap it on BOTH axes, and then `keep` clears it by whichever side keeps the larger zoom. So a top row may run past the minimap's column above it, and a collapsed minimap never lets the zoom grow past what `keep` allows. The readability floor (`minZoom`) still wins on a pane too small for `keep` (a phone opens at the floor, as before). Only MMO carries `keep`; the left alignment, the vertical centring and the other Templates' views are unchanged. diff --git a/docs/edge-routing.md b/docs/edge-routing.md index 5c710123..d65d1ec5 100644 --- a/docs/edge-routing.md +++ b/docs/edge-routing.md @@ -139,6 +139,11 @@ produce a **byte-identical** route. ### ER3.2 Obstacles +- **Handle points** (issue #344, `docs/diagram-layout.md` §DL1): a resource + handle (`in` / `out`) starts or ends a route on the fixed port row 28 px below + the node's top, at the x where the drawn outline is on that row + (`portInsetFraction`); a state handle at the top / bottom centre. Before + #344 a resource handle was at the side's vertical centre. - An **obstacle** is a node's box `{x, y, width, height}` from `node.position` + `node.measured` (fall back to `node.width/height`), inflated by `ROUTE_PAD` on all sides. @@ -690,3 +695,264 @@ Machine-checkable, mirroring the Visual Refresh specs. semantics). Nothing here touches it. - **No node re-layout.** Routing steps edges around nodes where they *are*; it never moves a node. + +--- + +## ER14. The guarded router (issue #344 step 2, v0.25.0) + +Diagram Readability (`docs/diagram-layout.md`) makes the orthogonal route the +default for new and migrated connections and replaces the router behind +`route: "orthogonal"` with a guarded one. The wire contract of §ER6 is +unchanged: `route` absent is still a Bézier, `waypoints` are still meaningful +only with `orthogonal`. + +### ER14.1 Route meaning and the migration + +- `route` absent = Bézier, everywhere, as before. Nothing reinterprets it. +- A revision or proposal payload keeps its bytes, its digests AND its drawing: + an absent `route` there is drawn as a Bézier. +- The layout migration of an ordinary pre-grid document (`layoutVersion` < 1: + a graph file, a Workspace, a share link, the autosave without a Project + header — §DL2.8) writes `route: "orthogonal"` on every connection without a + `route`. Its full content digest changes; its engine digest does not. Tidy to + grid never writes a route. +- A new connection is created with `route: "orthogonal"`. +- `orthogonal` without `waypoints` = **Auto** (the guarded route); + with `waypoints` = **Manual** (routed through them). +- Bundled Templates and modules are not migrated; step 4 re-places them. + +### ER14.2 No node fill is ever crossed + +`computeGuardedRoute` (`orthogonalRoute.ts`) runs the §ER3 search with: + +- **own end nodes as obstacles** — a route leaves its port along the port + normal to a stub `ROUTE_STUB` beyond its own box (shorter, half the gap, when + another node is nearer) and enters its target the same way; only those two + stubs may run inside the connection's own nodes; +- **the clearance ladder** `GUARD_PADS` (12, 6, 2, 0 px) — a failed search is + retried with less clearance (`routeClass` `tight` below 12); +- **the outer corridor** — then rulers `OUTER_MARGIN` outside the bounding box + of every node, both stubs and every waypoint, with a larger budget (`outer`); +- **`blocked`** — the one case no route can avoid: another node covers a port + or a stub. The route avoids every node except the covering one. + +Every candidate is checked before it is returned: between the stubs no segment +enters a node fill (own end nodes included), and the stubs enter no other node. +There is no cost-based crossing and no plain L / Z: a route that would cross is +never drawn. A waypoint inside a node fill keeps its value and the §ER4 cue but +is not routed through. The v1 entry point `computeOrthogonalRoute` stays as the +pinned single-edge core (golden + differential tests); the app no longer calls it. + +### ER14.3 Shared trunks and labels + +- Edges are routed in id order. Each placed route's segments are indexed by + ruler line; an **unrelated** edge (sharing neither its source port nor its + target port) pays `TRUNK_COST` per px it runs along one and `CROSS_COST` per + crossing. The result never depends on input order. +- **A port fan** (Lumi, 2026-10-09): connections of exactly the same port share + only that port's stub, `ROUTE_STUB` (16 px) beyond the node box, in the same + direction; past it there is no line-on-line overlap at all. + - Each member gets its own **branch point** inside the stub. The members are + sorted by where their far end lies across the stub, then by edge id; + members on the negative side leave toward it, members on the positive side + toward that one, at most one (the lowest id among those level with the + port) goes straight on, and other level members alternate sides. Within a + side the member reaching furthest out branches nearest the node, so + siblings never cross; members on opposite sides may share an offset. The + offsets split the 16 px evenly. The router fixes the first move after the + branch (and, at a target fan, the move into it). + - Every end's stub and every branch's first leg are **reserved** before any + route: another route pays `FAN_COST` per px to run along them (a route's + own ports' stubs excepted), so neither a later sibling nor an earlier + unrelated route takes them. Past the branch, running along a sibling also + costs `FAN_COST`, and the sibling's lines ± `PARALLEL_GAP` are offered as + lanes. + - If obstacles block a branch, the route is found without it, drawn on the + outer corridor when needed, and listed in `routeDiagnostics().fanBlocked`; + `fanOverlaps` lists any pair that still runs together past the stub. Both + are reported, never hidden. + - No label sits on a shared stub. A selected connection's underlay runs + alone from its branch on. A parallel set (the same two ports) keeps its own + offset (§ER3.7). + - Measured (every connection routed): Early MMO 0 fan overlaps (a port with + 17 connections included), the 3-zone gacha 0; 0 `fanBlocked`. +- The judge (survey and collision check) keeps the same-port trunk as an + informational count; unrelated line-on-line overlap is a failure. +- A label takes the first free slot on its own route — the midpoint, then 0.4, + 0.6, 0.3, 0.7, 0.25, 0.75, 0.2, 0.8 of its length — clear of every node box + (2 px margin) and every label already placed; the midpoint when none is free. +- Then up to `LABEL_ROUNDS` (2) guarded rounds: an edge whose route runs + through other edges' labels is routed again with those labels as soft + obstacles (`computeSoftReroute`: one rung of `SOFT_PADS` — 12, then 6 px — + per step, each with the budget `SOFT_EXPANSIONS`), kept only when it crosses + fewer of them and is not a lower rung; otherwise the edge keeps its route. +- Label sizes are the labels' REST sizes (shown, not selected, no run value), + reported by the rendered labels; the last known size stays while a label is + hidden by zoom. A run, a selection or a zoom never moves a route. + +### ER14.4 Failures are reported, never hidden + +`routeDiagnostics()` returns, for the last full generation, how many routes each +rung produced and every edge drawn `outer` or `blocked`; the collision check of +step 6 reads it. In a development build each such edge is warned once. + +### ER14.5 Cost: a provisional map at once, the full generation in slices + +Lumi, 2026-10-09: no pause at the drop, no long main-thread task. + +- A map is cached on the layout signature (§ER3.8, label sizes included). +- A layout change shows a **provisional** map at once: the last full generation + with only the routes the change touches routed again — new or changed edges, + edges incident to a moved or resized node, edges whose route now runs into a + moved node — incident ones first, within `PROVISIONAL_MS` (5 ms, budgeted by + the longest route so far; an edge with no route at all is always routed). +- The **full generation** for the new layout then runs as a job, in id order, + in slices of about `SLICE_MS` (8 ms; a step is started only when the longest + step so far still fits), each slice its own task, so input and frames run in + between. A single search is itself a step sequence: the A* search pauses + every `SEARCH_SLICE` (1,024) expansions, so no step is longer than a slice; + the synchronous router drains the same steps, so its result is unchanged. It is committed in one swap when complete; a partial map is never + drawn. A newer layout, or a drag starting, cancels a running job; a cancelled + job never commits. Diagnostics and development warnings are settled on the + committed generation only. +- During a node, selection or frame drag (`beginLiveLayout` … `endLiveLayout`) + only provisional maps are shown; the drop starts the job. +- **A cold start** (a document opened, or another document: most routed edges + new) runs the same sliced job. Until it commits, no routed edge and no label + of one is drawn — never a temporary path; only an invisible, off-canvas copy + of each label is rendered so its rest size reaches the router first. The + canvas carries `aria-busy` while any routed edge waits. The last + `GENERATIONS_KEPT` (4) committed generations are kept by signature, so a + layout seen again (undo, redo, the same document reopened) is shown at once; + a newer document cancels the job. +- Only a run without a task scheduler (unit tests, the offline judge) computes + the full generation at once — the same steps, so the committed map is + byte-identical to the synchronous one (`__syncFullGeneration`). +- Acceptance (Lumi's criteria; Early MMO, every connection routed): + - production build: drag p95 16.8 ms (≤ 20), max frame 17.4 ms, 0 long tasks + during the drag and the drop; the 3-zone gacha 16.9 ms, 0; + - routing-owned work (development build, where it is instrumented): the + largest slice 6.9 ms on the cold load and 10 ms at the drop, the longest + step 6.9 ms, the provisional map at most 6 ms (≤ 10); no routing task of + 50 ms or more; + - the committed map equals the synchronous generation byte for byte; 0 + cancelled generations committed. + - Not routing: the document mount (~145 ms in production) and the node press + (51–100 ms on a development build) are long tasks with no routing at all + (the curves-only control shows them too). Out of scope for #344. + +### ER14.6 Invariants that change + +- ER-INV-5 "never fails to draw" now draws without crossing: the L / Z fallback + is replaced by the ladder, the outer corridor and `blocked`. +- ER-INV-6 Bézier parity still holds for `route` absent. +- ER-INV-3 holds at rest; during a live gesture the map is incremental by design. + +## ER15. Curved and Straight connections (issue #344 step 3, v0.25.0) + +`SEMANTICS-R10.md` adds `route: "straight"`. The four shapes and their stored +form are in §R10-0; `docs/diagram-layout.md` §DL4 is the product rule. + +- **Straight** is drawn as one straight segment between the two port anchors + React Flow gives the edge (`M sx,sy L tx,ty`). **Curved** is unchanged: React + Flow's Bézier, byte for byte (ER-INV-6). +- Neither is routed: the user chose to give up obstacle avoidance. A Curved or + Straight connection never changes any route (the router does not see it). +- **Their labels are placed**, not left at the middle. The route map samples + each line from its own port anchors (`freeLine.ts`: the straight segment, or + the Bézier with React Flow's control points, 24 segments) and gives the label + the first slot of `LABEL_SLOTS` that is clear of every node, every label + already placed and every other line (routed, Curved or Straight); failing + that, clear of nodes and labels; the middle last — the same rule as a routed + label (§ER14.3). +- Order: the routed labels first, exactly as before (they also keep off Curved + and Straight lines), then the Curved and Straight labels in id order. A + document with only orthogonal connections gets a byte-identical generation. +- A slot is stored as a fraction of the line's length; `LoopEdge` puts the + label at that fraction of the line it draws, so the label sits on the drawn + curve even where React Flow's anchors differ from the map's by a pixel. +- A label whose slot is not known yet (its size not measured, or the first + generation of a document still running) is rendered hidden and measured, + never shown in a temporary place; the canvas is `aria-busy` while a measured + label waits (§ER14.5). The provisional map re-places only the labels of the + Curved and Straight edges that are new, changed or incident to a moved node. + +### ER15.1 Records keep their recorded label places (Lumi, 2026-10-10) + +- A revision or proposal whose header declares semantics before + `loop-revision/10` (no `semantics` key, or an earlier one — `SEMANTICS-R10.md` + §R10-6; the edges are never inspected for this) opens with `recordLabels`: + its Curved labels sit at the + Bézier middle, where they were when it was recorded, and the route map places + no Curved / Straight label and keeps no label off their lines — so the routed + edges get the generation they had before step 3. A record's Review, which is + drawn on that same canvas, keeps its visual reference too. +- Ordinary documents, Templates, share links and `/10` records use the free + slots of §ER15. +- The first shape or bend-point edit, or Tidy to grid (even on a record + already on the grid), ends the rule for that document, inside the same undo + entry: undoing it brings the record's label places back. +- The autosaved Project header carries the rule as `labelLayoutVersion` (0 the + record's, 1 current; absent in an older header reads as 0), so a reload + restores it. An ordinary document has no Project header and follows the + layout migration contract; `layoutVersion` means the layout migration only. + +## ER16. Shapes and bend points in the editor (issue #344 step 3, v0.25.0) + +### ER16.1 The Inspector + +- **Route**: Orthogonal / Curved / Straight. Under Orthogonal it says + *Automatic route* or, with bend points, *Manual route*. +- **Add bend** (a toggle) and, for a Manual route, **Reset to automatic** + (keeps `orthogonal`, drops every bend point). +- Switching to Curved or Straight drops the bend points (R10-INV-3). Changing a + connection's kind (resource / state) keeps its shape and bend points. +- Under the edit lock the select and the buttons are disabled by the + Inspector's fieldset; on the phone the select is read-only (the sheet's + fieldset) and the bend tools are not rendered at all. + +### ER16.2 Editing on the canvas + +- **Add bend**: armed for the selected connection. The next click on its line + inserts one bend point on the segment clicked (`bendAtClick`), snapped to + the 16 px grid ALONG that segment and kept on its line across it (Alt: at + the click) — so adding a bend moves nothing; a port row is off the grid and + a full snap would make the route jog. **Enter** inserts one in the middle of + the longest editable segment (between the two stub ends; equal lengths: the + one nearer the start; a refused point gives way to the next segment — + `keyboardBend`) and moves the focus to its handle. Either goes into the + span it lies on (`bendInsertIndex`), is one undo entry, makes an Auto route + Manual, and disarms. When no segment can take one, Enter changes nothing, + stays armed, and says so in the canvas live region and under the hint. + Escape, another selection, the edit lock and the phone layout disarm it. +- **Handles**: a selected Manual connection shows one handle per bend point + (`RouteBends.tsx`, a button named "Bend point n of total"). A pointer drag + moves it on the grid (Alt: free); the arrow keys move a focused handle one + grid step, Shift four; Delete or Backspace removes it (the last one removed + makes the route automatic again); Escape cancels the drag or key gesture in + progress and puts the point back. +- **Refused**: a bend point dropped inside a node, or within `BEND_STUB_TOL` + (8 px) of the connection's own port stub (the stretch from a port to + `ROUTE_STUB` past its node's side), goes back where it was; nothing is + adjusted and nothing is recorded. While it is over such a place the handle + has a dashed border (the non-colour tell). +- **Undo**: adding, moving (one drag, or one held key gesture), removing, + Reset and a shape change are one history entry each, never merged with the + next edit; undoing or redoing one does not reset the run (R10-INV-2, the + flow-colour rule). The Inspector says so in its restart note: the route, + the bend points and the colour are drawing only. +- Changing a connection's kind (resource / state) keeps its shape and bend + points; nothing else about a kind change is different. +- During a drag the route map shows provisional maps (`beginLiveLayout`); the + drop starts the full generation. + +### ER16.3 How a Manual route goes through its bend points + +The route is the guarded route (§ER14) through the stub ends and every bend +point, in order, one search per span. A span after a bend point starts from +the way the route arrived there: leaving along it is free, turning is one +bend, and going straight back costs two — so a Manual route turns AT its bend +point instead of running up to it and folding back over itself (which +`simplify` would then hide, leaving the bend point off the drawn line). It is +a cost, not a ban: a span that has no other way still finds one. Auto routes +(one span) are unchanged. diff --git a/docs/large-graph-readability-auto-frames.md b/docs/large-graph-readability-auto-frames.md index acd05b59..f93b3ad6 100644 --- a/docs/large-graph-readability-auto-frames.md +++ b/docs/large-graph-readability-auto-frames.md @@ -424,7 +424,7 @@ Table B (final) — pinned separately by the impl PR's fixture test | Graph | Contaminated LP/split groups | Outcome | |---|---|---| | Coffee | none (0 foreign in every group) | 3 candidates pass through unchanged | -| MMO | the 28 / 18 / 13 / 10 / 7 / 7 raw groups all fail S8 once bisected | recursive bisection yields **12 clean candidates** (`{10, 8, 7, 6, 4, 4, 4, 4, 3, 3, 3, 3}`) + **7 dropped** (`no valid spatial gap`: sizes 3, 4, 3, 7, 4, 4, 6) | +| MMO | the 28 / 18 / 13 / 10 / 7 / 7 raw groups all fail S8 once bisected | recursive bisection yields **14 clean candidates** (`{10, 8, 7, 6, 4, 4, 4, 4, 4, 3, 3, 3, 3, 3}`) + **6 dropped** (`no valid spatial gap`: sizes 6, 4, 4, 4, 3, 3); before issue #344 step 5 re-placed the Template by its widest boxes, 12 clean + 7 dropped (the 7 now splits into a 4 and a 3) | **Table B — final suggested frames (measured, after steps 3b – 5):** diff --git a/docs/large-graph-readability.md b/docs/large-graph-readability.md index b9610398..d481d701 100644 --- a/docs/large-graph-readability.md +++ b/docs/large-graph-readability.md @@ -526,7 +526,10 @@ router). The relation is **derived at the moment the drag starts and never stored** — `frames` on the wire stays `{ id, label, rect, color? }` (`SEMANTICS-R5.md` R5-D3 unchanged). A node inside two overlapping frames follows whichever frame is dragged; a merely overlapping frame does not move. -**Alt held at pointer-down** (frozen for the gesture) moves the frame alone. +**Ctrl (⌘ on a Mac) held at pointer-down** (frozen for the gesture) moves the +frame alone — Alt until issue #344, which gave Alt its one meaning on every +drag: a free move, off the grid (`docs/diagram-layout.md` §DL3.3). A frame +drag lands its top-left on the 16 px grid (§DL3.2). Moving a **node** never moves a frame. One gesture is **exactly one** undo entry through an explicit transaction (§SF11.1 "Move" — not the 600 ms coalescing, so a paused drag is still one entry); `Esc` or a `pointercancel` @@ -566,12 +569,13 @@ container already exposes. `aria-describedby` points at one of three shared descriptions (idle / selected / read-only). **Keys.** `Enter` / `Space` select. `Escape` cancels a running gesture, and -deselects when none is running. `Arrow` moves by **5 px**, `Shift+Arrow` by -**20 px** — the steps React Flow already gives a node, so one canvas never -teaches two rules. A keyboard move **carries exactly what a pointer drag -carries** (§LGR6.5). There is deliberately **no keyboard frame-only escape -hatch**: `Alt+Arrow` is the browser's Back / Forward, and Alt+drag stays a -pointer contract. On the selected frame's **resize handle** (the existing +deselects when none is running. `Arrow` moves by **one grid step (16 px)**, +`Shift+Arrow` by **four (64 px)**, landing on the grid — the same steps a node +takes since issue #344 (`docs/diagram-layout.md` §DL3.4; 5 / 20 px before), +so one canvas never teaches two rules. A keyboard move **carries exactly what a +pointer drag carries** (§LGR6.5). There is deliberately **no keyboard +frame-only escape hatch**: `Alt+Arrow` is the browser's Back / Forward, and the +frame-only move (Ctrl / ⌘ + drag) stays a pointer contract. On the selected frame's **resize handle** (the existing bottom-right corner, now a real button), `Left`/`Right` change the width and `Up`/`Down` the height by the same steps, with the same anchor, minimum and "a resize never moves the contents" rules the pointer path has. No mode key. @@ -914,11 +918,11 @@ exercised at three run phases: **start** (step 0–2), **mid** (≈ step 40), ** | **LGR-D6** | run distinction — what is the v1 source? | **`StepReport` only, as it already exists** (`events` / `activated` / `fired` / `stateEvents`). **`effective`** = node in `fired`, or edge in `events`, or state edge in `stateEvents`. **`evaluated`** = a **node** in `activated` but not `fired` — **node-only**. A zero-flow edge / unselected gate branch gets **no cue** (the committed result has no such data; marking them would need engine instrumentation — deferred). No engine field, no playback-builder field, no execution-path instrumentation. | | **LGR-D7** | past-step cues | **Cleared each step.** The only accumulation is an **opt-in Activity overlay**, off by default, **never persisted**; window length + decay curve are a Slice-4a tuning detail. | | **LGR-D8** | group frames — which models ship, when? | **Transient (session-only, in memory) in Slice 4a — fully specified here.** **Auto** frames in **Slice 4b** — the clustering algorithm + label generation are **their own detailed design pass**; this doc fixes only their boundary (§LGR6.3), not the algorithm. **Saved** frames in **Slice 5**, behind a **Frozen `loop-revision/5` cosmetic `frames` contract** — designed in `docs/large-graph-readability-saved-frames.md` (§LGR6.4). | -| **LGR-D9** | can a frame move its members? | **Yes, derived (2026-09-20)** — a frame **drag** carries every node / manual frame **fully inside** its rect at pointer-down (+ the waypoints of edges with both ends carried); Alt+drag moves the frame alone; nothing is stored (R5-D3 holds); a node drag never moves a frame. Was "No in v1" until the frame-membership pass (§LGR6.5). | +| **LGR-D9** | can a frame move its members? | **Yes, derived (2026-09-20)** — a frame **drag** carries every node / manual frame **fully inside** its rect at pointer-down (+ the waypoints of edges with both ends carried); Ctrl / ⌘+drag moves the frame alone (Alt until #344, now the free move); nothing is stored (R5-D3 holds); a node drag never moves a frame. Was "No in v1" until the frame-membership pass (§LGR6.5). | | **LGR-D10** | does anything here move / resize / reorder a node? | **Never** — including node z-order — with **one** exception: the user's own frame **drag** (LGR-D9), which moves the carried nodes by the drag's Δ and nothing else. Resize / rename / colour / delete / Focus / Filter / Activity / auto frames still move nothing. (§LGR13.) | | **LGR-D11** | does selecting / focusing / filtering move the viewport? | **Never.** Only an explicit "fit / frame selection" does, unchanged. | | **LGR-D12** | mobile extent | Global hit-test rule **yes**; Focus + Filters **yes** (More sheet); frame **drawing** no; auto frames render once Slice 4b ships. **Saved frames on mobile — and on a locked desktop canvas — are view + select only (2026-09-20, D6):** no move / resize / rename / colour / delete / promote, no Frame tool, no "Clear all" — on mobile the More sheet keeps only the session-only **Clear suggested frames** row (the saved-frame-deleting "Clear all frames" row was removed with this pass); on desktop "Clear all" is off under the edit-lock. | -| **LGR-D14** | can a frame be used from the keyboard? | **Yes (2026-09-20, §LGR6.6).** The container is the focus unit (`role="group"`, one tab stop, post-selection stops for the label / ✕ / resize handle — the swatches moved out of the frame entirely with LGR-D16); Enter·Space select, Escape cancels-then-deselects, Arrow 5 px / Shift+Arrow 20 px move **with** the contents, the resize handle's arrows change width / height. No mode key and **no keyboard frame-only escape hatch** (Alt+Arrow is the browser's Back). Both gestures reuse the pointer's **transaction** — one entry per burst, nothing for an out-and-back or an Escape, a focus loss commits. `Backspace` **and** `Delete` delete (nodes / edges first, then the selected frame), guarded against text fields, any open modal dialog, and the edit-lock / mobile. | +| **LGR-D14** | can a frame be used from the keyboard? | **Yes (2026-09-20, §LGR6.6).** The container is the focus unit (`role="group"`, one tab stop, post-selection stops for the label / ✕ / resize handle — the swatches moved out of the frame entirely with LGR-D16); Enter·Space select, Escape cancels-then-deselects, Arrow 16 px / Shift+Arrow 64 px (one / four grid steps since #344) move **with** the contents, the resize handle's arrows change width / height. No mode key and **no keyboard frame-only escape hatch** (Alt+Arrow is the browser's Back). Both gestures reuse the pointer's **transaction** — one entry per burst, nothing for an out-and-back or an Escape, a focus loss commits. `Backspace` **and** `Delete` delete (nodes / edges first, then the selected frame), guarded against text fields, any open modal dialog, and the edit-lock / mobile. | | **LGR-D15** | is a keyboard node move undoable? | **Yes (2026-09-21, §LGR6.7).** It was not: React Flow reports it as `dragging: false`, which the store does not commit, so one `Ctrl+Z` afterwards undid the PREVIOUS edit instead. A capture-phase arrow `keydown` now brackets React Flow's own movement with the §SF11.1 transaction — one entry per burst, none for an out-and-back or an Escape (which restores and announces the origin), safe settle on blur / tab switch / pointer press. It opens only for an arrow aimed at a selected, draggable React Flow node, so frames, buttons, text fields, dialogs and the edit-lock never open one. The pointer path is untouched and still commits on its first `dragging: true`. | | **LGR-D16** | where is a frame's name / colour edited? | **In one properties popover reached from its TITLE (2026-09-25, §FC10).** The accent picker used to be a swatch row pinned to the frame's BOTTOM edge in canvas coordinates, so a frame taller than the viewport put its only colour control off screen — measured on the shipped gacha `Premium Pickup` (1790×1000). Name and accent now sit together in a screen-space panel portaled out of the canvas transform, flipped and clamped inside the viewport; a colour applies immediately, Escape cancels the name. `uiStore.frameProps` holds the open id (session-only, never GraphDoc / undo) and `Canvas.tsx` freezes pan + zoom while it is set — opening it and recolouring from it move nothing (LGR-D10 / D11 hold). **LGR-D12 / D6 is unchanged:** none of this exists on mobile or under the edit-lock. | | **LGR-D13** | where does view state live? | Sticky toggles: **one global `localStorage` blob**. Everything else (filter selections, focus selection, transient frames, activity tint): **in memory only**. Never GraphDoc / digest / Share / revision / `SimState`. Full table in §LGR3.4. | diff --git a/docs/mmo-multilingual-layout.md b/docs/mmo-multilingual-layout.md index fe296ed5..a8d7be38 100644 --- a/docs/mmo-multilingual-layout.md +++ b/docs/mmo-multilingual-layout.md @@ -60,9 +60,12 @@ general policy with **two representative fixtures: MMO and Coffee**. - **2-line (or wider) nodes grow in height to fit their content.** The fixed 64px node height is replaced by a content-driven height. The title must not overlap the value / unit / sub-description for that node kind. -- **Handles reposition to the grown box** — side handles to the real vertical - centre of the new height, top/bottom and kind-specific handles to their - defined positions on the new box. +- **Handles reposition to the grown box** — top/bottom and kind-specific + handles to their defined positions on the new box. The side (resource) + handles stay on the port row 28 px from the top whatever the height, so a + node grows downward and its connections never move + (`docs/diagram-layout.md` §DL1, issue #344; until then they followed the + vertical centre). - **Line breaking is the browser's.** The wrap rule (note `word-break: keep-all` would block breaking *between* JA characters, so it cannot be combined with "browser CJK breaking"): @@ -168,6 +171,11 @@ EN / KO / JA exactly like MMO. "Coffee 배치 불변" (acceptance #9) means: - The framed nodes must **not sit under the minimap** — the fit uses the pane MINUS the fixed overlays (minimap bottom-right, zoom Controls left), and left-aligns the rect (a progression graph reads beginning-first). +- Since issue #344 a fixed **`keep`** rectangle (the core start nodes at their + widest box over the 18 languages, also graph coordinates, no locale branch) + caps the zoom so those nodes open whole at any pane size, with the minimap + expanded or collapsed; the minimap counts only where the two would overlap + on both axes (`docs/diagram-layout.md` §DL5.6). - The camera is **not** re-initialised on: a language change, a plain reload, an Import / Share / Workspace restore, or Undo / Redo. A viewport the user has panned or zoomed is **never** overwritten by a language change. diff --git a/docs/node-shell-content-in-vessel.md b/docs/node-shell-content-in-vessel.md index c96b362d..98c100dd 100644 --- a/docs/node-shell-content-in-vessel.md +++ b/docs/node-shell-content-in-vessel.md @@ -184,7 +184,64 @@ the three templates exactly one node widens (early MMO `r_income`, +5.66 px). The Source / Drain / Converter mode line, the Gate's rows and two-line Pool titles are left as they were: their pointed, notched or slanted outlines grow with the width, so the same rule would widen 37 template nodes by up to 49 px -(a separate follow-up). +(the follow-up below, #337). + +## Follow-up — width-parametric outlines (issue #337, v0.21.4) + +The Pool, the Source, the Drain, the Converter and the Gate were drawn in the +`0 0 120 h` viewBox stretched to the node's width, so every point, notch and +slant got deeper as a node widened while its text stayed at fixed px insets. +Measured on v0.21.3 in the five Templates in all 18 languages, desktop and +phone: of 2,899 such nodes, 2,493 had a text line crossing the outline, every +one had something closer than 8 px, and the kind chip crossed on every Pool, +Drain and Converter. Widening a stretched outline cannot fix it (a waist or a +slope deepens as fast as the node widens: 242 Converters and 369 Gates would +need more than 260 px). On #332's rows: 892 of 1,898 Pool value / capacity rows +started later than their title, because the title itself sat closer than 8 px +to the slant and `rowFit` then placed the row at the outline + 8. #332's e2e +allowed it (it fails only a row that starts before the title or sits under +8 px), but it departed from #332's rule 1. + +1. **The outline keeps its features' px depth at every width and height** + (`./src/components/nodes/silhouette.ts` `FIXED_DEPTH_KINDS`, + `OUTLINE_DEPTH`): the Pool's slanted sides run 8 px, the Source's arrow is + 16 px deep, the Drain's notch 16 px, the Converter's two waists 16 px, and the + Gate is an elongated hexagon with a 16 px point at each end (the stretched + diamond could not hold its text below 260 px). The path is drawn in CSS px + for the node's own width (`viewBox="0 0 w h"`); only the straight runs + stretch, the way the height caps of §MML1b already keep their offsets. The + End, the Parameter and the Register keep their 120-wide viewBox. +2. **Every painted element keeps 8 px** (`ROW_CLEAR`) from the outline at its + own height, the way #332 measures (the line box's top + 1, middle and + bottom − 1): the kind chip, each title line and each row. + `./src/components/nodes/outlineFit.ts` (`fitOutline`, pure) moves the head + (chip + title, as one) in by the least its most constrained element needs + (`--of-head-shift`, a physical `margin-left`), starts every row where the + title starts (#332's rule for the Pool, the same for the Source / Drain / + Converter mode row) or further in only where the outline needs it, keeps the + Gate's lines centred, and sets the node's width to what the content and the + outline need, up to 260 px, where a row is cut by its ellipsis. Because the + left side of these outlines never depends on the width and the right side + keeps its distance from the right edge, the fit is a direct computation, not + a search. The width is built from widths that do not depend on the node's + current one (each row's whole text, the title on one line up to its own + 135 px max), so a node an earlier fit made narrow grows when its title does. + With the head clear of a fixed slant, every Pool row starts at its title. +3. **The bundled Templates were adjusted minimally**, as their positions are + shared by every language: in Early MMO 24 nodes moved right (x only, by + 2 – 112 px), in the gacha Template two ticket Pools moved 9 px right and the + three zone frames widened (+37 / +45 / +49 px, which also brings inside the + 17 nodes that already ended past their frame in long languages on v0.21.3), + and in Coffee one column (green stock, green wholesale, dessert stock) moved + 25 px left. In all 18 languages: every pair of nodes stays at least + min(its gap before, 8 px) apart and no node leaves its frame. The engine + digest (`semanticDigest`) of every Template is unchanged; their full revision + digest moves, because positions and frame rects are cosmetic fields of it. + Users' files are not migrated or moved. + +Unchanged: node heights and wrapping, the engine, simulation results, port hit +areas (the ports sit on the box edges, as when a node widens today), the +activity overlay (#330), and the Parameter, Register and End outlines and rows. ## Regression tests @@ -214,3 +271,11 @@ with the width, so the same rule would widen 37 template nodes by up to 49 px - `e2e/coffee-zone-frames.spec.ts` (EN / KO / JA) + `coffee-roastery.test.ts` §CR17 — every Forecast node keeps ≥ 24 px frame margin (unchanged by the re-cut). +- #337: `src/components/nodes/silhouette.test.ts` (the five outlines' depths at + every width and height) and `outlineFit.test.ts` (the fit on a grid of titles + and rows: every element ≥ 8 px, rows at the title, the narrowest width); + `e2e/outline-containment.spec.ts` — every chip, title line and row of the + five kinds against the drawn outline in a synthetic graph of every mode and + the five Templates in all 18 languages, desktop and phone; every Pool and + mode row at its title; no two Template nodes overlapping and every node + inside its saved frame in every language. diff --git a/docs/specs/SEMANTICS-R10.md b/docs/specs/SEMANTICS-R10.md new file mode 100644 index 00000000..0617faf5 --- /dev/null +++ b/docs/specs/SEMANTICS-R10.md @@ -0,0 +1,138 @@ +# Project Revision / Proposal — straight connection extension + +``` +Spec ID: loop-revision/10 +Status: Frozen +``` + +**Frozen (2026-10-10).** Issue #344, step 3 (`docs/diagram-layout.md` §DL4). One new value +for the routing slot of `loop-revision/3`: a connection drawn as a straight line, and a +declared semantics in the revision header (§R10-6). A behavioural change after this is a new spec +id in a new document. + +**No behavioural change to `loop-revision/1` … `/9`.** A graph with no surviving straight +connection has a canonical projection, digest, diff and Apply **byte-identical** to before +this document (R10-INV-1). Revision and proposal payloads written before it keep their +bytes and their digests. + +**`loop-workspace/1` is NOT bumped**: a connection's shape changes nothing `SimState` +computes (R10-INV-2). + +--- + +## R10-0. Scope + +Added over `loop-revision/9`: + +- one value, `"straight"`, for the existing optional key `data.route` on both edge kinds; +- the reading rules of §R10-1.1; +- the version predicate of §R10-1; +- the header field `semantics` and the autosave header's `labelLayoutVersion` (§R10-6); +- nothing else: the key, its place in the projection (§R3-2.1) and its field tag + (`cosmetic`, §R3-3) are unchanged. + +The four connection shapes and how each is stored: + +| shape | `data.route` | `data.waypoints` | +|---|---|---| +| Curved (the Bézier) | absent — `"bezier"` is never written | absent | +| Straight | `"straight"` | absent | +| Auto orthogonal | `"orthogonal"` | absent | +| Manual orthogonal | `"orthogonal"` | 1 … 64 points | + +Manual orthogonal is not a route value: it is an orthogonal route with bend points. + +## R10-1. Version inference + +A side is `loop-revision/10` iff, after the defensive read (`normalizeGraph`), at least one +edge carries `route: "straight"`. It takes the highest label precedence, above `/9`; like +every label from `/2` up it is served by the one `{ modelLayer: true }` projection, so the +precedence changes the reported label only. Survival decides, as for `/5` and `/9`. + +### R10-1.1 Reading rules + +Extends §R3-1.1; each drop is reported by `routingReadIssues` on import. + +- `route` absent or `"bezier"`: Curved; `"bezier"` is read as absent. +- `route: "straight"`: kept. Any `waypoints` beside it are dropped (a warning when the + array was not empty); the shape stays. +- `route: "orthogonal"`: as §R3-1.1 (1 … 64 finite points, verbatim; more than 64 or a + malformed entry drops the whole payload). +- any other `route` value: the whole routing payload is dropped (unchanged). +- `waypoints` with no `route`: dropped (unchanged). + +## R10-2. The projection + +`data.route` keeps its slot (§R3-2.1) and is emitted only under `{ modelLayer: true }`: +`"orthogonal"` as before, `"straight"` for a straight connection, nothing for Curved. +`data.waypoints` is emitted only beside `"orthogonal"` (unchanged). The literal +`loop-revision/1` projection emits neither. + +## R10-3. Field tag and Apply + +`fieldTag(edge, 'data.route')` stays `cosmetic`: projected, diffed, dirty-tracked, never +`engineAffecting`, never `advisoryAffecting`. A shape change and a bend-point change are +real changes in Review. `route` and `waypoints` stay in `OPTIONAL_PROJECTED_KEYS`, so a +selective Apply whose chosen side is Curved removes the key, and one whose chosen side +has no bend points removes `waypoints`. + +## R10-4. Invariants + +- **R10-INV-1** — no surviving straight connection ⇒ projection, digest, diff and Apply + are byte-identical to `loop-revision/9` and earlier. +- **R10-INV-2** — `semanticDigest` and every simulation result are the same before and + after any shape or bend-point change. +- **R10-INV-3** — a Curved connection never carries `route`; switching to Curved or + Straight removes `waypoints`; Reset to automatic keeps `route: "orthogonal"` and removes + `waypoints`. + +## R10-5. What a build before this document does with a `/10` file — measured + +Measured 2026-10-10 in Google Chrome (headless) against v0.24.0 on its production deploy, +in a fresh browser context per file, reading only. The files were Project revisions +exported by this build: Faucet → Gold Straight and Gold → Sink Curved; the control file +is the same graph with both connections Curved. + +| | control (no straight) | `/10` file | +|---|---|---| +| graph opens | yes, 3 nodes, 2 connections | yes, 3 nodes, 2 connections | +| project header (the revision chip) | accepted | **refused**: no chip | +| the Straight connection | — | drawn **Curved** | +| re-saved as Graph JSON | no `route` | **no `route`**: `"straight"` is gone | +| a warning to the user | none | **none** | + +So v0.24.0 and earlier open a `/10` diagram but refuse its project header, draw a Straight +connection as Curved, drop `route: "straight"` when the document is saved again, and say +nothing. **Round-tripping a `/10` file through v0.24.0 or earlier is not supported and +does not preserve its data.** The shipped build cannot be changed to warn; this limit is +recorded as it is. + +## R10-6. The declared semantics of a record, and the label rule + +A revision's content version is inferred (§R10-1), so it cannot say which build recorded +it: a `/10` record may hold no straight connection. The header therefore declares it. + +- Every revision and proposal header written from this document on carries + `"semantics": "loop-revision/10"`, after `meta`. It is not content: no digest covers + it, and a reader that does not know it ignores it (v0.24.0 rebuilds the header field + by field). A reader keeps it only in the exact form `loop-revision/N`; anything else + reads as absent. +- A record whose header declares no semantics, or semantics before `/10`, keeps its + Curved labels where they were recorded (`docs/edge-routing.md` §ER15.1). One that + declares `/10` or later takes the current label placement. The edges are never + inspected for this. +- The autosaved Project header carries the rule in force as `labelLayoutVersion`: `0` + the record's own rule, `1` the current placement; a header saved before the key + existed reads as `0`. An ordinary document has no Project header and never holds the + record rule; `layoutVersion` keeps meaning the layout migration only. + +## R10-D. Settled decisions (Lumi, 2026-10-09 and 2026-10-10) + +- Curved keeps today's canonical form: no `route`, never `"bezier"`. +- Manual orthogonal is told apart by its bend points, not by a value. +- Invalid values, and bend points beside Straight or Curved, are removed by the reader and + reported; nothing is repaired silently. +- Revisions and proposals written before this document are read unchanged; only this + version recognises `"straight"`. +- A record keeps its recorded Curved label places by its DECLARED header semantics (§R10-6), never by inferring from its edges; `layoutVersion` is the layout migration only. +- §R10-5 is recorded as measured: v0.24.0 shows no warning, and round-tripping a `/10` file through it is not supported. diff --git a/docs/visual-language.md b/docs/visual-language.md index 6e49a010..5886ee0d 100644 --- a/docs/visual-language.md +++ b/docs/visual-language.md @@ -604,7 +604,8 @@ The visual system must not break the local / offline / deterministic posture. grows the vessel to its ceiling, and force-breaks an unbreakable token. The multi-script / CJK / Cyrillic / emoji stress case lives in `e2e/node-long-label.spec.ts` as a **DOM** test — full string kept on the - element, no sideways spill, silhouette grows, handles re-centre — and never + element, no sideways spill, silhouette grows, the side handles stay on the + 28 px port row (`docs/diagram-layout.md` §DL1) — and never compares pixels. - **Parameter / Register are in the committed matrix.** Their frames — `param` resting, `param` out-of-range, `register` valid mid-run, `register` `invalid` diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L0-chromium-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L0-chromium-win32.png index e8b41c3d..b456546a 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L0-chromium-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L0-chromium-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L0-mobile-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L0-mobile-win32.png index 022a3bda..5913185d 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L0-mobile-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L0-mobile-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-chromium-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-chromium-win32.png index 6f0d745f..8375e098 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-chromium-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-chromium-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-mobile-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-mobile-win32.png index 98944fcd..899fb9e6 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-mobile-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/forced-colors-L2-mobile-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L0-chromium-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L0-chromium-win32.png index 64121261..2eb2930d 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L0-chromium-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L0-chromium-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L0-mobile-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L0-mobile-win32.png index b0b8e0ef..097b2667 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L0-mobile-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L0-mobile-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L1-chromium-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L1-chromium-win32.png index 54d3811e..eadfae44 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L1-chromium-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L1-chromium-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L1-mobile-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L1-mobile-win32.png index ca673d94..d883f118 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L1-mobile-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L1-mobile-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-chromium-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-chromium-win32.png index ef162c6a..e9dd21fb 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-chromium-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-chromium-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-mobile-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-mobile-win32.png index 8f7c4b63..77ab6ecf 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-mobile-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-dark-L2-mobile-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L0-chromium-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L0-chromium-win32.png index 21b377fa..d36a9b71 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L0-chromium-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L0-chromium-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L0-mobile-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L0-mobile-win32.png index 2284ebc5..78a313e5 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L0-mobile-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L0-mobile-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L1-chromium-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L1-chromium-win32.png index e86561af..21beb7ff 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L1-chromium-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L1-chromium-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L1-mobile-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L1-mobile-win32.png index cd1420f5..e3c54168 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L1-mobile-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L1-mobile-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-chromium-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-chromium-win32.png index d9d67dd6..0326db1e 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-chromium-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-chromium-win32.png differ diff --git a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-mobile-win32.png b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-mobile-win32.png index 969be24c..7158fb09 100644 Binary files a/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-mobile-win32.png and b/e2e/canvas-refresh-visual.spec.ts-snapshots/matrix-light-L2-mobile-win32.png differ diff --git a/e2e/connection-shapes.spec.ts b/e2e/connection-shapes.spec.ts new file mode 100644 index 00000000..b8d654c7 --- /dev/null +++ b/e2e/connection-shapes.spec.ts @@ -0,0 +1,485 @@ +import { readFileSync } from 'node:fs' +import type { Page } from '@playwright/test' +import { expect, importGraph, openApp, resetAll, routesSettled, test } from './support/loop' + +// issue #344 step 3 (docs/diagram-layout.md §DL4, docs/edge-routing.md §ER15–§ER16, +// SEMANTICS-R10.md) — the connection shapes and the bend-point editor, end to end: +// +// • the Inspector shape select writes the stored form of each shape, one undo +// entry each, and never resets the run; +// • Straight is drawn as a straight line, Curved as today's Bézier, and the +// label of either sits in a free slot ON its own drawn line; +// • Add bend inserts one snapped bend point where the route is clicked; +// • a bend point drags on the grid (Alt: free), moves with the arrow keys, +// goes with Delete, and Escape cancels the gesture in progress — each edit +// one undo entry; +// • a drop inside a node or on the port stub puts the point back; +// • the edit lock and the phone offer no route editing. + +const node = (id: string, type: string, x: number, y: number) => ({ + id, + type, + position: { x, y }, + data: + type === 'source' + ? { kind: 'source', label: id, activation: 'automatic', mode: 'pushAny' } + : type === 'drain' + ? { kind: 'drain', label: id, activation: 'automatic', mode: 'pullAny' } + : { kind: 'pool', label: id, activation: 'passive', initial: 0, capacity: null, mode: 'pullAny' }, +}) +const edge = (id: string, source: string, target: string, routing: Record) => ({ + id, + type: 'loop', + source, + target, + sourceHandle: 'out', + targetHandle: 'in', + data: { kind: 'resource', flow: '1', ...routing }, +}) +const doc = (nodes: unknown[], edges: unknown[]) => JSON.stringify({ schema: 'loop-studio/graph', version: 1, nodes, edges }) + +/** one connection a → b with a bend in the automatic route */ +const ONE = (routing: Record) => doc([node('a', 'source', 0, 0), node('b', 'drain', 480, 192)], [edge('e', 'a', 'b', routing)]) + +type Wp = { x: number; y: number } +const edgeData = (page: Page, id: string) => + page.evaluate((eid) => (window as any).__loop.graph.getState().edges.find((e: any) => e.id === eid)?.data ?? null, id) +const history = (page: Page) => page.evaluate(() => (window as any).__loop.graph.getState().past.length as number) +const simRev = (page: Page) => page.evaluate(() => (window as any).__loop.graph.getState().simulationRev as number) + +/** the client point at fraction `f` of an edge's drawn path */ +const pathPoint = (page: Page, id: string, f: number) => + page.evaluate( + ({ eid, f }) => { + const p = document.querySelector(`.react-flow__edge[data-id="${eid}"] path.react-flow__edge-path`) as SVGPathElement + const pt = p.getPointAtLength(p.getTotalLength() * f) + const m = p.getScreenCTM()! + return { x: m.a * pt.x + m.c * pt.y + m.e, y: m.b * pt.x + m.d * pt.y + m.f } + }, + { eid: id, f }, + ) +/** flow → client, through the edge layer's own transform */ +const toClient = (page: Page, id: string, q: Wp) => + page.evaluate( + ({ eid, q }) => { + const p = document.querySelector(`.react-flow__edge[data-id="${eid}"] path.react-flow__edge-path`) as SVGPathElement + const m = p.getScreenCTM()! + return { x: m.a * q.x + m.c * q.y + m.e, y: m.b * q.x + m.d * q.y + m.f, zoom: m.a } + }, + { eid: id, q }, + ) +const selectEdge = async (page: Page, id: string, f = 0.5) => { + const c = await pathPoint(page, id, f) + await page.mouse.click(c.x, c.y) + await expect(page.locator('.inspector select').filter({ has: page.locator('option[value="straight"]') })).toBeVisible() +} +const shapeSelect = (page: Page) => page.locator('.inspector select').filter({ has: page.locator('option[value="straight"]') }) +const bend = (page: Page, i = 0) => page.locator(`.route-bend[data-edge-id="e"][data-bend="${i}"]`) +async function dragBend(page: Page, i: number, to: { x: number; y: number }, opts: { alt?: boolean; beforeUp?: () => Promise } = {}) { + const box = (await bend(page, i).boundingBox())! + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + if (opts.alt) await page.keyboard.down('Alt') + await page.mouse.down() + await page.mouse.move(to.x, to.y, { steps: 6 }) + await opts.beforeUp?.() + await page.mouse.up() + if (opts.alt) await page.keyboard.up('Alt') +} + +test.describe('connection shapes — issue #344 step 3', () => { + test.beforeEach(async ({ page }) => { + await openApp(page) + await resetAll(page) + }) + + test('the shape select writes each stored form, one undo entry each, and leaves the run alone', async ({ page }) => { + await importGraph(page, ONE({ route: 'orthogonal' })) + await selectEdge(page, 'e') + const rev = await simRev(page) + const h0 = await history(page) + const path = page.locator('.react-flow__edge[data-id="e"] path.react-flow__edge-path') + + await shapeSelect(page).selectOption('straight') + expect(await edgeData(page, 'e')).toMatchObject({ route: 'straight' }) + await expect(path).toHaveAttribute('d', /^M [-\d.]+,[-\d.]+L [-\d.]+,[-\d.]+$/) + + await shapeSelect(page).selectOption('curved') + const curved = await edgeData(page, 'e') + expect(curved.route).toBeUndefined() + expect(curved.waypoints).toBeUndefined() + await expect(path).toHaveAttribute('d', /C/) + + await shapeSelect(page).selectOption('orthogonal') + expect(await edgeData(page, 'e')).toMatchObject({ route: 'orthogonal' }) + await expect(page.locator('.react-flow__edge[data-id="e"] path.route-orthogonal')).toHaveCount(1) + await expect(page.locator('.inspector')).toContainText('Automatic route') + + expect(await history(page)).toBe(h0 + 3) + expect(await simRev(page)).toBe(rev) + }) + + test('a Curved and a Straight label sit in a free slot on their own drawn line, off a node on its middle', async ({ page }) => { + await importGraph( + page, + doc( + [node('a', 'source', 0, 0), node('b', 'drain', 480, 0), node('mid', 'pool', 240, 0), node('c', 'source', 0, 320), node('d', 'drain', 480, 320), node('mid2', 'pool', 240, 320)], + [edge('st', 'a', 'b', { route: 'straight' }), edge('cv', 'c', 'd', {})], + ), + ) + const found = await page.evaluate(() => { + const out: Record = {} + for (const [id, nid] of [['st', 'mid'], ['cv', 'mid2']] as const) { + const label = document.querySelector(`.edge-label[data-edge-id="${id}"]`) as HTMLElement + const r = label.getBoundingClientRect() + const c = { x: r.x + r.width / 2, y: r.y + r.height / 2 } + const p = document.querySelector(`.react-flow__edge[data-id="${id}"] path.react-flow__edge-path`) as SVGPathElement + const m = p.getScreenCTM()! + let best = Infinity + const L = p.getTotalLength() + for (let i = 0; i <= 400; i++) { + const q = p.getPointAtLength((L * i) / 400) + best = Math.min(best, Math.hypot(m.a * q.x + m.e - c.x, m.d * q.y + m.f - c.y)) + } + const n = document.querySelector(`.react-flow__node[data-id="${nid}"]`)!.getBoundingClientRect() + out[id] = { + onLine: best, + overNode: r.x < n.right && n.x < r.right && r.y < n.bottom && n.y < r.bottom, + hidden: getComputedStyle(label).visibility === 'hidden', + } + } + return out + }) + for (const id of ['st', 'cv']) { + expect(found[id]!.hidden, `${id} label shown`).toBe(false) + expect(found[id]!.onLine, `${id} label centred on its line`).toBeLessThan(1.5) + expect(found[id]!.overNode, `${id} label clear of the node on its middle`).toBe(false) + } + }) + + test('Add bend inserts one snapped bend point where the route is clicked; one undo returns to Auto', async ({ page }) => { + await importGraph(page, ONE({ route: 'orthogonal' })) + await selectEdge(page, 'e') + const h0 = await history(page) + const add = page.getByRole('button', { name: 'Add bend' }) + await add.click() + await expect(add).toHaveAttribute('aria-pressed', 'true') + await expect(page.locator('.inspector')).toContainText('Click the connection where the bend goes') + + const route = await page.evaluate(() => (window as any).__loop.routeMap.get('e').points as { x: number; y: number }[]) + const c = await pathPoint(page, 'e', 0.55) + await page.mouse.click(c.x, c.y) + const data = await edgeData(page, 'e') + expect(data.route).toBe('orthogonal') + expect(data.waypoints).toHaveLength(1) + const [w] = data.waypoints as Wp[] + // §ER16.2 — on a segment of the route as it was, snapped ALONG it: adding + // the bend moved nothing + const onSegment = route.some((a, i) => { + const b = route[i + 1] + if (!b) return false + if (a.y === b.y) return w!.y === a.y && w!.x % 16 === 0 && w!.x >= Math.min(a.x, b.x) && w!.x <= Math.max(a.x, b.x) + return w!.x === a.x && w!.y % 16 === 0 && w!.y >= Math.min(a.y, b.y) && w!.y <= Math.max(a.y, b.y) + }) + expect(onSegment, `bend ${JSON.stringify(w)} on ${JSON.stringify(route)}`).toBe(true) + expect(await history(page)).toBe(h0 + 1) + await expect(add).toHaveAttribute('aria-pressed', 'false') + await expect(page.locator('.inspector')).toContainText('Manual route') + await expect(page.getByRole('button', { name: 'Reset to automatic' })).toBeVisible() + await expect(bend(page)).toHaveAccessibleName('Bend point 1 of 1') + + await page.evaluate(() => (window as any).__loop.graph.getState().undo()) + expect((await edgeData(page, 'e')).waypoints).toBeUndefined() + }) + + test('Escape disarms Add bend; a click then changes nothing', async ({ page }) => { + await importGraph(page, ONE({ route: 'orthogonal' })) + await selectEdge(page, 'e') + const add = page.getByRole('button', { name: 'Add bend' }) + await add.click() + await page.keyboard.press('Escape') + await expect(add).toHaveAttribute('aria-pressed', 'false') + const c = await pathPoint(page, 'e', 0.55) + await page.mouse.click(c.x, c.y) + expect((await edgeData(page, 'e')).waypoints).toBeUndefined() + }) + + test('a bend point drags on the grid, Alt drags it freely, each drop one undo entry', async ({ page }) => { + await importGraph(page, ONE({ route: 'orthogonal', waypoints: [{ x: 304, y: 96 }] })) + await selectEdge(page, 'e', 0.8) + await expect(bend(page)).toBeVisible() + const h0 = await history(page) + const z = (await toClient(page, 'e', { x: 0, y: 0 })).zoom + + const t1 = await toClient(page, 'e', { x: 304 + 48 + 5, y: 96 }) + await dragBend(page, 0, t1) + expect((await edgeData(page, 'e')).waypoints).toEqual([{ x: 352, y: 96 }]) + expect(await history(page)).toBe(h0 + 1) + + const t2 = await toClient(page, 'e', { x: 352 + 7, y: 96 }) + await dragBend(page, 0, t2, { alt: true }) + const [w] = (await edgeData(page, 'e')).waypoints as Wp[] + expect(Math.abs(w!.x - 359), `free x (zoom ${z})`).toBeLessThan(0.75) + expect(w!.x % 16).not.toBe(0) + expect(await history(page)).toBe(h0 + 2) + }) + + test('arrow keys move a bend point a grid step (Shift: four), a held gesture is one entry, Escape cancels it', async ({ page }) => { + await importGraph(page, ONE({ route: 'orthogonal', waypoints: [{ x: 304, y: 96 }] })) + await selectEdge(page, 'e', 0.8) + const h0 = await history(page) + await bend(page).focus() + + await page.keyboard.press('ArrowRight') + expect((await edgeData(page, 'e')).waypoints).toEqual([{ x: 320, y: 96 }]) + await page.keyboard.press('Shift+ArrowDown') + expect((await edgeData(page, 'e')).waypoints).toEqual([{ x: 320, y: 160 }]) + expect(await history(page)).toBe(h0 + 2) + + // two keys held together are one gesture + await page.keyboard.down('ArrowLeft') + await page.keyboard.down('ArrowUp') + await page.keyboard.up('ArrowLeft') + await page.keyboard.up('ArrowUp') + expect((await edgeData(page, 'e')).waypoints).toEqual([{ x: 304, y: 144 }]) + expect(await history(page)).toBe(h0 + 3) + + // Escape while a key is held puts the point back and records nothing + await page.keyboard.down('ArrowRight') + expect((await edgeData(page, 'e')).waypoints).toEqual([{ x: 320, y: 144 }]) + await page.keyboard.press('Escape') + await page.keyboard.up('ArrowRight') + expect((await edgeData(page, 'e')).waypoints).toEqual([{ x: 304, y: 144 }]) + expect(await history(page)).toBe(h0 + 3) + }) + + test('Escape during a drag puts the bend point back; nothing is recorded', async ({ page }) => { + await importGraph(page, ONE({ route: 'orthogonal', waypoints: [{ x: 304, y: 96 }] })) + await selectEdge(page, 'e', 0.8) + const h0 = await history(page) + const t = await toClient(page, 'e', { x: 400, y: 128 }) + await dragBend(page, 0, t, { beforeUp: () => page.keyboard.press('Escape') }) + expect((await edgeData(page, 'e')).waypoints).toEqual([{ x: 304, y: 96 }]) + expect(await history(page)).toBe(h0) + }) + + test('a drop inside a node or on the port stub puts the bend point back, with a dashed cue while there', async ({ page }) => { + await importGraph(page, ONE({ route: 'orthogonal', waypoints: [{ x: 304, y: 96 }] })) + await selectEdge(page, 'e', 0.8) + const h0 = await history(page) + const inNode = await toClient(page, 'e', { x: 540, y: 216 }) + await dragBend(page, 0, inNode, { + beforeUp: async () => { + await expect(bend(page)).toHaveClass(/is-invalid/) + expect(await bend(page).evaluate((el) => getComputedStyle(el).borderStyle)).toBe('dashed') + }, + }) + expect((await edgeData(page, 'e')).waypoints).toEqual([{ x: 304, y: 96 }]) + // a's out port is on its right side, on the port row (y 28) + const aw = await page.evaluate(() => (window as any).__loop.graph.getState().nodes.find((n: any) => n.id === 'a').measured.width as number) + const onStub = await toClient(page, 'e', { x: aw + 8, y: 28 }) + await dragBend(page, 0, onStub) + expect((await edgeData(page, 'e')).waypoints).toEqual([{ x: 304, y: 96 }]) + expect(await history(page)).toBe(h0) + await expect(bend(page)).not.toHaveClass(/is-invalid/) + }) + + test('Delete removes a bend point (the last one makes the route automatic), Reset drops them all; one entry each', async ({ page }) => { + await importGraph(page, ONE({ route: 'orthogonal', waypoints: [{ x: 304, y: 96 }, { x: 352, y: 144 }] })) + await selectEdge(page, 'e', 0.8) + const h0 = await history(page) + await bend(page, 1).focus() + await page.keyboard.press('Delete') + expect((await edgeData(page, 'e')).waypoints).toEqual([{ x: 304, y: 96 }]) + expect(await page.evaluate(() => (window as any).__loop.graph.getState().edges.length), 'the connection itself stays').toBe(1) + await page.getByRole('button', { name: 'Reset to automatic' }).click() + const data = await edgeData(page, 'e') + expect(data.route).toBe('orthogonal') + expect(data.waypoints).toBeUndefined() + expect(await history(page)).toBe(h0 + 2) + await expect(page.locator('.route-bend')).toHaveCount(0) + }) + + test('under the edit lock there are no bend handles, the shape tools are disabled and the store refuses', async ({ page }) => { + await importGraph(page, ONE({ route: 'orthogonal', waypoints: [{ x: 304, y: 96 }] })) + await selectEdge(page, 'e', 0.8) + await expect(bend(page)).toBeVisible() + await page.evaluate(() => (window as any).__loop.ui.getState().setCanvasLocked(true)) + await expect(page.locator('.route-bend')).toHaveCount(0) + await expect(shapeSelect(page)).toBeDisabled() + await expect(page.getByRole('button', { name: 'Add bend' })).toBeDisabled() + await page.evaluate(() => (window as any).__loop.graph.getState().setEdgeRouting('e', { route: 'straight' })) + expect((await edgeData(page, 'e')).route).toBe('orthogonal') + }) + + test('the phone draws every shape and offers no route editing', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await importGraph( + page, + doc( + [node('a', 'source', 0, 0), node('b', 'drain', 480, 0), node('c', 'source', 0, 192), node('d', 'drain', 480, 384), node('f', 'source', 0, 576), node('g', 'drain', 480, 576)], + [edge('st', 'a', 'b', { route: 'straight' }), edge('e', 'c', 'd', { route: 'orthogonal', waypoints: [{ x: 304, y: 240 }] }), edge('cv', 'f', 'g', {})], + ), + ) + await expect(page.locator('.react-flow__edge[data-id="st"] path.react-flow__edge-path')).toHaveAttribute('d', /^M [-\d.]+,[-\d.]+L [-\d.]+,[-\d.]+$/) + await expect(page.locator('.react-flow__edge[data-id="e"] path.route-orthogonal')).toHaveCount(1) + await expect(page.locator('.react-flow__edge[data-id="cv"] path.react-flow__edge-path')).toHaveAttribute('d', /C/) + await page.evaluate(() => { + const g = (window as any).__loop.graph + g.setState({ edges: g.getState().edges.map((x: any) => ({ ...x, selected: x.id === 'e' })) }) + g.getState().setSelection(null, 'e') + }) + await expect(page.locator('.react-flow__edge[data-id="e"].selected')).toHaveCount(1) + await expect(page.locator('.route-bend')).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Add bend' })).toHaveCount(0) + }) +}) + +test.describe('connection shapes — keyboard Add bend and the record label rule (issue #344 step 3)', () => { + test.beforeEach(async ({ page }) => { + await openApp(page) + await resetAll(page) + }) + + test('armed Add bend + Enter adds a bend on the longest segment, focuses its handle; one undo entry', async ({ page }) => { + await importGraph(page, ONE({ route: 'orthogonal' })) + await selectEdge(page, 'e') + const before = await page.locator('.react-flow__edge[data-id="e"] path.react-flow__edge-path').getAttribute('d') + const h0 = await history(page) + const add = page.getByRole('button', { name: 'Add bend' }) + await add.focus() + await page.keyboard.press('Enter') // arms (the button) + await expect(add).toHaveAttribute('aria-pressed', 'true') + await expect(page.locator('.inspector')).toContainText('press Enter to add one') + await page.keyboard.press('Enter') // adds + const wps = (await edgeData(page, 'e')).waypoints as Wp[] + expect(wps).toHaveLength(1) + expect(await history(page)).toBe(h0 + 1) + await expect(add).toHaveAttribute('aria-pressed', 'false') + // the new point is on the route as it was drawn: adding it moved nothing + const off = await page.evaluate( + ({ d, p }) => { + const el = document.createElementNS('http://www.w3.org/2000/svg', 'path') + el.setAttribute('d', d) + const L = el.getTotalLength() + let best = Infinity + for (let i = 0; i <= 2000; i++) { + const q = el.getPointAtLength((L * i) / 2000) + best = Math.min(best, Math.hypot(q.x - p.x, q.y - p.y)) + } + return best + }, + { d: before!, p: wps[0]! }, + ) + expect(off, 'on the drawn route').toBeLessThan(0.75) + // the focus is on the new handle: the arrow keys move it at once + await expect(bend(page)).toBeFocused() + await page.keyboard.press('ArrowRight') + expect(((await edgeData(page, 'e')).waypoints as Wp[])[0]!.x).toBe(wps[0]!.x + 16) + expect(await history(page)).toBe(h0 + 2) + }) + + test('with no segment that can take a bend, Enter changes nothing and says so', async ({ page }) => { + // two nodes so close that the route is its two stubs + await importGraph(page, doc([node('a', 'source', 0, 0), node('b', 'drain', 176, 0)], [edge('e', 'a', 'b', { route: 'orthogonal' })])) + await selectEdge(page, 'e') + const h0 = await history(page) + const add = page.getByRole('button', { name: 'Add bend' }) + await add.click() + await add.focus() + await page.keyboard.press('Enter') + expect((await edgeData(page, 'e')).waypoints).toBeUndefined() + expect(await history(page)).toBe(h0) + await expect(add).toHaveAttribute('aria-pressed', 'true') + await expect(page.locator('.inspector')).toContainText('No segment of this connection has room for a bend point') + await expect(page.locator('[id^="react-flow__aria-live"]').first()).toContainText('No segment of this connection has room for a bend point') + await page.keyboard.press('Escape') + await expect(add).toHaveAttribute('aria-pressed', 'false') + }) + + test('the Inspector note says, in one statement, what restarts the run and what does not', async ({ page }) => { + await importGraph(page, ONE({ route: 'orthogonal' })) + await selectEdge(page, 'e') + const note = page.locator('.inspector .inspector__note', { hasText: 'simulation settings' }) + await expect(note).toHaveCount(1) + await expect(note).toHaveText('Changing a connection’s simulation settings restarts the current run. Route, bends, and colour are drawing-only and do not restart it.') + await expect(page.locator('.inspector')).not.toContainText('Editing a connection restarts the run') + }) + + test('a revision recorded before /10 keeps its Curved labels where they were recorded, until a shape edit; undo restores it', async ({ page }) => { + await openRecord(page, readFileSync(new URL('../examples/revision/base.revision.json', import.meta.url), 'utf8')) + expect(await recordRule(page)).toBe(true) + const recorded = await fromRecorded(page) + expect(Object.keys(recorded).length).toBeGreaterThan(0) + for (const [id, d] of Object.entries(recorded)) expect(d, `${id} at its recorded place`).toBeLessThan(1) + + // a shape edit moves the document to the current rule, in one undo entry + const first = Object.keys(recorded)[0]! + await page.evaluate((id) => (window as any).__loop.graph.getState().setEdgeRouting(id, { route: 'straight' }), first) + expect(await recordRule(page)).toBe(false) + await page.evaluate(() => (window as any).__loop.graph.getState().undo()) + await routesSettled(page) + expect(await recordRule(page)).toBe(true) + for (const [id, d] of Object.entries(await fromRecorded(page))) expect(d, `${id} back at its recorded place`).toBeLessThan(1) + }) + + test('the record rule survives a reload of the Project (labelLayoutVersion in the autosaved header)', async ({ page }) => { + await openRecord(page, readFileSync(new URL('../examples/revision/base.revision.json', import.meta.url), 'utf8')) + expect(await recordRule(page)).toBe(true) + await page.evaluate(() => (window as any).__loop.autosave.flush()) + expect(await page.evaluate(() => JSON.parse(localStorage.getItem('loop-studio:graph:v1')!).project.labelLayoutVersion)).toBe(0) + await page.reload() + await page.waitForFunction(() => Boolean((window as any).__loop) && (window as any).__loop.project.getState().open != null) + await routesSettled(page) + expect(await recordRule(page)).toBe(true) + for (const [id, d] of Object.entries(await fromRecorded(page))) expect(d, `${id} at its recorded place after the reload`).toBeLessThan(1) + // after a shape edit, the reload brings the current rule back instead + const first = Object.keys(await fromRecorded(page))[0]! + await page.evaluate((id) => (window as any).__loop.graph.getState().setEdgeRouting(id, { route: 'orthogonal' }), first) + await page.evaluate(() => (window as any).__loop.autosave.flush()) + await page.reload() + await page.waitForFunction(() => Boolean((window as any).__loop) && (window as any).__loop.project.getState().open != null) + expect(await recordRule(page)).toBe(false) + }) + + test('a record whose header declares /10 takes the current placement, though it has no straight connection', async ({ page }) => { + // a Curved-only graph, recorded by this build + await importGraph(page, doc([node('a', 'source', 0, 0), node('b', 'drain', 480, 0), node('mid', 'pool', 240, 0)], [edge('cv', 'a', 'b', {})])) + const text = await page.evaluate(() => { + const r = (window as any).__loop.project.getState().planRevision() + return r.ok ? (r.text as string) : '' + }) + expect(JSON.parse(text).project.semantics).toBe('loop-revision/10') + expect(text).not.toContain('"straight"') + await resetAll(page) + await openRecord(page, text) + expect(await recordRule(page)).toBe(false) + expect(await page.evaluate(() => (window as any).__loop.routeMap.free().has('cv')), 'its Curved label takes a free slot').toBe(true) + }) +}) + +/** open a Project revision file through the File input and wait for its routes */ +async function openRecord(page: Page, text: string) { + await page.setInputFiles('.toolbar__actions input[type=file]', { name: 'record.revision.json', mimeType: 'application/json', buffer: Buffer.from(text, 'utf8') }) + await page.waitForFunction(() => (window as any).__loop.project.getState().open != null) + await routesSettled(page) +} +const recordRule = (page: Page) => page.evaluate(() => (window as any).__loop.graph.getState().recordLabels as boolean) +/** each Curved label's distance from its Bézier's t = 0.5 point (where it was recorded) */ +const fromRecorded = (page: Page) => + page.evaluate(() => { + const out: Record = {} + for (const g of document.querySelectorAll('.react-flow__edge')) { + const id = g.getAttribute('data-id')! + const p = g.querySelector('path.react-flow__edge-path') as SVGPathElement + const m = /^M\s*([-\d.]+),([-\d.]+)\s*C\s*([-\d.]+),([-\d.]+)\s+([-\d.]+),([-\d.]+)\s+([-\d.]+),([-\d.]+)$/.exec(p.getAttribute('d')!.trim()) + const label = document.querySelector(`.edge-label[data-edge-id="${id}"]`) as HTMLElement | null + if (!m || !label) continue + const n = m.slice(1).map(Number) + const bx = 0.125 * n[0]! + 0.375 * n[2]! + 0.375 * n[4]! + 0.125 * n[6]! + const by = 0.125 * n[1]! + 0.375 * n[3]! + 0.375 * n[5]! + 0.125 * n[7]! + const ctm = p.getScreenCTM()! + const r = label.getBoundingClientRect() + out[id] = Math.hypot(ctm.a * bx + ctm.e - (r.x + r.width / 2), ctm.d * by + ctm.f - (r.y + r.height / 2)) + } + return out + }) diff --git a/e2e/diagram-grid.spec.ts b/e2e/diagram-grid.spec.ts new file mode 100644 index 00000000..c63fd4df --- /dev/null +++ b/e2e/diagram-grid.spec.ts @@ -0,0 +1,179 @@ +import type { Page } from '@playwright/test' +import { expect, importGraph, openApp, resetAll, test } from './support/loop' + +// issue #344 Step 1 — docs/diagram-layout.md §DL1–§DL3: the 16 px grid, the +// resource port row fixed 28 px below a node's top, snapping for pointer drags +// (Alt = free), arrow keys (16 / Shift 64 px), frames (Ctrl / ⌘ = the frame +// alone), new nodes, the one-time conversion of a pre-grid file (no undo +// entry) and Tidy to grid (one undo entry). + +type XY = { x: number; y: number } +type Rect = { x: number; y: number; w: number; h: number } +type Bridge = { + __loop: { + graph: { getState: () => { nodes: { id: string; position: XY; selected?: boolean; measured?: { width: number; height: number } }[]; canUndo: boolean; undo: () => void; tidyToGrid: () => number } } + frame: { getState: () => { frames: { id: string; rect: Rect }[]; addFrame: (r: Rect) => string } } + ui: { getState: () => { setCanvasLocked: (v: boolean) => void } } + rf: { setViewport: (v: { x: number; y: number; zoom: number }, o?: { duration: number }) => void } + } +} +const onGrid = (p: XY) => Math.abs(p.x % 16) === 0 && Math.abs((p.y + 28) % 16) === 0 + +const POOL = (id: string, x: number, y: number, label = id) => ({ id, type: 'pool', position: { x, y }, data: { kind: 'pool', label, initial: 0, capacity: null, mode: 'pullAny', activation: 'passive' } }) +/** off-grid on purpose: the scene a user had before the grid */ +const SCENE = JSON.stringify({ + schema: 'loop-studio/graph', + version: 1, + nodes: [POOL('a', 3, 7), POOL('b', 205, 11), POOL('c', 410, 160, 'A much longer pool title that wraps')], + edges: [{ id: 'e1', source: 'a', target: 'b', sourceHandle: 'out', targetHandle: 'in', type: 'loop', data: { kind: 'resource', flow: '1' } }], +}) + +const positions = (page: Page) => + page.evaluate(() => Object.fromEntries((window as unknown as Bridge).__loop.graph.getState().nodes.map((n) => [n.id, { ...n.position }]))) +const canUndo = (page: Page) => page.evaluate(() => (window as unknown as Bridge).__loop.graph.getState().canUndo) + +async function load(page: Page, opts: { legacyLayout?: boolean } = {}) { + await openApp(page) + await resetAll(page) + await importGraph(page, SCENE, opts) + await expect(page.locator('.react-flow__node')).toHaveCount(3) + await page.evaluate(() => (window as unknown as Bridge).__loop.rf.setViewport({ x: 120, y: 160, zoom: 1 }, { duration: 0 })) + await page.waitForTimeout(250) +} + +/** a real pointer drag on node `id` by (dx, dy) screen px (zoom 1) */ +async function dragNode(page: Page, id: string, dx: number, dy: number, alt = false) { + const b = (await page.locator(`.react-flow__node[data-id="${id}"]`).boundingBox())! + const x0 = b.x + b.width / 2 + const y0 = b.y + 12 + if (alt) await page.keyboard.down('Alt') + await page.mouse.move(x0, y0) + await page.mouse.down() + await page.mouse.move(x0 + dx / 2, y0 + dy / 2, { steps: 4 }) + await page.mouse.move(x0 + dx, y0 + dy, { steps: 4 }) + await page.mouse.up() + if (alt) await page.keyboard.up('Alt') + await page.waitForTimeout(150) +} + +test('a resource port sits 28 px below the node top, whatever the height, on the outline', async ({ page }) => { + await load(page) + const rows = await page.evaluate(() => + ['a', 'c'].map((id) => { + const n = document.querySelector(`.react-flow__node[data-id="${id}"]`)! + const box = n.querySelector('.nodef')!.getBoundingClientRect() + const h = (sel: string) => n.querySelector(sel)!.getBoundingClientRect() + const inH = h('.react-flow__handle.h--in'), outH = h('.react-flow__handle.h--out') + return { id, height: box.height, inRow: inH.top + inH.height / 2 - box.top, outRow: outH.top + outH.height / 2 - box.top, inX: inH.left + inH.width / 2 - box.left, outX: box.right - (outH.left + outH.width / 2) } + }), + ) + expect(rows[1].height, 'c wraps to a taller box').toBeGreaterThan(rows[0].height + 10) + for (const r of rows) { + expect(Math.abs(r.inRow - 28), `${r.id} in`).toBeLessThan(0.6) + expect(Math.abs(r.outRow - 28), `${r.id} out`).toBeLessThan(0.6) + // a Pool's slanted sides: the port is drawn on the outline, inside the box edge + expect(r.inX, `${r.id} in on the outline`).toBeGreaterThan(8) + expect(r.outX, `${r.id} out on the outline`).toBeGreaterThan(8) + } +}) + +test('a pointer drag lands on the grid; Alt held moves freely and the free position is kept', async ({ page }) => { + await load(page) + await dragNode(page, 'a', 37, 23) + const p1 = await positions(page) + expect(onGrid(p1.a)).toBe(true) + await dragNode(page, 'b', 37, 23, true) + const p2 = await positions(page) + expect(onGrid(p2.b)).toBe(false) + // free: the pointer's own Δ (less React Flow's few-px drag threshold) + expect(Math.abs(p2.b.x - (205 + 37))).toBeLessThan(6) + expect(Math.abs(p2.b.y - (11 + 23))).toBeLessThan(6) + // and kept: re-reading later changes nothing + await page.waitForTimeout(300) + expect((await positions(page)).b).toEqual(p2.b) +}) + +test('a selection moves as a group: the grabbed node lands on the grid, offsets are kept', async ({ page }) => { + await load(page) + const p0 = await positions(page) + await page.locator('.react-flow__node[data-id="a"]').click() + await page.locator('.react-flow__node[data-id="b"]').click({ modifiers: [process.platform === 'darwin' ? 'Meta' : 'Control'] }) + await dragNode(page, 'a', 50, 31) + const p1 = await positions(page) + expect(onGrid(p1.a)).toBe(true) + expect({ x: p1.b.x - p1.a.x, y: p1.b.y - p1.a.y }).toEqual({ x: p0.b.x - p0.a.x, y: p0.b.y - p0.a.y }) +}) + +test('the arrow keys move by one grid step, Shift by four, onto the grid', async ({ page }) => { + await load(page) + await page.locator('.react-flow__node[data-id="a"]').focus() + await page.keyboard.press('Enter') + await page.keyboard.press('ArrowRight') + const p1 = (await positions(page)).a + expect(onGrid(p1)).toBe(true) + await page.keyboard.press('ArrowRight') + expect((await positions(page)).a).toEqual({ x: p1.x + 16, y: p1.y }) + await page.keyboard.press('Shift+ArrowDown') + expect((await positions(page)).a).toEqual({ x: p1.x + 16, y: p1.y + 64 }) +}) + +test('a frame drag lands on the grid; Ctrl / Command moves the frame alone; Alt moves it freely', async ({ page }) => { + await load(page) + const id = await page.evaluate(() => (window as unknown as Bridge).__loop.frame.getState().addFrame({ x: -45, y: -37, w: 520, h: 140 })) + const rect = () => page.evaluate((fid) => (window as unknown as Bridge).__loop.frame.getState().frames.find((f) => f.id === fid)!.rect, id) + const strip = page.locator('.lgr-frame').first().locator('.lgr-frame__edge-hit--top') + const drag = async (dx: number, dy: number, mod?: 'Control' | 'Alt') => { + const b = (await strip.boundingBox())! + const x0 = b.x + 40, y0 = b.y + b.height / 2 + if (mod) await page.keyboard.down(mod) + await page.mouse.move(x0, y0) + await page.mouse.down() + await page.mouse.move(x0 + dx, y0 + dy, { steps: 6 }) + await page.mouse.up() + if (mod) await page.keyboard.up(mod) + await page.waitForTimeout(150) + } + const n0 = await positions(page) + await drag(30, 21) + const r1 = await rect() + expect(Math.abs(r1.x % 16)).toBe(0) + expect(Math.abs(r1.y % 16)).toBe(0) + const n1 = await positions(page) + expect(n1.a, 'a node inside rides along by the same Δ').toEqual({ x: n0.a.x + (r1.x + 45), y: n0.a.y + (r1.y + 37) }) + await drag(32, 0, 'Control') + expect((await rect()).x).toBe(r1.x + 32) + expect(await positions(page), 'Ctrl: the frame alone').toEqual(n1) + await drag(5, 3, 'Alt') + expect((await rect()).x).toBe(r1.x + 32 + 5) +}) + +test('a palette click places the new node on the grid, clear of the nodes already there', async ({ page }) => { + await load(page) + await page.getByRole('button', { name: /^Pool/ }).first().click() + const nodes = await page.evaluate(() => (window as unknown as Bridge).__loop.graph.getState().nodes.map((n) => ({ id: n.id, ...n.position }))) + const added = nodes.find((n) => !['a', 'b', 'c'].includes(n.id))! + expect(onGrid(added)).toBe(true) +}) + +test('a pre-grid file is re-placed once when it is opened, with no undo entry', async ({ page }) => { + await load(page, { legacyLayout: true }) + const p = await positions(page) + for (const id of ['a', 'b', 'c']) expect(onGrid(p[id]), id).toBe(true) + expect(await canUndo(page)).toBe(false) + // the two connected Pools' port rows (35 and 39) share one row + expect(p.a.y).toBe(p.b.y) +}) + +test('Tidy to grid re-places the document as one undo step, and is disabled while locked', async ({ page }) => { + await load(page) + const button = page.locator('.react-flow__controls .rf-tidy') + await expect(button).toBeEnabled() + await button.click() + const p1 = await positions(page) + for (const id of ['a', 'b', 'c']) expect(onGrid(p1[id]), id).toBe(true) + expect(await canUndo(page)).toBe(true) + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+z' : 'Control+z') + expect((await positions(page)).a).toEqual({ x: 3, y: 7 }) + await page.evaluate(() => (window as unknown as Bridge).__loop.ui.getState().setCanvasLocked(true)) + await expect(button).toBeDisabled() +}) diff --git a/e2e/dist.spec.ts b/e2e/dist.spec.ts index a7cb299a..e3739d76 100644 --- a/e2e/dist.spec.ts +++ b/e2e/dist.spec.ts @@ -15,6 +15,19 @@ import { expectShownNoticesAre, manifestNoticesSha, openAbout, openLicences, sha const RF = 'examples/risky-factory.json' const ORIGIN = 'http://localhost:4173' // playwright.dist.config.ts baseURL +/** issue #344 §ER14.5 — the DOM side of the route settle contract, for a build + * without the dev bridge: two frames (a change that just landed has rendered + * and started its generation), then the canvas is no longer aria-busy and + * every connection has its path */ +async function domRoutesSettled(page: Page): Promise { + await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())))) + await page.waitForFunction(() => { + if (document.querySelector('.canvas[aria-busy="true"]')) return false + const paths = [...document.querySelectorAll('.react-flow__edge path.react-flow__edge-path')] + return paths.length > 0 && paths.every((p) => (p.getAttribute('d') ?? '').length > 0) + }) +} + /** boot the prod build; fail on any console error, page error, failed request, * or cross-origin request */ async function openProd(page: Page): Promise<{ bad: string[] }> { @@ -260,6 +273,11 @@ test.describe('production build (Cloudflare Pages shape)', () => { await page.evaluate(() => document.fonts.ready) // representative Korean node label, confirming the KO Template actually loaded await expect(page.locator('.react-flow__node', { hasText: '레벨' }).first()).toBeVisible() + // issue #344 §ER14.5 — the routed connections are drawn once their first + // generation commits; the canvas is aria-busy until then (the DOM side of + // the settle contract `routesSettled` reads in dev). "Before" is read only + // when every route is drawn. + await domRoutesSettled(page) async function snapshot() { return page.evaluate(() => { @@ -354,6 +372,7 @@ test.describe('production build (Cloudflare Pages shape)', () => { await page.locator('.lang-menu__item[data-locale="en"]').click() await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe('en') await expect(page.locator('.react-flow__node', { hasText: 'Level' }).first()).toBeVisible() + await domRoutesSettled(page) // the switch must have actually changed at least one node's real height — // otherwise the edge-alignment checks below would trivially pass with diff --git a/e2e/edge-routing.spec.ts b/e2e/edge-routing.spec.ts index 66ab66b7..7db5feb7 100644 --- a/e2e/edge-routing.spec.ts +++ b/e2e/edge-routing.spec.ts @@ -193,17 +193,19 @@ test.describe('edge routing — Slice 1', () => { await settle(page) // incremental: settle `gold` at a new position through the real change stream + // (a grid position — issue #344: a drag snaps, so an off-grid target would + // not be the position the cold load below opens with) await page.evaluate(() => { const g = (window as any).__loop.graph.getState() - g.onNodesChange([{ type: 'position', id: 'gold', position: { x: 360, y: 40 }, dragging: true }]) - g.onNodesChange([{ type: 'position', id: 'gold', position: { x: 360, y: 40 }, dragging: false }]) + g.onNodesChange([{ type: 'position', id: 'gold', position: { x: 368, y: 36 }, dragging: true }]) + g.onNodesChange([{ type: 'position', id: 'gold', position: { x: 368, y: 36 }, dragging: false }]) }) await page.waitForTimeout(50) const incremental = await edgePaths(page) // cold: load the SAME final graph fresh const cold = JSON.parse(G2({ route: true })) - cold.nodes.find((n: any) => n.id === 'gold').position = { x: 360, y: 40 } + cold.nodes.find((n: any) => n.id === 'gold').position = { x: 368, y: 36 } await importGraph(page, JSON.stringify(cold)) await expect(page.locator('.react-flow__edge[data-id="e_sg"] path.route-orthogonal')).toHaveCount(1) await settle(page) @@ -253,7 +255,7 @@ test.describe('edge routing — Slice 1', () => { await expect(page.locator('.react-flow__edge[data-id="e_sg"] path.route-orthogonal')).toHaveCount(1) expect(await edgeData(page, 'e_sg')).toMatchObject({ route: 'orthogonal' }) - await select.selectOption('bezier') + await select.selectOption('curved') await expect(page.locator('.react-flow__edge[data-id="e_sg"] path.route-orthogonal')).toHaveCount(0) const data = await edgeData(page, 'e_sg') expect(data.route).toBeUndefined() @@ -285,11 +287,12 @@ test.describe('edge routing — Slice 1', () => { expect(before.ab.dom).toBe(before.ab.map) expect(before.cd.dom).toBe(before.cd.map) - // move the obstacle a long way through both corridors + // move the obstacle a long way through both corridors (to a grid position, + // which a drag keeps — issue #344) await page.evaluate(() => { const g = (window as any).__loop.graph.getState() - g.onNodesChange([{ type: 'position', id: 'obst', position: { x: 280, y: 4 }, dragging: true }]) - g.onNodesChange([{ type: 'position', id: 'obst', position: { x: 280, y: 4 }, dragging: false }]) + g.onNodesChange([{ type: 'position', id: 'obst', position: { x: 288, y: 4 }, dragging: true }]) + g.onNodesChange([{ type: 'position', id: 'obst', position: { x: 288, y: 4 }, dragging: false }]) }) await settle(page) const after = await genAndPaths() @@ -301,19 +304,19 @@ test.describe('edge routing — Slice 1', () => { expect(after.ab.dom).toBe(after.ab.map) expect(after.cd.dom).toBe(after.cd.map) - // the previous route is not an input: clearing the cache and recomputing - // from scratch yields byte-identical paths + // the previous route is not an input: recomputing from scratch (the + // synchronous full generation, computed aside — issue #344 §ER14.5) yields + // byte-identical paths const recomputed = await page.evaluate(() => { - const l = (window as any).__loop - l.routeMap.reset() - return { ab: l.routeMap.get('e_ab')?.d ?? '', cd: l.routeMap.get('e_cd')?.d ?? '' } + const m = (window as any).__loop.routeMap.syncFull() + return { ab: m.get('e_ab')?.d ?? '', cd: m.get('e_cd')?.d ?? '' } }) expect(recomputed.ab).toBe(after.ab.map) expect(recomputed.cd).toBe(after.cd.map) // incremental == cold: import the moved graph fresh const cold = JSON.parse(GRID()) - cold.nodes.find((n: any) => n.id === 'obst').position = { x: 280, y: 4 } + cold.nodes.find((n: any) => n.id === 'obst').position = { x: 288, y: 4 } await importGraph(page, JSON.stringify(cold)) await expect(page.locator('.react-flow__edge[data-id="e_ab"] path.route-orthogonal')).toHaveCount(1) await settle(page) @@ -356,9 +359,20 @@ test.describe('edge routing — Slice 1', () => { const p = document.querySelector('.react-flow__edge[data-id="e_ac"] path.react-flow__edge-path') as SVGPathElement const route = (window as any).__loop.routeMap.get('e_ac') const g = (window as any).__loop.graph.getState() + // issue #344 §ER14.2 — the route skips a waypoint inside a node and never + // enters the node (no L / Z fallback through it any more) + const mid = g.nodes.find((n: any) => n.id === 'mid') + const box = { x: mid.position.x, y: mid.position.y, w: mid.measured.width, h: mid.measured.height } + const pts = route.points as { x: number; y: number }[] + const entersMid = pts.some((q, i) => { + if (i === 0) return false + const a = pts[i - 1] + return Math.max(a.x, q.x) > box.x && Math.min(a.x, q.x) < box.x + box.w && Math.max(a.y, q.y) > box.y && Math.min(a.y, q.y) < box.y + box.h + }) return { invalidClass: p.classList.contains('route-invalid'), - fallback: route?.routeClass, + routeClass: route?.routeClass, + entersMid, invalidFlag: route?.invalidWaypoint, dash: getComputedStyle(p).strokeDasharray, waypoints: g.edges[0].data.waypoints, // value kept @@ -366,7 +380,8 @@ test.describe('edge routing — Slice 1', () => { }) expect(cue.invalidFlag).toBe(true) expect(cue.invalidClass).toBe(true) - expect(cue.fallback).toBe('fallback-lz') + expect(cue.routeClass).not.toBe('fallback-lz') + expect(cue.entersMid).toBe(false) expect(cue.dash).not.toBe('none') // dashed WARNING treatment expect(cue.waypoints).toEqual([{ x: 300, y: 12 }]) // not consumed / not moved }) diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-forced-minimap-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-forced-minimap-chromium-win32.png index 3b0f48bc..9f013676 100644 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-forced-minimap-chromium-win32.png and b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-forced-minimap-chromium-win32.png differ diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-minimap-dark-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-minimap-dark-chromium-win32.png index 24b6e3f9..2c9d3589 100644 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-minimap-dark-chromium-win32.png and b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-minimap-dark-chromium-win32.png differ diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-minimap-light-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-minimap-light-chromium-win32.png index 4387e9d1..93da1458 100644 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-minimap-light-chromium-win32.png and b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-minimap-light-chromium-win32.png differ diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-dark-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-dark-chromium-win32.png index da5d9dff..39463f26 100644 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-dark-chromium-win32.png and b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-dark-chromium-win32.png differ diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-light-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-light-chromium-win32.png index a3459ee4..18aa43c4 100644 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-light-chromium-win32.png and b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-coffee-light-chromium-win32.png differ diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-gacha-dark-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-gacha-dark-chromium-win32.png index 6b5a28ae..62760c2b 100644 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-gacha-dark-chromium-win32.png and b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-gacha-dark-chromium-win32.png differ diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-gacha-light-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-gacha-light-chromium-win32.png index eae70f3d..9741def3 100644 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-gacha-light-chromium-win32.png and b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-gacha-light-chromium-win32.png differ diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-dark-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-dark-chromium-win32.png index 136a62be..ee29dd04 100644 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-dark-chromium-win32.png and b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-dark-chromium-win32.png differ diff --git a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-light-chromium-win32.png b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-light-chromium-win32.png index 120563ad..8564c251 100644 Binary files a/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-light-chromium-win32.png and b/e2e/flow-colour-views-visual.spec.ts-snapshots/flow-views-template-mmo-light-chromium-win32.png differ diff --git a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-dark-L0-chromium-win32.png b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-dark-L0-chromium-win32.png index 6555fe84..aa6b3b7b 100644 Binary files a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-dark-L0-chromium-win32.png and b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-dark-L0-chromium-win32.png differ diff --git a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-dark-L2-chromium-win32.png b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-dark-L2-chromium-win32.png index 553c409c..a32f709f 100644 Binary files a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-dark-L2-chromium-win32.png and b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-dark-L2-chromium-win32.png differ diff --git a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-light-L0-chromium-win32.png b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-light-L0-chromium-win32.png index 2e786f3c..de333128 100644 Binary files a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-light-L0-chromium-win32.png and b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-light-L0-chromium-win32.png differ diff --git a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-light-L2-chromium-win32.png b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-light-L2-chromium-win32.png index a2122945..beb9a249 100644 Binary files a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-light-L2-chromium-win32.png and b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-light-L2-chromium-win32.png differ diff --git a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-states-chromium-win32.png b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-states-chromium-win32.png index d1953866..da76dcaa 100644 Binary files a/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-states-chromium-win32.png and b/e2e/flow-colour-visual.spec.ts-snapshots/flow-colour-states-chromium-win32.png differ diff --git a/e2e/flow-colour.spec.ts b/e2e/flow-colour.spec.ts index 633cd3c5..44973dc9 100644 --- a/e2e/flow-colour.spec.ts +++ b/e2e/flow-colour.spec.ts @@ -43,7 +43,20 @@ const accentOf = (page: Page, kind: 'nodes' | 'edges', id: string) => const section = (page: Page) => page.locator('.accent-field') const swatch = (page: Page, name: string) => section(page).getByRole('button', { name, exact: true }) const node = (page: Page, id: string) => page.locator(`.react-flow__node[data-id="${id}"]`) -const edgeHit = (page: Page, id: string) => page.locator(`.react-flow__edge[data-id="${id}"] .react-flow__edge-interaction`) +/** click an edge at the midpoint of its own hit path. A level edge (both ports + * on the same row — issue #344) has a box with no height, which a locator + * click cannot land on. */ +const clickEdge = async (page: Page, id: string, modifiers: 'Control'[] = []) => { + const mid = await page.evaluate((i) => { + const p = document.querySelector(`.react-flow__edge[data-id="${i}"] path.react-flow__edge-interaction`) as SVGPathElement + const pt = p.getPointAtLength(p.getTotalLength() / 2) + const m = p.getScreenCTM()! + return { x: m.a * pt.x + m.c * pt.y + m.e, y: m.b * pt.x + m.d * pt.y + m.f } + }, id) + for (const k of modifiers) await page.keyboard.down(k) + await page.mouse.click(mid.x, mid.y) + for (const k of modifiers) await page.keyboard.up(k) +} test.describe('flow colour — the Colour section (FC-5)', () => { test('a palette colour colours the node; Default removes the key', async ({ page }) => { @@ -61,7 +74,7 @@ test.describe('flow colour — the Colour section (FC-5)', () => { test('an edge takes the colour: line, its own arrow, its label border', async ({ page }) => { await load(page) // e2 (Gold → Spend): nothing crosses it in this fixture (s1 curves over e1) - await edgeHit(page, 'e2').click({ force: true }) + await clickEdge(page, 'e2') await swatch(page, 'Slate').click() expect(await accentOf(page, 'edges', 'e2')).toBe('#638EA5') const path = page.locator('.react-flow__edge[data-id="e2"] path.react-flow__edge-path') @@ -78,7 +91,7 @@ test.describe('flow colour — the Colour section (FC-5)', () => { await load(page) await node(page, 'src').click() await node(page, 'sink').click({ modifiers: ['Control'] }) - await edgeHit(page, 'e2').click({ force: true, modifiers: ['Control'] }) + await clickEdge(page, 'e2', ['Control']) const targets = await page.evaluate(() => { const g = (window as any).__loop.graph.getState() return { @@ -120,7 +133,7 @@ test.describe('flow colour — the Colour section (FC-5)', () => { // on a mixed selection, opening it applies nothing either await page.evaluate(() => (window as any).__loop.graph.getState().setAccent([], ['e2'], '#654321')) await node(page, 'gold').click() - await edgeHit(page, 'e2').click({ force: true, modifiers: ['Control'] }) + await clickEdge(page, 'e2', ['Control']) await expect(section(page).locator('.accent-field__mixed')).toBeVisible() await picker.click() await page.keyboard.press('Escape') @@ -297,7 +310,7 @@ test.describe('flow colour — drawing and state priority (FC-4)', () => { test('a selected coloured edge keeps its colour over the underlay; a state edge stays dashed', async ({ page }) => { await load(page) await page.evaluate(() => (window as any).__loop.graph.getState().setAccent([], ['e2', 's1'], '#9182A8')) - await edgeHit(page, 'e2').click({ force: true }) + await clickEdge(page, 'e2') const path = page.locator('.react-flow__edge[data-id="e2"] path.react-flow__edge-path') await expect(path).toHaveCSS('stroke', 'rgb(145, 130, 168)') await expect(path).toHaveCSS('stroke-width', '2px') diff --git a/e2e/forced-colors-edge-tell.spec.ts b/e2e/forced-colors-edge-tell.spec.ts index 7d04e903..c715883f 100644 --- a/e2e/forced-colors-edge-tell.spec.ts +++ b/e2e/forced-colors-edge-tell.spec.ts @@ -175,16 +175,51 @@ type Dash = { runs: number; inkShare: number; meanLen: number; n: number } * decode of this screenshot instead of re-sending the same PNG and the same * point array per window. The arithmetic below is unchanged — same band, same * background sample, 0.15 / reach 1 for scoring, `inkShare - 0.05 * runs`, - * width 80, stride 20, first window wins a tie. */ + * width 80, stride 20, first window wins a tie. + * + * issue #344 step 4 — with `edgeId`, a window more than a quarter of which + * lies within 3 px of ANOTHER edge's drawn path (a line running alongside; + * a crossing touches only a few points) is skipped: the scoring prefers continuous ink, + * and another line running alongside is continuous even while this edge is + * dashed, so the probe measured the neighbour (the placed MMO put a line next + * to `a_z1_enc_src_hi` at zoom 0.75). */ async function cleanStretch( page: Page, offPng: Buffer, pts: Pt[], offThr: number, offReach: number, + edgeId?: string, ): Promise<{ stretch: [number, number]; off: Dash } | null> { return page.evaluate( - async ({ b64, pts, offThr, offReach }) => { + async ({ b64, pts, offThr, offReach, edgeId }) => { + // every other edge's drawn path, in screen px, bucketed by 4 px + const near = new Map() + if (edgeId) { + for (const g of document.querySelectorAll('.react-flow__edge')) { + if (g.getAttribute('data-id') === edgeId) continue + const p = g.querySelector('path.react-flow__edge-path') as SVGPathElement | null + if (!p) continue + const m = p.getScreenCTM()! + const sc = Math.hypot(m.a, m.b) || 1 + const L = p.getTotalLength() + for (let s = 0; s <= L; s += 2 / sc) { + const q = p.getPointAtLength(s) + const x = m.a * q.x + m.c * q.y + m.e + const y = m.b * q.x + m.d * q.y + m.f + const k = `${Math.floor(x / 4)},${Math.floor(y / 4)}` + ;(near.get(k) ?? near.set(k, []).get(k)!).push({ x, y }) + } + } + } + const crowded = (q: { x: number; y: number }): boolean => { + const bx = Math.floor(q.x / 4) + const by = Math.floor(q.y / 4) + for (let dx = -1; dx <= 1; dx++) + for (let dy = -1; dy <= 1; dy++) + for (const o of near.get(`${bx + dx},${by + dy}`) ?? []) if (Math.hypot(o.x - q.x, o.y - q.y) < 3) return true + return false + } const im = new Image() im.src = `data:image/png;base64,${b64}` await im.decode() @@ -257,7 +292,10 @@ async function cleanStretch( let best: [number, number] | null = null let bestShare = -1 for (let a = 0; a + W <= pts.length; a += 20) { - const inside = pts.slice(a, a + W).every((p) => p.x > pane.left && p.x < pane.right && p.y > pane.top && p.y < pane.bottom) + const win = pts.slice(a, a + W) + const inside = win.every((p) => p.x > pane.left && p.x < pane.right && p.y > pane.top && p.y < pane.bottom) + // a crossing touches a few points; a line running alongside, many + if (edgeId && win.filter(crowded).length > W / 4) continue if (!inside) continue const { inkShare, runs } = scan(a, a + W, 0.15, 1) const score = inkShare - 0.05 * runs // a stretch under a label/crossing breaks into pieces @@ -269,7 +307,7 @@ async function cleanStretch( if (!best) return null return { stretch: best, off: scan(best[0], best[1], offThr, offReach) } }, - { b64: offPng.toString('base64'), pts, offThr, offReach }, + { b64: offPng.toString('base64'), pts, offThr, offReach, edgeId }, ) } @@ -424,7 +462,7 @@ test.describe('§LGR9 forced-colors — active edge tell: Highlight + butt dashe const offPng = await page.screenshot() const pts = await pathPoints(page, id) // reach-2 for the plain stroke: a solid line must count as ONE piece however its anti-aliasing breaks - const picked = await cleanStretch(page, offPng, pts, 0.15, 2) + const picked = await cleanStretch(page, offPng, pts, 0.15, 2, id) if (!picked) { // shorter than 80 screen px at this zoom — too few periods to count await activityBtn(page).click() @@ -498,7 +536,12 @@ test.describe('§LGR9 forced-colors — active edge tell: Highlight + butt dashe await activityBtn(page).click() await commitSteps(page, 10) for (const zoom of [0.4, 0.75]) { - for (const id of await longestActive(page, 'edge-state', 2)) { + // issue #344 step 4 — the two longest active state edges that HAVE an + // 80 px stretch clear of every other line (on the placed MMO a long one + // may run beside another line all the way at zoom 0.4), out of six + let measured = 0 + for (const id of await longestActive(page, 'edge-state', 6)) { + if (measured === 2) break await centreOn(page, id, zoom) await activityBtn(page).click() await page.waitForTimeout(150) @@ -507,8 +550,13 @@ test.describe('§LGR9 forced-colors — active edge tell: Highlight + butt dashe // a state edge is 1 px wide: a low presence threshold for the grey 4 4 (reach-2 for the plain // reference); the ON count uses plain 8-connectivity — fragmentation only raises the count // an activator-OFF state edge is painted at opacity 0.5 in the plain state → a very low presence threshold - const picked = await cleanStretch(page, offPng, pts, 0.06, 2) - expect(picked, `${id} z${zoom}: an 80 px stretch inside the pane`).not.toBeNull() + const picked = await cleanStretch(page, offPng, pts, 0.06, 2, id) + if (!picked) { + await activityBtn(page).click() // back ON for the next candidate + await page.waitForTimeout(150) + continue + } + measured++ const [a, b] = picked!.stretch const off = picked!.off await activityBtn(page).click() @@ -524,6 +572,7 @@ test.describe('§LGR9 forced-colors — active edge tell: Highlight + butt dashe expect(on.inkShare, `${id} z${zoom}: gaps survive`).toBeLessThan(0.9) expect(on.inkShare, `${id} z${zoom}: the tell is painted`).toBeGreaterThan(0.05) } + expect(measured, `z${zoom}: two active state edges measured`).toBe(2) } }) diff --git a/e2e/forced-colors-edge-tell.spec.ts-snapshots/forced-active-resource-coffee-z05-chromium-win32.png b/e2e/forced-colors-edge-tell.spec.ts-snapshots/forced-active-resource-coffee-z05-chromium-win32.png index 49f8bbd4..4dd66c74 100644 Binary files a/e2e/forced-colors-edge-tell.spec.ts-snapshots/forced-active-resource-coffee-z05-chromium-win32.png and b/e2e/forced-colors-edge-tell.spec.ts-snapshots/forced-active-resource-coffee-z05-chromium-win32.png differ diff --git a/e2e/forced-colors-edge-tell.spec.ts-snapshots/forced-four-state-mmo-z075-chromium-win32.png b/e2e/forced-colors-edge-tell.spec.ts-snapshots/forced-four-state-mmo-z075-chromium-win32.png index 9cb2ed8d..1bed824a 100644 Binary files a/e2e/forced-colors-edge-tell.spec.ts-snapshots/forced-four-state-mmo-z075-chromium-win32.png and b/e2e/forced-colors-edge-tell.spec.ts-snapshots/forced-four-state-mmo-z075-chromium-win32.png differ diff --git a/e2e/frame-a11y.spec.ts b/e2e/frame-a11y.spec.ts index 1436faf1..69e8ad5e 100644 --- a/e2e/frame-a11y.spec.ts +++ b/e2e/frame-a11y.spec.ts @@ -12,9 +12,10 @@ import { expect, importGraph, openApp, readRiskyFactory, resetAll, test } from ' // stops (the rename path is kept: select, Tab to the label, Enter); the // four edge hit-strips stay unfocusable and out of the accessibility tree. // • Enter / Space select; Escape deselects when no gesture is running. -// • Arrow keys move the frame by 5 px (Shift 20) and carry exactly what a -// pointer drag carries; the resize handle's arrows change width / height by -// the same steps. +// • Arrow keys move the frame by one grid step, 16 px (Shift 64), its +// top-left landing on the grid (issue #344 §DL3.4), and carry exactly what +// a pointer drag carries; the resize handle's arrows change width / height +// by the same steps, the corner landing on the grid. // • both gestures are ONE history transaction with the SAME lifetime as the // pointer one: captured on the first keydown, silent origin + accumulated // absolute Δ while keys repeat, ONE entry when the last arrow comes up and @@ -64,7 +65,7 @@ async function load(page: Page, opts: { locale?: string } = {}) { await page.evaluate(() => (document as unknown as { fonts: { ready: Promise } }).fonts.ready) } -const FRAME: Rect = { x: -60, y: -60, w: 520, h: 320 } +const FRAME: Rect = { x: -64, y: -64, w: 528, h: 320 } const container = (page: Page) => page.locator('.lgr-frame').first() const handle = (page: Page) => page.locator('.lgr-frame__resize').first() @@ -173,7 +174,28 @@ test.describe('frame accessibility — role, name and focus unit (§LGR6.6)', () }) test.describe('frame accessibility — keyboard move and resize (§LGR6.6)', () => { - test('Arrow moves the frame 5 px and Shift+Arrow 20 px, carrying the same contents a pointer drag carries', async ({ page }) => { + test('an off-grid frame lands on the grid with its first step, its contents carried by the same Δ', async ({ page }) => { + await load(page) + const id = await seedFrame(page, { x: -60, y: -60, w: 520, h: 320 }) + await container(page).focus() + await page.keyboard.press('Enter') + const r0 = await rectOf(page, id) + const p0 = await positions(page) + await page.keyboard.press('ArrowRight') + await page.waitForTimeout(200) + const r1 = await rectOf(page, id) + // −60 + 16 = −44 → −48; y −60 → −64: the top-left is on the grid, the size kept + expect(r1).toEqual({ x: -48, y: -64, w: 520, h: 320 }) + const p1 = await positions(page) + const moved = Object.keys(p0).filter((k) => p1[k].x !== p0[k].x || p1[k].y !== p0[k].y) + expect(moved.length).toBeGreaterThan(0) + for (const nid of moved) { + expect(p1[nid].x - p0[nid].x).toBe(r1.x - r0.x) + expect(p1[nid].y - p0[nid].y).toBe(r1.y - r0.y) + } + }) + + test('Arrow moves the frame one grid step (16 px) and Shift+Arrow four (64 px), carrying the same contents a pointer drag carries', async ({ page }) => { await load(page) const id = await seedFrame(page, FRAME) await container(page).focus() @@ -182,12 +204,12 @@ test.describe('frame accessibility — keyboard move and resize (§LGR6.6)', () const p0 = await positions(page) await page.keyboard.press('ArrowRight') await page.waitForTimeout(200) - expect((await rectOf(page, id)).x).toBe(r0.x + 5) + expect((await rectOf(page, id)).x).toBe(r0.x + 16) await page.keyboard.press('Shift+ArrowDown') await page.waitForTimeout(200) const r1 = await rectOf(page, id) - expect(r1.x).toBe(r0.x + 5) - expect(r1.y).toBe(r0.y + 20) + expect(r1.x).toBe(r0.x + 16) + expect(r1.y).toBe(r0.y + 64) // whatever the frame carried moved by EXACTLY the frame's own delta, and // every other node stayed put — the same all-or-nothing containment rule a // pointer drag uses (asserted without restating that rule here) @@ -195,8 +217,8 @@ test.describe('frame accessibility — keyboard move and resize (§LGR6.6)', () const moved = Object.keys(p0).filter((k) => p1[k].x !== p0[k].x || p1[k].y !== p0[k].y) expect(moved.length, 'the fixture frame carries nodes').toBeGreaterThan(0) for (const nid of moved) { - expect(p1[nid].x - p0[nid].x, `${nid} carried in x`).toBe(5) - expect(p1[nid].y - p0[nid].y, `${nid} carried in y`).toBe(20) + expect(p1[nid].x - p0[nid].x, `${nid} carried in x`).toBe(16) + expect(p1[nid].y - p0[nid].y, `${nid} carried in y`).toBe(64) } for (const nid of Object.keys(p0).filter((k) => !moved.includes(k))) { expect(p1[nid], `${nid} untouched`).toEqual(p0[nid]) @@ -300,20 +322,20 @@ test.describe('frame accessibility — keyboard move and resize (§LGR6.6)', () tag: el.tagName, tabIndex: (el as HTMLElement).tabIndex, name: el.getAttribute('aria-label'), role: el.getAttribute('role'), })) expect(meta.tabIndex).toBe(0) - expect(meta.name, 'the handle names itself and the current size').toMatch(/520/) + expect(meta.name, 'the handle names itself and the current size').toMatch(/528/) expect(meta.role === 'button' || meta.tag === 'BUTTON').toBe(true) const r0 = await rectOf(page, id) await handle(page).focus() await page.keyboard.press('ArrowRight') await page.waitForTimeout(200) - expect(await rectOf(page, id)).toEqual({ ...r0, w: r0.w + 5 }) + expect(await rectOf(page, id)).toEqual({ ...r0, w: r0.w + 16 }) await page.keyboard.press('Shift+ArrowDown') await page.waitForTimeout(200) - expect(await rectOf(page, id)).toEqual({ ...r0, w: r0.w + 5, h: r0.h + 20 }) + expect(await rectOf(page, id)).toEqual({ ...r0, w: r0.w + 16, h: r0.h + 64 }) await page.keyboard.press('ArrowLeft') await page.keyboard.press('ArrowUp') await page.waitForTimeout(250) - expect(await rectOf(page, id)).toEqual({ ...r0, w: r0.w, h: r0.h + 15 }) + expect(await rectOf(page, id)).toEqual({ ...r0, w: r0.w, h: r0.h + 48 }) }) test('a keyboard resize never moves the contents and is ONE entry per burst', async ({ page }) => { diff --git a/e2e/frame-props-popover.spec.ts b/e2e/frame-props-popover.spec.ts index f50ae5ff..187d8b5a 100644 --- a/e2e/frame-props-popover.spec.ts +++ b/e2e/frame-props-popover.spec.ts @@ -26,8 +26,9 @@ type Viewport = { x: number; y: number; zoom: number } const GACHA = readFileSync(new URL('../examples/gacha-banner-zones.json', import.meta.url), 'utf8') -/** the shipped rect of the frame this whole spec exists for */ -const PICKUP = { x: 2440, y: 330, w: 1790, h: 1000 } +/** the shipped rect of the frame this whole spec exists for (read from the + * file: issue #344 step 4 placed the Template, and a copied number goes stale) */ +const PICKUP = (JSON.parse(GACHA) as { frames: { id: string; rect: { x: number; y: number; w: number; h: number } }[] }).frames.find((f) => f.id === 'zone_pickup')!.rect const PICKUP_LABEL = 'Premium Pickup' const ui = (page: Page) => diff --git a/e2e/gacha-banner-zones.spec.ts b/e2e/gacha-banner-zones.spec.ts index ba7d7bb8..2607ff64 100644 --- a/e2e/gacha-banner-zones.spec.ts +++ b/e2e/gacha-banner-zones.spec.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs' import type { Page } from '@playwright/test' -import { expect, importGraph, openApp, resetAll, test } from './support/loop' +import { expect, importGraph, openApp, resetAll, routesSettled, test } from './support/loop' // docs/gacha-banner-zones.md (GZ) — the 3-zone gacha banner comparison // Template, exercised through the app. The engine-level acceptance (GZ8) is @@ -70,6 +70,10 @@ async function pickDesktopTemplate(page: Page, name: string) { .locator('.toolbar__actions .menu').first() .locator('.menu__pop [role="menuitem"]', { hasText: name }) .click() + // issue #344 §ER14.5 — its routes are drawn once their first sliced + // generation commits + await page.waitForFunction(() => (window as unknown as { __loop: any }).__loop.graph.getState().nodes.length > 0) + await routesSettled(page) } /** Step the live sim exactly `n` times (a fixed count, not "until ended" — @@ -361,6 +365,7 @@ test.describe('3-zone gacha banner comparison Template', () => { for (const locale of ['en', 'ko', 'ja'] as const) { await setLocale(page, locale) + await routesSettled(page) // issue #344 — the new label sizes route again for (const { id, text } of await labelTexts()) { expect(text, `${locale}: edge ${id} leaks an internal id ("${text}")`).not.toMatch(/@/) } @@ -449,6 +454,7 @@ test.describe('3-zone gacha banner comparison Template', () => { for (const locale of ['en', 'ko', 'ja'] as const) { await setLocale(page, locale) + await routesSettled(page) // issue #344 — the new label sizes route again const expected = ERROR_LABEL[locale] for (const { id, text } of await labelTexts(BROKEN_EDGE_IDS)) { expect(text, `${locale}: edge ${id} should show the translated error label`).toBe(expected) @@ -615,6 +621,7 @@ test.describe('3-zone gacha banner comparison Template', () => { for (const locale of ['en', 'ko', 'ja'] as const) { await setLocale(page, locale) + await routesSettled(page) // issue #344 — the new label sizes route again for (const zoneFrameId of ['zone_standard', 'zone_pickup'] as const) { const frame = frames.find((f) => f.id === zoneFrameId)! const cx = frame.rect.x + frame.rect.w / 2 @@ -655,24 +662,34 @@ test.describe('3-zone gacha banner comparison Template', () => { // #325 PR 3 (compact nodes), layout round 7 of // scripts/gen-gacha-banner-zones-example.ts — with 56 / 58 px nodes both // edges ran through the one band between the first two flow rows and their - // `+1` labels overlapped in ko. `e_pickup_36` now leaves its bottom port - // downward through one waypoint into the band under the second row. This - // pins the pair the broader every-label test above caught. + // `+1` labels overlapped in ko. Round 7 fixed it with a waypoint; issue + // #344 step 4 (round 9) placed the Template for the grid and made every + // connection Auto orthogonal, so the guarded router and the label slots + // now keep the pair apart with no waypoint. This pins the pair the broader + // every-label test above caught. await openApp(page) await resetAll(page) await pickDesktopTemplate(page, EN_NAME) await page.setViewportSize({ width: 1600, height: 900 }) - const waypoints = await page.evaluate( + const routing = await page.evaluate( () => - (window as unknown as { __loop: { graph: { getState: () => { edges: { id: string; data?: { waypoints?: unknown } }[] } } } }) + (window as unknown as { __loop: { graph: { getState: () => { edges: { id: string; data?: { route?: unknown; waypoints?: unknown } }[] } } } }) .__loop.graph.getState() - .edges.find((e) => e.id === 'e_pickup_36')?.data?.waypoints, + .edges.find((e) => e.id === 'e_pickup_36')?.data, ) - expect(waypoints).toEqual([{ x: 3200, y: 650 }]) + expect(routing?.route).toBe('orthogonal') + expect(routing?.waypoints, 'Auto: no bend points').toBeUndefined() for (const locale of ['en', 'ko', 'ja'] as const) { await setLocale(page, locale) - await page.evaluate(() => - (window as unknown as { __loop: { rf: { setViewport: (v: object) => void } } }).__loop.rf.setViewport({ x: 800 - 3200, y: 450 - 600, zoom: 1 }), + await routesSettled(page) // issue #344 — the new label sizes route again + // centre the pair (wherever the placement put it) + const at = await page.evaluate(() => { + const r = (window as unknown as { __loop: { routeMap: { get: (id: string) => { label: { x: number; y: number } | null; mid: { x: number; y: number } } | null } } }).__loop.routeMap.get('e_pickup_36')! + return r.label ?? r.mid + }) + await page.evaluate( + (p) => (window as unknown as { __loop: { rf: { setViewport: (v: object) => void } } }).__loop.rf.setViewport({ x: 800 - p.x, y: 450 - p.y, zoom: 1 }), + at, ) const boxes = await page.evaluate(() => ['e_pickup_34', 'e_pickup_36'].map((id) => { diff --git a/e2e/i18n-visual.spec.ts-snapshots/ko-desktop-app-chromium-win32.png b/e2e/i18n-visual.spec.ts-snapshots/ko-desktop-app-chromium-win32.png index 9fad723e..b467289b 100644 Binary files a/e2e/i18n-visual.spec.ts-snapshots/ko-desktop-app-chromium-win32.png and b/e2e/i18n-visual.spec.ts-snapshots/ko-desktop-app-chromium-win32.png differ diff --git a/e2e/i18n-visual.spec.ts-snapshots/ko-export-menu-chromium-win32.png b/e2e/i18n-visual.spec.ts-snapshots/ko-export-menu-chromium-win32.png index a6bffb96..0e2d3488 100644 Binary files a/e2e/i18n-visual.spec.ts-snapshots/ko-export-menu-chromium-win32.png and b/e2e/i18n-visual.spec.ts-snapshots/ko-export-menu-chromium-win32.png differ diff --git a/e2e/i18n-visual.spec.ts-snapshots/ko-long-label-and-tip-chromium-win32.png b/e2e/i18n-visual.spec.ts-snapshots/ko-long-label-and-tip-chromium-win32.png index 42503541..2b7e4f73 100644 Binary files a/e2e/i18n-visual.spec.ts-snapshots/ko-long-label-and-tip-chromium-win32.png and b/e2e/i18n-visual.spec.ts-snapshots/ko-long-label-and-tip-chromium-win32.png differ diff --git a/e2e/i18n-visual.spec.ts-snapshots/ko-mobile-app-chromium-win32.png b/e2e/i18n-visual.spec.ts-snapshots/ko-mobile-app-chromium-win32.png index 6790605f..8c8374ba 100644 Binary files a/e2e/i18n-visual.spec.ts-snapshots/ko-mobile-app-chromium-win32.png and b/e2e/i18n-visual.spec.ts-snapshots/ko-mobile-app-chromium-win32.png differ diff --git a/e2e/large-graph-readability.spec.ts b/e2e/large-graph-readability.spec.ts index 681fef2f..cffb3ec9 100644 --- a/e2e/large-graph-readability.spec.ts +++ b/e2e/large-graph-readability.spec.ts @@ -2316,9 +2316,14 @@ test.describe('§LGR9 forced-colors — rail toggles keep a visible keyboard foc await resetAll(page) await importGraph(page, readFileSync(new URL(`../examples/${file}`, import.meta.url), 'utf8')) await expect(page.locator('.react-flow__node').first()).toBeVisible() - await page.evaluate(() => - (window as unknown as { __loop: { rf: { fitView: (o: object) => void } } }).__loop.rf.fitView({ duration: 0, padding: 0.1 }), - ) + // frame the measured edges' own nodes (issue #344 step 4: fitting the + // whole, now larger, MMO graph made the zoom — and so the stroke's pixel + // coverage — depend on the graph's size, not on the edges under test) + await page.evaluate((ids) => { + const l = (window as unknown as { __loop: { rf: { fitView: (o: object) => void }; graph: { getState: () => { edges: { id: string; source: string; target: string }[] } } } }).__loop + const ends = l.graph.getState().edges.filter((e) => (ids as string[]).includes(e.id)).flatMap((e) => [e.source, e.target]) + l.rf.fitView({ duration: 0, padding: 0.1, nodes: [...new Set(ends)].map((id) => ({ id })) }) + }, [...edgeIds]) await page.waitForTimeout(300) for (let i = 0; i < 10; i++) await commitOneStep(page) await page.evaluate(() => (document as unknown as { fonts: { ready: Promise } }).fonts.ready) @@ -3409,8 +3414,9 @@ test.describe('LGR Slice 5 — saved frames (SF / loop-revision/5)', () => { // CARRIES its contents. Derived at pointer-down (full containment, like the // creation guard), nothing stored (R5-D3 holds), ONE undo entry per gesture // through an explicit transaction (never the 600 ms coalescing), Esc / -// pointercancel restore the origin with no entry, Alt+drag moves the frame -// alone, nothing changes under the edit-lock or on mobile. +// pointercancel restore the origin with no entry, Ctrl/⌘+drag moves the frame +// alone (Alt before issue #344, which made Alt a free move off the grid), +// nothing changes under the edit-lock or on mobile. // --------------------------------------------------------------------------- test.describe('LGR frame membership — a frame drag carries its contents (§LGR6.5 / LGR-D9)', () => { type Rect = { x: number; y: number; w: number; h: number } @@ -3441,23 +3447,28 @@ test.describe('LGR frame membership — a frame drag carries its contents (§LGR const shifted = (r: Rect, dx: number, dy: number): Rect => ({ x: r.x + dx, y: r.y + dy, w: r.w, h: r.h }) const strip = (page: Page, frameIndex: number) => page.locator('.lgr-frame').nth(frameIndex).locator('.lgr-frame__edge-hit--top') - /** a real pointer drag on a frame's TOP hit-strip by (dx, dy) screen px (zoom 1 ⇒ flow units). */ - async function dragStrip(page: Page, frameIndex: number, dx: number, dy: number, opts: { alt?: boolean; pauseMs?: number; midway?: () => Promise } = {}) { + /** a real pointer drag on a frame's TOP hit-strip by (dx, dy) screen px (zoom 1 ⇒ flow units). + * Membership is the subject here, so by default Alt is held — a FREE move + * (issue #344 §DL3.3), the frame moving by exactly (dx, dy); `snap` drops it + * to see the grid snap. `frameOnly` holds Ctrl: the frame moves alone. */ + async function dragStrip(page: Page, frameIndex: number, dx: number, dy: number, opts: { snap?: boolean; frameOnly?: boolean; pauseMs?: number; midway?: () => Promise } = {}) { const b = (await strip(page, frameIndex).boundingBox())! const x0 = b.x + Math.min(40, b.width / 2) const y0 = b.y + b.height / 2 - if (opts.alt) await page.keyboard.down('Alt') + const keys = [...(opts.snap ? [] : ['Alt']), ...(opts.frameOnly ? ['Control'] : [])] + for (const k of keys) await page.keyboard.down(k) await page.mouse.move(x0, y0) await page.mouse.down() await page.mouse.move(x0 + dx / 2, y0 + dy / 2, { steps: 4 }) if (opts.pauseMs) await page.waitForTimeout(opts.pauseMs) if (opts.midway) { await opts.midway() + for (const k of keys) await page.keyboard.up(k) return } await page.mouse.move(x0 + dx, y0 + dy, { steps: 4 }) await page.mouse.up() - if (opts.alt) await page.keyboard.up('Alt') + for (const k of keys) await page.keyboard.up(k) } // the fixture: a(0,0) b(260,0) c(520,0) d(780,0) mid(260,170) ma(0,170) mc(520,170) lone(780,200) @@ -3470,12 +3481,16 @@ test.describe('LGR frame membership — a frame drag carries its contents (§LGR const inner = await seedFrame(page, INNER) const p0 = await positions(page) const h0 = await history(page) - await dragStrip(page, 0, 60, 40) // index 0 = outer (creation order) + // a SNAPPED drag (issue #344 §DL3.2): the pointer moves (60, 40), the + // outer top-left (−40, −40) + (60, 40) = (20, 0) lands on the grid at + // (16, 0), so the Δ everything rides by is (56, 40) + await dragStrip(page, 0, 60, 40, { snap: true }) // index 0 = outer (creation order) + const D = { x: 56, y: 40 } const p1 = await positions(page) - for (const id of ['a', 'b', 'c', 'mid', 'ma', 'mc']) expect(p1[id], `${id} carried`).toEqual({ x: p0[id].x + 60, y: p0[id].y + 40 }) + for (const id of ['a', 'b', 'c', 'mid', 'ma', 'mc']) expect(p1[id], `${id} carried`).toEqual({ x: p0[id].x + D.x, y: p0[id].y + D.y }) for (const id of ['d', 'lone']) expect(p1[id], `${id} left alone`).toEqual(p0[id]) - expect(await rectOf(page, outer)).toEqual(shifted(OUTER, 60, 40)) - expect(await rectOf(page, inner), 'the nested frame rides along').toEqual(shifted(INNER, 60, 40)) + expect(await rectOf(page, outer)).toEqual(shifted(OUTER, D.x, D.y)) + expect(await rectOf(page, inner), 'the nested frame rides along').toEqual(shifted(INNER, D.x, D.y)) // the edges between carried nodes re-rendered (route map rebuilt) — the path still exists await expect(edge(page, 'e_ab').locator('path.react-flow__edge-path')).toHaveCount(1) const h1 = await history(page) @@ -3555,7 +3570,7 @@ test.describe('LGR frame membership — a frame drag carries its contents (§LGR } }) - test('Alt+drag moves the frame ONLY — nodes, the nested frame and waypoints stay', async ({ page }) => { + test('Ctrl+drag (⌘ on a Mac) moves the frame ONLY — nodes, the nested frame and waypoints stay', async ({ page }) => { await load(page) const outer = await seedFrame(page, OUTER) const inner = await seedFrame(page, INNER) @@ -3566,7 +3581,7 @@ test.describe('LGR frame membership — a frame drag carries its contents (§LGR }) const p0 = await positions(page) const h0 = await history(page) - await dragStrip(page, 0, 70, 20, { alt: true }) + await dragStrip(page, 0, 70, 20, { frameOnly: true }) expect(await rectOf(page, outer)).toEqual(shifted(OUTER, 70, 20)) expect(await rectOf(page, inner)).toEqual(INNER) expect(await positions(page)).toEqual(p0) @@ -3697,12 +3712,12 @@ test.describe('LGR frame membership — a frame drag carries its contents (§LGR const notes = page.locator('.hint-note') await expect(notes).toHaveCount(1) await expect(notes.first()).toContainText(/Sheet1/) - await expect(notes.first()).not.toContainText(/Alt/) + await expect(notes.first()).not.toContainText(/Ctrl/) expect(await page.evaluate(() => JSON.parse(localStorage.getItem('loop-studio/contextual-help/1') ?? '{}')), 'the hidden frame-move note is NOT consumed').not.toHaveProperty('frame-move') // close the import note; the frame is still selected → the frame-move note takes the slot await notes.first().locator('.hint-note__x').click() expect((await frameHead(page)).selectedId).not.toBeNull() - await expect(page.locator('.hint-note', { hasText: /Alt/ })).toHaveCount(1) + await expect(page.locator('.hint-note', { hasText: /Ctrl/ })).toHaveCount(1) await expect(page.locator('.hint-note')).toHaveCount(1) expect(await page.evaluate(() => JSON.parse(localStorage.getItem('loop-studio/contextual-help/1') ?? '{}'))).toMatchObject({ 'frame-move': true }) }) @@ -3753,7 +3768,7 @@ test.describe('LGR frame membership — a frame drag carries its contents (§LGR expect((await frameHead(page)).frames.map((f) => f.id)).toEqual([manual]) }) - test('the first time a frame is selected a one-time note explains "edge drag carries contents / Alt+drag frame only"; it is listed in the contextual-tips dialog', async ({ page }) => { + test('the first time a frame is selected a one-time note explains "edge drag carries contents / Ctrl+drag frame only"; it is listed in the contextual-tips dialog', async ({ page }) => { await load(page) await page.evaluate(() => localStorage.removeItem('loop-studio/contextual-help/1')) await page.reload() @@ -3764,10 +3779,10 @@ test.describe('LGR frame membership — a frame drag carries its contents (§LGR await strip(page, 0).click({ position: { x: 10, y: 6 } }) const note = page.locator('.hint-note') await expect(note).toHaveCount(1) - await expect(note).toContainText(/Alt/) + await expect(note).toContainText(/Ctrl/) await note.locator('.hint-note__x').click() // closing it hands the slot back to whatever tier-3 note was waiting — the frame note itself is gone - await expect(page.locator('.hint-note', { hasText: /Alt/ })).toHaveCount(0) + await expect(page.locator('.hint-note', { hasText: /Ctrl/ })).toHaveCount(0) expect(await page.evaluate(() => JSON.parse(localStorage.getItem('loop-studio/contextual-help/1') ?? '{}'))).toMatchObject({ 'frame-move': true }) }) }) diff --git a/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-chromium-win32.png b/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-chromium-win32.png index 7d7317ae..1d8d5861 100644 Binary files a/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-chromium-win32.png and b/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-chromium-win32.png differ diff --git a/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-mixed-chromium-win32.png b/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-mixed-chromium-win32.png index 0258e7b4..3919fcb1 100644 Binary files a/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-mixed-chromium-win32.png and b/e2e/large-graph-readability.spec.ts-snapshots/auto-frames-mixed-chromium-win32.png differ diff --git a/e2e/large-graph-readability.spec.ts-snapshots/frame-colours-chromium-win32.png b/e2e/large-graph-readability.spec.ts-snapshots/frame-colours-chromium-win32.png index 97c2eb97..e54ae279 100644 Binary files a/e2e/large-graph-readability.spec.ts-snapshots/frame-colours-chromium-win32.png and b/e2e/large-graph-readability.spec.ts-snapshots/frame-colours-chromium-win32.png differ diff --git a/e2e/large-graph-readability.spec.ts-snapshots/frame-colours-dark-chromium-win32.png b/e2e/large-graph-readability.spec.ts-snapshots/frame-colours-dark-chromium-win32.png index 5db57338..6da63fcf 100644 Binary files a/e2e/large-graph-readability.spec.ts-snapshots/frame-colours-dark-chromium-win32.png and b/e2e/large-graph-readability.spec.ts-snapshots/frame-colours-dark-chromium-win32.png differ diff --git a/e2e/large-graph-readability.spec.ts-snapshots/frame-colours-forced-chromium-win32.png b/e2e/large-graph-readability.spec.ts-snapshots/frame-colours-forced-chromium-win32.png index 8fb7cac5..0b795ed8 100644 Binary files a/e2e/large-graph-readability.spec.ts-snapshots/frame-colours-forced-chromium-win32.png and b/e2e/large-graph-readability.spec.ts-snapshots/frame-colours-forced-chromium-win32.png differ diff --git a/e2e/large-graph-readability.spec.ts-snapshots/frame-colours-overlap-chromium-win32.png b/e2e/large-graph-readability.spec.ts-snapshots/frame-colours-overlap-chromium-win32.png index 09ceef87..2394fac8 100644 Binary files a/e2e/large-graph-readability.spec.ts-snapshots/frame-colours-overlap-chromium-win32.png and b/e2e/large-graph-readability.spec.ts-snapshots/frame-colours-overlap-chromium-win32.png differ diff --git a/e2e/large-graph-readability.spec.ts-snapshots/frames-activity-chromium-win32.png b/e2e/large-graph-readability.spec.ts-snapshots/frames-activity-chromium-win32.png index 534b188d..cd8fac6d 100644 Binary files a/e2e/large-graph-readability.spec.ts-snapshots/frames-activity-chromium-win32.png and b/e2e/large-graph-readability.spec.ts-snapshots/frames-activity-chromium-win32.png differ diff --git a/e2e/large-graph-readability.spec.ts-snapshots/run-distinction-states-chromium-win32.png b/e2e/large-graph-readability.spec.ts-snapshots/run-distinction-states-chromium-win32.png index 306ae8d9..03b53b8d 100644 Binary files a/e2e/large-graph-readability.spec.ts-snapshots/run-distinction-states-chromium-win32.png and b/e2e/large-graph-readability.spec.ts-snapshots/run-distinction-states-chromium-win32.png differ diff --git a/e2e/minimap-responsive.spec.ts b/e2e/minimap-responsive.spec.ts index 80eb34f4..3eaf1767 100644 --- a/e2e/minimap-responsive.spec.ts +++ b/e2e/minimap-responsive.spec.ts @@ -202,6 +202,21 @@ const onScreen = (p: Page, id: string) => return b.right > a.left + 2 && b.left < a.right - 2 && b.bottom > a.top + 2 && b.top < a.bottom - 2 }, id) +// issue #344 (docs/diagram-layout.md §DL5.6) — the MMO opening view's core +// start nodes open WHOLE (inside the pane, not merely touching it); returns the +// ones that do not +const MMO_CORE = ['char_creation', 'active_char', 'z1_enc_src', 'z1_enc'] +const notWhole = (p: Page) => + p.evaluate((ids) => { + const a = document.querySelector('.react-flow')!.getBoundingClientRect() + return ids.filter((nid) => { + const el = document.querySelector(`.react-flow__node[data-id="${nid}"]`) + if (!el) return true + const b = el.getBoundingClientRect() + return !(b.left >= a.left - 0.5 && b.right <= a.right + 0.5 && b.top >= a.top - 0.5 && b.bottom <= a.bottom + 0.5) + }) + }, MMO_CORE) + test.describe('minimap — the collapse / restore control', () => { test.beforeEach(async ({ page }) => { await openApp(page) @@ -340,15 +355,20 @@ test.describe('minimap — the collapse / restore control', () => { expect(await onScreen(page, 'char_creation')).toBe(true) expect(await onScreen(page, 'z1_enc')).toBe(true) expect(await onScreen(page, 'end15')).toBe(false) - // sanity: the framed early band does not sit under the minimap - const clearOfMinimap = await page.evaluate(() => { + expect(await notWhole(page)).toEqual([]) + // sanity: the framed early band does not sit under the minimap — the two + // screen rects collide only when they overlap on BOTH axes (issue #344: + // the opening view lets the top row run past the minimap's column, above it) + const underMinimap = await page.evaluate(() => { const m = document.querySelector('.react-flow__minimap')!.getBoundingClientRect() - return ['char_creation', 'active_char', 'z1_enc'].every((id) => { + return ['char_creation', 'active_char', 'z1_enc'].filter((id) => { const el = document.querySelector(`.react-flow__node[data-id="${id}"]`) - return !el || el.getBoundingClientRect().right <= m.left + 1 + if (!el) return false + const r = el.getBoundingClientRect() + return r.left < m.right - 1 && m.left < r.right - 1 && r.top < m.bottom - 1 && m.top < r.bottom - 1 }) }) - expect(clearOfMinimap).toBe(true) + expect(underMinimap).toEqual([]) // collapse, re-open the same Template at the same pane size await toggleBtn(page).click() @@ -358,11 +378,13 @@ test.describe('minimap — the collapse / restore control', () => { expect(ivCollapsed!.insetR).toBe(0) // no gap for an invisible minimap expect(ivCollapsed!.insetB).toBe(0) expect(ivCollapsed!.zoom).toBeGreaterThanOrEqual(MMO_MIN_ZOOM - 1e-6) - // a wider usable width ⇒ same-or-larger fit zoom for the same rect + // a wider usable width ⇒ same-or-larger fit zoom for the same rect, capped + // so the core start nodes stay whole (the `keep` rect) expect(ivCollapsed!.zoom).toBeGreaterThanOrEqual(ivExpanded!.zoom - 1e-6) expect(await onScreen(page, 'char_creation')).toBe(true) expect(await onScreen(page, 'z1_enc')).toBe(true) expect(await onScreen(page, 'end15')).toBe(false) + expect(await notWhole(page)).toEqual([]) }) test('the toggle is keyboard operable with a visible focus ring', async ({ page }) => { diff --git a/e2e/minimap.spec.ts-snapshots/minimap-dark-chromium-win32.png b/e2e/minimap.spec.ts-snapshots/minimap-dark-chromium-win32.png index 5955adcf..cb8a0c0c 100644 Binary files a/e2e/minimap.spec.ts-snapshots/minimap-dark-chromium-win32.png and b/e2e/minimap.spec.ts-snapshots/minimap-dark-chromium-win32.png differ diff --git a/e2e/minimap.spec.ts-snapshots/minimap-light-chromium-win32.png b/e2e/minimap.spec.ts-snapshots/minimap-light-chromium-win32.png index 45e15d48..55862e52 100644 Binary files a/e2e/minimap.spec.ts-snapshots/minimap-light-chromium-win32.png and b/e2e/minimap.spec.ts-snapshots/minimap-light-chromium-win32.png differ diff --git a/e2e/model-nodes-visual.spec.ts-snapshots/register-unit-row-chromium-win32.png b/e2e/model-nodes-visual.spec.ts-snapshots/register-unit-row-chromium-win32.png index 4fc82d02..aafe6fea 100644 Binary files a/e2e/model-nodes-visual.spec.ts-snapshots/register-unit-row-chromium-win32.png and b/e2e/model-nodes-visual.spec.ts-snapshots/register-unit-row-chromium-win32.png differ diff --git a/e2e/node-keyboard-move.spec.ts b/e2e/node-keyboard-move.spec.ts index ce889a9c..aec9f5db 100644 --- a/e2e/node-keyboard-move.spec.ts +++ b/e2e/node-keyboard-move.spec.ts @@ -17,9 +17,11 @@ const ORTHOGONAL = readFileSync(resolve(import.meta.dirname, '..', 'examples', ' // while the position only *looked* restored (audit 2026-09-20). // // The contract pinned here: a Canvas capture-phase arrow keydown opens the same -// transaction the frame gestures use — React Flow keeps doing the movement and -// the announcement — and exactly ONE entry is pushed when the last arrow comes -// up and the positions really changed. The transaction opens ONLY for an arrow +// transaction the frame gestures use, and exactly ONE entry is pushed when the +// last arrow comes up and the positions really changed. Since issue #344 +// (docs/diagram-layout.md §DL3.4) the capture handler also MAKES the move — +// one 16 px grid step, Shift 64, onto the grid — and the announcement React +// Flow used to make; React Flow's own 5 / 20 px step never runs. The transaction opens ONLY for an arrow // aimed at a real, selected, draggable React Flow node; a frame, a button, a // text field, a dialog, the locked canvas and mobile never open one. Escape // restores the origin, pushes nothing, keeps the selection and announces the @@ -39,6 +41,7 @@ type Bridge = { redo: () => void onNodesChange: (c: unknown[]) => void updateNodeData: (id: string, d: unknown) => void + tidyToGrid: () => number } } frame: { getState: () => { frames: { id: string; rect: { x: number; y: number; w: number; h: number } }[]; addFrame: (r: { x: number; y: number; w: number; h: number }) => string; selectFrame: (id: string | null) => void } } @@ -68,6 +71,9 @@ async function load(page: Page, fixture = readRiskyFactory()) { await importGraph(page, fixture) await expect(page.locator('.react-flow__node').first()).toBeVisible() await page.evaluate(() => (window as unknown as Bridge).__loop.rf.setViewport({ x: 200, y: 200, zoom: 1 }, { duration: 0 })) + // issue #344 §DL3 — the scene on the grid first, so every arrow step is + // exactly one grid step (16 px, Shift 64) from a node already on the grid + await page.evaluate(() => (window as unknown as Bridge).__loop.graph.getState().tidyToGrid()) await page.waitForTimeout(250) } @@ -96,7 +102,7 @@ test.describe('node keyboard move — one gesture, one undo entry (§LGR6.7 / F2 await page.keyboard.press('ArrowRight') await page.waitForTimeout(300) const a = await state(page) - expect(a.pos[id].x - b.pos[id].x, 'React Flow still moves it 5 px').toBe(5) + expect(a.pos[id].x - b.pos[id].x, 'one grid step (#344)').toBe(16) expect(a.past - b.past, 'one press = one entry').toBe(1) expect(a.simRev, 'a position is never engine content').toBe(b.simRev) await page.evaluate(() => (window as unknown as Bridge).__loop.graph.getState().undo()) @@ -111,7 +117,7 @@ test.describe('node keyboard move — one gesture, one undo entry (§LGR6.7 / F2 await holdArrow(page, 'ArrowRight', 5) const a1 = await state(page) expect(a1.past - b.past, 'the whole burst is one entry').toBe(1) - expect(a1.pos[id].x - b.pos[id].x).toBe(25) + expect(a1.pos[id].x - b.pos[id].x).toBe(80) await holdArrow(page, 'ArrowRight', 2) const a2 = await state(page) expect(a2.past - a1.past, 'a second burst is its own entry').toBe(1) @@ -120,7 +126,7 @@ test.describe('node keyboard move — one gesture, one undo entry (§LGR6.7 / F2 expect((await state(page)).pos[id].x, 'one undo removes only the second burst').toBe(a1.pos[id].x) }) - test('Shift accelerates to 20 px inside the same gesture', async ({ page }) => { + test('Shift moves four grid steps (64 px) inside the same gesture', async ({ page }) => { await load(page) const id = await focusAndSelect(page) const b = await state(page) @@ -131,7 +137,7 @@ test.describe('node keyboard move — one gesture, one undo entry (§LGR6.7 / F2 await page.keyboard.up('Shift') await page.waitForTimeout(250) const a = await state(page) - expect(a.pos[id].x - b.pos[id].x).toBe(20) + expect(a.pos[id].x - b.pos[id].x).toBe(64) expect(a.past - b.past).toBe(1) }) @@ -208,7 +214,7 @@ test.describe('node keyboard move — one gesture, one undo entry (§LGR6.7 / F2 const labelBefore = await page.evaluate(() => (window as unknown as Bridge).__loop.graph.getState().nodes[1].data!.label) expect(labelBefore).toBe('MARKER') await holdArrow(page, 'ArrowRight', 4) - expect((await state(page)).pos[id].x).toBe(b.pos[id].x + 20) + expect((await state(page)).pos[id].x).toBe(b.pos[id].x + 64) await page.evaluate(() => (window as unknown as Bridge).__loop.graph.getState().undo()) await page.waitForTimeout(300) expect((await state(page)).pos[id], 'the move is undone').toEqual(b.pos[id]) @@ -232,7 +238,7 @@ test.describe('node keyboard move — one gesture, one undo entry (§LGR6.7 / F2 await holdArrow(page, 'ArrowRight', 3) const a = await state(page) expect(a.past - b.past, 'one entry for the whole selection').toBe(1) - for (const id of ids) expect(a.pos[id].x - b.pos[id].x, `${id} moved`).toBe(15) + for (const id of ids) expect(a.pos[id].x - b.pos[id].x, `${id} moved`).toBe(48) for (const id of Object.keys(b.pos).filter((k) => !ids.includes(k))) { expect(a.pos[id], `${id} untouched`).toEqual(b.pos[id]) } @@ -241,7 +247,7 @@ test.describe('node keyboard move — one gesture, one undo entry (§LGR6.7 / F2 for (const id of ids) expect((await state(page)).pos[id]).toEqual(b.pos[id]) await page.evaluate(() => (window as unknown as Bridge).__loop.graph.getState().redo()) await page.waitForTimeout(300) - for (const id of ids) expect((await state(page)).pos[id].x).toBe(b.pos[id].x + 15) + for (const id of ids) expect((await state(page)).pos[id].x).toBe(b.pos[id].x + 48) }) test('a move touches no waypoint, no frame rect and no simulationRev', async ({ page }) => { @@ -362,18 +368,18 @@ test.describe('node keyboard move — what must NOT open a transaction (§LGR6.7 // React Flow's node handler has NO modifier gate: every combination moves, // and only Shift changes the step (measured 2026-09-20). for (const [combo, step] of [ - ['ArrowRight', 5], - ['Shift+ArrowRight', 20], - ['Control+ArrowRight', 5], - ['Alt+ArrowRight', 5], - ['Meta+ArrowRight', 5], - ['Control+Shift+ArrowRight', 20], + ['ArrowRight', 16], + ['Shift+ArrowRight', 64], + ['Control+ArrowRight', 16], + ['Alt+ArrowRight', 16], + ['Meta+ArrowRight', 16], + ['Control+Shift+ArrowRight', 64], ] as const) { const b = await state(page) await page.keyboard.press(combo) await page.waitForTimeout(300) const a = await state(page) - expect(a.pos[id].x - b.pos[id].x, `${combo}: React Flow's step`).toBe(step) + expect(a.pos[id].x - b.pos[id].x, `${combo}: the grid step`).toBe(step) expect(a.past - b.past, `${combo}: a move React Flow makes is a move we record`).toBe(1) } }) @@ -445,7 +451,7 @@ test.describe('node keyboard move — orthogonal routing (§LGR6.7 / §ER3.9)', await holdArrow(page, 'ArrowRight', 3) const gen1 = await page.evaluate(() => (window as unknown as Bridge).__loop.routeMap.genCount()) const a = await state(page) - expect(a.pos[id].x - b.pos[id].x, 'the node moved').toBe(15) + expect(a.pos[id].x - b.pos[id].x, 'the node moved').toBe(48) expect(gen1, 'the route map followed the move').toBeGreaterThan(gen0) expect(a.past - b.past, 'and it is one entry').toBe(1) // the commit at keyup must not force another rebuild on top of the last move diff --git a/e2e/node-long-label.spec.ts b/e2e/node-long-label.spec.ts index cabf7451..a67191a7 100644 --- a/e2e/node-long-label.spec.ts +++ b/e2e/node-long-label.spec.ts @@ -1,12 +1,13 @@ import type { Page } from '@playwright/test' +import { PORT_ROW } from '../src/model/layout/grid' import { expect, importGraph, openApp, resetAll, test } from './support/loop' // docs/mmo-multilingual-layout.md §MML1 — the general node renderer must keep a // long title inside the box: // • an official-length label wraps to AT MOST TWO lines around the ~135 px // soft-max, the box GROWS in height to fit (never past the per-kind -// ceiling), the title stays clear of the value / sub rows, and the handles -// re-centre on the grown box; +// ceiling), the title stays clear of the value / sub rows, and the side +// handles stay on the 28 px port row of the grown box (issue #344 §DL1); // • an abnormally long SINGLE token (no break opportunity) is the last-resort // case — it may force-break, but it must never spill outside the silhouette; // • a pathologically long multi-word label may wrap to many lines, but the @@ -26,7 +27,7 @@ const HUGE_TOKEN = 'Supercalifragilisticexpialidocious' + 'antidisestablishmenta // because its glyphs depend on the runner's installed OS fonts. The guarantees // are the same as any long label: the full string is kept on the element, it // never spills sideways out of the vessel, the silhouette grows to fit, and the -// resource handles re-centre on the grown box. +// resource handles stay on the port row. const MULTISCRIPT = "Trésor d'or — a deliberately very long label that overflows · 黄金の保管庫 · Хранилище · 🪙" const GRAPH = JSON.stringify({ @@ -44,6 +45,20 @@ const GRAPH = JSON.stringify({ const node = (page: Page, id: string) => page.locator(`.react-flow__node[data-id="${id}"]`) +/** each side (resource) port's centre below the node top, in flow px */ +const sidePorts = (page: Page, id: string) => + page.evaluate((nid) => { + const wrap = document.querySelector(`.react-flow__node[data-id="${nid}"]`) as HTMLElement + const nf = wrap.querySelector('.nodef') as HTMLElement + const nfR = nf.getBoundingClientRect() + const zoom = nfR.height / nf.offsetHeight + const ports = [...wrap.querySelectorAll('.h--in, .h--out')].map((h) => { + const r = h.getBoundingClientRect() + return ((r.top + r.bottom) / 2 - nfR.top) / zoom + }) + return { ports, nfH: nf.offsetHeight, wrapH: wrap.offsetHeight } + }, id) + async function box(page: Page, id: string) { return page.evaluate((nid) => { const wrap = document.querySelector(`.react-flow__node[data-id="${nid}"]`)! @@ -55,6 +70,7 @@ async function box(page: Page, id: string) { const lineH = parseFloat(getComputedStyle(title).lineHeight) || 16 return { boxW: nf.offsetWidth, + cssW: parseFloat(getComputedStyle(nf).width), boxH: nf.offsetHeight, titleLines: Math.round(title.offsetHeight / lineH), titleScrollW: title.scrollWidth, @@ -67,6 +83,14 @@ async function box(page: Page, id: string) { }, id) } +// #337 — a Pool's outline is drawn for its own width (`0 0 w h`, w the node's +// CSS width), so its fixed-depth slant never stretches with a wide title +const drawnFor = (vb: string | null, b: { cssW: number; boxH: number }, what?: string) => { + const [x, y, w, h] = (vb ?? '').split(' ').map(Number) + expect([x, y, h], what).toEqual([0, 0, b.boxH]) + expect(Math.abs(w - b.cssW), `${what ?? 'viewBox'} width ${w} vs the node's ${b.cssW}`).toBeLessThan(0.01) +} + test.describe('§MML1 — long node labels stay inside the box', () => { test.beforeEach(async ({ page }) => { await openApp(page) @@ -91,7 +115,7 @@ test.describe('§MML1 — long node labels stay inside the box', () => { const b = await box(page, 'short') expect(b.titleLines).toBe(1) expect(b.boxH).toBe(58) - expect(b.viewBox).toBe('0 0 120 58') + drawnFor(b.viewBox, b) }) test('an official-length label wraps to ≤ 2 lines and the box grows to fit', async ({ page }) => { @@ -101,7 +125,7 @@ test.describe('§MML1 — long node labels stay inside the box', () => { expect(b.titleScrollW, 'no sideways overflow').toBeLessThanOrEqual(b.titleClientW + 1) expect(b.boxH, 'box grew past the base').toBeGreaterThan(64) expect(b.boxH, 'but not past the pool ceiling (MAX_NODE_H.pool)').toBeLessThanOrEqual(132) - expect(b.viewBox, 'silhouette redrawn at the grown height').toBe(`0 0 120 ${b.boxH}`) + drawnFor(b.viewBox, b, 'silhouette redrawn at the grown height') expect(b.titleRect!.bottom, 'title clears the value row').toBeLessThanOrEqual(b.valueRect!.top + 0.5) expect(b.titleRect!.right, 'title stays inside the box').toBeLessThanOrEqual(b.nfRect!.right + 1) }) @@ -114,7 +138,7 @@ test.describe('§MML1 — long node labels stay inside the box', () => { expect(b.titleRect!.left).toBeGreaterThanOrEqual(b.nfRect!.left - 1) expect(b.titleRect!.right).toBeLessThanOrEqual(b.nfRect!.right + 1) expect(b.titleRect!.bottom).toBeLessThanOrEqual(b.nfRect!.bottom + 1) - expect(b.viewBox).toBe(`0 0 120 ${b.boxH}`) + drawnFor(b.viewBox, b) expect(b.boxH).toBeLessThanOrEqual(132) }) @@ -126,29 +150,21 @@ test.describe('§MML1 — long node labels stay inside the box', () => { expect(b.titleScrollW).toBeLessThanOrEqual(b.titleClientW + 2) expect(b.titleRect!.left).toBeGreaterThanOrEqual(b.nfRect!.left - 1) expect(b.titleRect!.right).toBeLessThanOrEqual(b.nfRect!.right + 1) - expect(b.viewBox).toBe(`0 0 120 ${b.boxH}`) + drawnFor(b.viewBox, b) expect(b.boxH).toBeLessThanOrEqual(132) }) - test('handles re-centre on the grown box; the selection ring uses the grown height', async ({ page }) => { + test('the side handles stay on the port row of the grown box; the selection ring uses the grown height', async ({ page }) => { const b = await box(page, 'official') - // the left/right RESOURCE ports (not the top/bottom state ports) sit on the - // vertical centre of the GROWN box - const m = await page.evaluate(() => { - const wrap = document.querySelector('.react-flow__node[data-id="official"]') as HTMLElement - const nf = wrap.querySelector('.nodef') as HTMLElement - const nfR = nf.getBoundingClientRect() - const ports = [...wrap.querySelectorAll('.h--in, .h--out')].map((h) => { - const r = h.getBoundingClientRect() - return (r.top + r.bottom) / 2 - nfR.top - }) - return { ports, nfH: nf.offsetHeight, wrapH: wrap.offsetHeight } - }) + // the left/right RESOURCE ports (not the top/bottom state ports) stay on + // the port row 28 px from the top of the GROWN box — it grows downward + const m = await sidePorts(page, 'official') expect(m.ports.length).toBeGreaterThan(0) // the RF node wrapper tracks the grown box height… expect(Math.abs(m.wrapH - m.nfH)).toBeLessThanOrEqual(2) - // …and each resource port is on that grown box's vertical centre - for (const cy of m.ports) expect(Math.abs(cy - b.boxH / 2)).toBeLessThanOrEqual(8) + // …and each resource port is on the port row, well above the grown centre + for (const cy of m.ports) expect(Math.abs(cy - PORT_ROW)).toBeLessThanOrEqual(1.5) + expect(b.boxH / 2 - PORT_ROW).toBeGreaterThan(8) await node(page, 'official').click() await expect(node(page, 'official').locator('.nodef__sel')).toBeVisible() @@ -157,10 +173,10 @@ test.describe('§MML1 — long node labels stay inside the box', () => { .querySelector('.react-flow__node[data-id="official"] .nodef__shape')! .getAttribute('viewBox'), ) - expect(selVB).toBe(`0 0 120 ${b.boxH}`) + drawnFor(selVB, b, 'the selection ring') }) - test('a multi-script / CJK / Cyrillic / emoji label — full string kept, no spill, vessel grows, handles re-centre', async ({ page }) => { + test('a multi-script / CJK / Cyrillic / emoji label — full string kept, no spill, vessel grows, handles stay on the port row', async ({ page }) => { // bring the multiscript node (flow y 840) fully on screen at zoom 1 await page.evaluate(() => (window as unknown as { __loop: { rf: { setViewport: (v: object, o: object) => void } } }).__loop.rf.setViewport( @@ -181,22 +197,13 @@ test.describe('§MML1 — long node labels stay inside the box', () => { expect(b.titleRect!.bottom).toBeLessThanOrEqual(b.nfRect!.bottom + 1) expect(b.boxH, 'the vessel grew to fit').toBeGreaterThan(64) expect(b.boxH, 'but not past the pool ceiling').toBeLessThanOrEqual(132) - expect(b.viewBox, 'silhouette redrawn at the grown height').toBe(`0 0 120 ${b.boxH}`) + drawnFor(b.viewBox, b, 'silhouette redrawn at the grown height') - // the resource ports sit on the vertical centre of the GROWN box - const m = await page.evaluate(() => { - const wrap = document.querySelector('.react-flow__node[data-id="multiscript"]') as HTMLElement - const nf = wrap.querySelector('.nodef') as HTMLElement - const nfR = nf.getBoundingClientRect() - const ports = [...wrap.querySelectorAll('.h--in, .h--out')].map((h) => { - const r = h.getBoundingClientRect() - return (r.top + r.bottom) / 2 - nfR.top - }) - return { ports, nfH: nf.offsetHeight, wrapH: wrap.offsetHeight } - }) + // the resource ports stay on the port row of the GROWN box + const m = await sidePorts(page, 'multiscript') expect(m.ports.length).toBeGreaterThan(0) expect(Math.abs(m.wrapH - m.nfH)).toBeLessThanOrEqual(2) - for (const cy of m.ports) expect(Math.abs(cy - b.boxH / 2)).toBeLessThanOrEqual(8) + for (const cy of m.ports) expect(Math.abs(cy - PORT_ROW)).toBeLessThanOrEqual(1.5) }) test('renders in dark mode without sideways clipping', async ({ page }) => { diff --git a/e2e/outline-containment.spec.ts b/e2e/outline-containment.spec.ts new file mode 100644 index 00000000..51e8bd15 --- /dev/null +++ b/e2e/outline-containment.spec.ts @@ -0,0 +1,198 @@ +import type { Page } from '@playwright/test' +import { expect, importGraph, openApp, resetAll, test } from './support/loop' +import { fitCount, frames, zoomOne } from './support/rowFit' +import { layoutClashes, nodeBoxes, OUTLINE_GRAPH, OUTLINE_KINDS, outlineMisfits, readOutline } from './support/outlineFit' + +// issue #337 — the Pool, Source, Drain, Converter and Gate outlines have fixed +// px depths (a slant, an arrow, a notch, a waist, the Gate's points), drawn for +// the node's own width, and every painted element — the kind chip, each title +// line, each row — keeps 8 px inside the DRAWN outline at both ends; a row +// starts where the title text starts; the Gate stays centred +// (src/components/nodes/outlineFit.ts). The bundled Templates moved the least +// nodes and widened the least frames so that, in every language, no two nodes +// meet and no node crosses a frame's edge. + +type Bridge = { __loop: Record any }> } + +const LOCALES = ['en', 'ko', 'ja', 'zh-Hans', 'zh-Hant', 'de', 'fr', 'es-ES', 'es-419', 'pt-BR', 'pt-PT', 'it', 'nl', 'ru', 'tr', 'vi', 'th', 'ar'] +const TEMPLATE_IDS = ['equilibrium', 'deadlock', 'coffee-roastery', 'mmo-progression', 'gacha-banner-zones'] + +const load = async (page: Page) => { + await openApp(page) + await resetAll(page) + await importGraph(page, OUTLINE_GRAPH) + await page.locator('.react-flow__node[data-id="gate p 3"]').waitFor() + await page.evaluate(() => document.fonts.ready) + await zoomOne(page) + await frames(page, 2) +} + +const setLocale = async (page: Page, loc: string) => { + await page.evaluate((l) => (window as unknown as Bridge).__loop.i18n.getState().setLocale(l), loc) + await page.waitForFunction((l) => document.documentElement.lang === l, loc) +} + +/** open a bundled Template fresh in `loc`, as the menu does; its node count */ +const openTemplate = (page: Page, id: string, loc: string) => + page.evaluate( + async ([id, loc]) => { + const T = await import('/src/model/templates.ts') + const L = await import('/src/i18n/templateLabels/index.ts') + await L.ensureTemplateLabelDict(loc) + const o = L.openTemplate(T.TEMPLATES.find((t) => t.id === id)!, loc) + ;(window as unknown as Bridge).__loop.graph.getState().loadGraph(o.graph, { canvasLocked: false, modelVersion: o.modelVersion, initialView: null, frames: o.graph.frames }) + return o.graph.nodes.length + }, + [id, loc] as const, + ) + +const savedFrames = (page: Page) => + page.evaluate(() => ((window as unknown as Bridge).__loop.frame.getState().frames as { id: string; rect: { x: number; y: number; w: number; h: number } }[]).map((f) => ({ id: f.id, rect: f.rect }))) + +test.describe('#337 every element inside the drawn outline', () => { + for (const scheme of ['light', 'dark', 'forced'] as const) { + test(`${scheme}: chip, title lines and rows keep 8 px inside; rows at the title; the Gate centred`, async ({ page }) => { + if (scheme === 'forced') await page.emulateMedia({ forcedColors: 'active' }) + else await page.emulateMedia({ colorScheme: scheme }) + await load(page) + const els = await readOutline(page) + // 40 nodes: each a chip, at least one title line and its rows + expect(new Set(els.map((e) => e.id)).size).toBe(40) + expect(els.filter((e) => e.role === 'title' && e.line === 1).length, 'the two-line titles are exercised').toBeGreaterThan(0) + expect(outlineMisfits(els)).toEqual([]) + }) + } + + test('each outline is drawn for its node: the viewBox is the CSS width, the width at most 260 px', async ({ page }) => { + await load(page) + const out = await page.evaluate((kinds) => + [...document.querySelectorAll('.react-flow__node .nodef')] + .filter((f) => kinds.some((k) => f.classList.contains(`nodef--${k}`))) + .map((f) => ({ w: parseFloat(getComputedStyle(f).width), h: f.offsetHeight, vb: f.querySelector('.nodef__shape')!.getAttribute('viewBox')! })), + [...OUTLINE_KINDS] as string[]) + expect(out.length).toBe(40) + for (const o of out) { + const [, , w, h] = o.vb.split(' ').map(Number) + expect(Math.abs(w - o.w), o.vb).toBeLessThan(0.01) + expect(h).toBe(o.h) + expect(o.w).toBeLessThanOrEqual(260) + } + }) + + test('ar: the same, right to left', async ({ page }) => { + await load(page) + await setLocale(page, 'ar') + await page.waitForFunction(() => document.documentElement.dir === 'rtl') + await page.evaluate(() => document.fonts.ready) + await frames(page, 2) + expect(outlineMisfits(await readOutline(page))).toEqual([]) + }) + + test('a longer title widens each kind, the shorter one gives the width back, with a bounded number of measurements', async ({ page }) => { + await load(page) + const ids = ['pool 0', 'source 0', 'drain 0', 'converter 0', 'gate 0'] + const widths = () => + page.evaluate((ids) => ids.map((id) => parseFloat(getComputedStyle(document.querySelector(`.react-flow__node[data-id="${id}"] .nodef`)!).width)), ids) + const setLabels = (label: string) => + page.evaluate(([ids, label]) => { + const g = (window as unknown as Bridge).__loop.graph.getState() + for (const id of ids) g.updateNodeData(id, { label }) + }, [ids, label] as const) + const misfits = async () => outlineMisfits((await readOutline(page)).filter((e) => ids.includes(e.id))) + const w0 = await widths() + const n0 = await fitCount(page) + await setLabels('Daily login reward chest') + await expect.poll(async () => (await widths()).every((w, i) => w > w0[i])).toBe(true) + await expect.poll(misfits).toEqual([]) + // each node is read at most twice for one change (the natural layout, then + // once more if its height moved), and never again while nothing changes + const n1 = await fitCount(page) + expect(n1 - n0).toBeLessThanOrEqual(2 * ids.length) + await frames(page, 30) + expect(await fitCount(page)).toBe(n1) + await setLabels('Gold') + await expect.poll(async () => (await widths()).every((w, i) => Math.abs(w - w0[i]) <= 1 / 16)).toBe(true) + await expect.poll(misfits).toEqual([]) + const n2 = await fitCount(page) + expect(n2 - n1).toBeLessThanOrEqual(2 * ids.length) + await frames(page, 30) + expect(await fitCount(page)).toBe(n2) + }) + + for (const id of TEMPLATE_IDS) { + test(`${id}, all 18 languages: every element fits, no overlap, no frame crossed on any side, a reopened node fits as a fresh one, measurements bounded`, async ({ page }) => { + test.slow() + await openApp(page) + await resetAll(page) + const problems: string[] = [] + const open = async (loc: string) => { + const n = await openTemplate(page, id, loc) + await page.waitForFunction((n) => document.querySelectorAll('.react-flow__node .nodef').length === n, n) + await page.evaluate(() => document.fonts.ready) + // the readings are in CSS px at any zoom (a Template's own fit-all may + // land after this); zoom 1 only keeps the text at its usual raster + await zoomOne(page) + await frames(page, 3) + return n + } + for (const loc of LOCALES) { + await setLocale(page, loc) + await frames(page, 3) + // the previous language's document is open: the same node ids, so + // React keeps the node components and their last fit + const c0 = await fitCount(page) + const n = await open(loc) + const c1 = await fitCount(page) + if (c1 - c0 > 2 * n) problems.push(`${loc}: ${c1 - c0} measurements for ${n} nodes`) + await frames(page, 20) + if ((await fitCount(page)) !== c1) problems.push(`${loc}: measured while idle`) + problems.push(...outlineMisfits(await readOutline(page)).map((m) => `${loc}: ${m}`)) + const boxes = await nodeBoxes(page) + const saved = await savedFrames(page) + problems.push(...layoutClashes(boxes, saved).map((m) => `${loc}: ${m}`)) + if (id === 'gacha-banner-zones') { + // the three clashes older than #337: each bottom weight Parameter 8 px + // inside its zone frame, the two Russian Gates 8 px apart + const box = (n: string) => boxes.find((b) => b.id === n)! + const frame = (f: string) => saved.find((s) => s.id === f)!.rect + for (const [n, f] of [['zone1_free_w_r', 'zone_free'], ['zone3_pickup_w_standard', 'zone_pickup']] as const) { + const m = frame(f).y + frame(f).h - (box(n).y + box(n).h) + if (m < 8) problems.push(`${loc}: ${n} ${m} px above the bottom of ${f}`) + } + const gap = box('sr_hit_standard').x - (box('forced_ssr_standard').x + box('forced_ssr_standard').w) + if (gap < 8) problems.push(`${loc}: forced_ssr_standard ${gap.toFixed(2)} px from sr_hit_standard`) + } + const reused = await styles(page) + // the same Template opened fresh, every node mounted anew + await page.evaluate(() => (window as unknown as Bridge).__loop.graph.getState().newGraph()) + await frames(page, 2) + await open(loc) + problems.push(...styleDiff(await styles(page), reused).map((m) => `${loc}: ${m}`)) + } + expect(problems).toEqual([]) + }) + } +}) + +/** every node's inline style (the fit: width, the head's shift, the rows' starts) */ +const styles = (page: Page) => + page.evaluate( + () => Object.fromEntries([...document.querySelectorAll('.react-flow__node')].map((n) => [n.dataset.id!, n.querySelector('.nodef')!.getAttribute('style')])) as Record, + ) + +/** where a reused node's fit differs from a fresh one's: the same declarations, + * each length within 1/16 px (a node's screen position moves each measured + * edge by up to a layout unit, 1/64 px, and a width is read from two) */ +const styleDiff = (fresh: Record, reused: Record) => { + const parse = (s: string | null) => Object.fromEntries((s ?? '').split(';').map((d) => d.split(':').map((x) => x.trim())).filter(([k]) => k)) + const off: string[] = [] + for (const [id, s] of Object.entries(fresh)) { + const a = parse(s) + const b = parse(reused[id] ?? null) + if (Object.keys(a).sort().join() !== Object.keys(b).sort().join()) off.push(`${id}: fresh "${s}", reused "${reused[id]}"`) + else for (const k of Object.keys(a)) { + if (a[k] !== b[k] && !(Math.abs(parseFloat(a[k]) - parseFloat(b[k])) <= 1 / 16)) off.push(`${id} ${k}: ${a[k]} fresh, ${b[k]} reused`) + } + } + return off +} diff --git a/e2e/playback-node-cues.spec.ts b/e2e/playback-node-cues.spec.ts index d7c4c7b5..1ffa555d 100644 --- a/e2e/playback-node-cues.spec.ts +++ b/e2e/playback-node-cues.spec.ts @@ -134,7 +134,10 @@ test('the conversion mark shows from the Converter onset, at its own spot inside const hits = boxes.filter((b) => !(m.right <= b.left || m.left >= b.right || m.bottom <= b.top || m.top >= b.bottom)).length return { out, hits, size: m.width / scale } }) - expect(geo).toEqual({ out: 0, hits: 0, size: 10 }) + // (#337: the Converter's width is a fraction of a px, so the size read back + // through the zoom carries a float error far below a px) + expect({ out: geo.out, hits: geo.hits }).toEqual({ out: 0, hits: 0 }) + expect(geo.size).toBeCloseTo(10, 3) // after the settle it fades await call(page, 'play') await expect.poll(async () => markOf(page, 'tpl-conv').getAttribute('data-conv-mark'), { timeout: 20000, intervals: [16] }).toBe('done') diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-chromium-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-chromium-win32.png index 77a0944e..fc9adfea 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-chromium-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-chromium-win32.png differ diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-mobile-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-mobile-win32.png index cce7ad79..25d19222 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-mobile-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-dark-L2-mobile-win32.png differ diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-chromium-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-chromium-win32.png index d31097bd..64801538 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-chromium-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-chromium-win32.png differ diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-mobile-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-mobile-win32.png index 238cf935..4f105eb1 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-mobile-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-forced-colors-L2-mobile-win32.png differ diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-chromium-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-chromium-win32.png index a75199f7..9fb8aeca 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-chromium-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-chromium-win32.png differ diff --git a/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-mobile-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-mobile-win32.png index 00f49a68..67c39a44 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-mobile-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-depart-light-L2-mobile-win32.png differ diff --git a/e2e/playback-visual.spec.ts-snapshots/play-travel-L0-chromium-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-travel-L0-chromium-win32.png index 3b54d5ca..85312ab1 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-travel-L0-chromium-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-travel-L0-chromium-win32.png differ diff --git a/e2e/playback-visual.spec.ts-snapshots/play-travel-L0-mobile-win32.png b/e2e/playback-visual.spec.ts-snapshots/play-travel-L0-mobile-win32.png index 6508729e..2db4fecc 100644 Binary files a/e2e/playback-visual.spec.ts-snapshots/play-travel-L0-mobile-win32.png and b/e2e/playback-visual.spec.ts-snapshots/play-travel-L0-mobile-win32.png differ diff --git a/e2e/register-expr-authoring.spec.ts b/e2e/register-expr-authoring.spec.ts index 7fe42e9b..cfcd2b96 100644 --- a/e2e/register-expr-authoring.spec.ts +++ b/e2e/register-expr-authoring.spec.ts @@ -140,28 +140,44 @@ test('§RXA9.3b — nothing the pick defers may touch the caret after the user h // loaded machine, not an exotic one. Releasing it *after* the user has typed // is the interleaving that produced `@wallet+ 10 ` in CI. await page.evaluate(() => { - const w = window as unknown as { __heldFrames: FrameRequestCallback[] } + const w = window as unknown as { __heldFrames: FrameRequestCallback[]; __realRaf: typeof window.requestAnimationFrame } w.__heldFrames = [] + w.__realRaf = window.requestAnimationFrame window.requestAnimationFrame = ((cb: FrameRequestCallback) => w.__heldFrames.push(cb)) as unknown as typeof window.requestAnimationFrame }) - await expr(page).fill('') - await expr(page).pressSequentially('@wal') - await expect(listbox(page)).toBeVisible() - await page.locator('[role="option"]', { hasText: 'Wallet' }).first().click() - await expect(expr(page)).toHaveValue('@wallet') - // the pick has to be finished here, with no frame having run - await expect.poll(() => caretState(page)).toEqual({ focused: true, start: 7, end: 7 }) - - // real sequential typing, the path this test exists for - await expr(page).pressSequentially(' + 10') - await expect(expr(page)).toHaveValue('@wallet + 10') - - // now let the held frames run. Whatever the pick left on one of them must - // not move the caret out from under the text the user just typed. - await page.evaluate(() => { - const w = window as unknown as { __heldFrames: FrameRequestCallback[] } - for (const cb of w.__heldFrames.splice(0)) cb(performance.now()) - }) + // the stub is this test's own: the real requestAnimationFrame comes back + // right after the held frames run, and on any failure before that + const restoreRaf = () => + page.evaluate(() => { + const w = window as unknown as { __realRaf?: typeof window.requestAnimationFrame } + if (w.__realRaf) window.requestAnimationFrame = w.__realRaf + delete w.__realRaf + }) + try { + await expr(page).fill('') + await expr(page).pressSequentially('@wal') + await expect(listbox(page)).toBeVisible() + await page.locator('[role="option"]', { hasText: 'Wallet' }).first().click() + await expect(expr(page)).toHaveValue('@wallet') + // the pick has to be finished here, with no frame having run + await expect.poll(() => caretState(page)).toEqual({ focused: true, start: 7, end: 7 }) + + // real sequential typing, the path this test exists for + await expr(page).pressSequentially(' + 10') + await expect(expr(page)).toHaveValue('@wallet + 10') + + // now let the held frames run. Whatever the pick left on one of them must + // not move the caret out from under the text the user just typed. + await page.evaluate(() => { + const w = window as unknown as { __heldFrames: FrameRequestCallback[] } + for (const cb of w.__heldFrames.splice(0)) cb(performance.now()) + }) + } finally { + await restoreRaf() + } + // nothing of the stub is left for what follows (the import below waits on real frames) + expect(await page.evaluate(() => /\[native code\]/.test(Function.prototype.toString.call(window.requestAnimationFrame)))).toBe(true) + expect(await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => r(true))))).toBe(true) await expect.poll(() => caretState(page)).toEqual({ focused: true, start: 12, end: 12 }) // and the next character still lands at the end, where the caret is diff --git a/e2e/shard-weights.json b/e2e/shard-weights.json index 75f77e41..4f0620b4 100644 --- a/e2e/shard-weights.json +++ b/e2e/shard-weights.json @@ -2,6 +2,11 @@ "about": "Seconds each spec file took on the CI runners (windows-latest): the durations Playwright reported for its tests, summed over every project, one sample per CI run, newest first, at most six. portable.setup.ts is counted with portable-file.spec.ts, the spec that depends on it. Read by scripts/e2e-shards.mjs to split the files into shards of equal predicted time; refreshed by scripts/e2e-shard-weights.mjs from the JSON reports the shard jobs upload.", "source": { "runs": [ + { + "sha": "30bf3d4", + "run": "38032960035", + "date": "2026-10-10" + }, { "sha": "35acc87", "run": 37097166320, @@ -26,1001 +31,1070 @@ "sha": "3ce52f4", "run": 37080825832, "date": "2026-10-03" - }, - { - "sha": "cddae79", - "run": 37024198193, - "date": "2026-10-02" } ], "samplesPerFile": 6 }, "files": { "a11y-announcement-contract.spec.ts": [ + 22.7, 20.3, 20.8, 20.6, 16.2, - 19.2, - 25.4 + 19.2 ], "a11y-direction-invisible.spec.ts": [ - 7.0, + 9.7, + 7, 7.3, 7.5, 3.9, - 5.5, - 4.1 + 5.5 ], "autosave.spec.ts": [ + 14.6, 12.6, 12.6, 14.4, 9.2, - 11.0, - 9.5 + 11 ], "canvas-aria-locale.spec.ts": [ + 3.4, 2.6, 2.8, 8.5, 2.3, - 2.5, - 2.0 + 2.5 ], "canvas-lock.spec.ts": [ + 29.3, 10.9, 11.4, 11.5, 6.4, - 8.4, - 6.3 + 8.4 ], "canvas-refresh-edges.spec.ts": [ + 16.1, 13.7, 14.4, 15.9, 9.5, - 11.5, - 9.6 + 11.5 ], "canvas-refresh-invariants.spec.ts": [ + 2.6, 2.4, 2.3, 2.8, 1.9, - 2.1, - 1.8 + 2.1 ], "canvas-refresh-lod.spec.ts": [ + 60.1, 38.5, 39.1, 40.7, 35.3, - 37.0, - 35.5 + 37 ], "canvas-refresh-visual.spec.ts": [ - 59.0, - 52.0, + 68.5, + 59, + 52, 55.4, 52.5, - 55.3, - 52.4 + 55.3 ], "cjk-punctuation-font.spec.ts": [ + 12.2, 8.8, 11.1, 9.2, 5.2, - 10.8, - 6.2 + 10.8 ], "coffee-zone-frames.spec.ts": [ + 12.5, 9.8, 10.4, 9.9, 11.4, - 8.7, - 7.4 + 8.7 ], "confirm-dialog.spec.ts": [ + 21.9, 17.6, 18.1, 18.7, - 12.0, - 14.9, - 12.7 + 12, + 14.9 ], "contextual-inline-help.spec.ts": [ + 31.9, 26.1, - 27.0, + 27, 29.1, 17.6, - 21.9, - 18.3 + 21.9 ], "csv-contract.spec.ts": [ + 8.4, 6.6, - 7.0, - 7.0, + 7, + 7, 4.1, - 5.3, - 5.0 + 5.3 ], "csv-download-encoding.spec.ts": [ + 8, 6.4, 6.7, 7.1, 4.2, - 5.3, - 7.5 + 5.3 ], "data-import-guide.spec.ts": [ + 39, 34.2, 34.2, 35.3, 20.6, - 26.6, - 26.1 + 26.6 ], "data-import-refresh.spec.ts": [ + 18.3, 15.7, 16.3, 16.7, 9.7, - 12.5, - 10.9 + 12.5 ], "data-import-wizard.spec.ts": [ + 31.7, 27.5, 26.8, 27.8, 18.4, - 24.2, - 18.8 + 24.2 ], "dense-graph-pan.spec.ts": [ + 30.5, 26.6, 26.5, 27.9, 16.5, - 19.6, - 16.4 + 19.6 ], "descriptive-copy-wrapping.spec.ts": [ + 67.1, 61.2, 64.3, 66.1, 57.8, - 53.4, - 52.1 + 53.4 ], "desktop-btn-boundary.spec.ts": [ - 36.0, + 36.9, + 36, 34.9, 33.2, 23.9, - 27.4, - 24.3 + 27.4 ], "dialog-layer.spec.ts": [ + 27, 19.6, 26.2, 20.1, 11.4, - 15.9, - 13.2 + 15.9 ], "dialogs.spec.ts": [ + 4.1, 3.3, 4.3, 3.5, 2.1, - 2.7, - 2.3 + 2.7 ], "edge-label-ref-types.spec.ts": [ + 9.8, 5.6, 5.9, 5.3, 2.8, - 3.9, - 3.4 + 3.9 ], "edge-routing.spec.ts": [ + 19.3, 15.9, 16.6, 16.7, 11.9, - 14.1, - 12.9 + 14.1 ], "export.spec.ts": [ + 5.1, 4.1, 5.2, 4.5, 2.4, - 3.2, - 2.8 + 3.2 ], "forced-colors-edge-tell.spec.ts": [ + 199.4, 149.8, 151.8, 154.1, 125.5, - 139.2, - 131.3 + 139.2 ], "frame-a11y.spec.ts": [ + 67.7, 58.1, 59.7, 63.5, 38.5, - 46.8, - 42.4 + 46.8 ], "frame-label-locale-switch.spec.ts": [ + 11.6, 7.8, 8.3, 8.6, 5.5, - 7.0, - 6.8 + 7 ], "frame-props-popover.spec.ts": [ + 18.7, 14.6, 15.6, 17.6, 8.7, - 11.3, - 10.3 + 11.3 ], "gacha-banner-zones.spec.ts": [ + 68.9, 64.4, 65.9, 77.1, - 37.0, - 48.9, - 41.8 + 37, + 48.9 ], "guided-tour.spec.ts": [ + 47.9, 44.2, 45.2, 50.6, 27.3, - 34.6, - 31.3 + 34.6 ], "help-menu-readability.spec.ts": [ - 13.0, + 14.2, + 13, 13.1, 15.5, 7.2, - 10.4, - 8.7 + 10.4 ], "i18n-acceptance.spec.ts": [ + 26.7, 23.8, 24.6, 27.4, 14.8, - 19.4, - 17.2 + 19.4 ], "i18n-ar-isolation.spec.ts": [ + 26.7, 22.5, 24.4, 27.7, 13.3, - 17.5, - 16.4 + 17.5 ], "i18n-ar.spec.ts": [ + 6.7, 5.7, 5.7, 6.8, 3.3, - 4.2, - 4.1 + 4.2 ], "i18n-de.spec.ts": [ + 32.3, 27.5, 26.6, 33.9, 15.5, - 21.3, - 19.5 + 21.3 ], "i18n-es-419.spec.ts": [ + 38.7, 37.4, - 39.0, + 39, 41.4, 20.7, - 26.6, - 25.1 + 26.6 ], "i18n-es-es.spec.ts": [ + 21.1, 18.6, 17.9, 21.2, 9.7, - 19.7, - 19.5 + 19.7 ], "i18n-fr.spec.ts": [ + 54.8, 45.8, 46.5, 53.6, 46.5, - 42.9, - 44.3 + 42.9 ], "i18n-it.spec.ts": [ + 28.2, 23.8, 23.7, 24.1, - 24.0, - 23.3, - 24.0 + 24, + 23.3 ], "i18n-lazy.spec.ts": [ + 15.4, 13.2, 13.2, 13.2, 13.5, - 13.2, - 13.4 + 13.2 ], "i18n-nl.spec.ts": [ + 28.2, 25.1, 24.7, 23.9, - 25.0, - 25.2, - 25.0 + 25, + 25.2 ], "i18n-pt-br.spec.ts": [ + 33.6, 28.9, 28.9, 29.4, 28.9, - 28.1, - 28.8 + 28.1 ], "i18n-pt-pt.spec.ts": [ + 19.8, 16.8, 16.6, - 20.0, + 20, 16.9, - 16.3, 16.3 ], "i18n-ru.spec.ts": [ + 28.1, 22.6, 23.2, - 26.0, + 26, 23.4, - 22.5, - 22.6 + 22.5 ], "i18n-th.spec.ts": [ + 22.7, 19.5, 19.7, 19.3, - 20.0, - 19.5, - 20.3 + 20, + 19.5 ], "i18n-tr.spec.ts": [ + 22.8, 19.3, 19.4, 19.7, 19.6, - 19.2, - 20.9 + 19.2 ], "i18n-vi.spec.ts": [ + 42.6, 36.6, 36.5, 37.9, 36.7, - 36.2, - 36.1 + 36.2 ], "i18n-visual.spec.ts": [ + 13, 11.8, 11.9, 11.2, 11.6, - 11.4, - 11.3 + 11.4 ], "i18n-zh-hans.spec.ts": [ + 23, 9.2, 9.5, - 9.0, + 9, 9.3, - 8.9, - 12.5 + 8.9 ], "i18n-zh-hant.spec.ts": [ + 31.1, 17.5, 16.6, 16.3, 16.4, - 17.0, - 17.1 + 17 ], "i18n.spec.ts": [ + 46.4, 39.8, 40.6, - 40.0, + 40, 40.7, - 41.9, - 40.8 + 41.9 ], "label-timing.spec.ts": [ + 21.3, 19.7, 18.5, 18.4, 20.5, - 17.4, - 19.1 + 17.4 ], "large-graph-readability.spec.ts": [ + 224.1, 211.7, 214.2, 204.4, 212.9, - 209.5, - 213.9 + 209.5 ], "mc-invalidation.spec.ts": [ + 11.8, 7.9, - 8.0, + 8, 8.3, 8.6, - 8.3, - 8.4 + 8.3 ], "mc-paths.spec.ts": [ + 35.9, 33.1, 35.2, 34.8, 33.9, - 33.7, - 36.0 + 33.7 ], "minimap-responsive.spec.ts": [ + 20.7, 18.2, 18.1, 18.3, 18.6, - 18.6, - 20.3 + 18.6 ], "minimap.spec.ts": [ + 5.2, 4.4, 4.2, - 4.0, + 4, 4.8, - 4.1, - 4.6 + 4.1 ], "mmo-progression.spec.ts": [ + 18.9, 15.7, 15.5, - 15.0, + 15, 17.6, - 15.3, - 17.2 + 15.3 ], "mobile.spec.ts": [ + 108.8, 75.4, 44.9, 56.1, 72.6, - 78.5, - 71.1 + 78.5 ], "model-nodes-visual.spec.ts": [ + 9.8, 8.6, 8.7, 8.3, 8.6, - 8.2, - 9.3 + 8.2 ], "model-nodes.spec.ts": [ - 17.0, - 15.0, + 17.6, + 17, + 15, 15.6, - 15.0, - 14.1, - 15.9 + 15, + 14.1 ], "model-panels.spec.ts": [ + 22.9, 19.4, 19.4, - 21.0, + 21, 19.1, - 17.9, - 19.6 + 17.9 ], "model-verification.spec.ts": [ + 12.7, 13.5, 10.1, 12.5, 13.8, - 13.0, - 12.7 + 13 ], "module-label-localization.spec.ts": [ + 108.8, 89.2, 90.1, 82.9, 82.2, - 83.4, - 85.9 + 83.4 ], "module-system.spec.ts": [ + 25.1, 20.3, 19.2, - 18.0, + 18, 17.9, - 17.6, - 18.7 + 17.6 ], "monte-carlo.spec.ts": [ + 10.4, 9.5, - 9.0, + 9, 9.1, 8.7, - 8.7, - 9.5 + 8.7 ], "node-hue-edge-label-contract.spec.ts": [ + 10.8, 9.4, 8.8, 8.8, 8.8, - 8.5, - 9.2 + 8.5 ], "node-keyboard-move.spec.ts": [ + 57.1, 51.1, 49.5, 48.9, 50.4, - 48.4, - 50.3 + 48.4 ], "node-long-label.spec.ts": [ + 25.6, 22.9, 21.8, - 22.0, + 22, 22.1, - 21.5, - 22.7 + 21.5 ], "palette-tooltip-menu-suppression.spec.ts": [ + 15.8, 13.7, 12.6, 12.8, 12.8, - 12.3, - 13.1 + 12.3 ], "palette-tooltip-overflow-clip.spec.ts": [ + 6.3, 5.1, 4.7, 4.8, 4.7, - 4.6, - 4.8 + 4.6 ], "palette-tooltip.spec.ts": [ + 13.5, 11.9, 11.7, 11.2, 11.5, - 11.4, - 11.5 + 11.4 ], "parameter-activator-authoring.spec.ts": [ + 22.6, 19.4, 19.1, 18.3, 18.6, - 18.2, - 19.5 + 18.2 ], "parameter-inputs.spec.ts": [ - 6.0, + 6.9, + 6, 5.8, 5.6, 5.5, - 5.4, - 6.0 + 5.4 ], "percent-affix.spec.ts": [ + 6.4, 5.2, 5.6, 5.1, 5.1, - 5.0, - 5.3 + 5 ], "playback-a11y-background.spec.ts": [ + 70.2, 66.2, 62.2, 63.8, 64.8, - 65.6, - 65.7 + 65.6 ], "playback-caps-perf.spec.ts": [ + 58.4, 43.2, 42.6, 43.9, 41.4, - 41.4, - 43.6 + 41.4 ], "playback-choreography.spec.ts": [ + 72.1, 66.7, 64.9, 62.4, 65.3, - 65.9, - 65.3 + 65.9 ], "playback-fixture.spec.ts": [ + 23.6, 21.1, 21.2, 20.1, 20.1, - 20.2, - 20.7 + 20.2 ], "playback-invariants.spec.ts": [ + 28.4, 27.8, 27.9, 27.4, 27.5, - 27.8, 27.8 ], "playback-ordering-timing.spec.ts": [ + 19.6, 15.4, 16.5, 15.3, 15.5, - 15.4, - 15.6 + 15.4 ], "playback-ordering.spec.ts": [ + 25.7, 21.9, 22.3, 21.6, 20.3, - 21.7, - 20.8 + 21.7 ], "playback-steady-state.spec.ts": [ + 12.8, 12.8, 10.2, 12.3, 10.3, - 10.7, - 10.6 + 10.7 ], "playback-visual.spec.ts": [ + 66.3, 59.7, 49.2, 55.5, - 59.0, - 57.3, - 54.8 + 59, + 57.3 ], "playback.spec.ts": [ + 30.3, 26.3, 26.5, 27.9, 27.6, - 26.1, - 26.0 + 26.1 ], "pool-input-guard.spec.ts": [ + 2.9, 2.4, 2.8, 2.6, 3.3, - 2.4, - 2.3 + 2.4 ], "portable-file.spec.ts": [ + 60, 45.6, - 27.0, + 27, 32.2, 44.7, - 47.6, - 41.1 + 47.6 ], "ports.spec.ts": [ + 3.6, 3.1, 3.5, 3.2, 3.2, - 3.1, 3.1 ], "recommended-config.spec.ts": [ + 8.6, 6.5, 6.9, 7.4, 6.4, - 6.3, - 6.4 + 6.3 ], "register-expr-authoring-slice2.spec.ts": [ - 20.0, + 23.6, + 20, 22.8, 19.5, 20.3, - 19.3, - 19.5 + 19.3 ], "register-expr-authoring.spec.ts": [ + 26.6, 21.9, 25.5, 21.8, 21.3, - 21.2, 21.2 ], "register-expr-operators.spec.ts": [ - 16.0, + 17.4, + 16, 16.2, 15.8, 15.3, - 15.0, - 15.3 + 15 ], "revision-fixture.spec.ts": [ + 1.8, 1.8, 1.8, 1.7, 1.6, - 1.6, 1.6 ], "revision.spec.ts": [ + 37.4, 34.8, 32.3, - 35.0, + 35, 33.7, - 35.3, - 35.8 + 35.3 ], "rtl-arrow-direction.spec.ts": [ + 6.4, 5.6, 15.9, 6.3, 5.6, - 5.5, - 7.5 + 5.5 ], "rtl-canvas-content-direction.spec.ts": [ + 21.4, 16.2, 19.1, 16.3, 16.7, - 16.8, - 21.2 + 16.8 ], "rtl-canvas-contract.spec.ts": [ + 6.2, 5.1, 5.5, 4.9, 5.2, - 5.6, - 6.6 + 5.6 ], "rtl-form-direction.spec.ts": [ + 17.2, 12.2, 13.8, - 12.0, - 12.7, + 12, 12.7, - 15.8 + 12.7 ], "rtl-locale-direction.spec.ts": [ + 3.8, 3.1, 3.5, - 3.0, + 3, 3.6, - 3.3, - 4.1 + 3.3 ], "rtl-mixed-direction.spec.ts": [ + 11.3, 9.6, 10.8, 9.3, 11.2, - 10.0, - 12.6 + 10 ], "rtl-playbar-pin.spec.ts": [ + 5.7, 5.1, - 6.0, + 6, 5.1, 5.7, - 6.7, 6.7 ], "run-bar-fit.spec.ts": [ + 115, 93.4, 102.9, 90.7, 90.2 ], "share.spec.ts": [ + 26.3, 19.8, 14.4, 16.8, 20.2, - 18.4, - 20.0 + 18.4 ], "shell-control-boundary.spec.ts": [ + 112.5, 92.6, 72.5, 77.4, 93.2, - 97.4, - 93.2 + 97.4 ], "shell-function-colours.spec.ts": [ + 12.1, 9.9, 6.2, 7.7, 11.2, - 10.5, - 9.9 + 10.5 ], "smoke.spec.ts": [ + 1.7, 1.1, 0.6, 0.8, 1.3, - 1.1, 1.1 ], "state-fixture.spec.ts": [ + 12.2, 10.1, 7.6, 8.5, 11.8, - 10.6, - 10.1 + 10.6 ], "state-ui.spec.ts": [ + 30.2, 24.9, 16.6, 20.1, - 25.0, - 26.1, - 24.9 + 25, + 26.1 ], "storage-port-runtime.spec.ts": [ + 15, 7.6, 6.5, 6.4, - 7.0, - 7.4, - 7.6 + 7, + 7.4 ], "template-label-overlay.spec.ts": [ + 69.1, 40.2, 37.7, 30.6, 38.4, - 40.6, - 38.0 + 40.6 ], "template-load-viewport.spec.ts": [ + 27.6, 26.1, 18.6, 20.1, 24.5, - 29.8, - 24.3 + 29.8 ], "theme-boot.spec.ts": [ + 86.5, 75.8, 68.4 ], "theme-submenu.spec.ts": [ + 12.5, 10.7, 6.5, - 8.0, + 8, 10.3, - 13.6, - 10.2 + 13.6 ], "timeline-end-labels.spec.ts": [ + 39, 31.8, 16.1, 21.4, 30.5, - 33.1, - 28.6 + 33.1 ], "timeline-rolling-domain.spec.ts": [ + 13.5, 11.3, 7.1, 8.8, 11.1, - 11.4, - 11.0 + 11.4 ], "timeline-series.spec.ts": [ - 40.0, + 55.4, + 40, 23.7, 29.2, 38.7, - 43.1, - 37.5 + 43.1 ], "toolbar-locale-width.spec.ts": [ + 24.1, 39.7, 16.3, 15.6, 21.2, - 23.5, - 20.4 + 23.5 ], "toolbar-menu-dismiss.spec.ts": [ + 25.6, 24.5, 12.9, 16.1, - 22.0, - 22.5, - 21.0 + 22, + 22.5 ], "toolbar-responsive.spec.ts": [ + 40.6, 33.2, 21.2, 25.6, 33.2, - 33.6, - 33.3 + 33.6 ], "visual.spec.ts": [ + 3.5, 3.1, 1.7, 2.2, - 3.0, - 3.1, - 2.9 + 3, + 3.1 ], "whats-new.spec.ts": [ - 168.0, + 202.5, + 168, 120.2, 137.5, 190.6, - 172.3, - 171.8 + 172.3 ], "workspace.spec.ts": [ + 20, 17.9, 12.1, 13.8, 17.5, - 18.0, - 19.0 + 18 + ], + "app-ready-diagnostics.spec.ts": [ + 7.3 + ], + "connection-shapes.spec.ts": [ + 25.5 + ], + "diagram-grid.spec.ts": [ + 15.5 + ], + "document-boundary.spec.ts": [ + 18 + ], + "flow-colour-mobile.spec.ts": [ + 4.5 + ], + "flow-colour-views-visual.spec.ts": [ + 32.8 + ], + "flow-colour-views.spec.ts": [ + 35.4 + ], + "flow-colour-visual.spec.ts": [ + 8.7 + ], + "flow-colour.spec.ts": [ + 23.9 + ], + "focus-edge-dimming.spec.ts": [ + 9.5 + ], + "licenses.spec.ts": [ + 26.2 + ], + "lock-control.spec.ts": [ + 13.8 + ], + "menu-keyboard.spec.ts": [ + 41.9 + ], + "modal-inert.spec.ts": [ + 12.6 + ], + "outline-containment.spec.ts": [ + 169.2 + ], + "playback-gate-path.spec.ts": [ + 58.9 + ], + "playback-node-cues.spec.ts": [ + 106.1 + ], + "playback-speed-tiers.spec.ts": [ + 37.5 + ], + "share-protected.spec.ts": [ + 49.2 + ], + "smart-guides.spec.ts": [ + 17.6 + ], + "storage-sessions.spec.ts": [ + 37.6 + ], + "tour-announcement.spec.ts": [ + 28.6 + ], + "value-row-alignment.mobile.spec.ts": [ + 4.7 + ], + "value-row-alignment.spec.ts": [ + 37.8 ] } } diff --git a/e2e/smart-guides.spec.ts b/e2e/smart-guides.spec.ts new file mode 100644 index 00000000..89d0ecc9 --- /dev/null +++ b/e2e/smart-guides.spec.ts @@ -0,0 +1,142 @@ +import type { Page } from '@playwright/test' +import { expect, importGraph, openApp, resetAll, test } from './support/loop' + +// docs/diagram-layout.md §DL3.6 (issue #344, Lumi 2026-10-10) — smart guides: +// a drag lands by the port row of a nearby node, then its centre / edges, then +// the 16 px grid; the guides show where during the drag; the grabbed point +// never changes the result; Alt shows them faint and pulls nothing; a selection +// uses its bounding box; an arrow-key move shows its row and column briefly. + +type XY = { x: number; y: number } +const POOL = (id: string, x: number, y: number) => ({ id, type: 'pool', position: { x, y }, data: { kind: 'pool', label: id, initial: 0, capacity: null, mode: 'pullAny', activation: 'passive' } }) +/** `t`'s port row is at 103 + 28 = 131: off the grid (128 / 144) on purpose */ +const SCENE = JSON.stringify({ + schema: 'loop-studio/graph', + version: 1, + nodes: [POOL('m', 0, 0), POOL('n', 0, 320), POOL('t', 480, 103), POOL('c', 800, 520)], + edges: [], +}) + +const positions = (page: Page) => + page.evaluate(() => Object.fromEntries((window as any).__loop.graph.getState().nodes.map((n: { id: string; position: XY }) => [n.id, { ...n.position }])) as Record) + +async function load(page: Page) { + await openApp(page) + await resetAll(page) + await importGraph(page, SCENE) + await expect(page.locator('.react-flow__node')).toHaveCount(4) + await page.evaluate(() => (window as any).__loop.rf.setViewport({ x: 100, y: 100, zoom: 1 }, { duration: 0 })) + await page.waitForTimeout(250) +} + +/** a pointer drag of `id` by (dx, dy) screen px, grabbed at `grab` (fractions + * of the node box); `during` runs before the button is released */ +async function drag(page: Page, id: string, dx: number, dy: number, opts: { grab?: XY; alt?: boolean; during?: () => Promise } = {}) { + const b = (await page.locator(`.react-flow__node[data-id="${id}"]`).boundingBox())! + const g = opts.grab ?? { x: 0.5, y: 0.2 } + const x0 = b.x + b.width * g.x + const y0 = b.y + b.height * g.y + if (opts.alt) await page.keyboard.down('Alt') + await page.mouse.move(x0, y0) + await page.mouse.down() + // start the drag with a nudge: React Flow measures from where the drag + // STARTS, so a long first step would be lost from the move + await page.mouse.move(x0 + 1, y0 + 1) + await page.mouse.move(x0 + dx / 2, y0 + dy / 2, { steps: 4 }) + await page.mouse.move(x0 + dx, y0 + dy, { steps: 4 }) + await opts.during?.() + await page.mouse.up() + if (opts.alt) await page.keyboard.up('Alt') + await page.waitForTimeout(120) +} + +test.describe('smart guides — issue #344 §DL3.6', () => { + test('a drag near another node’s port row lands ON it (before the grid), with a strong guide to that node during the drag', async ({ page }) => { + await load(page) + let seen: { at: string | null; strong: boolean } | null = null + await drag(page, 'm', 160, 102, { + during: async () => { + const g = page.locator('.smart-guide--port.is-strong') + await expect(g).toHaveCount(1) + seen = { at: await g.getAttribute('data-at'), strong: true } + }, + }) + expect(seen).toEqual({ at: '131', strong: true }) + const p = (await positions(page)).m + expect(p.y + 28).toBe(131) // the port rows line up: 103, not the grid's 100 + expect(p.x % 16).toBe(0) // nothing to align with across: the grid + await expect(page.locator('.smart-guides')).toHaveCount(0) // gone at the drop + }) + + test('where the node is grabbed never changes where it lands', async ({ page }) => { + await load(page) + await drag(page, 'm', 160, 102, { grab: { x: 0.15, y: 0.15 } }) + const a = (await positions(page)).m + await load(page) + await drag(page, 'm', 160, 102, { grab: { x: 0.85, y: 0.8 } }) + const b = (await positions(page)).m + expect(b).toEqual(a) + }) + + test('far from any node, a drag lands on the 16 px grid, with only the placement lines', async ({ page }) => { + await load(page) + await drag(page, 'm', 37, 211, { + during: async () => { + await expect(page.locator('.smart-guide--place')).toHaveCount(2) + await expect(page.locator('.smart-guide.is-strong')).toHaveCount(0) + }, + }) + const p = (await positions(page)).m + expect(p.x % 16).toBe(0) + expect((p.y + 28) % 16).toBe(0) + }) + + test('Alt shows the guides faint and pulls nothing', async ({ page }) => { + await load(page) + await drag(page, 'm', 160, 102, { + alt: true, + during: async () => { + await expect(page.locator('.smart-guides.is-faint')).toHaveCount(1) + }, + }) + const p = (await positions(page)).m + expect(p.y + 28).not.toBe(131) + expect(Math.abs(p.y - 102)).toBeLessThan(6) // the pointer's own Δ (less React Flow's drag threshold) + }) + + test('a selection is guided by its bounding box: its centre lines up with a node’s centre', async ({ page }) => { + await load(page) + await page.locator('.react-flow__node[data-id="m"]').click() + await page.locator('.react-flow__node[data-id="n"]').click({ modifiers: [process.platform === 'darwin' ? 'Meta' : 'Control'] }) + const before = await positions(page) + const h = await page.evaluate(() => (window as any).__loop.graph.getState().nodes.find((x: any) => x.id === 'c').measured.height as number) + const selH = before.n.y + h - before.m.y // both pools are the same height + // aim the selection's centre-y close to `c`'s centre-y (520 + h / 2) + const want = 520 + h / 2 - (before.m.y + selH / 2) + await drag(page, 'm', 300, want + 3) + const after = await positions(page) + expect(after.n.y - after.m.y).toBe(before.n.y - before.m.y) // offsets kept + expect(after.m.y + selH / 2).toBe(520 + h / 2) + }) + + test('an arrow-key move shows its row and column briefly, then the guides go', async ({ page }) => { + await load(page) + await page.locator('.react-flow__node[data-id="m"]').focus() + await page.keyboard.press('Enter') + await page.keyboard.press('ArrowRight') + await expect(page.locator('.smart-guide--place')).toHaveCount(2) + await expect(page.locator('.smart-guides')).toHaveCount(0, { timeout: 4000 }) + }) + + test('the guides are drawn 1 screen px wide at any zoom, and never take a pointer', async ({ page }) => { + await load(page) + await page.evaluate(() => (window as any).__loop.rf.setViewport({ x: 100, y: 100, zoom: 2 }, { duration: 0 })) + await page.waitForTimeout(200) + await drag(page, 'm', 160, 100, { + during: async () => { + const css = await page.locator('.smart-guide').first().evaluate((el) => ({ ve: getComputedStyle(el).getPropertyValue('vector-effect'), pe: getComputedStyle(el.parentElement!).pointerEvents })) + expect(css).toEqual({ ve: 'non-scaling-stroke', pe: 'none' }) + }, + }) + }) +}) diff --git a/e2e/state-ui.spec.ts-snapshots/state-inspector-dark-chromium-win32.png b/e2e/state-ui.spec.ts-snapshots/state-inspector-dark-chromium-win32.png index 4510fe11..45168265 100644 Binary files a/e2e/state-ui.spec.ts-snapshots/state-inspector-dark-chromium-win32.png and b/e2e/state-ui.spec.ts-snapshots/state-inspector-dark-chromium-win32.png differ diff --git a/e2e/state-ui.spec.ts-snapshots/state-inspector-light-chromium-win32.png b/e2e/state-ui.spec.ts-snapshots/state-inspector-light-chromium-win32.png index 5dc1001b..0672779c 100644 Binary files a/e2e/state-ui.spec.ts-snapshots/state-inspector-light-chromium-win32.png and b/e2e/state-ui.spec.ts-snapshots/state-inspector-light-chromium-win32.png differ diff --git a/e2e/support/loop.ts b/e2e/support/loop.ts index a0ae40d2..8610f414 100644 --- a/e2e/support/loop.ts +++ b/e2e/support/loop.ts @@ -3,6 +3,7 @@ import { test as base, expect, type BrowserContext, type Locator, type Page } fr import { waitForAppReady, watchContext, watchPage } from './appReady' import { SNAPSHOT_POLICY, snapshotKind } from './snapshot-policy' import { seedWhatsNewSeen } from './whatsNew' +import { LAYOUT_VERSION } from '../../src/model/serialize' export { safeUrl, waitForAppReady, watchContext, watchPage } from './appReady' @@ -277,16 +278,47 @@ export function mcSnapshot(page: Page): Promise { } /** Load a serialized graph — same path as the Import button, including applying - * the file's `recommendedRunConfig` to the Monte-Carlo config. */ -export async function importGraph(page: Page, json: string): Promise { + * the file's `recommendedRunConfig` to the Monte-Carlo config. + * + * issue #344 §DL2.8 — a file without `layoutVersion` is a pre-grid layout, + * which the app re-places once on open. A spec's hand-written scene is laid + * out deliberately, so it is imported AS AUTHORED (stamped with the current + * layout version) unless `legacyLayout` asks for the conversion. */ +export async function importGraph(page: Page, json: string, opts: { legacyLayout?: boolean; noRouteWait?: boolean } = {}): Promise { // issue #297 - the app (and its bridge) is loaded behind the storage gate, // after the document's load event; a spec that reloads and imports at once // must wait for the bridge the way `openApp` does await page.waitForFunction(() => Boolean((window as unknown as { __loop?: unknown }).__loop)) + if (!opts.legacyLayout) { + const doc = JSON.parse(json) as Record + if (doc.layoutVersion === undefined) json = JSON.stringify({ ...doc, layoutVersion: LAYOUT_VERSION }) + } await page.evaluate((text) => { const l = (window as unknown as { __loop: Record any }> }).__loop l.mc.getState().applyRecommended(l.graph.getState().loadJSON(text)) }, json) + if (!opts.noRouteWait) await routesSettled(page) +} + +/** issue #344 §ER14.5 — the routed connections of a freshly opened document are + * drawn only when their first sliced generation commits: wait until no + * generation is pending and every routed connection whose two ends exist has + * its route (a spec reading paths, labels or the route map right after a load + * must call this, or `importGraph`, which does). §ER15 — and until no Curved + * or Straight label is waiting for its slot (`busy`). */ +export async function routesSettled(page: Page): Promise { + // two frames first: a change that just landed (a load, a locale's new label + // sizes) has had its render, so a job it starts is already pending + await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())))) + await page.waitForFunction(() => { + const l = (window as unknown as { __loop: any }).__loop + const g = l.graph.getState() + const ids = new Set(g.nodes.map((n: { id: string }) => n.id)) + const routed = g.edges.filter( + (e: { source: string; target: string; data?: { route?: string } }) => e.data?.route === 'orthogonal' && ids.has(e.source) && ids.has(e.target), + ).length + return !l.routeMap.pending() && !l.routeMap.busy() && l.routeMap.all().size === routed + }) } export const readFixture = (): string => diff --git a/e2e/support/outlineFit.ts b/e2e/support/outlineFit.ts new file mode 100644 index 00000000..6f7fbd35 --- /dev/null +++ b/e2e/support/outlineFit.ts @@ -0,0 +1,182 @@ +import type { Page } from '@playwright/test' + +// issue #337 — reading every painted element of a Pool, a Source, a Drain, a +// Converter and a Gate against its DRAWN outline: the kind chip, each title +// line and each row. A line's glyphs are a DOM Range's rects (a row cut at its +// box where its ellipsis cuts the text), its top / bottom its line BOX; the +// fill's edges are found with `SVGGeometryElement.isPointInFill` along the +// line's top + 1, middle and bottom − 1 (#332's e2e, ./rowFit). All in CSS px +// at the current zoom. + +export const OUTLINE_KINDS = ['pool', 'source', 'drain', 'converter', 'gate'] as const + +export type OutlineReading = { + id: string + kind: string + role: 'chip' | 'title' | 'value' | 'sub' + /** the title line's index, 0 for the others */ + line: number + text: string + /** px from the fill's left edge to the element, the least over its height */ + startClear: number + /** px from the element to the fill's right edge, the least */ + endClear: number + /** a row's start minus the title box's start (the chip + its 6 px gap); + * null for the head and for the Gate */ + vsTitle: number | null + /** the Gate: the element's centre minus the node's; null for the others */ + offCentre: number | null +} + +export type NodeBox = { id: string; kind: string; x: number; y: number; w: number; h: number } + +type Bridge = { __loop: Record any }> } + +/** every kind's cases: short, near-max and two-line titles, each mode and + * distribution, a Pool with a long value and capacity, an Arabic title */ +export const OUTLINE_GRAPH = (() => { + const N = (id: string, kind: string, x: number, y: number, data: object) => ({ + id, type: kind, position: { x, y }, data: { kind, label: id, ...data }, + }) + const nodes: object[] = [] + const titles = ['Gold', 'Daily login reward', 'Weekly guild contribution bonus', 'مكافأة تسجيل الدخول اليومية'] + titles.forEach((label, i) => { + const y = i * 600 + nodes.push(N(`pool ${i}`, 'pool', 0, y, { label, activation: 'passive', initial: 90, capacity: 130, mode: 'pullAny' })) + nodes.push(N(`pool long ${i}`, 'pool', 300, y, { label, activation: 'passive', initial: 1234567.89, capacity: 9999999, mode: 'pullAny' })) + nodes.push(N(`source ${i}`, 'source', 0, y + 150, { label, activation: 'automatic', mode: 'pushAny' })) + nodes.push(N(`source all ${i}`, 'source', 300, y + 150, { label, activation: 'interactive', mode: 'pushAll' })) + nodes.push(N(`drain ${i}`, 'drain', 0, y + 300, { label, activation: 'automatic', mode: 'pullAny' })) + nodes.push(N(`drain all ${i}`, 'drain', 300, y + 300, { label, activation: 'onStart', mode: 'pullAll' })) + nodes.push(N(`converter ${i}`, 'converter', 600, y, { label, activation: 'automatic', mode: 'pullAny' })) + nodes.push(N(`converter all ${i}`, 'converter', 600, y + 150, { label, activation: 'passive', mode: 'pullAll' })) + nodes.push(N(`gate ${i}`, 'gate', 600, y + 300, { label, activation: 'automatic', distribution: 'deterministic' })) + nodes.push(N(`gate p ${i}`, 'gate', 900, y + 300, { label, activation: 'automatic', distribution: 'probabilistic' })) + }) + return JSON.stringify({ schema: 'loop-studio/graph', version: 1, nodes, edges: [] }) +})() + +export const readOutline = (page: Page): Promise => + page.evaluate((kinds) => { + const out: OutlineReading[] = [] + const rects = (el: Element) => { + const r = document.createRange() + r.selectNodeContents(el) + return [...r.getClientRects()].filter((c) => c.width > 0.5) + } + for (const n of document.querySelectorAll('.react-flow__node')) { + const f = n.querySelector('.nodef') + const kind = f && [...f.classList].find((c) => /^nodef--/.test(c))?.slice(7) + if (!f || !kind || !kinds.includes(kind)) continue + const path = f.querySelector('path.nodef__fill')! + const inv = path.ownerSVGElement!.getScreenCTM()!.inverse() + const box = f.getBoundingClientRect() + const z = box.width / parseFloat(getComputedStyle(f).width) + const inside = (x: number, y: number) => path.isPointInFill(new DOMPoint(x, y).matrixTransform(inv)) + // a 0.25 CSS px step at any zoom + const step = 0.25 * z + const edges = (y: number) => { + let l = box.left + let r = box.right + while (l < box.right && !inside(l, y)) l += step + while (r > box.left && !inside(r, y)) r -= step + return [l, r] + } + const clear = (left: number, right: number, top: number, bottom: number) => { + let s = Infinity + let e = Infinity + for (const y of [top + 1, (top + bottom) / 2, bottom - 1]) { + const [l, r] = edges(y) + s = Math.min(s, (left - l) / z) + e = Math.min(e, (r - right) / z) + } + return { startClear: +s.toFixed(2), endClear: +e.toFixed(2) } + } + const centre = (box.left + box.right) / 2 + const gate = kind === 'gate' + const id = n.dataset.id! + const chip = f.querySelector('.nodef__chip')!.getBoundingClientRect() + // the Gate centres its head (chip + title) as one: its centre is read on the chip's entry + const head = f.querySelector('.nodef__head')!.getBoundingClientRect() + out.push({ + id, kind, role: 'chip', line: 0, text: '', ...clear(chip.left, chip.right, chip.top, chip.bottom), vsTitle: null, + offCentre: gate ? +(((head.left + head.right) / 2 - centre) / z).toFixed(2) : null, + }) + // each title line: its glyphs, its line box + const title = f.querySelector('.nodef__title')! + const tb = title.getBoundingClientRect() + const lh = parseFloat(getComputedStyle(title).lineHeight) * z + const lines = new Map() + for (const q of rects(title)) { + const i = Math.max(0, Math.round(((q.top + q.bottom) / 2 - tb.top - lh / 2) / lh)) + const L = lines.get(i) + lines.set(i, L ? { left: Math.min(L.left, q.left), right: Math.max(L.right, q.right) } : { left: q.left, right: q.right }) + } + for (const [i, L] of lines) { + out.push({ id, kind, role: 'title', line: i, text: title.textContent ?? '', ...clear(L.left, L.right, tb.top + i * lh, tb.top + (i + 1) * lh), vsTitle: null, offCentre: null }) + } + const titleAt = chip.right + 6 * z + for (const role of ['value', 'sub'] as const) { + const el = f.querySelector(`.nodef__${role}`) + if (!el) continue + const g = rects(el) + if (!g.length) continue + const eb = el.getBoundingClientRect() + const clipped = el.scrollWidth > el.clientWidth + 0.5 + const left = Math.min(...g.map((q) => q.left)) + const right = clipped ? Math.min(Math.max(...g.map((q) => q.right)), eb.right) : Math.max(...g.map((q) => q.right)) + out.push({ + id, kind, role, line: 0, text: (el.textContent ?? '').trim(), ...clear(left, right, eb.top, eb.bottom), + vsTitle: gate ? null : +((left - titleAt) / z).toFixed(2), + offCentre: gate ? +(((left + right) / 2 - centre) / z).toFixed(2) : null, + }) + } + } + return out + }, [...OUTLINE_KINDS] as string[]) + +/** every element under `clear` px from the outline, a row not at the title, + * a Gate line off its centre */ +export const outlineMisfits = (els: OutlineReading[], clear = 8) => + els + .filter((e) => + e.startClear < clear - 0.3 || e.endClear < clear - 0.3 || + (e.vsTitle !== null && Math.abs(e.vsTitle) > 0.5) || + (e.offCentre !== null && Math.abs(e.offCentre) > 1)) + .map((e) => `${e.id} ${e.role}${e.role === 'title' ? ` line ${e.line}` : ''} "${e.text}" start ${e.startClear} end ${e.endClear} vsTitle ${e.vsTitle} offCentre ${e.offCentre}`) + +/** every node's stored position and drawn size, in flow px */ +export const nodeBoxes = (page: Page): Promise => + page.evaluate(() => { + const pos = new Map( + ((window as unknown as Bridge).__loop.graph.getState().nodes as { id: string; position: { x: number; y: number } }[]).map((g) => [g.id, g.position]), + ) + return [...document.querySelectorAll('.react-flow__node')].flatMap((n) => { + const f = n.querySelector('.nodef') + const p = pos.get(n.dataset.id!) + if (!f || !p) return [] + const kind = [...f.classList].find((c) => /^nodef--/.test(c))?.slice(7) ?? '' + return [{ id: n.dataset.id!, kind, x: p.x, y: p.y, w: parseFloat(getComputedStyle(f).width), h: f.offsetHeight }] + }) + }) + +/** every pair of nodes that overlap, and every node that crosses a saved + * frame's edge (partly inside, partly out) */ +export const layoutClashes = (boxes: NodeBox[], frames: { id: string; rect: { x: number; y: number; w: number; h: number } }[]) => { + const E = 0.01 + const out: string[] = [] + for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) { + const a = boxes[i] + const b = boxes[j] + if (a.x < b.x + b.w - E && b.x < a.x + a.w - E && a.y < b.y + b.h - E && b.y < a.y + a.h - E) out.push(`${a.id} × ${b.id}`) + } + for (const fr of frames) { + const R = fr.rect + for (const n of boxes) { + const meets = n.x < R.x + R.w - E && R.x < n.x + n.w - E && n.y < R.y + R.h - E && R.y < n.y + n.h - E + const within = n.x >= R.x - E && n.y >= R.y - E && n.x + n.w <= R.x + R.w + E && n.y + n.h <= R.y + R.h + E + if (meets && !within) out.push(`${n.id} crosses ${fr.id} (node ${n.x},${n.y} ${n.w.toFixed(1)}×${n.h}; frame ${R.x},${R.y} ${R.w}×${R.h})`) + } + } + return out +} diff --git a/e2e/template-label-overlay.spec.ts b/e2e/template-label-overlay.spec.ts index 884b9894..c8a59ba7 100644 --- a/e2e/template-label-overlay.spec.ts +++ b/e2e/template-label-overlay.spec.ts @@ -1,5 +1,5 @@ import type { Browser, Page } from '@playwright/test' -import { ensureTimelineOpen, expect, importGraph, openApp, resetAll, seedPersonalBrowser, test } from './support/loop' +import { ensureTimelineOpen, expect, importGraph, openApp, resetAll, routesSettled, seedPersonalBrowser, test } from './support/loop' // docs/template-label-overlay.md — the shared fresh-open Template label overlay: // a bundled Template opens with the current locale's node `label`s; `openTemplate` @@ -51,6 +51,10 @@ async function pickTemplate(page: Page, hasText: string) { // pristine first boot loads without a confirm; if a confirm appears, accept it const confirm = page.locator('.dialog button', { hasText: /replace|바꾸기|교체/i }) if (await confirm.isVisible().catch(() => false)) await confirm.click() + // issue #344 §ER14.5 — its routes are drawn once their first sliced + // generation commits + await page.waitForFunction(() => (window as unknown as { __loop: any }).__loop.graph.getState().nodes.length > 0) + await routesSettled(page) } const MMO_EN = 'Early MMO progression' @@ -472,9 +476,13 @@ test.describe('official template label — locale switch (§TLO11)', () => { // (offsetHeight, used for wrapH/nfH, is transform-invariant layout // size — comparing a scaled port offset against it would be a // coordinate-space mismatch, not a real geometry defect.) + // issue #344 §DL1.2 — a resource port sits on the fixed row 28 flow + // px below the box top (no longer at the box's vertical centre): + // this is its distance from that row, in flow px + const scale = nf.offsetHeight > 0 ? nfR.height / nf.offsetHeight : 1 const ports = [...wrap.querySelectorAll('.h--in, .h--out')].map((h) => { const r = h.getBoundingClientRect() - return (r.top + r.bottom) / 2 - nfR.top - nfR.height / 2 + return ((r.top + r.bottom) / 2 - nfR.top) / scale - 28 }) return { id: wrap.dataset.id!, wrapH: wrap.offsetHeight, nfH: nf.offsetHeight, ports } }) @@ -654,7 +662,7 @@ test.describe('official template label — locale switch (§TLO11)', () => { const snap = await geometrySnapshot() for (const n of snap) { for (const offCenter of n.ports) { - expect(Math.abs(offCenter), `node ${n.id} port centring after ${locale}`).toBeLessThanOrEqual(8) + expect(Math.abs(offCenter), `node ${n.id} port on its 28 px row after ${locale}`).toBeLessThanOrEqual(1.5) } } diff --git a/e2e/template-load-viewport.spec.ts b/e2e/template-load-viewport.spec.ts index f16770c5..5c56a971 100644 --- a/e2e/template-load-viewport.spec.ts +++ b/e2e/template-load-viewport.spec.ts @@ -367,9 +367,17 @@ test.describe('template load re-fits the viewport (whole-graph swap boundary)', const panned = await getVp(page) await pickMobileTemplate(page, COFFEE_EN, COFFEE_R) - await waitForFit(page, COFFEE_L, COFFEE_R) + // issue #344 step 5 — Coffee opens on its operating flow (its + // initialView, floor 0.46), from its first lever, on a phone as on a + // desktop: the far forecast column may be off screen + await expect.poll(() => nodeOnScreen(page, COFFEE_L)).toBe(true) const after = await getVp(page) expect(after).not.toEqual(panned) + expect(after.zoom).toBeGreaterThanOrEqual(0.46 - 1e-6) + // ... and Reset view still brings the whole graph in, the forecast + // column on the right included + await page.locator('.rf-resetview').click() + await waitForFit(page, COFFEE_L, COFFEE_R) expect((await graphSig(page)).positions).toEqual(filePositions(COFFEE)) } finally { await page.close() diff --git a/e2e/value-row-alignment.spec.ts b/e2e/value-row-alignment.spec.ts index 6e2ae654..1878b5d6 100644 --- a/e2e/value-row-alignment.spec.ts +++ b/e2e/value-row-alignment.spec.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs' import type { Page } from '@playwright/test' import { expect, importGraph, openApp, resetAll, test } from './support/loop' import { fitCount, frames, misfits, readRows, ROWS_GRAPH, zoomOne } from './support/rowFit' +import { outlineMisfits, readOutline } from './support/outlineFit' // issue #332 — a Pool's value and capacity, a Parameter's value and unit and a // Register's result and `= expr` start where the title text starts and keep @@ -68,8 +69,10 @@ test.describe('#332 value and detail rows inside the vessel', () => { const rows = await readRows(page) expect(rows.length).toBe(21) expect(misfits(rows)).toEqual([]) - // the long Pool value is cut short where the slanted side would reach its title - expect(rows.find((r) => r.id === 'Pool long' && r.key === 'value')!.clipped).toBe(true) + // #337 — the long Pool value was cut short where the slanted side would + // reach it; with the fixed 8 px slant the node widens to show it whole + // (a row is cut only past the 260 px maximum, src/components/nodes/outlineFit.test.ts) + expect(rows.find((r) => r.id === 'Pool long' && r.key === 'value')!.clipped).toBe(false) }) } @@ -95,18 +98,13 @@ test.describe('#332 value and detail rows inside the vessel', () => { expect(ar.vsTitle).toBeLessThan(1) }) - test('the Source, Drain and Converter mode rows are left as they were', async ({ page }) => { + // #337 — the Source, Drain and Converter mode rows were left in place here; + // with the fixed-depth outlines they take the same rule as the Pool's rows + test('the Source, Drain and Converter mode rows start at the title and keep 8 px inside (#337)', async ({ page }) => { await load(page, ROWS_GRAPH, 'Converter') - const out = await page.evaluate(() => - ['Source', 'Drain', 'Converter'].map((id) => { - const f = document.querySelector(`.react-flow__node[data-id="${id}"] .nodef`)! - return { id, style: f.getAttribute('style') ?? '', margin: getComputedStyle(f.querySelector('.nodef__sub')!).marginLeft } - }), - ) - for (const o of out) { - expect(o.style).not.toContain('--vra') - expect(o.margin).toBe('0px') - } + const rows = (await readOutline(page)).filter((e) => ['Source', 'Drain', 'Converter'].includes(e.id) && e.role === 'sub') + expect(rows.map((r) => r.id).sort()).toEqual(['Converter', 'Drain', 'Source']) + expect(outlineMisfits(rows)).toEqual([]) }) test('selection, keyboard focus, Focus mode and the activity overlay move no row and take no measurement', async ({ page }) => { diff --git a/e2e/whats-new.spec.ts b/e2e/whats-new.spec.ts index 5592205f..76cc3edc 100644 --- a/e2e/whats-new.spec.ts +++ b/e2e/whats-new.spec.ts @@ -238,7 +238,7 @@ test.describe('closing the notice and opening the panel are different things', ( expect(text).not.toContain('text unavailable') } } - expect(RELEASE_NOTES.map((n) => n.version)).toEqual(['0.24.0', '0.23.0', '0.22.0', '0.21.4', '0.21.3', '0.21.2', '0.21.1', '0.21.0', '0.20.0', '0.19.0','0.18.2', '0.18.1', '0.18.0', '0.17.2', '0.17.1', '0.17.0', '0.16.0', '0.15.3', '0.15.2', '0.15.1', '0.15.0', '0.14.0']) + expect(RELEASE_NOTES.map((n) => n.version)).toEqual(['0.25.0', '0.24.0', '0.23.0', '0.22.0', '0.21.4', '0.21.3', '0.21.2', '0.21.1', '0.21.0', '0.20.0', '0.19.0','0.18.2', '0.18.1', '0.18.0', '0.17.2', '0.17.1', '0.17.0', '0.16.0', '0.15.3', '0.15.2', '0.15.1', '0.15.0', '0.14.0']) await page.keyboard.press('Escape') await expect(panel).toHaveCount(0) diff --git a/examples/coffee-roastery.json b/examples/coffee-roastery.json index c797d611..205c303e 100644 --- a/examples/coffee-roastery.json +++ b/examples/coffee-roastery.json @@ -1,13 +1,14 @@ { "schema": "loop-studio/graph/2", "version": 1, + "layoutVersion": 1, "nodes": [ { "id": "cafe_retail_demand_kg", "type": "parameter", "position": { - "x": 40, - "y": 0 + "x": 48, + "y": 4 }, "data": { "kind": "parameter", @@ -20,7 +21,7 @@ "type": "parameter", "position": { "x": 320, - "y": 0 + "y": 4 }, "data": { "kind": "parameter", @@ -32,8 +33,8 @@ "id": "online_orders", "type": "parameter", "position": { - "x": 600, - "y": 0 + "x": 672, + "y": 4 }, "data": { "kind": "parameter", @@ -45,8 +46,8 @@ "id": "green_wholesale_kg", "type": "parameter", "position": { - "x": 880, - "y": 0 + "x": 960, + "y": 4 }, "data": { "kind": "parameter", @@ -58,8 +59,8 @@ "id": "dessert_prep", "type": "parameter", "position": { - "x": 1160, - "y": 0 + "x": 1264, + "y": 4 }, "data": { "kind": "parameter", @@ -71,8 +72,8 @@ "id": "green_delivery", "type": "source", "position": { - "x": 40, - "y": 200 + "x": 48, + "y": 228 }, "data": { "kind": "source", @@ -86,8 +87,8 @@ "id": "green_stock", "type": "pool", "position": { - "x": 300, - "y": 200 + "x": 304, + "y": 228 }, "data": { "kind": "pool", @@ -103,8 +104,8 @@ "id": "green_wholesale", "type": "drain", "position": { - "x": 300, - "y": 360 + "x": 304, + "y": 404 }, "data": { "kind": "drain", @@ -118,8 +119,8 @@ "id": "roasting", "type": "gate", "position": { - "x": 540, - "y": 200 + "x": 608, + "y": 228 }, "data": { "kind": "gate", @@ -133,8 +134,8 @@ "id": "roasted_stock", "type": "pool", "position": { - "x": 780, - "y": 200 + "x": 864, + "y": 228 }, "data": { "kind": "pool", @@ -150,8 +151,8 @@ "id": "roast_loss", "type": "drain", "position": { - "x": 540, - "y": 360 + "x": 592, + "y": 404 }, "data": { "kind": "drain", @@ -165,8 +166,8 @@ "id": "online_sales", "type": "drain", "position": { - "x": 1020, - "y": 160 + "x": 1120, + "y": 196 }, "data": { "kind": "drain", @@ -180,8 +181,8 @@ "id": "cafe_retail", "type": "drain", "position": { - "x": 780, - "y": 360 + "x": 848, + "y": 404 }, "data": { "kind": "drain", @@ -195,8 +196,8 @@ "id": "roasted_bleed", "type": "drain", "position": { - "x": 1020, - "y": 300 + "x": 1120, + "y": 356 }, "data": { "kind": "drain", @@ -210,8 +211,8 @@ "id": "dessert_prep_src", "type": "source", "position": { - "x": 40, - "y": 520 + "x": 48, + "y": 644 }, "data": { "kind": "source", @@ -225,8 +226,8 @@ "id": "dessert_stock", "type": "pool", "position": { - "x": 300, - "y": 520 + "x": 304, + "y": 644 }, "data": { "kind": "pool", @@ -242,8 +243,8 @@ "id": "dessert_sales", "type": "drain", "position": { - "x": 540, - "y": 520 + "x": 592, + "y": 644 }, "data": { "kind": "drain", @@ -257,8 +258,8 @@ "id": "dessert_wrapup", "type": "drain", "position": { - "x": 540, - "y": 660 + "x": 592, + "y": 788 }, "data": { "kind": "drain", @@ -272,8 +273,8 @@ "id": "projected_revenue", "type": "register", "position": { - "x": 1400, - "y": 0 + "x": 1504, + "y": 4 }, "data": { "kind": "register", @@ -286,8 +287,8 @@ "id": "planned_cost", "type": "register", "position": { - "x": 1400, - "y": 120 + "x": 1504, + "y": 148 }, "data": { "kind": "register", @@ -300,8 +301,8 @@ "id": "projected_operating_margin", "type": "register", "position": { - "x": 1400, - "y": 240 + "x": 1504, + "y": 292 }, "data": { "kind": "register", @@ -314,8 +315,8 @@ "id": "roasted_supply_margin", "type": "register", "position": { - "x": 1400, - "y": 360 + "x": 1504, + "y": 452 }, "data": { "kind": "register", @@ -327,8 +328,8 @@ "id": "dessert_prep_margin", "type": "register", "position": { - "x": 1400, - "y": 480 + "x": 1504, + "y": 612 }, "data": { "kind": "register", @@ -348,6 +349,7 @@ "data": { "kind": "resource", "flow": "34", + "route": "orthogonal", "accent": "#74906B" } }, @@ -361,6 +363,7 @@ "data": { "kind": "resource", "flow": "@green_wholesale_kg", + "route": "orthogonal", "accent": "#74906B" } }, @@ -373,7 +376,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "@daily_roast_kg" + "flow": "@daily_roast_kg", + "route": "orthogonal" } }, { @@ -386,6 +390,7 @@ "data": { "kind": "resource", "flow": "82", + "route": "orthogonal", "accent": "#A78243" } }, @@ -399,6 +404,7 @@ "data": { "kind": "resource", "flow": "18", + "route": "orthogonal", "accent": "#A78243" } }, @@ -412,6 +418,7 @@ "data": { "kind": "resource", "flow": "@online_orders", + "route": "orthogonal", "accent": "#A78243" } }, @@ -425,6 +432,7 @@ "data": { "kind": "resource", "flow": "@cafe_retail_demand_kg", + "route": "orthogonal", "accent": "#A78243" } }, @@ -438,6 +446,7 @@ "data": { "kind": "resource", "flow": "14%", + "route": "orthogonal", "accent": "#A78243" } }, @@ -451,6 +460,7 @@ "data": { "kind": "resource", "flow": "@dessert_prep", + "route": "orthogonal", "accent": "#B47599" } }, @@ -464,6 +474,7 @@ "data": { "kind": "resource", "flow": "16", + "route": "orthogonal", "accent": "#B47599" } }, @@ -477,6 +488,7 @@ "data": { "kind": "resource", "flow": "55%", + "route": "orthogonal", "accent": "#B47599" } } @@ -503,29 +515,29 @@ "label": "Supply & inventory", "rect": { "x": 16, - "y": 176, - "w": 473, - "h": 432 + "y": 192, + "w": 512, + "h": 560 } }, { "id": "zone_roasting", "label": "Roasting & sales", "rect": { - "x": 516, - "y": 136, - "w": 719, - "h": 612 + "x": 560, + "y": 160, + "w": 784, + "h": 736 } }, { "id": "zone_forecast", "label": "Forecast metrics", "rect": { - "x": 1376, - "y": -24, - "w": 308, - "h": 634 + "x": 1472, + "y": -32, + "w": 320, + "h": 800 } } ] diff --git a/examples/deadlock.json b/examples/deadlock.json index d2b9f52b..78fdb29a 100644 --- a/examples/deadlock.json +++ b/examples/deadlock.json @@ -1,13 +1,14 @@ { "schema": "loop-studio/graph", "version": 1, + "layoutVersion": 1, "nodes": [ { "id": "tpl-src", "type": "source", "position": { - "x": 40, - "y": 190 + "x": 48, + "y": 196 }, "data": { "kind": "source", @@ -20,7 +21,7 @@ "id": "tpl-vault", "type": "pool", "position": { - "x": 268, + "x": 288, "y": 180 }, "data": { @@ -36,7 +37,7 @@ "id": "tpl-gate", "type": "gate", "position": { - "x": 479, + "x": 528, "y": 180 }, "data": { @@ -50,8 +51,8 @@ "id": "tpl-conv", "type": "converter", "position": { - "x": 740, - "y": 110 + "x": 784, + "y": 116 }, "data": { "kind": "converter", @@ -64,8 +65,8 @@ "id": "tpl-prod", "type": "pool", "position": { - "x": 934, - "y": 110 + "x": 1008, + "y": 116 }, "data": { "kind": "pool", @@ -80,8 +81,8 @@ "id": "tpl-spill", "type": "drain", "position": { - "x": 740, - "y": 300 + "x": 784, + "y": 308 }, "data": { "kind": "drain", @@ -101,7 +102,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "3" + "flow": "3", + "route": "orthogonal" } }, { @@ -113,7 +115,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "all" + "flow": "all", + "route": "orthogonal" } }, { @@ -125,7 +128,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "2" + "flow": "2", + "route": "orthogonal" } }, { @@ -137,7 +141,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -149,7 +154,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } } ] diff --git a/examples/engine-b-verification.json b/examples/engine-b-verification.json index 2b593d49..831caa69 100644 --- a/examples/engine-b-verification.json +++ b/examples/engine-b-verification.json @@ -1,6 +1,7 @@ { "schema": "loop-studio/graph", "version": 1, + "layoutVersion": 1, "nodes": [ { "id": "source_mtc00jt3_1", diff --git a/examples/equilibrium.json b/examples/equilibrium.json index 9652d6b4..65f54d7e 100644 --- a/examples/equilibrium.json +++ b/examples/equilibrium.json @@ -1,13 +1,14 @@ { "schema": "loop-studio/graph", "version": 1, + "layoutVersion": 1, "nodes": [ { "id": "tpl-src", "type": "source", "position": { - "x": 40, - "y": 190 + "x": 48, + "y": 196 }, "data": { "kind": "source", @@ -20,7 +21,7 @@ "id": "tpl-vault", "type": "pool", "position": { - "x": 268, + "x": 288, "y": 180 }, "data": { @@ -36,7 +37,7 @@ "id": "tpl-gate", "type": "gate", "position": { - "x": 479, + "x": 528, "y": 180 }, "data": { @@ -50,8 +51,8 @@ "id": "tpl-conv", "type": "converter", "position": { - "x": 740, - "y": 110 + "x": 784, + "y": 116 }, "data": { "kind": "converter", @@ -64,8 +65,8 @@ "id": "tpl-prod", "type": "pool", "position": { - "x": 934, - "y": 110 + "x": 1008, + "y": 116 }, "data": { "kind": "pool", @@ -80,8 +81,8 @@ "id": "tpl-spill", "type": "drain", "position": { - "x": 740, - "y": 300 + "x": 784, + "y": 308 }, "data": { "kind": "drain", @@ -94,8 +95,8 @@ "id": "tpl-consume", "type": "drain", "position": { - "x": 1153, - "y": 110 + "x": 1248, + "y": 116 }, "data": { "kind": "drain", @@ -115,7 +116,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "3" + "flow": "3", + "route": "orthogonal" } }, { @@ -127,7 +129,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "all" + "flow": "all", + "route": "orthogonal" } }, { @@ -139,7 +142,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "2" + "flow": "2", + "route": "orthogonal" } }, { @@ -151,7 +155,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -163,7 +168,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -175,7 +181,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } } ] diff --git a/examples/gacha-banner-zones.json b/examples/gacha-banner-zones.json index 40d950f1..04290a34 100644 --- a/examples/gacha-banner-zones.json +++ b/examples/gacha-banner-zones.json @@ -1,13 +1,14 @@ { "schema": "loop-studio/graph/2", "version": 1, + "layoutVersion": 1, "nodes": [ { "id": "pulls_per_zone", "type": "parameter", "position": { - "x": 40, - "y": 40 + "x": 48, + "y": 36 }, "data": { "kind": "parameter", @@ -19,8 +20,8 @@ "id": "cmp1_hit_rate_free", "type": "register", "position": { - "x": 380, - "y": 40 + "x": 608, + "y": 36 }, "data": { "kind": "register", @@ -33,8 +34,8 @@ "id": "cmp2_hit_rate_standard", "type": "register", "position": { - "x": 720, - "y": 40 + "x": 944, + "y": 36 }, "data": { "kind": "register", @@ -47,8 +48,8 @@ "id": "cmp3_hit_rate_pickup", "type": "register", "position": { - "x": 1060, - "y": 40 + "x": 1280, + "y": 36 }, "data": { "kind": "register", @@ -61,8 +62,8 @@ "id": "cmp4_pickup_rate_pickup", "type": "register", "position": { - "x": 1400, - "y": 40 + "x": 1680, + "y": 36 }, "data": { "kind": "register", @@ -75,8 +76,8 @@ "id": "fund_free", "type": "source", "position": { - "x": 40, - "y": 370 + "x": 48, + "y": 404 }, "data": { "kind": "source", @@ -90,8 +91,8 @@ "id": "ticket_free", "type": "pool", "position": { - "x": 230, - "y": 370 + "x": 288, + "y": 404 }, "data": { "kind": "pool", @@ -107,8 +108,8 @@ "id": "pulls_made_free", "type": "pool", "position": { - "x": 40, - "y": 730 + "x": 48, + "y": 756 }, "data": { "kind": "pool", @@ -124,8 +125,8 @@ "id": "zone1_free_w_ssr", "type": "parameter", "position": { - "x": 300, - "y": 730 + "x": 400, + "y": 756 }, "data": { "kind": "parameter", @@ -137,8 +138,8 @@ "id": "zone1_free_w_sr", "type": "parameter", "position": { - "x": 560, - "y": 730 + "x": 784, + "y": 756 }, "data": { "kind": "parameter", @@ -150,8 +151,8 @@ "id": "zone1_free_w_r", "type": "parameter", "position": { - "x": 40, - "y": 870 + "x": 48, + "y": 900 }, "data": { "kind": "parameter", @@ -163,8 +164,8 @@ "id": "sr_count_free", "type": "pool", "position": { - "x": 800, - "y": 370 + "x": 1024, + "y": 404 }, "data": { "kind": "pool", @@ -180,8 +181,8 @@ "id": "r_count_free", "type": "pool", "position": { - "x": 800, - "y": 470 + "x": 1024, + "y": 500 }, "data": { "kind": "pool", @@ -197,8 +198,8 @@ "id": "ssr_count_free", "type": "pool", "position": { - "x": 800, - "y": 570 + "x": 1024, + "y": 596 }, "data": { "kind": "pool", @@ -214,8 +215,8 @@ "id": "roll_gate_free", "type": "gate", "position": { - "x": 420, - "y": 370 + "x": 512, + "y": 404 }, "data": { "kind": "gate", @@ -230,8 +231,8 @@ "id": "ssr_hit_free", "type": "gate", "position": { - "x": 610, - "y": 370 + "x": 832, + "y": 404 }, "data": { "kind": "gate", @@ -246,8 +247,8 @@ "id": "sr_hit_free", "type": "gate", "position": { - "x": 610, - "y": 470 + "x": 832, + "y": 500 }, "data": { "kind": "gate", @@ -262,8 +263,8 @@ "id": "r_hit_free", "type": "gate", "position": { - "x": 610, - "y": 570 + "x": 832, + "y": 596 }, "data": { "kind": "gate", @@ -278,8 +279,8 @@ "id": "fund_standard", "type": "source", "position": { - "x": 1260, - "y": 370 + "x": 1488, + "y": 404 }, "data": { "kind": "source", @@ -293,8 +294,8 @@ "id": "ticket_standard", "type": "pool", "position": { - "x": 1450, - "y": 370 + "x": 1728, + "y": 404 }, "data": { "kind": "pool", @@ -310,8 +311,8 @@ "id": "pulls_made_standard", "type": "pool", "position": { - "x": 1520, - "y": 730 + "x": 1792, + "y": 756 }, "data": { "kind": "pool", @@ -327,8 +328,8 @@ "id": "zone2_standard_w_ssr", "type": "parameter", "position": { - "x": 1780, - "y": 730 + "x": 2048, + "y": 756 }, "data": { "kind": "parameter", @@ -340,8 +341,8 @@ "id": "zone2_standard_w_sr", "type": "parameter", "position": { - "x": 1260, - "y": 870 + "x": 1488, + "y": 900 }, "data": { "kind": "parameter", @@ -353,8 +354,8 @@ "id": "zone2_standard_w_r", "type": "parameter", "position": { - "x": 1520, - "y": 870 + "x": 1792, + "y": 900 }, "data": { "kind": "parameter", @@ -366,8 +367,8 @@ "id": "sr_count_standard", "type": "pool", "position": { - "x": 2020, - "y": 370 + "x": 2368, + "y": 404 }, "data": { "kind": "pool", @@ -383,8 +384,8 @@ "id": "r_count_standard", "type": "pool", "position": { - "x": 2020, - "y": 470 + "x": 2368, + "y": 500 }, "data": { "kind": "pool", @@ -400,8 +401,8 @@ "id": "ssr_count_standard", "type": "pool", "position": { - "x": 2020, - "y": 570 + "x": 2368, + "y": 596 }, "data": { "kind": "pool", @@ -417,8 +418,8 @@ "id": "roll_gate_standard", "type": "gate", "position": { - "x": 1640, - "y": 370 + "x": 1920, + "y": 404 }, "data": { "kind": "gate", @@ -433,8 +434,8 @@ "id": "ssr_hit_standard", "type": "gate", "position": { - "x": 1830, - "y": 370 + "x": 2176, + "y": 404 }, "data": { "kind": "gate", @@ -449,8 +450,8 @@ "id": "sr_hit_standard", "type": "gate", "position": { - "x": 1830, - "y": 470 + "x": 2176, + "y": 500 }, "data": { "kind": "gate", @@ -465,8 +466,8 @@ "id": "r_hit_standard", "type": "gate", "position": { - "x": 1830, - "y": 570 + "x": 2096, + "y": 596 }, "data": { "kind": "gate", @@ -481,8 +482,8 @@ "id": "pity_standard", "type": "pool", "position": { - "x": 1260, - "y": 730 + "x": 1488, + "y": 756 }, "data": { "kind": "pool", @@ -498,8 +499,8 @@ "id": "zone2_standard_hard_pity", "type": "parameter", "position": { - "x": 1780, - "y": 870 + "x": 2048, + "y": 900 }, "data": { "kind": "parameter", @@ -511,8 +512,8 @@ "id": "forced_ssr_standard", "type": "gate", "position": { - "x": 1640, - "y": 470 + "x": 1920, + "y": 500 }, "data": { "kind": "gate", @@ -527,8 +528,8 @@ "id": "ceiling_hits_standard", "type": "pool", "position": { - "x": 1260, - "y": 1010 + "x": 1488, + "y": 1044 }, "data": { "kind": "pool", @@ -544,8 +545,8 @@ "id": "fund_pickup", "type": "source", "position": { - "x": 2480, - "y": 370 + "x": 2832, + "y": 404 }, "data": { "kind": "source", @@ -559,8 +560,8 @@ "id": "ticket_pickup", "type": "pool", "position": { - "x": 2800, - "y": 370 + "x": 3152, + "y": 404 }, "data": { "kind": "pool", @@ -576,8 +577,8 @@ "id": "pulls_made_pickup", "type": "pool", "position": { - "x": 3000, - "y": 830 + "x": 3360, + "y": 868 }, "data": { "kind": "pool", @@ -593,8 +594,8 @@ "id": "zone3_pickup_w_ssr", "type": "parameter", "position": { - "x": 2480, - "y": 970 + "x": 2832, + "y": 996 }, "data": { "kind": "parameter", @@ -606,8 +607,8 @@ "id": "zone3_pickup_w_sr", "type": "parameter", "position": { - "x": 2740, - "y": 970 + "x": 3088, + "y": 996 }, "data": { "kind": "parameter", @@ -619,8 +620,8 @@ "id": "zone3_pickup_w_r", "type": "parameter", "position": { - "x": 3000, - "y": 970 + "x": 3360, + "y": 996 }, "data": { "kind": "parameter", @@ -632,8 +633,8 @@ "id": "sr_count_pickup", "type": "pool", "position": { - "x": 3760, - "y": 370 + "x": 4112, + "y": 404 }, "data": { "kind": "pool", @@ -649,8 +650,8 @@ "id": "r_count_pickup", "type": "pool", "position": { - "x": 3760, - "y": 470 + "x": 4112, + "y": 500 }, "data": { "kind": "pool", @@ -666,8 +667,8 @@ "id": "ssr_count_pickup", "type": "pool", "position": { - "x": 2480, - "y": 470 + "x": 2832, + "y": 500 }, "data": { "kind": "pool", @@ -683,8 +684,8 @@ "id": "pity_pickup", "type": "pool", "position": { - "x": 2480, - "y": 830 + "x": 2832, + "y": 868 }, "data": { "kind": "pool", @@ -700,8 +701,8 @@ "id": "zone3_pickup_hard_pity", "type": "parameter", "position": { - "x": 2480, - "y": 1110 + "x": 2832, + "y": 1140 }, "data": { "kind": "parameter", @@ -713,8 +714,8 @@ "id": "ceiling_hits_pickup", "type": "pool", "position": { - "x": 2740, - "y": 1110 + "x": 3088, + "y": 1140 }, "data": { "kind": "pool", @@ -730,8 +731,8 @@ "id": "missed_pickup_pickup", "type": "pool", "position": { - "x": 2740, - "y": 830 + "x": 3088, + "y": 868 }, "data": { "kind": "pool", @@ -747,8 +748,8 @@ "id": "zone3_pickup_w_pickup", "type": "parameter", "position": { - "x": 3000, - "y": 1110 + "x": 3360, + "y": 1140 }, "data": { "kind": "parameter", @@ -760,8 +761,8 @@ "id": "zone3_pickup_w_standard", "type": "parameter", "position": { - "x": 2480, - "y": 1250 + "x": 2832, + "y": 1284 }, "data": { "kind": "parameter", @@ -773,8 +774,8 @@ "id": "roll_normal_open_pickup", "type": "gate", "position": { - "x": 3120, - "y": 370 + "x": 3472, + "y": 404 }, "data": { "kind": "gate", @@ -789,8 +790,8 @@ "id": "roll_normal_owed_pickup", "type": "gate", "position": { - "x": 3120, - "y": 470 + "x": 3472, + "y": 500 }, "data": { "kind": "gate", @@ -805,8 +806,8 @@ "id": "roll_forced_open_pickup", "type": "gate", "position": { - "x": 3120, - "y": 570 + "x": 3472, + "y": 596 }, "data": { "kind": "gate", @@ -821,8 +822,8 @@ "id": "roll_forced_owed_pickup", "type": "gate", "position": { - "x": 3120, - "y": 670 + "x": 3472, + "y": 708 }, "data": { "kind": "gate", @@ -837,8 +838,8 @@ "id": "ssr_split_open_pickup", "type": "gate", "position": { - "x": 3440, - "y": 370 + "x": 3792, + "y": 404 }, "data": { "kind": "gate", @@ -853,8 +854,8 @@ "id": "pickup_hit_pickup", "type": "gate", "position": { - "x": 3760, - "y": 570 + "x": 4112, + "y": 596 }, "data": { "kind": "gate", @@ -869,8 +870,8 @@ "id": "standard_hit_pickup", "type": "gate", "position": { - "x": 3760, - "y": 670 + "x": 4112, + "y": 708 }, "data": { "kind": "gate", @@ -885,8 +886,8 @@ "id": "sr_hit_pickup", "type": "gate", "position": { - "x": 3440, - "y": 470 + "x": 3792, + "y": 500 }, "data": { "kind": "gate", @@ -901,8 +902,8 @@ "id": "r_hit_pickup", "type": "gate", "position": { - "x": 3440, - "y": 570 + "x": 3792, + "y": 596 }, "data": { "kind": "gate", @@ -917,8 +918,8 @@ "id": "pickup_count_pickup", "type": "pool", "position": { - "x": 4080, - "y": 370 + "x": 4432, + "y": 404 }, "data": { "kind": "pool", @@ -934,8 +935,8 @@ "id": "standard_count_pickup", "type": "pool", "position": { - "x": 4080, - "y": 470 + "x": 4432, + "y": 500 }, "data": { "kind": "pool", @@ -951,8 +952,8 @@ "id": "termination_fuel", "type": "pool", "position": { - "x": 40, - "y": 170 + "x": 48, + "y": 180 }, "data": { "kind": "pool", @@ -967,8 +968,8 @@ "id": "all_zones_done", "type": "end", "position": { - "x": 264, - "y": 170 + "x": 368, + "y": 164 }, "data": { "kind": "end", @@ -1191,12 +1192,6 @@ "kind": "resource", "flow": "@zone2_standard_w_r", "route": "orthogonal", - "waypoints": [ - { - "x": 1740, - "y": 370 - } - ], "accent": "#9182A8" } }, @@ -1315,13 +1310,7 @@ "kind": "state", "mode": "activator", "expr": "< @zone2_standard_hard_pity - 1", - "route": "orthogonal", - "waypoints": [ - { - "x": 1200, - "y": 550 - } - ] + "route": "orthogonal" } }, { @@ -1335,13 +1324,7 @@ "kind": "state", "mode": "activator", "expr": ">= @zone2_standard_hard_pity - 1", - "route": "orthogonal", - "waypoints": [ - { - "x": 1320, - "y": 550 - } - ] + "route": "orthogonal" } }, { @@ -1563,13 +1546,7 @@ "kind": "state", "mode": "activator", "expr": "< 1", - "route": "orthogonal", - "waypoints": [ - { - "x": 2680, - "y": 600 - } - ] + "route": "orthogonal" } }, { @@ -1583,13 +1560,7 @@ "kind": "state", "mode": "activator", "expr": "< @zone3_pickup_hard_pity - 1", - "route": "orthogonal", - "waypoints": [ - { - "x": 2420, - "y": 600 - } - ] + "route": "orthogonal" } }, { @@ -1603,13 +1574,7 @@ "kind": "state", "mode": "activator", "expr": ">= 1", - "route": "orthogonal", - "waypoints": [ - { - "x": 2800, - "y": 600 - } - ] + "route": "orthogonal" } }, { @@ -1623,13 +1588,7 @@ "kind": "state", "mode": "activator", "expr": ">= @zone3_pickup_hard_pity - 1", - "route": "orthogonal", - "waypoints": [ - { - "x": 2540, - "y": 600 - } - ] + "route": "orthogonal" } }, { @@ -1883,17 +1842,7 @@ "expr": "=0", "timing": "afterPull", "when": "source-fired", - "route": "orthogonal", - "waypoints": [ - { - "x": 3760, - "y": 780 - }, - { - "x": 2480, - "y": 780 - } - ] + "route": "orthogonal" } }, { @@ -2005,13 +1954,7 @@ "expr": "+1", "timing": "afterPull", "when": "source-fired", - "route": "orthogonal", - "waypoints": [ - { - "x": 3200, - "y": 700 - } - ] + "route": "orthogonal" } }, { @@ -2027,13 +1970,7 @@ "expr": "+1", "timing": "afterPull", "when": "source-fired", - "route": "orthogonal", - "waypoints": [ - { - "x": 3200, - "y": 650 - } - ] + "route": "orthogonal" } }, { @@ -2049,17 +1986,7 @@ "expr": "+1", "timing": "afterPull", "when": "source-fired", - "route": "orthogonal", - "waypoints": [ - { - "x": 3827, - "y": 340 - }, - { - "x": 2539, - "y": 340 - } - ] + "route": "orthogonal" } }, { @@ -2107,13 +2034,7 @@ "expr": "=1", "timing": "afterPull", "when": "source-fired", - "route": "orthogonal", - "waypoints": [ - { - "x": 3200, - "y": 750 - } - ] + "route": "orthogonal" } }, { @@ -2218,9 +2139,9 @@ "label": "Comparison", "rect": { "x": 0, - "y": 0, - "w": 1720, - "h": 300 + "y": -16, + "w": 2000, + "h": 336 } }, { @@ -2228,9 +2149,9 @@ "label": "General / Free", "rect": { "x": 0, - "y": 330, - "w": 950, - "h": 620 + "y": 352, + "w": 1232, + "h": 688 }, "color": "sage" }, @@ -2238,10 +2159,10 @@ "id": "zone_standard", "label": "Premium Standard", "rect": { - "x": 1220, - "y": 330, - "w": 950, - "h": 760 + "x": 1440, + "y": 352, + "w": 1152, + "h": 800 }, "color": "violet" }, @@ -2249,10 +2170,10 @@ "id": "zone_pickup", "label": "Premium Pickup", "rect": { - "x": 2440, - "y": 330, - "w": 1790, - "h": 1000 + "x": 2784, + "y": 352, + "w": 1872, + "h": 1072 }, "color": "rose" } diff --git a/examples/mmo-progression.json b/examples/mmo-progression.json index 44230a8c..7274f9d2 100644 --- a/examples/mmo-progression.json +++ b/examples/mmo-progression.json @@ -1,13 +1,14 @@ { "schema": "loop-studio/graph", "version": 1, + "layoutVersion": 1, "nodes": [ { "id": "level", "type": "pool", "position": { - "x": 1520, - "y": 800 + "x": 2128, + "y": 980 }, "data": { "kind": "pool", @@ -23,8 +24,8 @@ "id": "xp", "type": "pool", "position": { - "x": 1120, - "y": 800 + "x": 1552, + "y": 980 }, "data": { "kind": "pool", @@ -40,8 +41,8 @@ "id": "xp_earned", "type": "pool", "position": { - "x": 1320, - "y": 800 + "x": 1792, + "y": 980 }, "data": { "kind": "pool", @@ -57,8 +58,8 @@ "id": "reward", "type": "pool", "position": { - "x": 1720, - "y": 800 + "x": 2464, + "y": 980 }, "data": { "kind": "pool", @@ -74,8 +75,8 @@ "id": "fail_pool", "type": "pool", "position": { - "x": 560, - "y": 800 + "x": 768, + "y": 980 }, "data": { "kind": "pool", @@ -90,8 +91,8 @@ "id": "death_pool", "type": "pool", "position": { - "x": 720, - "y": 800 + "x": 1040, + "y": 980 }, "data": { "kind": "pool", @@ -106,8 +107,8 @@ "id": "reward_router", "type": "gate", "position": { - "x": 1590, - "y": 940 + "x": 2320, + "y": 1188 }, "data": { "kind": "gate", @@ -120,8 +121,8 @@ "id": "hunt_payout", "type": "converter", "position": { - "x": 1820, - "y": 890 + "x": 2688, + "y": 1060 }, "data": { "kind": "converter", @@ -135,8 +136,8 @@ "id": "quest_payout", "type": "converter", "position": { - "x": 1800, - "y": 1000 + "x": 2592, + "y": 1252 }, "data": { "kind": "converter", @@ -150,8 +151,8 @@ "id": "hunt_xp", "type": "pool", "position": { - "x": 2030, - "y": 890 + "x": 2960, + "y": 1060 }, "data": { "kind": "pool", @@ -167,8 +168,8 @@ "id": "quest_xp", "type": "pool", "position": { - "x": 2030, - "y": 980 + "x": 2960, + "y": 1236 }, "data": { "kind": "pool", @@ -184,8 +185,8 @@ "id": "fail_conv", "type": "converter", "position": { - "x": 730, - "y": 940 + "x": 1072, + "y": 1188 }, "data": { "kind": "converter", @@ -198,8 +199,8 @@ "id": "death_conv", "type": "converter", "position": { - "x": 900, - "y": 940 + "x": 1328, + "y": 1188 }, "data": { "kind": "converter", @@ -212,8 +213,8 @@ "id": "combat_wins", "type": "pool", "position": { - "x": 550, - "y": 890 + "x": 752, + "y": 1124 }, "data": { "kind": "pool", @@ -228,8 +229,8 @@ "id": "combat_fails", "type": "pool", "position": { - "x": 550, - "y": 980 + "x": 768, + "y": 1236 }, "data": { "kind": "pool", @@ -244,8 +245,8 @@ "id": "deaths", "type": "pool", "position": { - "x": 570, - "y": 1070 + "x": 800, + "y": 1332 }, "data": { "kind": "pool", @@ -260,8 +261,8 @@ "id": "gold", "type": "pool", "position": { - "x": 1500, - "y": 1130 + "x": 2112, + "y": 1396 }, "data": { "kind": "pool", @@ -278,8 +279,8 @@ "id": "gold_earned", "type": "pool", "position": { - "x": 1680, - "y": 1140 + "x": 2416, + "y": 1412 }, "data": { "kind": "pool", @@ -295,8 +296,8 @@ "id": "vendor_revenue", "type": "pool", "position": { - "x": 1860, - "y": 1140 + "x": 2736, + "y": 1412 }, "data": { "kind": "pool", @@ -312,8 +313,8 @@ "id": "repair_spend", "type": "pool", "position": { - "x": 1520, - "y": 1250 + "x": 2160, + "y": 1524 }, "data": { "kind": "pool", @@ -329,8 +330,8 @@ "id": "resupply_spend", "type": "pool", "position": { - "x": 1700, - "y": 1340 + "x": 2432, + "y": 1636 }, "data": { "kind": "pool", @@ -346,8 +347,8 @@ "id": "training_spend", "type": "pool", "position": { - "x": 1860, - "y": 1250 + "x": 2720, + "y": 1524 }, "data": { "kind": "pool", @@ -363,8 +364,8 @@ "id": "water", "type": "pool", "position": { - "x": 1540, - "y": 1430 + "x": 2272, + "y": 1748 }, "data": { "kind": "pool", @@ -380,8 +381,8 @@ "id": "food", "type": "pool", "position": { - "x": 1520, - "y": 1530 + "x": 2128, + "y": 1844 }, "data": { "kind": "pool", @@ -397,8 +398,8 @@ "id": "water_bought", "type": "pool", "position": { - "x": 1720, - "y": 1480 + "x": 2512, + "y": 1796 }, "data": { "kind": "pool", @@ -413,8 +414,8 @@ "id": "food_bought", "type": "pool", "position": { - "x": 1940, - "y": 1480 + "x": 2864, + "y": 1796 }, "data": { "kind": "pool", @@ -429,8 +430,8 @@ "id": "water_consumed", "type": "pool", "position": { - "x": 1200, - "y": 1220 + "x": 1648, + "y": 1492 }, "data": { "kind": "pool", @@ -445,8 +446,8 @@ "id": "food_consumed", "type": "pool", "position": { - "x": 1130, - "y": 1400 + "x": 1568, + "y": 1716 }, "data": { "kind": "pool", @@ -461,8 +462,8 @@ "id": "water_upkeep", "type": "converter", "position": { - "x": 1330, - "y": 1310 + "x": 1920, + "y": 1588 }, "data": { "kind": "converter", @@ -475,8 +476,8 @@ "id": "food_upkeep", "type": "converter", "position": { - "x": 1370, - "y": 1400 + "x": 1984, + "y": 1716 }, "data": { "kind": "converter", @@ -489,8 +490,8 @@ "id": "resupply", "type": "converter", "position": { - "x": 1700, - "y": 1250 + "x": 2432, + "y": 1524 }, "data": { "kind": "converter", @@ -504,8 +505,8 @@ "id": "gear_score", "type": "pool", "position": { - "x": 2340, - "y": 1080 + "x": 3536, + "y": 1332 }, "data": { "kind": "pool", @@ -521,8 +522,8 @@ "id": "gear_wear", "type": "pool", "position": { - "x": 2160, - "y": 1080 + "x": 3168, + "y": 1348 }, "data": { "kind": "pool", @@ -537,8 +538,8 @@ "id": "wear_cleared", "type": "pool", "position": { - "x": 2160, - "y": 800 + "x": 3168, + "y": 980 }, "data": { "kind": "pool", @@ -553,8 +554,8 @@ "id": "repair_wear", "type": "converter", "position": { - "x": 2190, - "y": 890 + "x": 3248, + "y": 1092 }, "data": { "kind": "converter", @@ -567,8 +568,8 @@ "id": "repair_gold", "type": "converter", "position": { - "x": 2220, - "y": 990 + "x": 3280, + "y": 1252 }, "data": { "kind": "converter", @@ -582,8 +583,8 @@ "id": "drop", "type": "pool", "position": { - "x": 380, - "y": 800 + "x": 512, + "y": 980 }, "data": { "kind": "pool", @@ -599,8 +600,8 @@ "id": "loot_feed", "type": "pool", "position": { - "x": 370, - "y": 1070 + "x": 496, + "y": 1332 }, "data": { "kind": "pool", @@ -616,8 +617,8 @@ "id": "loot_dispatch", "type": "converter", "position": { - "x": 370, - "y": 940 + "x": 496, + "y": 1188 }, "data": { "kind": "converter", @@ -631,8 +632,8 @@ "id": "loot_category", "type": "gate", "position": { - "x": 380, - "y": 1180 + "x": 512, + "y": 1460 }, "data": { "kind": "gate", @@ -646,8 +647,8 @@ "id": "bucket_equip", "type": "pool", "position": { - "x": 270, - "y": 1300 + "x": 400, + "y": 1572 }, "data": { "kind": "pool", @@ -663,8 +664,8 @@ "id": "bucket_vendor", "type": "pool", "position": { - "x": 440, - "y": 1300 + "x": 640, + "y": 1572 }, "data": { "kind": "pool", @@ -680,8 +681,8 @@ "id": "bucket_consumable", "type": "pool", "position": { - "x": 640, - "y": 1300 + "x": 960, + "y": 1572 }, "data": { "kind": "pool", @@ -697,8 +698,8 @@ "id": "bucket_rare", "type": "pool", "position": { - "x": 850, - "y": 1300 + "x": 1232, + "y": 1572 }, "data": { "kind": "pool", @@ -714,8 +715,8 @@ "id": "items_looted", "type": "pool", "position": { - "x": 200, - "y": 940 + "x": 208, + "y": 1188 }, "data": { "kind": "pool", @@ -732,7 +733,7 @@ "type": "pool", "position": { "x": 80, - "y": 1290 + "y": 1556 }, "data": { "kind": "pool", @@ -748,8 +749,8 @@ "id": "items_sold", "type": "pool", "position": { - "x": 100, - "y": 1390 + "x": 96, + "y": 1684 }, "data": { "kind": "pool", @@ -765,8 +766,8 @@ "id": "items_consumed", "type": "pool", "position": { - "x": 90, - "y": 1480 + "x": 96, + "y": 1796 }, "data": { "kind": "pool", @@ -782,8 +783,8 @@ "id": "equip_conv", "type": "converter", "position": { - "x": 290, - "y": 1420 + "x": 352, + "y": 1748 }, "data": { "kind": "converter", @@ -797,8 +798,8 @@ "id": "vendor_conv", "type": "converter", "position": { - "x": 450, - "y": 1420 + "x": 640, + "y": 1748 }, "data": { "kind": "converter", @@ -812,8 +813,8 @@ "id": "consumable_conv", "type": "converter", "position": { - "x": 630, - "y": 1420 + "x": 896, + "y": 1748 }, "data": { "kind": "converter", @@ -827,8 +828,8 @@ "id": "rare_conv", "type": "converter", "position": { - "x": 830, - "y": 1420 + "x": 1168, + "y": 1748 }, "data": { "kind": "converter", @@ -842,8 +843,8 @@ "id": "elapsed", "type": "pool", "position": { - "x": 3020, - "y": 290 + "x": 4272, + "y": 356 }, "data": { "kind": "pool", @@ -858,8 +859,8 @@ "id": "clock", "type": "source", "position": { - "x": 2820, - "y": 290 + "x": 4016, + "y": 356 }, "data": { "kind": "source", @@ -872,8 +873,8 @@ "id": "completion", "type": "pool", "position": { - "x": 3030, - "y": 170 + "x": 4256, + "y": 196 }, "data": { "kind": "pool", @@ -888,8 +889,8 @@ "id": "completion_src", "type": "source", "position": { - "x": 2810, - "y": 170 + "x": 4016, + "y": 196 }, "data": { "kind": "source", @@ -902,8 +903,8 @@ "id": "end15", "type": "end", "position": { - "x": 3020, - "y": 40 + "x": 4224, + "y": 36 }, "data": { "kind": "end", @@ -915,8 +916,8 @@ "id": "z1_enc_src", "type": "source", "position": { - "x": 460, - "y": 160 + "x": 656, + "y": 164 }, "data": { "kind": "source", @@ -929,8 +930,8 @@ "id": "z1_enc", "type": "pool", "position": { - "x": 720, - "y": 40 + "x": 1040, + "y": 36 }, "data": { "kind": "pool", @@ -945,8 +946,8 @@ "id": "z1_combat", "type": "gate", "position": { - "x": 690, - "y": 300 + "x": 1008, + "y": 372 }, "data": { "kind": "gate", @@ -959,8 +960,8 @@ "id": "z1_win", "type": "pool", "position": { - "x": 460, - "y": 320 + "x": 656, + "y": 388 }, "data": { "kind": "pool", @@ -975,8 +976,8 @@ "id": "z1_winamp", "type": "converter", "position": { - "x": 460, - "y": 460 + "x": 656, + "y": 564 }, "data": { "kind": "converter", @@ -989,8 +990,8 @@ "id": "z1_lootroll", "type": "pool", "position": { - "x": 460, - "y": 600 + "x": 656, + "y": 740 }, "data": { "kind": "pool", @@ -1006,8 +1007,8 @@ "id": "z1_loot", "type": "gate", "position": { - "x": 690, - "y": 620 + "x": 1008, + "y": 772 }, "data": { "kind": "gate", @@ -1021,8 +1022,8 @@ "id": "z1_xp_meter", "type": "gate", "position": { - "x": 960, - "y": 290 + "x": 1392, + "y": 356 }, "data": { "kind": "gate", @@ -1037,8 +1038,8 @@ "id": "z1_xp2lvl", "type": "converter", "position": { - "x": 960, - "y": 440 + "x": 1392, + "y": 532 }, "data": { "kind": "converter", @@ -1052,8 +1053,8 @@ "id": "z1_training", "type": "converter", "position": { - "x": 690, - "y": 470 + "x": 1008, + "y": 564 }, "data": { "kind": "converter", @@ -1066,8 +1067,8 @@ "id": "z2_enc_src", "type": "source", "position": { - "x": 1200, - "y": 160 + "x": 1632, + "y": 164 }, "data": { "kind": "source", @@ -1080,8 +1081,8 @@ "id": "z2_enc", "type": "pool", "position": { - "x": 1460, - "y": 40 + "x": 2064, + "y": 36 }, "data": { "kind": "pool", @@ -1096,8 +1097,8 @@ "id": "z2_combat", "type": "gate", "position": { - "x": 1430, - "y": 300 + "x": 2032, + "y": 372 }, "data": { "kind": "gate", @@ -1110,8 +1111,8 @@ "id": "z2_win", "type": "pool", "position": { - "x": 1200, - "y": 320 + "x": 1648, + "y": 388 }, "data": { "kind": "pool", @@ -1126,8 +1127,8 @@ "id": "z2_winamp", "type": "converter", "position": { - "x": 1200, - "y": 460 + "x": 1664, + "y": 564 }, "data": { "kind": "converter", @@ -1140,8 +1141,8 @@ "id": "z2_lootroll", "type": "pool", "position": { - "x": 1200, - "y": 600 + "x": 1632, + "y": 740 }, "data": { "kind": "pool", @@ -1157,8 +1158,8 @@ "id": "z2_loot", "type": "gate", "position": { - "x": 1430, - "y": 620 + "x": 2032, + "y": 772 }, "data": { "kind": "gate", @@ -1172,8 +1173,8 @@ "id": "z2_xp_meter", "type": "gate", "position": { - "x": 1700, - "y": 290 + "x": 2432, + "y": 356 }, "data": { "kind": "gate", @@ -1188,8 +1189,8 @@ "id": "z2_xp2lvl", "type": "converter", "position": { - "x": 1700, - "y": 440 + "x": 2432, + "y": 532 }, "data": { "kind": "converter", @@ -1203,8 +1204,8 @@ "id": "z2_training", "type": "converter", "position": { - "x": 1430, - "y": 470 + "x": 2032, + "y": 564 }, "data": { "kind": "converter", @@ -1217,8 +1218,8 @@ "id": "z3_enc_src", "type": "source", "position": { - "x": 1940, - "y": 160 + "x": 2816, + "y": 164 }, "data": { "kind": "source", @@ -1231,8 +1232,8 @@ "id": "z3_enc", "type": "pool", "position": { - "x": 2200, - "y": 40 + "x": 3264, + "y": 36 }, "data": { "kind": "pool", @@ -1247,8 +1248,8 @@ "id": "z3_combat", "type": "gate", "position": { - "x": 2170, - "y": 300 + "x": 3184, + "y": 372 }, "data": { "kind": "gate", @@ -1261,8 +1262,8 @@ "id": "z3_win", "type": "pool", "position": { - "x": 1940, - "y": 320 + "x": 2816, + "y": 388 }, "data": { "kind": "pool", @@ -1277,8 +1278,8 @@ "id": "z3_winamp", "type": "converter", "position": { - "x": 1940, - "y": 460 + "x": 2864, + "y": 564 }, "data": { "kind": "converter", @@ -1291,8 +1292,8 @@ "id": "z3_lootroll", "type": "pool", "position": { - "x": 1940, - "y": 600 + "x": 2816, + "y": 740 }, "data": { "kind": "pool", @@ -1308,8 +1309,8 @@ "id": "z3_loot", "type": "gate", "position": { - "x": 2170, - "y": 620 + "x": 3184, + "y": 772 }, "data": { "kind": "gate", @@ -1323,8 +1324,8 @@ "id": "z3_xp_meter", "type": "gate", "position": { - "x": 2440, - "y": 290 + "x": 3648, + "y": 356 }, "data": { "kind": "gate", @@ -1339,8 +1340,8 @@ "id": "z3_xp2lvl", "type": "converter", "position": { - "x": 2440, - "y": 440 + "x": 3648, + "y": 532 }, "data": { "kind": "converter", @@ -1354,8 +1355,8 @@ "id": "z3_training", "type": "converter", "position": { - "x": 2170, - "y": 470 + "x": 3184, + "y": 564 }, "data": { "kind": "converter", @@ -1368,8 +1369,8 @@ "id": "void", "type": "drain", "position": { - "x": 1920, - "y": 780 + "x": 2800, + "y": 964 }, "data": { "kind": "drain", @@ -1382,8 +1383,8 @@ "id": "char_creation", "type": "source", "position": { - "x": 40, - "y": 40 + "x": 48, + "y": 36 }, "data": { "kind": "source", @@ -1396,8 +1397,8 @@ "id": "active_char", "type": "pool", "position": { - "x": 320, - "y": 40 + "x": 448, + "y": 36 }, "data": { "kind": "pool", @@ -1412,8 +1413,8 @@ "id": "r_income", "type": "register", "position": { - "x": 3280, - "y": 260 + "x": 4528, + "y": 324 }, "data": { "kind": "register", @@ -1427,8 +1428,8 @@ "id": "r_expense", "type": "register", "position": { - "x": 3280, - "y": 370 + "x": 4528, + "y": 468 }, "data": { "kind": "register", @@ -1442,8 +1443,8 @@ "id": "r_netgold", "type": "register", "position": { - "x": 3280, - "y": 480 + "x": 4528, + "y": 596 }, "data": { "kind": "register", @@ -1457,8 +1458,8 @@ "id": "r_huntshare", "type": "register", "position": { - "x": 3280, - "y": 150 + "x": 4528, + "y": 180 }, "data": { "kind": "register", @@ -1471,8 +1472,8 @@ "id": "r_efflevel", "type": "register", "position": { - "x": 3280, - "y": 40 + "x": 4528, + "y": 36 }, "data": { "kind": "register", @@ -1485,8 +1486,8 @@ "id": "r_items_acct", "type": "register", "position": { - "x": 3280, - "y": 590 + "x": 4528, + "y": 740 }, "data": { "kind": "register", @@ -1500,8 +1501,8 @@ "id": "r_burned", "type": "register", "position": { - "x": 3280, - "y": 700 + "x": 4528, + "y": 884 }, "data": { "kind": "register", @@ -1522,7 +1523,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "all" + "flow": "all", + "route": "orthogonal" } }, { @@ -1534,7 +1536,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "3" + "flow": "3", + "route": "orthogonal" } }, { @@ -1546,7 +1549,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -1558,7 +1562,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "3" + "flow": "3", + "route": "orthogonal" } }, { @@ -1570,7 +1575,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "3" + "flow": "3", + "route": "orthogonal" } }, { @@ -1582,7 +1588,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "3" + "flow": "3", + "route": "orthogonal" } }, { @@ -1595,6 +1602,7 @@ "data": { "kind": "resource", "flow": "2", + "route": "orthogonal", "accent": "#A78243" } }, @@ -1608,6 +1616,7 @@ "data": { "kind": "resource", "flow": "2", + "route": "orthogonal", "accent": "#A78243" } }, @@ -1620,7 +1629,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "9" + "flow": "9", + "route": "orthogonal" } }, { @@ -1632,7 +1642,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "9" + "flow": "9", + "route": "orthogonal" } }, { @@ -1644,7 +1655,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "9" + "flow": "9", + "route": "orthogonal" } }, { @@ -1657,6 +1669,7 @@ "data": { "kind": "resource", "flow": "7", + "route": "orthogonal", "accent": "#A78243" } }, @@ -1670,6 +1683,7 @@ "data": { "kind": "resource", "flow": "7", + "route": "orthogonal", "accent": "#A78243" } }, @@ -1682,7 +1696,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -1694,7 +1709,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -1706,7 +1722,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1.5" + "flow": "1.5", + "route": "orthogonal" } }, { @@ -1718,7 +1735,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -1730,7 +1748,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -1742,7 +1761,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -1754,7 +1774,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "3" + "flow": "3", + "route": "orthogonal" } }, { @@ -1766,7 +1787,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "3" + "flow": "3", + "route": "orthogonal" } }, { @@ -1778,7 +1800,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -1790,7 +1813,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -1802,7 +1826,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -1814,7 +1839,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -1827,6 +1853,7 @@ "data": { "kind": "resource", "flow": "6", + "route": "orthogonal", "accent": "#A78243" } }, @@ -1839,7 +1866,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "8" + "flow": "8", + "route": "orthogonal" } }, { @@ -1851,7 +1879,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "8" + "flow": "8", + "route": "orthogonal" } }, { @@ -1863,7 +1892,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "8" + "flow": "8", + "route": "orthogonal" } }, { @@ -1875,7 +1905,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "8" + "flow": "8", + "route": "orthogonal" } }, { @@ -1888,6 +1919,7 @@ "data": { "kind": "resource", "flow": "6", + "route": "orthogonal", "accent": "#A78243" } }, @@ -1901,7 +1933,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": "< 5" + "expr": "< 5", + "route": "orthogonal" } }, { @@ -1913,7 +1946,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "4" + "flow": "4", + "route": "orthogonal" } }, { @@ -1925,7 +1959,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "4" + "flow": "4", + "route": "orthogonal" } }, { @@ -1938,6 +1973,7 @@ "data": { "kind": "resource", "flow": "4", + "route": "orthogonal", "accent": "#A78243" } }, @@ -1951,6 +1987,7 @@ "data": { "kind": "resource", "flow": "4", + "route": "orthogonal", "accent": "#A78243" } }, @@ -1964,7 +2001,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": "> 6" + "expr": "> 6", + "route": "orthogonal" } }, { @@ -1977,7 +2015,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": ">= 1" + "expr": ">= 1", + "route": "orthogonal" } }, { @@ -1990,7 +2029,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": "> 6" + "expr": "> 6", + "route": "orthogonal" } }, { @@ -2003,7 +2043,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": ">= 1" + "expr": ">= 1", + "route": "orthogonal" } }, { @@ -2016,6 +2057,7 @@ "data": { "kind": "resource", "flow": "1", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2029,6 +2071,7 @@ "data": { "kind": "resource", "flow": "1", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2042,6 +2085,7 @@ "data": { "kind": "resource", "flow": "1", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2055,6 +2099,7 @@ "data": { "kind": "resource", "flow": "all", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2068,6 +2113,7 @@ "data": { "kind": "resource", "flow": "34", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2081,6 +2127,7 @@ "data": { "kind": "resource", "flow": "40", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2094,6 +2141,7 @@ "data": { "kind": "resource", "flow": "20", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2107,6 +2155,7 @@ "data": { "kind": "resource", "flow": "6", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2120,6 +2169,7 @@ "data": { "kind": "resource", "flow": "1", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2133,6 +2183,7 @@ "data": { "kind": "resource", "flow": "1", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2145,7 +2196,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "4" + "flow": "4", + "route": "orthogonal" } }, { @@ -2158,6 +2210,7 @@ "data": { "kind": "resource", "flow": "1", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2171,6 +2224,7 @@ "data": { "kind": "resource", "flow": "1", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2183,7 +2237,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "3" + "flow": "3", + "route": "orthogonal" } }, { @@ -2195,7 +2250,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "3" + "flow": "3", + "route": "orthogonal" } }, { @@ -2207,7 +2263,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "3" + "flow": "3", + "route": "orthogonal" } }, { @@ -2220,6 +2277,7 @@ "data": { "kind": "resource", "flow": "1", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2233,6 +2291,7 @@ "data": { "kind": "resource", "flow": "1", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2245,7 +2304,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "2" + "flow": "2", + "route": "orthogonal" } }, { @@ -2257,7 +2317,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "2" + "flow": "2", + "route": "orthogonal" } }, { @@ -2269,7 +2330,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "2" + "flow": "2", + "route": "orthogonal" } }, { @@ -2281,7 +2343,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "2" + "flow": "2", + "route": "orthogonal" } }, { @@ -2294,6 +2357,7 @@ "data": { "kind": "resource", "flow": "1", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2307,6 +2371,7 @@ "data": { "kind": "resource", "flow": "1", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2319,7 +2384,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "14" + "flow": "14", + "route": "orthogonal" } }, { @@ -2331,7 +2397,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "14" + "flow": "14", + "route": "orthogonal" } }, { @@ -2343,7 +2410,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "14" + "flow": "14", + "route": "orthogonal" } }, { @@ -2355,7 +2423,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -2367,7 +2436,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -2379,7 +2449,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "all" + "flow": "all", + "route": "orthogonal" } }, { @@ -2392,7 +2463,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": ">= 15" + "expr": ">= 15", + "route": "orthogonal" } }, { @@ -2404,7 +2476,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -2416,7 +2489,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "all" + "flow": "all", + "route": "orthogonal" } }, { @@ -2428,7 +2502,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "9" + "flow": "9", + "route": "orthogonal" } }, { @@ -2466,7 +2541,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -2504,7 +2580,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -2517,6 +2594,7 @@ "data": { "kind": "resource", "flow": "all", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2571,6 +2649,7 @@ "data": { "kind": "resource", "flow": "10", + "route": "orthogonal", "accent": "#9182A8" } }, @@ -2624,7 +2703,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": "< 5" + "expr": "< 5", + "route": "orthogonal" } }, { @@ -2637,7 +2717,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": "< 5" + "expr": "< 5", + "route": "orthogonal" } }, { @@ -2650,7 +2731,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": "< 5" + "expr": "< 5", + "route": "orthogonal" } }, { @@ -2663,7 +2745,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": "< 5" + "expr": "< 5", + "route": "orthogonal" } }, { @@ -2675,7 +2758,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -2687,7 +2771,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "all" + "flow": "all", + "route": "orthogonal" } }, { @@ -2699,7 +2784,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "7" + "flow": "7", + "route": "orthogonal" } }, { @@ -2737,7 +2823,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -2775,7 +2862,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -2788,6 +2876,7 @@ "data": { "kind": "resource", "flow": "all", + "route": "orthogonal", "accent": "#74906B" } }, @@ -2842,6 +2931,7 @@ "data": { "kind": "resource", "flow": "19", + "route": "orthogonal", "accent": "#9182A8" } }, @@ -2895,7 +2985,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": ">= 5" + "expr": ">= 5", + "route": "orthogonal" } }, { @@ -2908,7 +2999,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": "< 10" + "expr": "< 10", + "route": "orthogonal" } }, { @@ -2921,7 +3013,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": ">= 5" + "expr": ">= 5", + "route": "orthogonal" } }, { @@ -2934,7 +3027,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": "< 10" + "expr": "< 10", + "route": "orthogonal" } }, { @@ -2947,7 +3041,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": ">= 5" + "expr": ">= 5", + "route": "orthogonal" } }, { @@ -2960,7 +3055,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": "< 10" + "expr": "< 10", + "route": "orthogonal" } }, { @@ -2973,7 +3069,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": ">= 5" + "expr": ">= 5", + "route": "orthogonal" } }, { @@ -2986,7 +3083,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": "< 10" + "expr": "< 10", + "route": "orthogonal" } }, { @@ -2998,7 +3096,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -3010,7 +3109,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "all" + "flow": "all", + "route": "orthogonal" } }, { @@ -3022,7 +3122,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "6.5" + "flow": "6.5", + "route": "orthogonal" } }, { @@ -3060,7 +3161,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -3098,7 +3200,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -3111,6 +3214,7 @@ "data": { "kind": "resource", "flow": "all", + "route": "orthogonal", "accent": "#74906B" } }, @@ -3165,6 +3269,7 @@ "data": { "kind": "resource", "flow": "27", + "route": "orthogonal", "accent": "#9182A8" } }, @@ -3218,7 +3323,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": ">= 10" + "expr": ">= 10", + "route": "orthogonal" } }, { @@ -3231,7 +3337,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": ">= 4" + "expr": ">= 4", + "route": "orthogonal" } }, { @@ -3244,7 +3351,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": ">= 10" + "expr": ">= 10", + "route": "orthogonal" } }, { @@ -3257,7 +3365,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": ">= 4" + "expr": ">= 4", + "route": "orthogonal" } }, { @@ -3270,7 +3379,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": ">= 10" + "expr": ">= 10", + "route": "orthogonal" } }, { @@ -3283,7 +3393,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": ">= 4" + "expr": ">= 4", + "route": "orthogonal" } }, { @@ -3296,7 +3407,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": ">= 10" + "expr": ">= 10", + "route": "orthogonal" } }, { @@ -3309,7 +3421,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": ">= 4" + "expr": ">= 4", + "route": "orthogonal" } }, { @@ -3321,7 +3434,8 @@ "type": "loop", "data": { "kind": "resource", - "flow": "1" + "flow": "1", + "route": "orthogonal" } }, { @@ -3334,7 +3448,8 @@ "data": { "kind": "state", "mode": "activator", - "expr": ">= 1" + "expr": ">= 1", + "route": "orthogonal" } } ], diff --git a/examples/model-verification.json b/examples/model-verification.json index 8a703072..747c1701 100644 --- a/examples/model-verification.json +++ b/examples/model-verification.json @@ -1,6 +1,7 @@ { "schema": "loop-studio/graph", "version": 1, + "layoutVersion": 1, "nodes": [ { "id": "src", diff --git a/examples/module-buffered-step.json b/examples/module-buffered-step.json index 4f0ee8a9..9e0bcf61 100644 --- a/examples/module-buffered-step.json +++ b/examples/module-buffered-step.json @@ -1,6 +1,7 @@ { "schema": "loop-studio/graph", "version": 1, + "layoutVersion": 1, "nodes": [ { "id": "supply", diff --git a/examples/module-reward-split.json b/examples/module-reward-split.json index cdf6671d..3bb61f26 100644 --- a/examples/module-reward-split.json +++ b/examples/module-reward-split.json @@ -1,6 +1,7 @@ { "schema": "loop-studio/graph", "version": 1, + "layoutVersion": 1, "nodes": [ { "id": "activity", diff --git a/examples/playback-choreography.json b/examples/playback-choreography.json index ecdcee33..86ac2838 100644 --- a/examples/playback-choreography.json +++ b/examples/playback-choreography.json @@ -1,6 +1,7 @@ { "schema": "loop-studio/graph", "version": 1, + "layoutVersion": 1, "nodes": [ { "id": "fan_src", diff --git a/examples/revision/proposal.clean.json b/examples/revision/proposal.clean.json index 264d09ed..57b82f82 100644 --- a/examples/revision/proposal.clean.json +++ b/examples/revision/proposal.clean.json @@ -255,6 +255,7 @@ "author": { "name": "Fixture" } - } + }, + "semantics": "loop-revision/10" } } diff --git a/examples/revision/proposal.structural.json b/examples/revision/proposal.structural.json index 40eeadf1..dd4c56ad 100644 --- a/examples/revision/proposal.structural.json +++ b/examples/revision/proposal.structural.json @@ -195,6 +195,7 @@ "author": { "name": "Fixture" } - } + }, + "semantics": "loop-revision/10" } } diff --git a/examples/risky-factory.json b/examples/risky-factory.json index 2b1dc51a..32b9742f 100644 --- a/examples/risky-factory.json +++ b/examples/risky-factory.json @@ -1,6 +1,7 @@ { "schema": "loop-studio/graph", "version": 1, + "layoutVersion": 1, "nodes": [ { "id": "ore_source", diff --git a/examples/state-verification.json b/examples/state-verification.json index 6917e9ae..1be2c1b5 100644 --- a/examples/state-verification.json +++ b/examples/state-verification.json @@ -1,6 +1,7 @@ { "schema": "loop-studio/graph", "version": 1, + "layoutVersion": 1, "nodes": [ { "id": "pulse_src", diff --git a/package-lock.json b/package-lock.json index e81b563b..fdd52cae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loop-studio", - "version": "0.24.0", + "version": "0.25.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loop-studio", - "version": "0.24.0", + "version": "0.25.0", "dependencies": { "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", diff --git a/package.json b/package.json index a649a522..156a7271 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "loop-studio", "private": true, - "version": "0.24.0", + "version": "0.25.0", "type": "module", "engines": { "node": ">=22.12.0" diff --git a/scripts/content-direction.json b/scripts/content-direction.json index dc4bc4a0..23b87e35 100644 --- a/scripts/content-direction.json +++ b/scripts/content-direction.json @@ -8,10 +8,10 @@ "branch-computed": "the attribute is an EXPRESSION and the file contains the declared marker - one element, several branches wanting different directions" }, "totals": { - "elements": 96, + "elements": 97, "byExpect": { "auto": 37, - "branch-computed": 4, + "branch-computed": 5, "locale-direction": 13, "ltr": 42 } @@ -731,7 +731,18 @@ "text" ], "expect": "branch-computed", - "marker": "dir={textDir}" + "marker": "dir={textDir}", + "ordinal": 0 + }, + { + "file": "src/components/edges/LoopEdge.tsx", + "tag": "div", + "find": [ + "text" + ], + "expect": "branch-computed", + "marker": "dir={textDir}", + "ordinal": 1 }, { "file": "src/components/edges/LoopEdge.tsx", diff --git a/scripts/gen-gacha-banner-zones-example.ts b/scripts/gen-gacha-banner-zones-example.ts index fad04c02..ec15f492 100644 --- a/scripts/gen-gacha-banner-zones-example.ts +++ b/scripts/gen-gacha-banner-zones-example.ts @@ -74,6 +74,7 @@ import { type ZoneKey, } from '../src/engine/gachaBannerZonesGraph' import { withTemplateFlowColours } from '../src/engine/templateFlowColours' +import { placeTemplate } from '../src/model/layout/templatePlacement' import { serialize, type RecommendedRunConfig, type SavedFrame } from '../src/model/serialize' import type { LoopEdge, LoopNode } from '../src/model/types' @@ -363,7 +364,16 @@ const recommendedRunConfig: RecommendedRunConfig = { // clear of each other AND of the rows, so `e_pickup_36` alone now leaves its // bottom port DOWNWARD into the free band under the second row (y 632..670) // through one waypoint; its label lands there, clear of every label and node. +// Layout round 8 (issue #344 step 1) — the resource ports moved to a fixed row +// 28 px below each node's top, so a two-line node's connections leave higher +// than its centre. Two Premium Pickup labels then touched a node in the +// every-label check: `e_pickup_17` ("50", SSR split -> Standard) the R count +// Pool by 0.3 px, and `e_pickup_20` ("900", Roll normal owed -> R hit) the SR +// hit Gate by up to 3.3 px. One waypoint each drops the vertical leg into the +// free band under the second flow row, where the label lands clear. const WAYPOINTS: Record = { + e_pickup_17: [{ x: 3700, y: 549 }], + e_pickup_20: [{ x: 3360, y: 548 }], e_pickup_36: [{ x: 3200, y: 650 }], e_standard_12: [{ x: 1200, y: 550 }], e_standard_13: [{ x: 1320, y: 550 }], @@ -387,7 +397,17 @@ const routedEdges: LoopEdge[] = (edges as LoopEdge[]).map((e) => ({ // same as its frame's (cosmetic data, like the routing above) const coloured = withTemplateFlowColours(positioned, routedEdges, GACHA_BANNER_ZONES_FLOWS) -const text = serialize(coloured.nodes, coloured.edges, recommendedRunConfig, undefined, undefined, 2, frames) +// Layout round 9 (issue #344 step 4, docs/diagram-layout.md §DL5) — the +// layout above, placed for the grid by `placeTemplate`: each node's widest box +// over the 18 languages plus a clearance (so no language puts a node over a +// port, and every port fan has room to branch), frames that travel with their +// nodes, and every connection AUTO orthogonal — the guarded router of step 2 +// resolves every case the `WAYPOINTS` above were added for, in all 18 +// languages, so none is kept (measured: no blocked port, no fan overlap past a +// stub, no outer route). +const placed = placeTemplate('gacha-banner-zones', { ...coloured, frames: frames.map((f) => ({ ...f, rect: { ...f.rect } })) }) + +const text = serialize(placed.nodes, placed.edges, recommendedRunConfig, undefined, undefined, 2, placed.frames) const outPath = fileURLToPath(new URL('../examples/gacha-banner-zones.json', import.meta.url)) writeFileSync(outPath, text + '\n') diff --git a/scripts/icu-argument-disposition.json b/scripts/icu-argument-disposition.json index 532a82bc..06ef162f 100644 --- a/scripts/icu-argument-disposition.json +++ b/scripts/icu-argument-disposition.json @@ -6,19 +6,19 @@ "ltr": "a technical token whose direction is known — needs `isolateLtr`. RECORDED, NOT IMPLEMENTED.", "per-fragment": "several INDEPENDENT user values in one argument — needs one isolate per fragment. RECORDED, NOT IMPLEMENTED.", "closed-value": "the value provably cannot carry user text: a catalog string, an engine phrase, a formatted number, a build constant, a model-vocabulary token.", - "attribute": "the string ends up in an HTML attribute on an intrinsic element. PR B kept these as a separate axis (115 sites) because MARKUP cannot reach an attribute. An isolate could — so each row here still says what the value is, and the class records the axis rather than a verdict that it is safe. TWO DIFFERENT NUMBERS: 12 rows carry this CLASS, while 18 arguments have an attribute SINK, and they differ in both directions — 8 attribute-sink arguments are mechanically classified (a number, an enum, a catalog string) and need no row, and 2 rows are attribute by TRACING rather than by sink, because the value is built in a helper whose return value is an `aria-label` and the sink detector cannot follow a value through a function. Every row reached that way says TRACED and names the destination.", + "attribute": "the string ends up in an HTML attribute on an intrinsic element. PR B kept these as a separate axis (115 sites) because MARKUP cannot reach an attribute. An isolate could — so each row here still says what the value is, and the class records the axis rather than a verdict that it is safe. TWO DIFFERENT NUMBERS: 12 rows carry this CLASS, while 20 arguments have an attribute SINK, and they differ in both directions — 10 attribute-sink arguments are mechanically classified (a number, an enum, a catalog string) and need no row, and 2 rows are attribute by TRACING rather than by sink, because the value is built in a helper whose return value is an `aria-label` and the sink detector cannot follow a value through a function. Every row reached that way says TRACED and names the destination.", "unreachable": "the site cannot render this value at all. NO ROW USES THIS CLASS: every argument enumerated is reachable, which is worth recording as a result rather than leaving the class quietly empty." }, "$implemented": "A row classed `auto` / `ltr` / `per-fragment` is an OPEN obligation: the evidence says the value needs bounding and the code does not bound it yet. C3.5 was scoped to fix the two families that were already evidenced — the unrecognized-column header and the five `import.loc.*` calls — and those moved to `isolate-obligations.json`. The checker asserts these rows are NOT wrapped, so implementing one without moving its row is red in both directions.", "totals": { - "arguments": 217, + "arguments": 222, "rows": 53, "byClass": { "attribute": 10, "closed-value": 43 }, "recordedNotImplemented": 0, - "attributeSink": 18 + "attributeSink": 20 }, "rows": [ { diff --git a/src/components/Canvas.tsx b/src/components/Canvas.tsx index 553ce542..b2e6c8b8 100644 --- a/src/components/Canvas.tsx +++ b/src/components/Canvas.tsx @@ -12,6 +12,7 @@ import { useStoreApi, } from '@xyflow/react' import { useGraphStore, type GestureSnapshot } from '../store/graphStore' +import type { InitialView } from '../model/templates' import { BUNDLED_MODULES, cloneModuleDoc } from '../model/modules' import { refInsertVerdict, type RefResolveKind } from '../model/exprRefs' import type { LoopEdge, LoopNode, NodeKind } from '../model/types' @@ -19,6 +20,11 @@ import { useUiStore } from '../store/uiStore' import { useIsMobile } from '../ui/media' import { blocksCanvasKey } from '../ui/keyboardTarget' import { createKeyGesture } from '../ui/keyGestureLifetime' +import { dragSnap, keyMoveGuides } from '../model/layout/dragSnap' +import { beginLiveLayout, bendAtClick, bendInsertIndex, bendRejection, currentRouteMap, endLiveLayout, keyboardBend, useRouteInputs } from '../store/routeMap' +import { useRouteEditStore } from '../store/routeEditStore' +import { useGuideStore } from '../store/guideStore' +import { GRID, snapNodePosition } from '../model/layout/grid' import { type MessageKey, useI18n, useLocaleDirection, useT } from '../i18n' import { moduleLabelOverlay } from '../i18n/moduleLabels' import { useFilterStore } from '../store/filterStore' @@ -29,6 +35,7 @@ import { useFocusSet } from './focusSet' import { useHiddenSet } from './filterSet' import { FilterPanel } from './FilterPanel' import { FrameLayer } from './frames/FrameLayer' +import { SmartGuideLayer } from './SmartGuideLayer' import { PanSurface } from './PanSurface' import { useFrameStore, hasFrames } from '../store/frameStore' import { useAutoFrameStore, hasAutoFrames, autoFramesStale } from '../store/autoFrameStore' @@ -76,6 +83,13 @@ const DEFAULT_EDGE_OPTIONS = { type: 'loop' } as const // see every one of them (a template literal would read as a dead key). const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) +/** issue #344 §DL3 — an arrow key's grid direction and React Flow's word for it */ +const ARROW_DIR: Record = { + ArrowLeft: [-1, 0, 'left'], + ArrowRight: [1, 0, 'right'], + ArrowUp: [0, -1, 'up'], + ArrowDown: [0, 1, 'down'], +} const RF_DIR_KEY: Record = { left: 'rf.dir.left', right: 'rf.dir.right', @@ -106,12 +120,16 @@ export function Canvas() { const onEdgesChange = useGraphStore((s) => s.onEdgesChange) const onConnect = useGraphStore((s) => s.onConnect) const addNodeAt = useGraphStore((s) => s.addNodeAt) + const tidyToGrid = useGraphStore((s) => s.tidyToGrid) const insertModule = useGraphStore((s) => s.insertModule) const setSelection = useGraphStore((s) => s.setSelection) const { screenToFlowPosition, fitView, setViewport, getViewport, deleteElements } = useReactFlow() const rfStore = useStoreApi() const isMobile = useIsMobile() const canvasLocked = useUiStore((s) => s.canvasLocked) + // issue #344 §ER14.5 — routed connections not drawn yet (their first sliced + // generation is running): the canvas says it is busy + const routesBusy = useRouteInputs((s) => s.busy) const toggleCanvasLocked = useUiStore((s) => s.toggleCanvasLocked) const focusMode = useUiStore((s) => s.focusMode) const toggleFocusMode = useUiStore((s) => s.toggleFocusMode) @@ -214,12 +232,13 @@ export function Canvas() { // size — identical for every UI language, and unchanged on a language switch // (this only runs on a `fitRev` swap). const applyInitialView = useCallback( - (iv: { rect: { x: number; y: number; width: number; height: number }; minZoom: number }) => { + (iv: InitialView) => { // the computation itself lives in `canvasFit.ts` (shared with the // import wizard's post-commit view, docs/data-import.md §DI17); the - // insets are this Canvas's own overlays. + // insets are this Canvas's own overlays. `keep` (issue #344) caps the + // zoom so the core start nodes stay whole. const insets = canvasFitInsets(minimapVisibleRef.current) - const vp = viewportForRect(iv.rect, { width: paneW, height: paneH }, insets, { floor: iv.minZoom, ceil: 1.2 }) + const vp = viewportForRect(iv.rect, { width: paneW, height: paneH }, insets, { floor: iv.minZoom, ceil: 1.2, keep: iv.keep }) if (!vp) return void fitView({ padding: 0.3, maxZoom: 1.2 }) setViewport(vp, { duration: 0 }) if (import.meta.env.DEV) lastInitialViewRef.current = { insetR: insets.right, insetB: insets.bottom, zoom: vp.zoom } @@ -599,6 +618,85 @@ export function Canvas() { return () => document.removeEventListener('keydown', onKey, true) }, [refInsertArmed, disarmRefInsert]) + // issue #344 step 3 (docs/edge-routing.md §ER16) — "Add bend": armed from the + // Inspector for the selected connection. The next click on its line inserts + // one bend point on the segment clicked, snapped along it (Alt: free), into + // the span the click lands on; or Enter inserts one in the middle of the + // longest editable segment (`keyboardBend`) and moves the focus to its + // handle. Either is one undo entry and disarms. A point inside a node or on + // the connection's own port stub is refused (nothing changes; Enter says so + // through the canvas live region and stays armed). Escape disarms; so does + // another selection, the edit lock or the phone layout, which also drop the + // focused bend point. + const addBendFor = useRouteEditStore((s) => s.addBendFor) + const selectedEdgeId = useGraphStore((s) => s.selectedEdgeId) + useEffect(() => { + const r = useRouteEditStore.getState() + if (noEdit) { + r.clear() + return + } + if (r.addBendFor != null && r.addBendFor !== selectedEdgeId) r.armAddBend(null) + if (r.bend != null && r.bend.edgeId !== selectedEdgeId) r.selectBend(null) + }, [noEdit, selectedEdgeId]) + /** insert bend point `p` of `edgeId` into the span it lies on: one entry */ + const insertBend = useCallback((edgeId: string, p: { x: number; y: number }) => { + const r = useRouteEditStore.getState() + const g = useGraphStore.getState() + const edge = g.edges.find((x) => x.id === edgeId) + const route = currentRouteMap(g.nodes, g.edges).get(edgeId) + if (!edge || !route) return + const wps = (edge.data as { waypoints?: { x: number; y: number }[] } | undefined)?.waypoints ?? [] + const i = bendInsertIndex(route.points, wps, p) + r.armAddBend(null) // one bend point per arming + g.setEdgeRouting(edgeId, { route: 'orthogonal', waypoints: [...wps.slice(0, i), p, ...wps.slice(i)] }) + r.selectBend({ edgeId, index: i }) + }, []) + useEffect(() => { + if (addBendFor == null) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + useRouteEditStore.getState().armAddBend(null) + return + } + if (e.key !== 'Enter' || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return + if (e.defaultPrevented || blocksCanvasKey(e.target)) return + // Enter would otherwise press the focused Add bend button (disarming) + e.preventDefault() + e.stopPropagation() + const g = useGraphStore.getState() + const route = currentRouteMap(g.nodes, g.edges).get(addBendFor) + const p = route ? keyboardBend(g.nodes, g.edges, addBendFor, route) : null + if (p) { + insertBend(addBendFor, p) + return + } + const st = rfStore.getState() as unknown as { ariaLiveMessage?: string } + if ('ariaLiveMessage' in st) rfStore.setState({ ariaLiveMessage: t('canvas.route.noBendRoom') } as never) + useRouteEditStore.getState().setNotice('canvas.route.noBendRoom') + } + document.addEventListener('keydown', onKey, true) + return () => document.removeEventListener('keydown', onKey, true) + }, [addBendFor, insertBend, rfStore, t]) + const onAddBendClick = useCallback( + (e: React.MouseEvent, edge: LoopEdge) => { + const r = useRouteEditStore.getState() + if (r.addBendFor !== edge.id) return + const g = useGraphStore.getState() + const route = currentRouteMap(g.nodes, g.edges).get(edge.id) + if (!route) return + const at = screenToFlowPosition({ x: e.clientX, y: e.clientY }) + const p = bendAtClick(route.points, at, e.altKey) + if (!p || bendRejection(g.nodes, g.edges, edge.id, p) !== null) { + r.armAddBend(null) + return + } + insertBend(edge.id, p) + }, + [screenToFlowPosition, insertBend], + ) + // §LGR6.6 — ONE owner for the destructive canvas keys. React Flow's built-in // handler is switched off (`deleteKeyCode={null}` below) because it (a) // defaults to `Backspace` ALONE, so `Delete` did nothing although our own @@ -717,6 +815,34 @@ export function Canvas() { open = { snapshot: g.captureGestureSnapshot(), positions, announceId: ids[0] } } gesture.press(e.key) + // issue #344 §DL3 — the move itself: one grid step (Shift: four), the + // focused node landing on the grid (left edge + port row) and every + // selected node moved by the same delta. React Flow's own 5 / 20 px step + // never runs (this capture listener is ahead of it). + e.preventDefault() + e.stopPropagation() + const dir = ARROW_DIR[e.key] + const g = useGraphStore.getState() + const focusedId = (e.target as HTMLElement | null)?.closest?.('.react-flow__node')?.getAttribute('data-id') + const anchor = g.nodes.find((n) => n.id === focusedId) + if (!dir || !anchor) return + const step = GRID * (e.shiftKey ? 4 : 1) + const to = snapNodePosition({ x: anchor.position.x + dir[0] * step, y: anchor.position.y + dir[1] * step }) + const dx = to.x - anchor.position.x + const dy = to.y - anchor.position.y + const moved: Record = {} + for (const id of ids) { + const n = g.nodes.find((x) => x.id === id) + if (n) moved[id] = { x: n.position.x + dx, y: n.position.y + dy } + } + g.applyGesturePositions(moved, {}) + // §DL3.6 — the row and column it now sits on, briefly + useGuideStore.getState().flash(keyMoveGuides(useGraphStore.getState().nodes, Object.keys(moved), anchor.id)) + // the announcement React Flow would have made for its own move + const st = rfStore.getState() as unknown as { ariaLiveMessage?: string } + if ('ariaLiveMessage' in st) { + rfStore.setState({ ariaLiveMessage: t('rf.node.moved', { direction: t(RF_DIR_KEY[dir[2]] ?? 'rf.dir.right'), x: Math.round(to.x), y: Math.round(to.y) }) } as never) + } } const onKeyUp = (e: KeyboardEvent) => { if (ARROW_KEYS.has(e.key)) gesture.release(e.key) @@ -756,6 +882,7 @@ export function Canvas() { const r = insertModule(cloneModuleDoc(block, moduleLabelOverlay(moduleId, locale)), { at, bundledModuleId: moduleId, + free: e.altKey, // issue #344 §DL3 — Alt at the drop: not snapped }) if (!r.ok) window.alert(r.reason) } @@ -763,7 +890,7 @@ export function Canvas() { } const kind = e.dataTransfer.getData(DND_TYPE) as NodeKind if (!kind) return - addNodeAt(kind, at) + addNodeAt(kind, at, { free: e.altKey }) // issue #344 §DL3 — on the grid; Alt = free }, [addNodeAt, insertModule, screenToFlowPosition], ) @@ -783,6 +910,7 @@ export function Canvas() { className={`canvas${canvasLocked ? ' canvas--locked' : ''}${refInsertArmed ? ' canvas--ref-insert' : ''}`} data-tour="canvas" data-covered-by-sheets="" + aria-busy={routesBusy || undefined} onDrop={noEdit ? undefined : handleDrop} onDragOver={noEdit ? undefined : handleDragOver} onContextMenu={noEdit ? (e) => e.preventDefault() : undefined} @@ -796,6 +924,16 @@ export function Canvas() { nodeTypes={nodeTypes} edgeTypes={edgeTypes} onNodesChange={onNodesChange} + // issue #344 §DL3 — which node a drag grabbed and whether Alt is held + // (Alt = free move); the store snaps the drag by that node's correction. + // §ER14.5 — while it lasts only the routes the moved nodes touch are + // routed again; the drop builds one full generation + onNodeDragStart={(e, node, dragged) => { dragSnap.begin(node.id, e.altKey, dragged.map((n) => n.id), useGraphStore.getState().nodes, getViewport().zoom); beginLiveLayout() }} + onNodeDrag={(e) => dragSnap.update(e.altKey)} + onNodeDragStop={(e) => { dragSnap.update(e.altKey); dragSnap.end(); useGuideStore.getState().clear(); endLiveLayout() }} + onSelectionDragStart={(e, dragged) => { dragSnap.begin(dragged[0]?.id ?? null, e.altKey, dragged.map((n) => n.id), useGraphStore.getState().nodes, getViewport().zoom); beginLiveLayout() }} + onSelectionDrag={(e) => dragSnap.update(e.altKey)} + onSelectionDragStop={(e) => { dragSnap.update(e.altKey); dragSnap.end(); useGuideStore.getState().clear(); endLiveLayout() }} onEdgesChange={onEdgesChange} onConnect={noEdit ? undefined : onConnect} onSelectionChange={onSelectionChange} @@ -808,6 +946,7 @@ export function Canvas() { nodesConnectable={!noEdit && !refInsertArmed} edgesReconnectable={!noEdit} onNodeClick={refInsertArmed ? (_e, n) => onArmedNodeClick(n.id) : undefined} + onEdgeClick={addBendFor != null && !noEdit ? onAddBendClick : undefined} zoomOnDoubleClick={!isMobile && !framePropsOpen} zoomOnScroll={!framePropsOpen} zoomOnPinch={!framePropsOpen} @@ -871,6 +1010,8 @@ export function Canvas() { {/* docs/large-graph-readability.md §LGR6 — transient group frames (behind the nodes) + their interactive chrome. Render / UI-only. */} + {/* docs/diagram-layout.md §DL3.6 — the smart guides of a node drag */} + {/* docs/large-graph-readability.md §LGR12.3 — the "N nodes selected" readout is NOT a canvas panel: desktop → DesktopInspector (the right column, directly above the Inspector), mobile → MobileInspectorSheet @@ -1089,6 +1230,29 @@ export function Canvas() { )} + {/* issue #344 §DL2.10 — Tidy to grid: re-place the document on the + grid by the conversion's own rules, as ONE undo step. Desktop + only (the phone does not edit); `disabled` while locked, never + removed, so the rail keeps its height (#338). */} + {!isMobile && ( + tidyToGrid()} + title={t('canvas.tidy')} + aria-label={t('canvas.tidy')} + disabled={canvasLocked} + className="rf-tidy" + > + + + )} {/* docs/…-auto-frames.md §AF4.1 — "Suggest frames": an EXPLICIT action (P1). Never runs on a sim / Activity / Focus / Filter change or on a graph edit. `is-stale` = the structure or a node diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index e9d48f5e..7900e0f0 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -26,6 +26,7 @@ import { } from '../model/model' import { useGraphStore } from '../store/graphStore' import { useRegisterOutcome } from '../store/registers' +import { useRouteEditStore } from '../store/routeEditStore' import { useUiStore } from '../store/uiStore' import { useIsMobile } from '../ui/media' import { AccentField } from './AccentField' @@ -257,13 +258,19 @@ export function Inspector() { onChange(e.target.value as 'bezier' | 'orthogonal')}> - - - - + <> + + + + {shape === 'orthogonal' && !isMobile ? ( +
+

{t(manual ? 'inspector.edge.route.manual' : 'inspector.edge.route.auto')}

+
+ + {manual ? ( + + ) : null} +
+ {armed || manual ? ( +

{t(armed ? 'inspector.edge.route.addBendHint' : 'inspector.edge.route.editHint')}

+ ) : null} + {/* the canvas live region announces it; this is the visible copy */} + {armed && notice ?

{t(notice)}

: null} +
+ ) : null} + ) } diff --git a/src/components/SmartGuideLayer.tsx b/src/components/SmartGuideLayer.tsx new file mode 100644 index 00000000..2f538c7a --- /dev/null +++ b/src/components/SmartGuideLayer.tsx @@ -0,0 +1,34 @@ +import { ViewportPortal } from '@xyflow/react' +import { useGuideStore } from '../store/guideStore' +import { useIsMobile } from '../ui/media' + +// docs/diagram-layout.md §DL3.6 (issue #344) — the smart guides, drawn in flow +// coordinates over the canvas: a thin dashed placement line through where the +// dragged node (or selection) will land, and a solid line out to every other +// node it is exactly aligned with (port row, centre or edge). Faint while Alt +// frees the move. Decorative (`aria-hidden`), no pointer events; never on the +// phone, which has no node dragging. +export function SmartGuideLayer() { + const guides = useGuideStore((s) => s.guides) + const faint = useGuideStore((s) => s.faint) + const isMobile = useIsMobile() + if (isMobile || guides.length === 0) return null + return ( + + + + ) +} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index d69bc37a..697ee11c 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -229,11 +229,13 @@ export function Toolbar() { const [activeDialog, setActiveDialog] = useState(null) + // issue #344 §DL3 — the canvas centre, on the grid, at the nearest spot no + // node covers (it used to add ±40 px of random jitter instead) const addCentered = (kind: NodeKind) => { const rect = document.querySelector('.canvas')?.getBoundingClientRect() - const cx = (rect ? rect.left + rect.width / 2 : window.innerWidth / 2) + (Math.random() * 80 - 40) - const cy = (rect ? rect.top + rect.height / 2 : window.innerHeight / 2) + (Math.random() * 80 - 40) - addNodeAt(kind, screenToFlowPosition({ x: cx, y: cy })) + const cx = rect ? rect.left + rect.width / 2 : window.innerWidth / 2 + const cy = rect ? rect.top + rect.height / 2 : window.innerHeight / 2 + addNodeAt(kind, screenToFlowPosition({ x: cx, y: cy }), { findFree: true }) } const onDragStart = (e: DragEvent, kind: NodeKind) => { diff --git a/src/components/canvasFit.test.ts b/src/components/canvasFit.test.ts index 08814179..989af679 100644 --- a/src/components/canvasFit.test.ts +++ b/src/components/canvasFit.test.ts @@ -61,3 +61,59 @@ describe('viewportForRect', () => { expect(viewportForRectOrNull({ x: 0, y: 0, width: 10, height: 10 }, { width: 0, height: 0 }, NO_MM, { floor: 0.5, ceil: 1 })).toBeNull() }) }) + +// issue #344 (docs/diagram-layout.md §DL5.6) — `keep`: the zoom is capped so +// the kept rect opens whole; the minimap counts only when the two overlap on +// both axes. +describe('viewportForRect — keep', () => { + // the MMO opening view (src/model/templates.ts) + const MMO = { x: 44, y: -4, width: 927, height: 368 } + const MMO_KEEP = { x: 48, y: 36, width: 1188, height: 200 } + const keepRight = (v: { x: number; zoom: number }, keep: { x: number; width: number }) => v.x + (keep.x + keep.width) * v.zoom + + it('the MMO view at 1600 x 1000 (a 1300 x 862 pane): 1.040, the top row running past the minimap column, above it', () => { + const pane = { width: 1300, height: 862 } + const plain = viewportForRect(MMO, pane, MM, { floor: 0.6, ceil: 1.2 }) + expect(plain.zoom).toBeCloseTo(1032 / (927 * 1.06)) + const v = viewportForRect(MMO, pane, MM, { floor: 0.6, ceil: 1.2, keep: MMO_KEEP }) + expect(v.zoom).toBeCloseTo((1300 - 8 - 52) / (1236 - 44)) + expect(keepRight(v, MMO_KEEP)).toBeCloseTo(1300 - 8) + expect(keepRight(v, MMO_KEEP)).toBeGreaterThan(1300 - 224) // past the minimap's column… + expect(v.y + (MMO_KEEP.y + MMO_KEEP.height) * v.zoom).toBeLessThan(862 - 176) // …above it + expect(v.x).toBeCloseTo(52 - 44 * v.zoom) // still left-aligned on the rect + }) + + it('the MMO view at 1280 x 800 with the minimap collapsed: the keep cap, not the wider fit', () => { + const pane = { width: 980, height: 662 } + const v = viewportForRect(MMO, pane, NO_MM, { floor: 0.6, ceil: 1.2, keep: MMO_KEEP }) + expect(v.zoom).toBeCloseTo((980 - 8 - 52) / (1236 - 44)) + expect(keepRight(v, MMO_KEEP)).toBeCloseTo(980 - 8) + // expanded at the same size the rect fit already shows `keep` whole + const e = viewportForRect(MMO, pane, MM, { floor: 0.6, ceil: 1.2, keep: MMO_KEEP }) + expect(e.zoom).toBeCloseTo(712 / (927 * 1.06)) + expect(v.zoom).toBeGreaterThanOrEqual(e.zoom) + }) + + it('a kept rect that would overlap the minimap on both axes clears its corner by the side that keeps the larger zoom', () => { + const rect = { x: 0, y: 0, width: 1000, height: 600 } + const keep = { x: 0, y: 0, width: 1300, height: 640 } + const v = viewportForRect(rect, PANE, MM, { floor: 0.35, ceil: 1.2, keep }) + // horizontally: 52 + 1300 z <= 1056 -> 0.772; vertically: 272 + 340 z <= 544 -> 0.8 + expect(v.zoom).toBeCloseTo(0.8) + expect(v.y + (keep.y + keep.height) * v.zoom).toBeCloseTo(720 - 176) + }) + + it('without an overlap the minimap does not cap the zoom; a keep that already fits changes nothing', () => { + const rect = { x: 0, y: 0, width: 1000, height: 600 } + const plain = viewportForRect(rect, PANE, MM, { floor: 0.35, ceil: 1.2 }) + const past = viewportForRect(rect, PANE, MM, { floor: 0.35, ceil: 1.2, keep: { x: 0, y: 0, width: 1300, height: 600 } }) + expect(past).toEqual(plain) + const inside = viewportForRect(rect, PANE, MM, { floor: 0.35, ceil: 1.2, keep: { x: 100, y: 100, width: 300, height: 200 } }) + expect(inside).toEqual(plain) + }) + + it('the floor still wins on a pane too small for the kept rect', () => { + const v = viewportForRect(MMO, { width: 375, height: 600 }, NO_MM, { floor: 0.6, ceil: 1.2, keep: MMO_KEEP }) + expect(v.zoom).toBe(0.6) + }) +}) diff --git a/src/components/canvasFit.ts b/src/components/canvasFit.ts index 06aba187..c7b33a82 100644 --- a/src/components/canvasFit.ts +++ b/src/components/canvasFit.ts @@ -17,11 +17,17 @@ // vertically centred, or top-anchored when it is taller than the area — // so a rect too large for the floor zoom is never shrunk further: the // view shows its top-left corner at the floor, which is the "start -// here" a large import needs, not a dot. +// here" a large import needs, not a dot; +// - issue #344 (docs/diagram-layout.md §DL5.6) — an optional `keep` rect (the +// core start nodes at their widest box over the 18 languages) caps the +// zoom at the largest one that shows `keep` whole: inside the pane, and +// clear of the minimap's corner only when `keep` would overlap it on BOTH +// axes (a top row running past the minimap's column, above it, is not +// under it). The floor still wins on a pane too small for `keep`. export type FitRect = { x: number; y: number; width: number; height: number } export type FitInsets = { left: number; right: number; bottom: number; top: number } -export type FitOptions = { floor: number; ceil: number } +export type FitOptions = { floor: number; ceil: number; keep?: FitRect } export type Viewport = { x: number; y: number; zoom: number } /** The Canvas's fixed overlays: the zoom Controls on the left (~44 px) and, @@ -45,7 +51,13 @@ export function viewportForRect( const usableW = Math.max(160, pane.width - insets.left - insets.right) const usableH = Math.max(120, pane.height - insets.top - insets.bottom) const rectW = Math.min(rect.width, (usableW - EDGE_GAP) / opts.floor) - const zoom = Math.min(opts.ceil, Math.max(opts.floor, Math.min(usableW / (rectW * PAD), usableH / (rect.height * PAD)))) + const fit = Math.min(opts.ceil, Math.max(opts.floor, Math.min(usableW / (rectW * PAD), usableH / (rect.height * PAD)))) + const zoom = opts.keep ? Math.max(opts.floor, keepZoom(rect, opts.keep, pane, insets, usableH, fit)) : fit + return place(rect, insets, usableH, zoom) +} + +/** Left-aligned; vertically centred, or top-anchored when taller than the area. */ +function place(rect: FitRect, insets: FitInsets, usableH: number, zoom: number): Viewport { const contentH = rect.height * zoom return { x: insets.left + EDGE_GAP - rect.x * zoom, @@ -56,3 +68,50 @@ export function viewportForRect( zoom, } } + +type Limits = { left: number; top: number; right: number; bottom: number } + +/** The largest zoom z with a + b·z <= c. */ +const upTo = (a: number, b: number, c: number) => (b > 0 ? (c - a) / b : a <= c ? Infinity : 0) + +/** The largest zoom at which `keep` lies inside `lim` (screen px) under one + * vertical placement: `place` pins one graph point per axis to a fixed screen + * anchor, so each edge of `keep` moves linearly with the zoom and gives one + * upper bound. */ +function keepBound(rect: FitRect, keep: FitRect, insets: FitInsets, usableH: number, lim: Limits, centred: boolean): number { + const ax = insets.left + EDGE_GAP + const ox = rect.x + const ay = centred ? insets.top + usableH / 2 : insets.top + TOP_GAP + const oy = centred ? rect.y + rect.height / 2 : rect.y + return Math.min( + upTo(ax, keep.x + keep.width - ox, lim.right), + upTo(-ax, ox - keep.x, -lim.left), + upTo(ay, keep.y + keep.height - oy, lim.bottom), + upTo(-ay, oy - keep.y, -lim.top), + ) +} + +/** The largest zoom <= `fit` at which `keep` is whole inside the pane and, when + * it would overlap the minimap's corner on both axes, clear of that corner. */ +function keepZoom(rect: FitRect, keep: FitRect, pane: { width: number; height: number }, insets: FitInsets, usableH: number, fit: number): number { + // the vertical placement switches from top-anchored to centred once the + // rect fits the area (zoom <= usableH / rect.height) + const switchAt = usableH / rect.height + const within = (lim: Limits, z: number) => { + if (z > switchAt) { + const top = Math.min(z, keepBound(rect, keep, insets, usableH, lim, false)) + if (top > switchAt) return top + } + return Math.min(z, switchAt, keepBound(rect, keep, insets, usableH, lim, true)) + } + const paneLim: Limits = { left: insets.left, top: insets.top, right: pane.width - EDGE_GAP, bottom: pane.height - EDGE_GAP } + const z = within(paneLim, fit) + if (insets.right <= 0 || insets.bottom <= 0) return z + const corner = { left: pane.width - insets.right, top: pane.height - insets.bottom } + const vp = place(rect, insets, usableH, z) + const right = vp.x + (keep.x + keep.width) * z + const bottom = vp.y + (keep.y + keep.height) * z + if (right <= corner.left || bottom <= corner.top) return z + // clear the corner by whichever side keeps the larger zoom + return Math.max(within({ ...paneLim, right: corner.left }, z), within({ ...paneLim, bottom: corner.top }, z)) +} diff --git a/src/components/edges/LoopEdge.tsx b/src/components/edges/LoopEdge.tsx index df57b6c3..062c7643 100644 --- a/src/components/edges/LoopEdge.tsx +++ b/src/components/edges/LoopEdge.tsx @@ -12,7 +12,10 @@ import { useGraphStore } from '../../store/graphStore' import { BEAT_ARRIVE, BEAT_DEPART_END, BEAT_SETTLE, useSimStore, type PlaybackPhase } from '../../store/simStore' import type { CueRole } from '../../store/playbackRank' import { useUiStore } from '../../store/uiStore' -import { currentRouteMap } from '../../store/routeMap' +import { currentRouteGeneration, setRouteLabelSize, useRouteInputs } from '../../store/routeMap' +import { freeLinePoints, pointAlong } from './freeLine' +import { RouteBends } from './RouteBends' +import { useIsMobile } from '../../ui/media' import { useLod } from '../lod' import { useEdgeActivityOpacity } from '../frames/useActivityTint' import { MAX_PLAYBACK_TOKENS } from './playback-caps' @@ -116,12 +119,29 @@ function LoopEdge({ // its `d` from the atomic route map (§ER3.9); everything else (marker, bead, // pulse, rings, LOD) just consumes the `path` string. const routeMode = (data as { route?: unknown } | undefined)?.route + // SEMANTICS-R10.md — a straight connection is a direct line between its ports + const straight = routeMode === 'straight' const gNodes = useGraphStore((s) => s.nodes) const gEdges = useGraphStore((s) => s.edges) - const route = routeMode === 'orthogonal' ? currentRouteMap(gNodes, gEdges).get(id) : undefined - const path = route ? route.d : bezierPath - const labelX = route ? route.mid.x : bezierLabelX - const labelY = route ? route.mid.y : bezierLabelY + // issue #344 §ER14 — label sizes and the end of a drag are routing inputs too + useRouteInputs((s) => s.rev) + const gen = currentRouteGeneration(gNodes, gEdges) + const route = routeMode === 'orthogonal' ? gen.map.get(id) : undefined + // §ER15 — a Curved or Straight edge is not routed, but its label takes the + // free slot the map chose on its line; until that slot is known the label is + // not shown (never in a temporary place) + const slot = routeMode === 'orthogonal' ? undefined : gen.free.get(id) + const freeAt = slot + ? pointAlong(freeLinePoints({ x: sourceX, y: sourceY }, sourcePosition, { x: targetX, y: targetY }, targetPosition, straight), slot.f) + : null + // §ER15.1 — a record shown by its recorded label rule: the label sits at the + // line's middle, as it was recorded, at once + const record = useRouteInputs((s) => s.record) + const labelPending = routeMode !== 'orthogonal' && !slot && !record + const path = route ? route.d : straight ? `M ${sourceX},${sourceY}L ${targetX},${targetY}` : bezierPath + // §ER14.3 — a routed edge's label sits in the free slot the map chose + const labelX = route ? (route.label ?? route.mid).x : freeAt ? freeAt.x : straight ? (sourceX + targetX) / 2 : bezierLabelX + const labelY = route ? (route.label ?? route.mid).y : freeAt ? freeAt.y : straight ? (sourceY + targetY) / 2 : bezierLabelY // docs/large-graph-readability.md §LGR6-cues — the opt-in Activity overlay's // per-edge tint. Read here (not via the edge object's `style`, which v12 does @@ -371,6 +391,12 @@ function LoopEdge({ // label lives in a portal outside the edge group, so it is gated here, not in // CSS. In focus set ⇔ the edge touches the selected node. const focusMode = useUiStore((s) => s.focusMode) + // issue #344 step 3 — the bend handles of a selected Manual orthogonal + // connection: desktop only, never under the edit lock + const isMobile = useIsMobile() + const locked = useUiStore((s) => s.canvasLocked) + const bends = routeMode === 'orthogonal' && Array.isArray(d.waypoints) ? d.waypoints : null + const editsBends = selected && route != null && !isMobile && !locked && bends != null && bends.length > 0 const selectedNodeId = useGraphStore((s) => s.selectedNodeId) const outOfFocus = focusMode && selectedNodeId != null && source !== selectedNodeId && target !== selectedNodeId @@ -397,11 +423,16 @@ function LoopEdge({ const labelRef = useRef(null) const [labelSize, setLabelSize] = useState<{ w: number; h: number } | null>(null) const labelKey = `${text}|${label.mark ?? ''}|${showLabel}|${selected}|${stepIndex}` + // issue #344 §ER14.3 — the router places labels from their REST size only + // (shown, not selected, no run value), so a run or a selection never moves a + // route + const reportsRestSize = !selected && sv == null useLayoutEffect(() => { const el = labelRef.current const next = el ? { w: el.offsetWidth, h: el.offsetHeight } : null setLabelSize((prev) => (prev && next && prev.w === next.w && prev.h === next.h ? prev : next)) - }, [labelKey]) + if (next && reportsRestSize) setRouteLabelSize(id, next.w, next.h) + }, [labelKey, reportsRestSize, id]) const labelBox = showLabel && labelSize ? { x: labelX, y: labelY, ...labelSize } : null const labelDimmed = (pbPt != null && labelUnderToken(labelBox, pbPt.x, pbPt.y, pbBadgeOn ? pbBadge : null)) || @@ -423,6 +454,26 @@ function LoopEdge({ const markerId = (accent ? accentMarkerId(accent) : null) ?? (isState ? EDGE_MARKER.state : EDGE_MARKER.resource) + // issue #344 §ER14.5 — a routed edge whose route is not committed yet (the + // first, sliced generation of a document) is not drawn at all: no temporary + // path, no label in a wrong place. Only an invisible, off-canvas copy of its + // label is rendered, so its rest size reaches the router before the routes do. + if (routeMode === 'orthogonal' && !route) { + return showLabel ? ( + + + + ) : null + } + return ( <> {/* FC-4.2 — selection is an underlay beneath the path, never a recolour */} @@ -439,7 +490,11 @@ function LoopEdge({ // an inline `stroke-dasharray` used to beat every stylesheet rule // and left active state edges with no tell at all. isState ? 'edge-state' : 'edge-resource', - route ? `route-${route.routeClass}${route.invalidWaypoint ? ' route-invalid' : ''}` : '', + // every guarded route is `route-orthogonal`; a lower rung of the + // ladder (§ER14.2) is named beside it + route + ? `route-orthogonal${route.routeClass !== 'orthogonal' ? ` route-${route.routeClass}` : ''}${route.invalidWaypoint ? ' route-invalid' : ''}` + : '', activityOp > 0 ? 'lgr-active-tint' : '', // issue #329 — an unsatisfied activator's 0.5 is a class, not an // inline `opacity`: an inline value beat Focus mode's 0.26 @@ -664,6 +719,8 @@ function LoopEdge({ ) : null} + {editsBends ? : null} + {showLabel ? (
{label.mark ? : text} diff --git a/src/components/edges/RouteBends.tsx b/src/components/edges/RouteBends.tsx new file mode 100644 index 00000000..43af2bd6 --- /dev/null +++ b/src/components/edges/RouteBends.tsx @@ -0,0 +1,180 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { EdgeLabelRenderer, useReactFlow } from '@xyflow/react' +import { useT } from '../../i18n' +import { snapPoint, GRID } from '../../model/layout/grid' +import { useGraphStore, type GestureSnapshot } from '../../store/graphStore' +import { useRouteEditStore } from '../../store/routeEditStore' +import { beginLiveLayout, bendRejection, endLiveLayout } from '../../store/routeMap' +import { createKeyGesture, type GestureEndReason } from '../../ui/keyGestureLifetime' + +// issue #344 step 3 (docs/diagram-layout.md §DL4, docs/edge-routing.md §ER16) — +// the bend points of the selected Manual orthogonal connection, as handles. +// +// • DRAG — a pointer drag moves one bend point, snapped to the 16 px grid +// (Alt: free). One explicit history transaction: the snapshot at +// pointer-down, silent writes while moving, ONE entry at the drop +// when the point really moved. +// • KEYS — on a focused handle the arrow keys move it one grid step, +// Shift four; a held key is one gesture and one entry (the shared +// key-gesture lifetime). Delete / Backspace removes the point +// (one entry); the last one removed makes the route automatic. +// • ESCAPE — cancels the drag or key gesture in progress: the point goes back. +// • REFUSED — a drop inside a node or on the connection's own port stub puts +// the point back where it was; nothing is adjusted or recorded. +// +// Rendered only on a desktop canvas that is not locked (the caller decides); +// the store refuses every write under the lock anyway. + +type Pt = { x: number; y: number } +const ARROWS: Record = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] } + +type Gesture = { snapshot: GestureSnapshot; start: Pt[]; index: number; at: Pt; free: boolean } + +export function RouteBends({ edgeId, waypoints }: { edgeId: string; waypoints: Pt[] }) { + const t = useT() + const { screenToFlowPosition } = useReactFlow() + const focusedBend = useRouteEditStore((s) => (s.bend?.edgeId === edgeId ? s.bend.index : null)) + const selectBend = useRouteEditStore((s) => s.selectBend) + const [invalidAt, setInvalidAt] = useState(null) + const gestureRef = useRef(null) + const buttons = useRef<(HTMLButtonElement | null)[]>([]) + + const store = () => useGraphStore.getState() + const rejected = (p: Pt) => { + const g = store() + return bendRejection(g.nodes, g.edges, edgeId, p) !== null + } + const write = (wps: Pt[]) => store().setEdgeRoutingSilently(edgeId, { route: 'orthogonal', waypoints: wps }) + + /** open a transaction on bend `index` */ + const open = (index: number): Gesture => { + const g: Gesture = { snapshot: store().captureGestureSnapshot(), start: waypoints.map((p) => ({ ...p })), index, at: { ...waypoints[index] }, free: false } + gestureRef.current = g + beginLiveLayout() + return g + } + /** close it: commit when the point moved to an allowed place, else put it back */ + const close = (commit: boolean) => { + const g = gestureRef.current + if (!g) return + gestureRef.current = null + setInvalidAt(null) + const from = g.start[g.index] + const moved = g.at.x !== from.x || g.at.y !== from.y + if (commit && moved && !rejected(g.at)) store().pushGestureEntry(g.snapshot) + else if (moved) write(g.start) + endLiveLayout() + } + const moveTo = (g: Gesture, p: Pt) => { + g.at = p + const wps = g.start.map((q, i) => (i === g.index ? p : q)) + write(wps) + setInvalidAt(rejected(p) ? g.index : null) + } + + // the latest `close`, for the gesture lifetime and the unmount clean-up + const closeRef = useRef(close) + closeRef.current = close + const keyGesture = useMemo(() => createKeyGesture((reason: GestureEndReason) => closeRef.current(reason !== 'cancel')), []) + useEffect(() => { + const onKeyUp = (e: KeyboardEvent) => { + if (ARROWS[e.key]) keyGesture.release(e.key) + } + const onBlur = () => keyGesture.end('blur') + const onVisibility = () => keyGesture.end('visibility') + window.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onBlur) + document.addEventListener('visibilitychange', onVisibility) + return () => { + window.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onBlur) + document.removeEventListener('visibilitychange', onVisibility) + keyGesture.end('blur') + closeRef.current(true) + } + }, [keyGesture]) + + useEffect(() => { + if (focusedBend != null) buttons.current[focusedBend]?.focus() + }, [focusedBend]) + + return ( + + {waypoints.map((p, i) => ( +
+ + {/* issue #344 §DL1 — resource ports on the fixed row PORT_ROW px below + the top, drawn ON the outline there (a fraction of the width: the + outline stretches with the width only), whatever the height. Last in + the frame, so they paint — and take the pointer — above the + silhouette and the text they sit on. */} + {ports?.in ? ( + + ) : null} + {ports?.out ? ( + + ) : null} ) } @@ -719,10 +854,10 @@ function PoolNode({ id, data, selected }: NodeProps) { // Pool's face is its count; mode / capacity stay in the inspector return ( <> - - ) } @@ -750,6 +884,7 @@ function SourceNode({ id, data, selected }: NodeProps) { - ) } @@ -771,10 +905,10 @@ function DrainNode({ id, data, selected }: NodeProps) { const stepKey = useSimStore((s) => s.stepIndex) return ( <> - s.stepIndex) return ( <> - - ) } @@ -820,10 +953,10 @@ function ConverterNode({ id, data, selected }: NodeProps) { const stepKey = useSimStore((s) => s.stepIndex) return ( <> - - ) } @@ -846,10 +978,10 @@ function EndNode({ id, data, selected }: NodeProps) { const stepKey = useSimStore((s) => s.stepIndex) return ( <> - (r.key === 'value' ? 20.7 : 15)) + const height = Math.max(56, Math.round(titleH + rowH.reduce((a, b) => a + b, 0) + rows.length + 16 + 8)) + const top = (height - (titleH + rowH.reduce((a, b) => a + b, 0) + rows.length)) / 2 + const longest = Math.max(...titleW, ...rows.map((r) => r.w - 14)) + const width = Math.max(kind === 'gate' ? 134 : 118, padStart + 14 + longest + padEnd) + const centre = width / 2 + // the Gate centres everything; the others start at the body's padding + const headStart = kind === 'gate' ? centre - (14 + Math.max(...titleW)) / 2 : padStart + const chip: Extent = { left: headStart, right: headStart + 8, top: top + lh / 2 - 4, bottom: top + lh / 2 + 4 } + const titleLines = titleW.map((w, i) => ({ left: headStart + 14, right: headStart + 14 + w, top: top + i * lh, bottom: top + (i + 1) * lh })) + let y = top + titleH + 1 + const out: OutlineRow[] = rows.map((r, i) => { + const row = { key: r.key, top: y, bottom: y + rowH[i], width: r.w, left: kind === 'gate' ? centre - r.w / 2 : padStart } + y += rowH[i] + 1 + return row + }) + return { + kind, height, width, stackStart: padStart, padEnd, minWidth: kind === 'gate' ? 134 : 118, + chip, titleLines, titleBoxStart: headStart + 14, titleOneLine: titleW.reduce((a, b) => a + b, 0), titleMax: 135, rows: out, + } +} + +const KINDS = ['pool', 'source', 'drain', 'converter', 'gate'] as const +const TITLES = [[30], [90], [135], [120, 60], [135, 135]] +const ROWS: Record<(typeof KINDS)[number], { key: OutlineRow['key']; w: number }[][]> = { + pool: [[{ key: 'value', w: 11 }], [{ key: 'value', w: 60 }, { key: 'sub', w: 30 }], [{ key: 'value', w: 160 }]], + source: [[{ key: 'sub', w: 96 }], [{ key: 'sub', w: 112 }]], + drain: [[{ key: 'sub', w: 90 }], [{ key: 'sub', w: 105 }]], + converter: [[{ key: 'sub', w: 40 }]], + gate: [[{ key: 'sub', w: 70 }], [{ key: 'sub', w: 80 }]], +} + +describe('fitOutline', () => { + it.each(KINDS)('%s: the chip, every title line and every row keep ROW_CLEAR inside the outline', (kind) => { + for (const titleW of TITLES) for (const rows of ROWS[kind]) { + const g = geometry({ kind, titleW, rows }) + const f = fitOutline(g) + const tag = `${kind} title ${titleW} rows ${rows.map((r) => r.w)}` + expect(f.width, tag).toBeGreaterThanOrEqual(g.minWidth) + expect(f.width, tag).toBeLessThanOrEqual(NODE_MAX_W) + // the Gate re-centres everything in its new width + const move = (e: Extent, dx: number): Extent => ({ ...e, left: e.left + dx, right: e.right + dx }) + const headDx = kind === 'gate' ? (f.width - g.width) / 2 : f.headShift + for (const e of [g.chip!, ...g.titleLines]) expect(clear(kind, f.width, g.height, move(e, headDx)), tag).toBeGreaterThanOrEqual(ROW_CLEAR - 0.01) + for (const r of g.rows) { + const max = f.maxWidth[r.key] + const w = Math.min(r.width, max ?? Infinity) + const left = kind === 'gate' ? f.width / 2 - w / 2 : g.stackStart + f.start[r.key]! + expect(clear(kind, f.width, g.height, { left, right: left + w, top: r.top, bottom: r.bottom }), tag).toBeGreaterThanOrEqual(ROW_CLEAR - 0.01) + if (kind !== 'gate') expect(left, `${tag}: a row never starts before the title`).toBeGreaterThanOrEqual(g.titleBoxStart + f.headShift - 0.01) + } + } + }) + + it('starts a Pool row and a mode row exactly at the title where the outline allows it', () => { + for (const kind of ['pool', 'source', 'drain', 'converter'] as const) { + const g = geometry({ kind, titleW: [60], rows: ROWS[kind][0] }) + const f = fitOutline(g) + for (const r of g.rows) expect(g.stackStart + f.start[r.key]!, kind).toBeCloseTo(g.titleBoxStart + f.headShift, 4) + } + }) + + it('moves the head in only as far as the outline needs it', () => { + // a Pool's chip at x 12 sits on its slant (8 px over the top): it moves in + const pool = fitOutline(geometry({ kind: 'pool', titleW: [60], rows: ROWS.pool[0] })) + expect(pool.headShift).toBeGreaterThan(0) + // a Source's left side is a plain rounded edge: the chip at 12 needs 4 px + const source = fitOutline(geometry({ kind: 'source', titleW: [60], rows: ROWS.source[0] })) + expect(source.headShift).toBeCloseTo(4, 4) + // the Gate is centred: no shift, only width + expect(fitOutline(geometry({ kind: 'gate', titleW: [60], rows: ROWS.gate[0] })).headShift).toBe(0) + }) + + it('is as narrow as its content and the outline allow: one unit narrower breaks a rule or the content', () => { + for (const kind of KINDS) for (const titleW of TITLES) for (const rows of ROWS[kind]) { + const g = geometry({ kind, titleW, rows }) + const f = fitOutline(g) + if (f.width <= g.minWidth || f.width >= NODE_MAX_W) continue + const w = f.width - 1 / 64 + const headDx = kind === 'gate' ? (w - g.width) / 2 : f.headShift + const heads = [g.chip!, ...g.titleLines].map((e) => clear(kind, w, g.height, { ...e, left: e.left + headDx, right: e.right + headDx })) + const rowsC = g.rows.map((r) => { + const left = kind === 'gate' ? w / 2 - r.width / 2 : g.stackStart + f.start[r.key]! + return clear(kind, w, g.height, { left, right: left + r.width, top: r.top, bottom: r.bottom }) + }) + const contentEnd = kind === 'gate' ? 0 : Math.max(g.titleBoxStart + f.headShift + Math.min(g.titleOneLine, 135), ...g.rows.map((r) => g.stackStart + f.start[r.key]! + r.width)) + g.padEnd + const broken = [...heads, ...rowsC].some((c) => c < ROW_CLEAR - 1e-6) || contentEnd > w + 1e-6 || (kind === 'gate' && w < 134) + expect(broken || kind === 'gate', `${kind} ${titleW} ${rows.map((r) => r.w)} at ${f.width}`).toBe(true) + } + }) + + it('cuts a row that would need more than the maximum width, and never passes it', () => { + const g = geometry({ kind: 'pool', titleW: [60], rows: [{ key: 'value', w: 300 }] }) + const f = fitOutline(g) + expect(f.width).toBe(NODE_MAX_W) + expect(f.maxWidth.value).toBeLessThan(300) + expect(f.maxWidth.value).toBeGreaterThan(0) + }) + + it('reads the title at its one-line width, so a node an earlier fit made narrow grows with a longer title', () => { + const g = geometry({ kind: 'source', titleW: [60, 60], rows: ROWS.source[0] }) + // the same title on one line (120 px, under its 135 px max): the node is as wide as that needs + const f = fitOutline({ ...g, titleOneLine: 120 }) + expect(f.width).toBeGreaterThanOrEqual(g.titleBoxStart + f.headShift + 120 + g.padEnd - 0.01) + }) +}) diff --git a/src/components/nodes/outlineFit.ts b/src/components/nodes/outlineFit.ts new file mode 100644 index 00000000..7595651e --- /dev/null +++ b/src/components/nodes/outlineFit.ts @@ -0,0 +1,149 @@ +// issue #337 (v0.21.4) — where the head and the rows of a Pool, a Source, a +// Drain, a Converter and a Gate go, and how wide the node is, so that every +// painted element keeps clear of the drawn outline. +// +// The outlines of these kinds are width-parametric (./silhouette +// `FIXED_DEPTH_KINDS`): their left side never depends on the width and their +// right side keeps its distance from the right edge, so the fit is a direct +// computation, not a search. The contract: +// 1. every painted element — the kind chip, each title line, each row — keeps +// at least `ROW_CLEAR` (8) px inside the outline at its own height, at +// both ends (#332's clearance); +// 2. the head (chip + title) moves in as one, by the least that holds its +// every element; +// 3. a row starts where the title text starts (the chip plus its gap, after +// the head moved in), or further in only where the outline needs it: #332's +// rule for the Pool's value and capacity rows, the same for the Source / +// Drain / Converter mode row; +// 4. the Gate's lines stay centred; +// 5. the node is as wide as its content needs and no wider, up to 260 px; a +// row that would need more is cut short (its ellipsis) where the room ends. +// +// The width it returns is the node's width (`NodeFrame` sets it): it is built +// only from widths that do not depend on the node's current width (each row's +// whole text, the title on one line up to its own max width), so a node whose +// content grows grows with it — a title squeezed by an earlier, narrower fit +// is never read as wrapping for want of room. Below 260 px a title wraps only +// at its own max width, as before, so the height never changes for a row. +// +// Pure: it reads only what `NodeFrame` measures (in CSS px within the box, the +// head and the rows at their place BEFORE this fit moves them) and the outline. + +import type { NodeKind } from '../../model/types' +import { fillSpanAt } from './silhouette' +import { NODE_MAX_W, ROW_CLEAR } from './rowFit' + +/** an element's extent in CSS px within the box: a text line's glyphs (its + * top / bottom the line BOX), or the chip */ +export type Extent = { left: number; right: number; top: number; bottom: number } + +export type OutlineRow = { key: 'value' | 'sub'; top: number; bottom: number; width: number; left: number } + +export type OutlineGeometry = { + kind: NodeKind + /** the box height (the outline's viewBox height) */ + height: number + /** the node's width as measured (only the Gate's centring reads it) */ + width: number + /** the body's start / end padding */ + stackStart: number + padEnd: number + /** the CSS minimum width of the kind */ + minWidth: number + chip: Extent | null + /** each title line's glyphs, top to bottom */ + titleLines: Extent[] + /** the title box's start (the chip plus its gap) */ + titleBoxStart: number + /** the title's width on one line, and its own max width */ + titleOneLine: number + titleMax: number + /** each row's whole text width (even while its ellipsis cuts it) and, for the + * Gate, its measured place */ + rows: OutlineRow[] +} + +export type OutlineFit = { + /** the node's width */ + width: number + /** how far the head moves in from its place (never for the Gate) */ + headShift: number + /** each row's start, in CSS px from the stack's start (its `margin-left`) */ + start: Partial> + /** a row's max width where the room ends before the row does */ + maxWidth: Partial> +} + +// Layout units (1/64 px), as ./rowFit: a clearance is rounded up, a room down. +const UNIT = 64 +const EPS = 1e-6 +const up = (v: number) => Math.ceil(v * UNIT - EPS) / UNIT +const down = (v: number) => Math.floor(v * UNIT + EPS) / UNIT + +/** where an element is sampled: its top + 1, bottom − 1 and three points + * between (#332's e2e reads top + 1, middle, bottom − 1) */ +const samples = (top: number, bottom: number): number[] => { + const a = top + 1 + const b = Math.max(a, bottom - 1) + return [0, 1, 2, 3, 4].map((k) => a + ((b - a) * k) / 4) +} + +export function fitOutline(g: OutlineGeometry): OutlineFit { + const { kind, height } = g + // the outline's left edge at y, and how far its right edge sits in from the + // node's right edge: both the same at every width + const REF = 1000 + const sides = (y: number): [number, number] | null => { + const s = fillSpanAt(kind, height, y, REF) + return s && [s[0], REF - s[1]] + } + const leftNeed = (e: { top: number; bottom: number }) => Math.max(...samples(e.top, e.bottom).map((y) => (sides(y)?.[0] ?? 0) + ROW_CLEAR)) + const rightIn = (e: { top: number; bottom: number }) => Math.max(...samples(e.top, e.bottom).map((y) => (sides(y)?.[1] ?? 0) + ROW_CLEAR)) + const head: Extent[] = [...(g.chip ? [g.chip] : []), ...g.titleLines] + // the title box as the content asks for it: one line, up to its max width + const titleWidth = Math.min(g.titleOneLine, g.titleMax) + + if (kind === 'gate') { + // every line is centred in the box: its centre's offset from the box + // centre is the same at any width, so each side sets a minimum width + const headWidth = g.titleBoxStart - (g.chip?.left ?? g.titleBoxStart) + titleWidth + let need = Math.max(g.minWidth, headWidth + 2 * g.padEnd) + const lines = [...head, ...g.rows.map((r) => ({ left: r.left, right: r.left + r.width, top: r.top, bottom: r.bottom }))] + for (const e of lines) { + const c = (e.left + e.right) / 2 - g.width / 2 + const half = (e.right - e.left) / 2 + need = Math.max(need, 2 * (leftNeed(e) + half - c), 2 * (c + half + rightIn(e)), 2 * (half + Math.abs(c)) + 2 * g.padEnd) + } + const width = Math.min(up(need), NODE_MAX_W) + const maxWidth: OutlineFit['maxWidth'] = {} + for (const r of g.rows) { + const c = r.left + r.width / 2 - g.width / 2 + const room = 2 * Math.min(width / 2 - leftNeed(r) - Math.abs(c), width / 2 - rightIn(r) - Math.abs(c)) + if (room < r.width - 0.01) maxWidth[r.key] = Math.max(0, down(room)) + } + return { width, headShift: 0, start: {}, maxWidth } + } + + // 2. the head moves in as far as its most constrained element needs + const headShift = up(Math.max(0, ...head.map((e) => leftNeed(e) - e.left))) + let need = Math.max(g.minWidth, g.titleBoxStart + headShift + titleWidth + g.padEnd) + for (const e of head) need = Math.max(need, e.right + headShift + rightIn(e)) + // 3. the rows start at the title, or further in where the outline needs it + const titleAt = g.titleBoxStart + headShift + const starts = g.rows.map((r) => up(Math.max(titleAt, leftNeed(r)))) + g.rows.forEach((r, i) => { + need = Math.max(need, starts[i] + r.width + rightIn(r), starts[i] + r.width + g.padEnd) + }) + // 5. never wider than the maximum + const width = Math.min(up(need), NODE_MAX_W) + const start: OutlineFit['start'] = {} + const maxWidth: OutlineFit['maxWidth'] = {} + // whole layout units, never a decimal rounding: layout snaps a length to + // 1/64 px DOWN, so 187.0156 (for 187 + 1/64) lays out at 187 + g.rows.forEach((r, i) => { + start[r.key] = up(starts[i] - g.stackStart) + const room = Math.min(width - rightIn(r), width - g.padEnd) - starts[i] + if (room < r.width - 0.01) maxWidth[r.key] = Math.max(0, down(room)) + }) + return { width, headShift, start, maxWidth } +} diff --git a/src/components/nodes/rowFit.test.ts b/src/components/nodes/rowFit.test.ts index 515e87b6..1da08b4c 100644 --- a/src/components/nodes/rowFit.test.ts +++ b/src/components/nodes/rowFit.test.ts @@ -3,12 +3,16 @@ import type { NodeKind } from '../../model/types' import { fitRows, type FrameGeometry, headRim, type MeasuredRow, NODE_MAX_W, NODE_MIN_W, ROW_CLEAR, TITLE_SLACK } from './rowFit' import { fillSpanAt, NODE_RINGS } from './silhouette' -// issue #332 — the value / detail row fit of a Pool, a Parameter, a Register. +// issue #332 — the value / detail row fit of a Parameter and a Register. The +// Pool's rows moved to ./outlineFit with its width-parametric outline (#337); +// its #332 rules are tested in ./outlineFit.test. describe('fillSpanAt', () => { it('reads the fill straight from the drawn path', () => { - // pool at 58: the left side runs (24, 13) → (8, 46), the right (96, 13) → (112, 46) - expect(fillSpanAt('pool', 58, 29.5)).toEqual([16, 104]) + // pool at 58 (width-parametric, #337): the left side runs (16, 13) → (8, 46) + // at every width, the right keeps the same distance from the right edge + expect(fillSpanAt('pool', 58, 29.5)).toEqual([12, 108]) + expect(fillSpanAt('pool', 58, 29.5, 200)).toEqual([12, 188]) // register at 64 (the historic cut): its ends bulge to x6 / x118 at mid-height const [l, r] = fillSpanAt('register', 64, 32)! expect(l).toBeCloseTo(6, 1) @@ -31,20 +35,11 @@ describe('fillSpanAt', () => { }) // geometry close to what NodeFrame measures in the shipped layout (index.css): -// Pool body padding 12 / 12, title one line at y 10 … 26; Parameter / Register -// padding 15 / 15, the head carrying the width-scaled rim -const pool = (width: number, over: Partial = {}): FrameGeometry => ({ - height: 74, width, stackStart: 12, padEnd: 12, titleStart: 26, titleWidth: 60, - titleTop: 10, titleBottom: 26, titleWrapped: false, titleMax: 135, ...over, -}) +// Parameter / Register padding 15 / 15, the head carrying the width-scaled rim const capsule = (kind: 'parameter' | 'register', width: number, over: Partial = {}): FrameGeometry => ({ height: 86, width, stackStart: 15, padEnd: 15, titleStart: 29 + headRim(kind, width), titleWidth: 70, titleTop: 16, titleBottom: 32, titleWrapped: false, titleMax: 135, ...over, }) -const POOL_ROWS = (value: number, sub = 40): MeasuredRow[] => [ - { key: 'value', top: 27, bottom: 48, width: value }, - { key: 'sub', top: 49, bottom: 64, width: sub }, -] const CAPSULE_ROWS = (value: number, sub = 40): MeasuredRow[] => [ { key: 'value', top: 33, bottom: 54, width: value }, { key: 'sub', top: 55, bottom: 70, width: sub }, @@ -70,22 +65,10 @@ describe('fitRows', () => { }) it('starts each row at the title text, and keeps the size when it already fits', () => { - // a one-line Pool (58 px tall, value only) and a Register - const p = fitRows('pool', pool(118, { height: 58 }), POOL_ROWS(20).slice(0, 1)) - expect(p).toEqual({ width: 118, start: { value: 14 }, minWidth: null, maxWidth: {} }) const r = fitRows('register', capsule('register', 118), CAPSULE_ROWS(20, 30)) expect(r).toEqual({ width: 118, start: { value: 14, sub: 14 }, minWidth: null, maxWidth: {} }) }) - it('moves a row past the title start where the slant needs it', () => { - // a capacity row makes the Pool 74 px tall, so its left side leans in - // further toward the top: at the value row's top the fill starts at - // x ≈ 19.4, and 8 px clear of it is past the title's 26 - const fit = fitRows('pool', pool(118), POOL_ROWS(20)) - expect(fit.start.value! + 12).toBeGreaterThan(26) - expect(fit.start.sub).toBe(14) - }) - it('widens a Register by the minimum that holds the row whole', () => { const rows = CAPSULE_ROWS(100) const fit = fitRows('register', capsule('register', 137), rows) @@ -111,14 +94,6 @@ describe('fitRows', () => { expect(fit.maxWidth.value).toBeUndefined() }) - it('never makes a Pool title worse: the slanted side would move toward it', () => { - // a 108 px value (`1234567.89`) set the shipped width (12 + 108 + 12); - // aligned at the title it would need ~185 px, where the title would cross - const fit = fitRows('pool', pool(132), POOL_ROWS(108)) - expect(fit.minWidth).toBe(132) - expect(fit.maxWidth.value).toBeLessThan(108) - }) - it('lets a title that had 8 px or more lose at most TITLE_SLACK, never below 8', () => { // a Parameter whose 78 px unit (`items per hour`) needs a wider node: below // ~129 px the head's rim is 0, so the body edge (x8) creeps toward the title @@ -158,15 +133,14 @@ describe('fitRows', () => { // title, keeps ROW_CLEAR inside the outline at both ends, and the node never // leaves the CSS width range it.each([ - ['pool', [118, 140, 200, 260]], ['parameter', [118, 140, 200, 260]], ['register', [118, 140, 200, 260]], ] as const)('%s rows stay inside the outline at every width', (kind, widths) => { for (const width of widths) { for (const value of [10, 60, 120, 240, 500]) { for (const sub of [20, 90, 300]) { - const g = kind === 'pool' ? pool(width) : capsule(kind, width) - const rows = kind === 'pool' ? POOL_ROWS(value, sub) : CAPSULE_ROWS(value, sub) + const g = capsule(kind, width) + const rows = CAPSULE_ROWS(value, sub) const fit = fitRows(kind, g, rows) const w = fit.width if (fit.minWidth != null) expect(fit.minWidth).toBe(w) diff --git a/src/components/nodes/silhouette.test.ts b/src/components/nodes/silhouette.test.ts index a0954163..27209d08 100644 --- a/src/components/nodes/silhouette.test.ts +++ b/src/components/nodes/silhouette.test.ts @@ -3,7 +3,10 @@ import type { NodeKind } from '../../model/types' import { BASE_NODE_H, clampNodeHeight, + FIXED_DEPTH_KINDS, + fillSpanAt, MAX_NODE_H, + OUTLINE_DEPTH, silhouettePath, } from './silhouette' @@ -20,14 +23,17 @@ const KINDS: NodeKind[] = [ // the historic hard-coded silhouettes, drawn at 64 — the parametric function // must still return these byte-for-byte at 64, so a node whose content keeps it -// at 64 or taller draws exactly as before the compact floor (FC-7) +// at 64 or taller draws exactly as before the compact floor (FC-7). Issue #337: +// the five width-parametric kinds have their own outline since v0.21.4, given +// here at width 120 (Pool slant 8, Source arrow 16, Drain notch 16, Converter +// waist 16, Gate a hexagon with 16 px points). const HISTORIC: Record = { - pool: 'M32 6 H88 Q95 6 96 13 L112 52 Q113 58 107 58 H13 Q7 58 8 52 L24 13 Q25 6 32 6 Z', - source: 'M14 8 Q8 8 8 14 V50 Q8 56 14 56 H84 L114 32 L84 8 Z', - drain: 'M6 32 L34 8 H104 Q112 8 112 15 V49 Q112 56 104 56 H34 Z', - gate: 'M60 3 L117 32 L60 61 L3 32 Z', + pool: 'M24 6 H96 Q103 6 104 13 L112 52 Q113 58 107 58 H13 Q7 58 8 52 L16 13 Q17 6 24 6 Z', + source: 'M14 8 Q8 8 8 14 V50 Q8 56 14 56 H98 L114 32 L98 8 Z', + drain: 'M6 32 L22 8 H104 Q112 8 112 15 V49 Q112 56 104 56 H22 Z', + gate: 'M19 3 H101 L117 32 L101 61 H19 L3 32 Z', converter: - 'M14 8 H106 Q112 8 112 14 L82 32 L112 50 Q112 56 106 56 H14 Q8 56 8 50 L38 32 L8 14 Q8 8 14 8 Z', + 'M14 8 H106 Q112 8 112 14 L96 32 L112 50 Q112 56 106 56 H14 Q8 56 8 50 L24 32 L8 14 Q8 8 14 8 Z', end: 'M28 8 H92 Q112 8 112 32 Q112 56 92 56 H28 Q8 56 8 32 Q8 8 28 8 Z', parameter: 'M14 12 H106 Q112 12 112 18 V46 Q112 52 106 52 H14 Q8 52 8 46 V40 H1 V24 H8 V18 Q8 12 14 12 Z', register: 'M14 12 H110 Q118 12 118 32 Q118 52 110 52 H14 Q6 52 6 32 Q6 12 14 12 Z', @@ -136,6 +142,72 @@ describe('silhouettePath — growth', () => { }) }) +// issue #337 — the five width-parametric outlines keep every feature's px depth +describe('silhouettePath — width-parametric (#337)', () => { + const FIXED = [...FIXED_DEPTH_KINDS] + const WIDTHS = [118, 134, 160, 200, 260] + + it('covers exactly the Pool, Source, Drain, Converter and Gate', () => { + expect(FIXED.sort()).toEqual(['converter', 'drain', 'gate', 'pool', 'source']) + expect(OUTLINE_DEPTH).toEqual({ poolSlant: 8, sourceArrow: 16, drainNotch: 16, converterWaist: 16, gatePoint: 16 }) + }) + + for (const k of FIXED) { + it(`${k}: at every width and height the outline stays in the w × h box and never reverses`, () => { + for (const w of WIDTHS) { + for (const H of [BASE_NODE_H, 64, 74, MAX_NODE_H[k]]) { + const d = silhouettePath(k, H, w) + for (const p of points(d)) { + expect(p.x, `${k} ${w}×${H}`).toBeGreaterThanOrEqual(0) + expect(p.x, `${k} ${w}×${H}`).toBeLessThanOrEqual(w) + expect(p.y, `${k} ${w}×${H}`).toBeGreaterThanOrEqual(0) + expect(p.y, `${k} ${w}×${H}`).toBeLessThanOrEqual(H) + } + expect(yTurns(d), `${k} ${w}×${H}: ${d}`).toBe(2) + } + } + }) + + it(`${k}: the left side is the same at every width, the right keeps its distance from the right edge`, () => { + for (const H of [BASE_NODE_H, 74]) { + for (let y = 4; y < H - 3; y += 3) { + const ref = fillSpanAt(k, H, y, 1000) + for (const w of WIDTHS) { + const s = fillSpanAt(k, H, y, w) + if (!ref) { expect(s).toBeNull(); continue } + expect(s![0], `${k} ${w}×${H} at ${y}`).toBeCloseTo(ref[0], 6) + expect(w - s![1], `${k} ${w}×${H} at ${y}`).toBeCloseTo(1000 - ref[1], 6) + } + } + } + }) + } + + it('each feature has its decided depth at the mid-height, at any width', () => { + const H = 56, y = 28 + for (const w of WIDTHS) { + // Source: the arrow tip 6 px in from the right, its root 16 px before it + expect(w - fillSpanAt('source', H, y, w)![1]).toBeCloseTo(6, 6) + expect(w - fillSpanAt('source', H, 8.001, w)![1]).toBeCloseTo(6 + OUTLINE_DEPTH.sourceArrow, 1) + // Drain: the notch's point 6 px in from the left, the body 16 px past it + expect(fillSpanAt('drain', H, y, w)![0]).toBeCloseTo(6, 6) + expect(fillSpanAt('drain', H, 8.001, w)![0]).toBeCloseTo(6 + OUTLINE_DEPTH.drainNotch, 1) + // Converter: each waist 16 px deep from the 8 px body edge + expect(fillSpanAt('converter', H, y, w)![0]).toBeCloseTo(8 + OUTLINE_DEPTH.converterWaist, 6) + expect(w - fillSpanAt('converter', H, y, w)![1]).toBeCloseTo(8 + OUTLINE_DEPTH.converterWaist, 6) + // Gate: points 3 px in, the flat top starting 16 px further in + expect(fillSpanAt('gate', H, y, w)![0]).toBeCloseTo(3, 6) + expect(fillSpanAt('gate', H, 3.001, w)![0]).toBeCloseTo(3 + OUTLINE_DEPTH.gatePoint, 1) + // Pool: the slant runs 8 px from its shoulder (y 13) to its foot (y H - 12) + expect(fillSpanAt('pool', H, 13, w)![0] - fillSpanAt('pool', H, H - 12, w)![0]).toBeCloseTo(OUTLINE_DEPTH.poolSlant, 6) + } + }) + + it('the End, the Parameter and the Register keep their 120-wide viewBox: the width does not change their path', () => { + for (const k of ['end', 'parameter', 'register'] as const) expect(silhouettePath(k, 64, 260)).toBe(HISTORIC[k]) + }) +}) + describe('clampNodeHeight', () => { it('floors at the base height', () => { expect(clampNodeHeight('pool', 40)).toBe(56) diff --git a/src/components/nodes/silhouette.ts b/src/components/nodes/silhouette.ts index 97313687..2a237470 100644 --- a/src/components/nodes/silhouette.ts +++ b/src/components/nodes/silhouette.ts @@ -16,7 +16,13 @@ // meet at mid-height instead of inserting a straight run, so no path ever // draws a reversed segment. // -// The SVG viewBox is `0 0 120 h`; the x geometry (width 120) never changes. +// The End, the Parameter and the Register are drawn in a `0 0 120 h` viewBox +// stretched to the node's width. Issue #337 (v0.21.4) — the Pool, the Source, +// the Drain, the Converter and the Gate are WIDTH-parametric as well: their +// path is drawn in CSS px for the node's own width (viewBox `0 0 w h`), every +// point, notch, slant and corner keeps its px depth (`OUTLINE_DEPTH`) and only +// the straight runs stretch. Stretched, those features grew as the node +// widened while the text stayed at fixed insets, so text crossed them. import type { NodeKind } from '../../model/types' @@ -52,9 +58,10 @@ export const POOL_PULSE_INSET = 7 /** the user-space rectangle every ring mask covers (`RingMasks.tsx`), in * viewBox units — vertical units are px, horizontal ones at least ~1 px; its - * height follows the node's */ + * height follows the node's, and (#337) its width the viewBox's (120, or a + * width-parametric outline's own) */ export type MaskBox = { x: number; y: number; width: number; height: number } -export const maskBox = (boxH: number): MaskBox => ({ x: -200, y: -200, width: 520, height: boxH + 400 }) +export const maskBox = (boxH: number, viewW = 120): MaskBox => ({ x: -200, y: -200, width: viewW + 400, height: boxH + 400 }) /** How many px the drawn vessel path leaves EMPTY at the top + bottom of its * `0 0 120 H` viewBox — the `y` where the top cap's straight run begins and @@ -96,8 +103,44 @@ export const MAX_NODE_H: Record = { const n = (v: number) => Math.round(v * 100) / 100 +/** issue #337 — the kinds whose outline is width-parametric (drawn in CSS px + * for the node's width, `viewBox="0 0 w h"`) */ +export const FIXED_DEPTH_KINDS: ReadonlySet = new Set(['pool', 'source', 'drain', 'converter', 'gate']) +export const isFixedDepth = (kind: NodeKind): boolean => FIXED_DEPTH_KINDS.has(kind) + +/** issue #337 — the px depth each feature keeps at every width and height: the + * Pool's slanted sides (horizontal run), the Source's arrow, the Drain's notch, + * the Converter's two waists and the Gate's two points (an elongated hexagon). */ +export const OUTLINE_DEPTH = { poolSlant: 8, sourceArrow: 16, drainNotch: 16, converterWaist: 16, gatePoint: 16 } as const + +/** The width-parametric outline of a `FIXED_DEPTH_KINDS` kind at width `w` and + * height `H` (already clamped), in CSS px. The left side never depends on the + * width and the right side only through `w − …`, so a feature's depth is the + * same at every width. */ +function fixedPath(kind: NodeKind, w: number, H: number): string { + const mid = n(H / 2) + const R = (x: number) => n(w - x) // x px in from the right edge + const { poolSlant: s, sourceArrow: a, drainNotch: d, converterWaist: c, gatePoint: g } = OUTLINE_DEPTH + switch (kind) { + case 'pool': + // the shipped corners (top r ≈ 7, bottom r ≈ 6) on a slant of `s` px + return `M${16 + s} 6 H${R(16 + s)} Q${R(9 + s)} 6 ${R(8 + s)} 13 L${R(8)} ${n(H - 12)} Q${R(7)} ${n(H - 6)} ${R(13)} ${n(H - 6)} H13 Q7 ${n(H - 6)} 8 ${n(H - 12)} L${8 + s} 13 Q${9 + s} 6 ${16 + s} 6 Z` + case 'source': + return `M14 8 Q8 8 8 14 V${n(H - 14)} Q8 ${n(H - 8)} 14 ${n(H - 8)} H${R(6 + a)} L${R(6)} ${mid} L${R(6 + a)} 8 Z` + case 'drain': + return `M6 ${mid} L${6 + d} 8 H${R(16)} Q${R(8)} 8 ${R(8)} 15 V${n(H - 15)} Q${R(8)} ${n(H - 8)} ${R(16)} ${n(H - 8)} H${6 + d} Z` + case 'converter': + return `M14 8 H${R(14)} Q${R(8)} 8 ${R(8)} 14 L${R(8 + c)} ${mid} L${R(8)} ${n(H - 14)} Q${R(8)} ${n(H - 8)} ${R(14)} ${n(H - 8)} H14 Q8 ${n(H - 8)} 8 ${n(H - 14)} L${8 + c} ${mid} L8 14 Q8 8 14 8 Z` + default: + // gate: an elongated hexagon, a `g` px point at each end, flat top and bottom + return `M${3 + g} 3 H${R(3 + g)} L${R(3)} ${mid} L${R(3 + g)} ${n(H - 3)} H${3 + g} L3 ${mid} Z` + } +} + /** The vessel outline for `kind` at body height `h` (viewBox `0 0 120 h`), - * clamped to the kind's range [`BASE_NODE_H`, `MAX_NODE_H`]. + * clamped to the kind's range [`BASE_NODE_H`, `MAX_NODE_H`]. A + * `FIXED_DEPTH_KINDS` kind is drawn for the node's width `w` instead + * (viewBox `0 0 w h`, issue #337). * * `parameter` / `register` were RE-CUT (docs/node-shell-content-in-vessel.md * "curved corner" follow-up): the old capsule / tag inset its left+right edges @@ -116,21 +159,16 @@ const n = (v: number) => Math.round(v * 100) / 100 * (`VESSEL_INSET_Y` 24). (A true stadium / semicircular-ended capsule cannot * contain the content: it pinches in hard exactly where the chip / title * corner sits, 4 px below the top edge.) */ -export function silhouettePath(kind: NodeKind, h = BASE_NODE_H): string { +export function silhouettePath(kind: NodeKind, h = BASE_NODE_H, w = 120): string { const H = Math.max(BASE_NODE_H, Math.min(h, MAX_NODE_H[kind])) const mid = n(H / 2) switch (kind) { case 'pool': - // top edge y6, shoulders y13; bottom edge y H-6, shoulders y H-12 - return `M32 6 H88 Q95 6 96 13 L112 ${n(H - 12)} Q113 ${n(H - 6)} 107 ${n(H - 6)} H13 Q7 ${n(H - 6)} 8 ${n(H - 12)} L24 13 Q25 6 32 6 Z` case 'source': - return `M14 8 Q8 8 8 14 V${n(H - 14)} Q8 ${n(H - 8)} 14 ${n(H - 8)} H84 L114 ${mid} L84 8 Z` case 'drain': - return `M6 ${mid} L34 8 H104 Q112 8 112 15 V${n(H - 15)} Q112 ${n(H - 8)} 104 ${n(H - 8)} H34 Z` - case 'gate': - return `M60 3 L117 ${mid} L60 ${n(H - 3)} L3 ${mid} Z` case 'converter': - return `M14 8 H106 Q112 8 112 14 L82 ${mid} L112 ${n(H - 14)} Q112 ${n(H - 8)} 106 ${n(H - 8)} H14 Q8 ${n(H - 8)} 8 ${n(H - 14)} L38 ${mid} L8 14 Q8 8 14 8 Z` + case 'gate': + return fixedPath(kind, w, H) case 'end': // the rounded caps meet at mid-height up to 64; above it a straight V // keeps the cap radius fixed @@ -158,12 +196,26 @@ export function silhouettePath(kind: NodeKind, h = BASE_NODE_H): string { * from `silhouettePath` itself (its `M H V L Q Z` commands, the quadratic * corners flattened), so it cannot drift from what is drawn. The Parameter's * left tab (`x1 … 8`) is left out: it is a port-side marker, not room for text, - * so the body's edge `x8` is the edge text must keep clear of. */ -export function fillSpanAt(kind: NodeKind, h: number, y: number): [number, number] | null { + * so the body's edge `x8` is the edge text must keep clear of. + * + * Issue #337 — for a `FIXED_DEPTH_KINDS` kind the span is in CSS px for the + * node's width `w` (its viewBox is `0 0 w h`). */ +export function fillSpanAt(kind: NodeKind, h: number, y: number, w = 120): [number, number] | null { const H = Math.max(BASE_NODE_H, Math.min(h, MAX_NODE_H[kind])) - const key = `${kind}|${H}` + if (!FIXED_DEPTH_KINDS.has(kind)) return spanOf(`${kind}|${H}`, () => silhouettePath(kind, H), kind, y) + // read once per height at a wide reference width: the left side never + // depends on the width and the right side keeps its distance from the right + // edge, so one reading serves every width + const s = spanOf(`${kind}|${H}|ref`, () => silhouettePath(kind, H, REF_W), kind, y) + return s && [s[0], w - (REF_W - s[1])] +} + +/** the reference width a width-parametric outline is read at */ +const REF_W = 1000 + +function spanOf(key: string, path: () => string, kind: NodeKind, y: number): [number, number] | null { let segs = segmentCache.get(key) - if (!segs) segmentCache.set(key, (segs = pathSegments(silhouettePath(kind, H)))) + if (!segs) segmentCache.set(key, (segs = pathSegments(path()))) const xs: number[] = [] for (const [a, b] of segs) { if ((a[1] <= y && b[1] >= y) || (b[1] <= y && a[1] >= y)) { @@ -176,6 +228,22 @@ export function fillSpanAt(kind: NodeKind, h: number, y: number): [number, numbe return [left, Math.max(...xs)] } +/** issue #344, docs/diagram-layout.md §DL1 — how far in from the box's left + * (`in`) or right (`out`) edge the drawn outline is on the port row, as a + * FRACTION of the node's width `w`, for a node `h` tall: the resource port is + * drawn there, ON the outline (a Pool's slanted side, a Drain's notch tip, a + * Converter's waist), while the routing lane stays on the row. `rowY` is the + * port row (`PORT_ROW`, passed in so this module keeps no layout import). 0 + * when the row misses the shape. A 120-wide-viewBox kind stretches with the + * width, so its fraction is the same at every width; a `FIXED_DEPTH_KINDS` + * kind (#337) keeps its px depth, so its fraction is read at `w`. */ +export function portInsetFraction(kind: NodeKind, h: number, side: 'in' | 'out', rowY: number, w = 120): number { + const W = FIXED_DEPTH_KINDS.has(kind) ? w : 120 + const span = fillSpanAt(kind, h, rowY, W) + if (!span) return 0 + return (side === 'in' ? span[0] : W - span[1]) / W +} + type Pt = [number, number] /** each (kind, height)'s flattened path, parsed once: a row fit samples it * about fifteen times per node */ @@ -183,12 +251,15 @@ const segmentCache = new Map() /** issue #330 PR 2 — the drawn outline of `kind` at height `h` as straight * segments in viewBox units (x 0 … 120, y = CSS px), quadratics flattened; - * the same parse `fillSpanAt` reads, cached per (kind, height) */ -export function silhouetteSegments(kind: NodeKind, h: number): readonly (readonly [Pt, Pt])[] { + * the same parse `fillSpanAt` reads, cached per (kind, height). Issue #337 — + * a `FIXED_DEPTH_KINDS` kind is drawn for its width, so it is read at width + * `w`, in CSS px (its viewBox is `0 0 w h`), cached per (kind, height, width). */ +export function silhouetteSegments(kind: NodeKind, h: number, w = 120): readonly (readonly [Pt, Pt])[] { const H = Math.max(BASE_NODE_H, Math.min(h, MAX_NODE_H[kind])) - const key = `${kind}|${H}` + const own = FIXED_DEPTH_KINDS.has(kind) + const key = own ? `${kind}|${H}|w${n(w)}` : `${kind}|${H}` let segs = segmentCache.get(key) - if (!segs) segmentCache.set(key, (segs = pathSegments(silhouettePath(kind, H)))) + if (!segs) segmentCache.set(key, (segs = pathSegments(own ? silhouettePath(kind, H, n(w)) : silhouettePath(kind, H)))) return segs } /** The straight segments of one of this module's paths, quadratics flattened. */ diff --git a/src/engine/coffee-roastery.fixture.ts b/src/engine/coffee-roastery.fixture.ts index ba3625ec..cad09fcc 100644 --- a/src/engine/coffee-roastery.fixture.ts +++ b/src/engine/coffee-roastery.fixture.ts @@ -1,6 +1,7 @@ import { defaultData } from '../model/factory' import type { LoopEdge, LoopNode, NodeKind } from '../model/types' import { withTemplateFlowColours, type TemplateFlows } from './templateFlowColours' +import { placeTemplate } from '../model/layout/templatePlacement' // Builder for examples/coffee-roastery.json — the "Coffee roastery operations // flow" Templates demo (docs/example-coffee-roastery.md, settled design, @@ -141,7 +142,7 @@ const res = (id: string, source: string, target: string, flow: string): LoopEdge const num = (n: number) => String(n) // ── the graph ───────────────────────────────────────────────────────────── -export function buildCoffeeRoastery(): { nodes: LoopNode[]; edges: LoopEdge[] } { +function buildCoffeeRoasteryAuthored(): { nodes: LoopNode[]; edges: LoopEdge[] } { const nodes: LoopNode[] = [] const edges: LoopEdge[] = [] const N = (n: LoopNode) => (nodes.push(n), n) @@ -317,7 +318,7 @@ export const COFFEE_ROASTERY_MC = { // (the Register column was moved +80 px for exactly this clearance). Frame gaps: // 27 px (1→2), 141 px (2→3); overlap: 0. Titles are EN canonical; the label // overlay localises them (§TLO12). -export const COFFEE_ROASTERY_FRAMES = [ +const COFFEE_ROASTERY_AUTHORED_FRAMES = [ { id: 'zone_supply', label: 'Supply & inventory', rect: { x: 16, y: 176, w: 473, h: 432 } }, { id: 'zone_roasting', label: 'Roasting & sales', rect: { x: 516, y: 136, w: 719, h: 612 } }, // bottom extended +20 (614 → 634) after the node-shell height fix @@ -327,3 +328,20 @@ export const COFFEE_ROASTERY_FRAMES = [ // right and the other two zones are unchanged. { id: 'zone_forecast', label: 'Forecast metrics', rect: { x: 1376, y: -24, w: 308, h: 634 } }, ] as const + +// issue #344 step 4 (docs/diagram-layout.md §DL5) — the authored graph and +// frames above, placed for the grid: widest boxes over 18 languages, +// clearance, frames that travel with their nodes (≥ 24 px margin, §CR17), +// every connection Auto orthogonal. What ships is this. +const PLACED = placeTemplate('coffee-roastery', { + ...buildCoffeeRoasteryAuthored(), + frames: COFFEE_ROASTERY_AUTHORED_FRAMES.map((f) => ({ id: f.id, label: f.label, rect: { ...f.rect } })), +}) + +/** the Coffee roastery graph as it ships (a fresh copy each call) */ +export function buildCoffeeRoastery(): { nodes: LoopNode[]; edges: LoopEdge[] } { + return structuredClone({ nodes: PLACED.nodes, edges: PLACED.edges }) +} + +/** the three group frames as they ship */ +export const COFFEE_ROASTERY_FRAMES: readonly { id: string; label: string; rect: { x: number; y: number; w: number; h: number } }[] = PLACED.frames! diff --git a/src/engine/mmo-progression.fixture.ts b/src/engine/mmo-progression.fixture.ts index f88d8867..b2dd12df 100644 --- a/src/engine/mmo-progression.fixture.ts +++ b/src/engine/mmo-progression.fixture.ts @@ -1,6 +1,7 @@ import { defaultData } from '../model/factory' import type { LoopEdge, LoopNode, NodeKind } from '../model/types' import { withTemplateFlowColours, type TemplateFlows } from './templateFlowColours' +import { placeTemplate } from '../model/layout/templatePlacement' // Builder for examples/mmo-progression.json — the "Early MMO progression // (levels 1–15)" Templates demo. Design: docs/example-mmo-progression.md @@ -609,7 +610,12 @@ export function buildMmoProgression(): { nodes: LoopNode[]; edges: LoopEdge[] } } } - return withTemplateFlowColours(nodes, edges, MMO_PROGRESSION_FLOWS) + // issue #344 step 4 (docs/diagram-layout.md §DL5) — the authored layout + // above, placed for the grid: widest boxes over 18 languages, clearance, + // every connection Auto orthogonal + const coloured = withTemplateFlowColours(nodes, edges, MMO_PROGRESSION_FLOWS) + const placed = placeTemplate('mmo-progression', coloured) + return { nodes: placed.nodes, edges: placed.edges } } // docs/flow-colour-and-compact-nodes.md FC-6 — the three economies: items and diff --git a/src/engine/templateFlowColours.test.ts b/src/engine/templateFlowColours.test.ts index 29e30e29..74c6a448 100644 --- a/src/engine/templateFlowColours.test.ts +++ b/src/engine/templateFlowColours.test.ts @@ -50,29 +50,32 @@ describe('withTemplateFlowColours', () => { // the current generated JSON, exactly: its engine digest equals the one recorded // before the colours (sha256Baseline.fixture.ts), its content and full-content -// digests are pinned here at their values WITH the colours +// digests are pinned here at their values WITH the colours (re-pinned for +// issue #344 step 4: the Templates placed for the grid, every connection Auto +// orthogonal — content, never engine data) const TEMPLATES: { file: string; flows: TemplateFlows; colours: string[]; contentDigest: string; fullContentDigest: string }[] = [ { file: 'coffee-roastery.json', flows: COFFEE_ROASTERY_FLOWS, colours: ['sage', 'gold', 'rose'], - contentDigest: 'b64fd6264bbf3aa0810ea85a86ca95be1c4bed3f3d6df290d4d222e575d79d71', - fullContentDigest: 'b64fd6264bbf3aa0810ea85a86ca95be1c4bed3f3d6df290d4d222e575d79d71', + contentDigest: '66f518eb9ebbe945a87362960866f61f534917fa7ff90c4df1d4a1674b74b5aa', + fullContentDigest: '66f518eb9ebbe945a87362960866f61f534917fa7ff90c4df1d4a1674b74b5aa', }, { file: 'gacha-banner-zones.json', flows: GACHA_BANNER_ZONES_FLOWS, colours: ['sage', 'violet', 'rose'], - // #325 PR 3: with the `e_pickup_36` routing waypoint of layout round 7 - contentDigest: 'ac61fc8bd006ca5b1bff67e9d2246e3874c883bbb4a729596064690f60e51b67', - fullContentDigest: 'ac61fc8bd006ca5b1bff67e9d2246e3874c883bbb4a729596064690f60e51b67', + // issue #344 step 4 (layout round 9): placed for the grid, every + // connection Auto orthogonal — the waypoints of rounds 7 and 8 are gone + contentDigest: 'a9b4dbb8566a94c509ede6e3253723feeb346315f0470826dc4646968e2941a0', + fullContentDigest: 'a9b4dbb8566a94c509ede6e3253723feeb346315f0470826dc4646968e2941a0', }, { file: 'mmo-progression.json', flows: MMO_PROGRESSION_FLOWS, colours: ['sage', 'gold', 'violet'], - contentDigest: '947b2fc2319b3dd1692bb1fd41cde79432828a154a6175bd23502f58d525c539', - fullContentDigest: '947b2fc2319b3dd1692bb1fd41cde79432828a154a6175bd23502f58d525c539', + contentDigest: '41674ea763b2330552878d7bbe72c88c2794a12ad406e98a5a478e9a430ed983', + fullContentDigest: '41674ea763b2330552878d7bbe72c88c2794a12ad406e98a5a478e9a430ed983', }, ] diff --git a/src/i18n/es419Copy.test.ts b/src/i18n/es419Copy.test.ts index efd185d8..f97df805 100644 --- a/src/i18n/es419Copy.test.ts +++ b/src/i18n/es419Copy.test.ts @@ -272,7 +272,9 @@ describe('es-419 copy — mechanical review', () => { 'inspector.labelTiming.warnSForm', ], Alt: ['hint.frameMove.body'], - Esc: ['canvas.regionSelect.on', 'canvas.frame.drawing'], + // issue #344: the grid size in the 0.25.0 release note + px: ['whatsNew.v0250.grid'], + Esc: ['canvas.regionSelect.on', 'canvas.frame.drawing', 'inspector.edge.route.addBendHint'], Escape: [ 'canvas.frame.a11y.descSelected', 'rf.node.a11y', @@ -286,7 +288,8 @@ describe('es-419 copy — mechanical review', () => { 'whatsNew.v0182.end', ], Tab: ['whatsNew.v0181.move'], - Ctrl: ['toolbar.undo.title', 'toolbar.redo.title'], + Ctrl: ['toolbar.undo.title', 'toolbar.redo.title', 'hint.frameMove.body'], + Mac: ['hint.frameMove.body'], // issue #344 — Ctrl / ⌘ moves a frame alone Cmd: ['toolbar.undo.title', 'toolbar.redo.title'], Z: ['toolbar.undo.title', 'toolbar.redo.title'], N: ['import.ignoreLastRows', 'import.issue.invalid-ignore-rows', 'import.issue.ragged-row', 'whatsNew.v0220.badge', 'whatsNew.v0240.tiers'], @@ -341,6 +344,8 @@ describe('es-419 copy — mechanical review', () => { // speed), and `s` is the seconds symbol of the four per-step times Normal: ['mobile.speed.normal', 'whatsNew.v0240.phone'], s: ['mobile.speed.slow.time', 'mobile.speed.normal.time', 'mobile.speed.fast.time', 'mobile.speed.veryFast.time', 'whatsNew.v0240.tiers'], + // issue #344 step 3 — the Spanish adjective IS `manual` (Trazado manual) + manual: ['inspector.edge.route.manual'], } const WORD = /[A-Za-zÀ-ÖØ-öø-ÿ]+/gu diff --git a/src/i18n/esEsCopy.test.ts b/src/i18n/esEsCopy.test.ts index 9511f348..e092bd63 100644 --- a/src/i18n/esEsCopy.test.ts +++ b/src/i18n/esEsCopy.test.ts @@ -99,6 +99,11 @@ describe('es-ES — the region audit, as a contract', () => { 'tour.desktop.pieces.body', 'hint.importFirstCommit.body', 'timeline.series.showAllHint', + // issue #344 step 3 — the Add bend button, and its hint (`pulse + // Intro`, `añadir`); the keyboard notice says `no se ha cambiado` + 'inspector.edge.route.addBend', + 'inspector.edge.route.addBendHint', + 'canvas.route.noBendRoom', // issue #297: the storage gate and the Storage and privacy area - // `ordenador` not `computadora`, `Ajustes` not `Configuración`, and the // `usted` register (`Puede`, `ha visto`, `Se ha eliminado`) diff --git a/src/i18n/itCopy.test.ts b/src/i18n/itCopy.test.ts index 144373f9..875f5cd3 100644 --- a/src/i18n/itCopy.test.ts +++ b/src/i18n/itCopy.test.ts @@ -119,10 +119,12 @@ const NEUTRAL = new Set([ const SURVIVING: ReadonlyArray = [ ['product and company names, never translated', [ 'loop', 'studio', 'github', 'google', 'sheets', 'excel', 'numbers', 'machinations', 'io', 'alex', + 'mac', // the frame-move hint (issue #344) ]], ['file formats, units and metric tokens', [ 'csv', 'tsv', 'json', 'xlsx', 'kg', 'xp', 'p10', 'p50', 'p90', 'montecarloresult', 'v1', 'v2', 'g1', 'mc', 'id', 'min', 'max', 'mmo', + 'px', // the grid size in the 0.25.0 release note (issue #344) ]], ['Monte Carlo — an Italian place name to begin with', ['monte', 'carlo']], // issue #307: `home` and `tab`, the key names in the 0.18.1 release note @@ -205,15 +207,15 @@ const IDENTICAL_TO_EN: ReadonlyArray = [ // ------------------------------------------------------------------ shape describe('it copy — the surfaces exist and are complete', () => { it('has exactly the base key set', () => { - expect(KEYS).toHaveLength(1064) // issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1079) // issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) expect(Object.keys(IT).sort()).toEqual([...KEYS].sort()) }) it('covers all four runtime surfaces, at the measured sizes', () => { const per: Record = {} for (const r of RUNTIME) per[r.surface.split('/')[0]!] = (per[r.surface.split('/')[0]!] ?? 0) + 1 - expect(per).toEqual({ catalog: 1064, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1286) + expect(per).toEqual({ catalog: 1079, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1301) }) it('EVERY row has a non-empty ENGLISH side — the vacuity guard', () => { diff --git a/src/i18n/locales/ar/canvas.ts b/src/i18n/locales/ar/canvas.ts index b50b995c..de97c219 100644 --- a/src/i18n/locales/ar/canvas.ts +++ b/src/i18n/locales/ar/canvas.ts @@ -95,6 +95,7 @@ const canvas = { 'canvas.frame.areaName': 'منطقة {n}', 'canvas.frame.dismiss': 'تجاهل هذا الإطار المقترح', 'canvas.frame.clearAll': 'مسح كل الإطارات', + 'canvas.tidy': 'محاذاة إلى الشبكة — ضع كل العقد والإطارات على الشبكة', 'canvas.frame.clearSuggested': 'مسح الإطارات المقترحة', 'canvas.frame.clearSuggestedRow': 'مسح الإطارات المقترحة', 'canvas.frame.colorRow': 'لون الإطار', @@ -110,6 +111,8 @@ const canvas = { 'canvas.activity.on': 'طبقة النشاط مُفعّلة — انقر لإخفاء التلوين', 'canvas.activity.rowLabel': 'طبقة النشاط', 'canvas.route.invalidFlag': 'مسار غير صالح — إحدى نقاط المسار داخل عقدة', + 'canvas.route.bend': 'نقطة الانعطاف {n} من {total}', + 'canvas.route.noBendRoom': 'لا يوجد في هذا الرابط مقطع يتّسع لانعطاف؛ لم يتغيّر شيء.', 'canvas.edgeLabel.clamp': 'تقييد', 'canvas.edgeLabel.clamp.title': 'أُزيل بفعل تقييد المَجمَع الهدف الوحيد في نهاية المرحلة 0', 'canvas.edgeLabel.blocked': 'محجوب', diff --git a/src/i18n/locales/ar/inspector.ts b/src/i18n/locales/ar/inspector.ts index 44dbbacb..7b0595ce 100644 --- a/src/i18n/locales/ar/inspector.ts +++ b/src/i18n/locales/ar/inspector.ts @@ -101,8 +101,15 @@ const inspector = { 'inspector.field.route': 'المسار', 'inspector.edge.route.curved': 'منحنٍ', 'inspector.edge.route.orthogonal': 'متعامد', + 'inspector.edge.route.straight': 'مستقيم', + 'inspector.edge.route.auto': 'مسار تلقائي', + 'inspector.edge.route.manual': 'مسار يدوي', + 'inspector.edge.route.reset': 'العودة إلى المسار التلقائي', + 'inspector.edge.route.addBend': 'إضافة انعطاف', + 'inspector.edge.route.addBendHint': 'انقر على الرابط حيث تريد الانعطاف، أو اضغط Enter لإضافة انعطاف في منتصف أطول مقطع، وEsc للإلغاء.', + 'inspector.edge.route.editHint': 'اسحب نقطة انعطاف، أو حدّدها وحرّكها بمفاتيح الأسهم (ومع Shift خطوة أكبر). Delete تزيلها.', 'inspector.edge.note': - 'تحرير اتصال يعيد بدء التشغيل من الخطوة 0 ويمسح أي مُحفِّزات معلّقة؛ وتُوسَم أي نتيجة مونت كارلو مكتملة بأنها قديمة.', + 'تغيير إعدادات المحاكاة لاتصال يعيد بدء التشغيل الحالي. أما المسار ونقاط الانعطاف واللون فللرسم فقط ولا تعيد بدءه.', 'inspector.field.mode': 'الوضع', 'inspector.edge.mode.trigger': 'مُحفِّز — نبضة تدفع الهدف للعمل', 'inspector.edge.mode.activator': 'مُفعِّل — تمكين الهدف أو تعطيله', diff --git a/src/i18n/locales/ar/ui.ts b/src/i18n/locales/ar/ui.ts index 38740118..89ad3801 100644 --- a/src/i18n/locales/ar/ui.ts +++ b/src/i18n/locales/ar/ui.ts @@ -424,7 +424,7 @@ const ui = { 'help.contextual.hint.import.name': 'استيراد جدول بيانات', 'help.contextual.hint.import.desc': 'تُعرض مرة واحدة، مباشرة بعد وصول أول استيراد لجدول بيانات إلى اللوحة.', 'hint.frameMove.body': - 'اسحب حافة الإطار لتحريكه مع كل ما بداخله. واضغط Alt أثناء السحب لتحريك الإطار وحده.', + 'اسحب حافة الإطار لتحريكه مع كل ما بداخله. واضغط Ctrl (⌘ على Mac) أثناء السحب لتحريك الإطار وحده، أو Alt لتحريكه بحرية خارج الشبكة.', 'help.contextual.hint.frameMove.name': 'تحريك الإطار', 'help.contextual.hint.frameMove.desc': 'تُعرض مرة واحدة، أول مرة يُحدَّد فيها إطار مجموعة على لوحة قابلة للتحرير.', 'hint.focusFilter.body': @@ -625,6 +625,11 @@ const ui = { 'whatsNew.v0240.tiers': 'أصبحت سرعة التشغيل تغيّر أيضًا ما تعرضه الخطوة: من 0.4 ثانية للخطوة فأكثر تتحرك العلامة ومعها +N، وأسرع من ذلك يظهر +N عند الوصول، وتحت 0.2 ثانية يومض المسار ثم تظهر العلامة في نهايته.', 'whatsNew.v0240.step': 'التقدّم خطوة واحدة يعرض الحركة كاملة دائمًا، مهما كانت السرعة المختارة.', 'whatsNew.v0240.phone': 'على الهاتف أصبحت قائمة المزيد تضم سرعة التشغيل (بطيئة أو عادية أو سريعة أو سريعة جدًا)، وتتحرك 12 علامة على الأكثر في الوقت نفسه.', + 'whatsNew.v0250.grid': 'تحرير محاذٍ للشبكة. تلتصق العقد والإطارات بشبكة مقاسها 16 px، مع أدلة ذكية لنقاط الاتصال والمراكز والحواف القريبة. تُصَفّ المخططات العادية الموجودة مرة واحدة، ويعيد زر محاذاة إلى الشبكة صفّ المخطط المفتوح في إجراء واحد يمكن التراجع عنه.', + 'whatsNew.v0250.routing': 'توجيه تلقائي أوضح. تتجنب الاتصالات المتعامدة التلقائية العقد، وتفصل بين الاتصالات التي تتشارك نقطة اتصال، وتضع تسمياتها في مساحات أوضح.', + 'whatsNew.v0250.shapes': 'أشكال اتصال قابلة للتعديل. اختر منحنٍ أو مستقيم أو متعامد، تلقائيًا أو بانعطافات خاصة بك: أضفها أو حرّكها أو احذفها على سطح المكتب. وعلى الهاتف تظهر هذه الأشكال من دون إمكانية تعديلها.', + 'whatsNew.v0250.outline': 'يبقى النص داخل العقد. صار إطار العقدة الخارجي يتّسع مع عنوانها وقيمها، فيبقى النص داخله في اللغات الـ18 كلها.', + 'whatsNew.v0250.templates': 'قوالب مُعاد تصميمها. تستخدم القوالب الخمسة المضمّنة الشبكة والمسارات الجديدة، مع تباعد أوضح وعروض افتتاحية محسّنة.', } as const export default ui diff --git a/src/i18n/locales/de/canvas.ts b/src/i18n/locales/de/canvas.ts index 04580400..e2de9750 100644 --- a/src/i18n/locales/de/canvas.ts +++ b/src/i18n/locales/de/canvas.ts @@ -112,6 +112,7 @@ const canvas = { 'canvas.frame.areaName': 'Bereich {n}', 'canvas.frame.dismiss': 'Diesen vorgeschlagenen Rahmen verwerfen', 'canvas.frame.clearAll': 'Alle Rahmen löschen', + 'canvas.tidy': 'Am Raster ausrichten — alle Knoten und Rahmen auf das Raster setzen', 'canvas.frame.clearSuggested': 'Vorgeschlagene Rahmen löschen', 'canvas.frame.clearSuggestedRow': 'Vorgeschlagene Rahmen löschen', 'canvas.frame.colorRow': 'Rahmenfarbe', @@ -129,6 +130,8 @@ const canvas = { 'canvas.activity.rowLabel': 'Aktivitätsebene', 'canvas.route.invalidFlag': 'ungültige Linienführung — ein Wegpunkt liegt in einem Knoten', + 'canvas.route.bend': 'Knickpunkt {n} von {total}', + 'canvas.route.noBendRoom': 'Kein Abschnitt dieser Verbindung hat Platz für einen Knickpunkt; nichts wurde geändert.', 'canvas.edgeLabel.clamp': 'gekappt', 'canvas.edgeLabel.clamp.title': 'vom einmaligen Kappen des Zielspeichers am Ende von Phase 0 entfernt', diff --git a/src/i18n/locales/de/inspector.ts b/src/i18n/locales/de/inspector.ts index 2a1ff867..771c9b9e 100644 --- a/src/i18n/locales/de/inspector.ts +++ b/src/i18n/locales/de/inspector.ts @@ -95,8 +95,15 @@ const inspector = { 'inspector.field.route': 'Linienführung', 'inspector.edge.route.curved': 'Gebogen', 'inspector.edge.route.orthogonal': 'Rechtwinklig', + 'inspector.edge.route.straight': 'Gerade', + 'inspector.edge.route.auto': 'Automatische Linienführung', + 'inspector.edge.route.manual': 'Manuelle Linienführung', + 'inspector.edge.route.reset': 'Auf automatisch zurücksetzen', + 'inspector.edge.route.addBend': 'Knick hinzufügen', + 'inspector.edge.route.addBendHint': 'Auf die Verbindung klicken, wo der Knick hinkommt, oder mit Eingabe einen in der Mitte des längsten Abschnitts einfügen. Esc bricht ab.', + 'inspector.edge.route.editHint': 'Einen Knickpunkt ziehen oder auswählen und mit den Pfeiltasten verschieben (Umschalt für größere Schritte). Entf entfernt ihn.', 'inspector.edge.note': - 'Das Bearbeiten einer Verbindung startet den Lauf bei Schritt 0 neu und verwirft ausstehende Auslöser; ein fertiges Monte-Carlo-Ergebnis gilt dann als veraltet.', + 'Das Ändern der Simulationseinstellungen einer Verbindung startet den aktuellen Lauf neu. Linienführung, Knickpunkte und Farbe betreffen nur die Darstellung und starten ihn nicht neu.', 'inspector.field.mode': 'Modus', 'inspector.edge.mode.trigger': 'Auslöser — stößt das Ziel zum Auslösen an', 'inspector.edge.mode.activator': 'Aktivator — schaltet das Ziel frei oder sperrt es', diff --git a/src/i18n/locales/de/ui.ts b/src/i18n/locales/de/ui.ts index 27ffdfe7..6ef3d30d 100644 --- a/src/i18n/locales/de/ui.ts +++ b/src/i18n/locales/de/ui.ts @@ -453,7 +453,7 @@ const ui = { 'help.contextual.hint.import.desc': 'Wird einmal gezeigt, direkt nachdem der erste Import aus einem Tabellenblatt auf der Arbeitsfläche gelandet ist.', 'hint.frameMove.body': - 'Den Rand eines Rahmens ziehen, um ihn mit allem darin zu bewegen. Alt gedrückt halten, um nur den Rahmen zu bewegen.', + 'Den Rand eines Rahmens ziehen, um ihn mit allem darin zu bewegen. Strg (⌘ auf dem Mac) gedrückt halten, um nur den Rahmen zu bewegen, oder Alt, um ihn frei, abseits des Rasters zu bewegen.', 'help.contextual.hint.frameMove.name': 'Rahmen bewegen', 'help.contextual.hint.frameMove.desc': 'Wird einmal gezeigt, wenn zum ersten Mal ein Gruppenrahmen auf einer bearbeitbaren Arbeitsfläche ausgewählt wird.', @@ -660,6 +660,11 @@ const ui = { 'whatsNew.v0240.tiers': 'Das Wiedergabetempo bestimmt jetzt auch, was ein Schritt zeigt: ab 0,4 s pro Schritt wandert die Markierung mit ihrem +N; schneller erscheint +N erst bei der Ankunft; unter 0,2 s leuchtet der Weg kurz auf und die Markierung erscheint an seinem Ende.', 'whatsNew.v0240.step': 'Einen Schritt weiter zeigt immer die ganze Bewegung, egal welches Tempo eingestellt ist.', 'whatsNew.v0240.phone': 'Auf dem Smartphone bietet „Mehr“ jetzt das Wiedergabetempo (Langsam, Normal, Schnell oder Sehr schnell), und höchstens 12 Markierungen bewegen sich gleichzeitig.', + 'whatsNew.v0250.grid': 'Bearbeiten am Raster. Knoten und Rahmen rasten in ein 16-px-Raster ein, und Hilfslinien zeigen nahe Verbindungspunkte, Mitten und Kanten. Bestehende gewöhnliche Diagramme werden einmal ausgerichtet, und Am Raster ausrichten richtet das geöffnete Diagramm jederzeit neu aus, in einem Schritt, der sich rückgängig machen lässt.', + 'whatsNew.v0250.routing': 'Übersichtlichere automatische Linienführung. Automatisch rechtwinklige Verbindungen weichen Knoten aus, trennen Verbindungen am selben Verbindungspunkt und setzen ihre Beschriftungen an freiere Stellen.', + 'whatsNew.v0250.shapes': 'Verbindungsformen bearbeiten. Zur Wahl stehen Gebogen, Gerade und Rechtwinklig, automatisch oder mit eigenen Knickpunkten, die sich am Desktop hinzufügen, verschieben und entfernen lassen. Auf dem Smartphone werden diese Formen nur angezeigt, nicht bearbeitet.', + 'whatsNew.v0250.outline': 'Text bleibt im Knoten. Der Umriss eines Knotens passt sich jetzt an Titel und Werte an, sodass der Text in allen 18 Sprachen darin bleibt.', + 'whatsNew.v0250.templates': 'Überarbeitete Vorlagen. Alle fünf mitgelieferten Vorlagen nutzen das neue Raster und die neue Linienführung, mit klareren Abständen und besseren Startansichten.', } satisfies Record export default ui diff --git a/src/i18n/locales/en/canvas.ts b/src/i18n/locales/en/canvas.ts index 1351e6a5..36aadd37 100644 --- a/src/i18n/locales/en/canvas.ts +++ b/src/i18n/locales/en/canvas.ts @@ -82,6 +82,7 @@ const canvas = { 'canvas.frame.areaName': 'Area {n}', 'canvas.frame.dismiss': 'Dismiss this suggested frame', 'canvas.frame.clearAll': 'Clear all frames', + 'canvas.tidy': 'Tidy to grid — line up every node and frame on the grid', 'canvas.frame.clearSuggested': 'Clear suggested frames', 'canvas.frame.clearSuggestedRow': 'Clear suggested frames', 'canvas.frame.colorRow': 'Frame colour', @@ -97,6 +98,8 @@ const canvas = { 'canvas.activity.on': 'Activity overlay on — click to hide the tint', 'canvas.activity.rowLabel': 'Activity overlay', 'canvas.route.invalidFlag': 'invalid route — a route point is inside a node', + 'canvas.route.bend': 'Bend point {n} of {total}', + 'canvas.route.noBendRoom': 'No segment of this connection has room for a bend point; nothing was changed.', 'canvas.edgeLabel.clamp': 'clamp', 'canvas.edgeLabel.clamp.title': 'removed by the target Pool\'s single end-of-Phase-0 clamp', 'canvas.edgeLabel.blocked': 'blocked', diff --git a/src/i18n/locales/en/inspector.ts b/src/i18n/locales/en/inspector.ts index 9b4615fc..fde97315 100644 --- a/src/i18n/locales/en/inspector.ts +++ b/src/i18n/locales/en/inspector.ts @@ -70,7 +70,14 @@ const inspector = { 'inspector.field.route': 'Route', 'inspector.edge.route.curved': 'Curved', 'inspector.edge.route.orthogonal': 'Orthogonal', - 'inspector.edge.note': 'Editing a connection restarts the run at step 0 and clears any pending triggers; a finished Monte-Carlo result is marked stale.', + 'inspector.edge.route.straight': 'Straight', + 'inspector.edge.route.auto': 'Automatic route', + 'inspector.edge.route.manual': 'Manual route', + 'inspector.edge.route.reset': 'Reset to automatic', + 'inspector.edge.route.addBend': 'Add bend', + 'inspector.edge.route.addBendHint': 'Click the connection where the bend goes, or press Enter to add one in the middle of its longest segment. Esc cancels.', + 'inspector.edge.route.editHint': 'Drag a bend point, or select it and move it with the arrow keys (Shift for a bigger step). Delete removes it.', + 'inspector.edge.note': 'Changing a connection’s simulation settings restarts the current run. Route, bends, and colour are drawing-only and do not restart it.', 'inspector.field.mode': 'Mode', 'inspector.edge.mode.trigger': 'trigger — pulse the target to fire', 'inspector.edge.mode.activator': 'activator — enable / disable the target', diff --git a/src/i18n/locales/en/ui.ts b/src/i18n/locales/en/ui.ts index 6f9ca510..8f4209d6 100644 --- a/src/i18n/locales/en/ui.ts +++ b/src/i18n/locales/en/ui.ts @@ -371,7 +371,8 @@ const ui = { "{n, plural, one {# Parameter} other {# Parameters}} added from {tables}. Their values are in the Inputs panel. To use one in a Register, type @ in its expression and pick the name; a connection's flow field and an Activator offer the same picker.", 'help.contextual.hint.import.name': 'Spreadsheet import', 'help.contextual.hint.import.desc': 'Shown once, right after the first spreadsheet import lands on the canvas.', - 'hint.frameMove.body': 'Drag a frame’s edge to move it together with everything inside it. Hold Alt while dragging to move the frame alone.', + 'hint.frameMove.body': + 'Drag a frame’s edge to move it together with everything inside it. Hold Ctrl (⌘ on a Mac) while dragging to move the frame alone, or Alt to move it freely, off the grid.', 'help.contextual.hint.frameMove.name': 'Frame move', 'help.contextual.hint.frameMove.desc': 'Shown once, the first time a group frame is selected on an editable canvas.', 'hint.focusFilter.body': 'Graph getting busy? Focus dims everything but one node’s neighbourhood; Filter hides node or connection types.', @@ -569,6 +570,11 @@ const ui = { 'whatsNew.v0240.tiers': 'Playback speed now also changes what a step shows: at 0.4 s a step or slower, the moving marker carries its +N; faster, +N appears when it arrives; under 0.2 s, the path flashes and the marker appears at its end.', 'whatsNew.v0240.step': 'Advancing one step always shows the full movement, whatever the speed is set to.', 'whatsNew.v0240.phone': 'On a phone, More now has Playback speed (Slow, Normal, Fast or Very fast), and at most 12 markers move at once.', + 'whatsNew.v0250.grid': 'Grid-aligned editing. Nodes and frames snap to a 16 px grid, with smart guides for nearby ports, centres and edges. Existing ordinary documents are aligned once, and Tidy to grid can realign the current document in one undoable action.', + 'whatsNew.v0250.routing': 'Clearer automatic routing. Auto orthogonal connections avoid nodes, separate connections that share a port and place their labels in clearer spaces.', + 'whatsNew.v0250.shapes': 'Editable connection shapes. Choose Curved, Straight or Orthogonal, automatic or with your own bends, and add, move or remove bends on desktop. Phones display these shapes without editing them.', + 'whatsNew.v0250.outline': 'Text stays inside nodes. Node outlines now grow with their titles and values, keeping text contained across all 18 languages.', + 'whatsNew.v0250.templates': 'Reworked templates. All five bundled templates use the new grid and routing, with clearer spacing and improved opening views.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/es-419/canvas.ts b/src/i18n/locales/es-419/canvas.ts index 77dd5c7b..7b2a9ff7 100644 --- a/src/i18n/locales/es-419/canvas.ts +++ b/src/i18n/locales/es-419/canvas.ts @@ -90,6 +90,7 @@ const canvas = { 'canvas.frame.areaName': 'Área {n}', 'canvas.frame.dismiss': 'Descartar este marco sugerido', 'canvas.frame.clearAll': 'Eliminar todos los marcos', + 'canvas.tidy': 'Alinear a la cuadrícula: coloca todos los nodos y marcos en la cuadrícula', 'canvas.frame.clearSuggested': 'Eliminar los marcos sugeridos', 'canvas.frame.clearSuggestedRow': 'Eliminar los marcos sugeridos', 'canvas.frame.colorRow': 'Color del marco', @@ -105,6 +106,8 @@ const canvas = { 'canvas.activity.on': 'Capa de actividad activada: haga clic para ocultar el resaltado', 'canvas.activity.rowLabel': 'Capa de actividad', 'canvas.route.invalidFlag': 'trazado inválido: un punto del trazado queda dentro de un nodo', + 'canvas.route.bend': 'Codo {n} de {total}', + 'canvas.route.noBendRoom': 'Ningún tramo de esta conexión tiene espacio para un codo; no se cambió nada.', 'canvas.edgeLabel.clamp': 'recorte', 'canvas.edgeLabel.clamp.title': 'retirado por el único recorte del Depósito de destino al final de la Fase 0', 'canvas.edgeLabel.blocked': 'bloqueado', diff --git a/src/i18n/locales/es-419/inspector.ts b/src/i18n/locales/es-419/inspector.ts index f7c2014b..1e01905a 100644 --- a/src/i18n/locales/es-419/inspector.ts +++ b/src/i18n/locales/es-419/inspector.ts @@ -74,7 +74,14 @@ const inspector = { 'inspector.field.route': 'Trazado', 'inspector.edge.route.curved': 'Curvo', 'inspector.edge.route.orthogonal': 'Ortogonal', - 'inspector.edge.note': 'Editar una conexión reinicia la ejecución en el paso 0 y descarta los disparadores pendientes; un resultado de Monte Carlo ya terminado se marca como desactualizado.', + 'inspector.edge.route.straight': 'Recto', + 'inspector.edge.route.auto': 'Trazado automático', + 'inspector.edge.route.manual': 'Trazado manual', + 'inspector.edge.route.reset': 'Volver al trazado automático', + 'inspector.edge.route.addBend': 'Agregar codo', + 'inspector.edge.route.addBendHint': 'Haga clic en la conexión donde va el codo o presione Entrar para agregar uno en el centro de su tramo más largo. Esc para cancelar.', + 'inspector.edge.route.editHint': 'Arrastre un codo, o selecciónelo y muévalo con las teclas de flecha (Mayús para un paso mayor). Suprimir lo elimina.', + 'inspector.edge.note': 'Cambiar la configuración de simulación de una conexión reinicia la ejecución actual. El trazado, los codos y el color solo afectan al dibujo y no la reinician.', 'inspector.field.mode': 'Modo', 'inspector.edge.mode.trigger': 'disparador: envía un pulso para que el destino actúe', 'inspector.edge.mode.activator': 'activador: habilita o deshabilita el destino', diff --git a/src/i18n/locales/es-419/ui.ts b/src/i18n/locales/es-419/ui.ts index 71252d84..026083c8 100644 --- a/src/i18n/locales/es-419/ui.ts +++ b/src/i18n/locales/es-419/ui.ts @@ -384,7 +384,8 @@ const ui = { '{n, plural, one {# Parámetro agregado} many {# Parámetros agregados} other {# Parámetros agregados}} desde {tables}. Sus valores están en el panel de Entradas. Para usar uno en un Valor calculado, escriba @ en su expresión y elija el nombre; el campo de flujo de una conexión y un Activador ofrecen el mismo selector.', 'help.contextual.hint.import.name': 'Importación de hoja de cálculo', 'help.contextual.hint.import.desc': 'Se muestra una vez, justo después de que la primera importación de hoja de cálculo llega al lienzo.', - 'hint.frameMove.body': 'Arrastre el borde de un marco para moverlo junto con todo lo que contiene. Mantenga Alt mientras arrastra para mover solo el marco.', + 'hint.frameMove.body': + 'Arrastre el borde de un marco para moverlo junto con todo lo que contiene. Mantenga Ctrl (⌘ en Mac) mientras arrastra para mover solo el marco, o Alt para moverlo libremente, fuera de la cuadrícula.', 'help.contextual.hint.frameMove.name': 'Mover un marco', 'help.contextual.hint.frameMove.desc': 'Se muestra una vez, la primera vez que se selecciona un marco de grupo en un lienzo editable.', 'hint.focusFilter.body': '¿El grafo se está poniendo denso? Enfocar atenúa todo salvo el vecindario de un nodo; Filtros oculta tipos de nodo o de conexión.', @@ -583,6 +584,11 @@ const ui = { 'whatsNew.v0240.tiers': 'Ahora la velocidad de reproducción también cambia lo que muestra un paso: desde 0,4 s por paso, la marca en movimiento lleva su +N; más rápido, el +N aparece al llegar; por debajo de 0,2 s, el camino se ilumina un momento y la marca aparece al final.', 'whatsNew.v0240.step': 'Avanzar un paso siempre muestra todo el movimiento, sea cual sea la velocidad elegida.', 'whatsNew.v0240.phone': 'En un teléfono, el menú Más ofrece ahora la velocidad de reproducción (Lenta, Normal, Rápida o Muy rápida), y se mueven como máximo 12 marcas a la vez.', + 'whatsNew.v0250.grid': 'Edición alineada a la cuadrícula. Los nodos y los marcos se ajustan a una cuadrícula de 16 px, con guías inteligentes para los puntos de conexión, centros y bordes cercanos. Los diagramas normales existentes se alinean una vez, y Alinear a la cuadrícula vuelve a alinear el diagrama abierto en una sola acción que se puede deshacer.', + 'whatsNew.v0250.routing': 'Trazado automático más claro. Las conexiones ortogonales automáticas evitan los nodos, separan las conexiones que comparten un punto de conexión y colocan sus etiquetas en espacios más despejados.', + 'whatsNew.v0250.shapes': 'Formas de conexión editables. Elija Curvo, Recto u Ortogonal, automático o con sus propios codos: colóquelos, muévalos o quítelos en la versión de escritorio. En el teléfono, estas formas se muestran sin poder editarlas.', + 'whatsNew.v0250.outline': 'El texto queda dentro de los nodos. El contorno de un nodo ahora se ajusta a su título y sus valores, y el texto queda dentro en los 18 idiomas.', + 'whatsNew.v0250.templates': 'Plantillas renovadas. Las cinco plantillas incluidas usan la nueva cuadrícula y el nuevo trazado, con un espaciado más claro y mejores vistas iniciales.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/es-ES/canvas.ts b/src/i18n/locales/es-ES/canvas.ts index 202c2e75..a9b7cbbe 100644 --- a/src/i18n/locales/es-ES/canvas.ts +++ b/src/i18n/locales/es-ES/canvas.ts @@ -104,6 +104,7 @@ const canvas = { 'canvas.frame.areaName': 'Área {n}', 'canvas.frame.dismiss': 'Descartar este marco sugerido', 'canvas.frame.clearAll': 'Eliminar todos los marcos', + 'canvas.tidy': 'Alinear a la cuadrícula: coloca todos los nodos y marcos en la cuadrícula', 'canvas.frame.clearSuggested': 'Eliminar los marcos sugeridos', 'canvas.frame.clearSuggestedRow': 'Eliminar los marcos sugeridos', 'canvas.frame.colorRow': 'Color del marco', @@ -119,6 +120,8 @@ const canvas = { 'canvas.activity.on': 'Capa de actividad activada: haga clic para ocultar el resaltado', 'canvas.activity.rowLabel': 'Capa de actividad', 'canvas.route.invalidFlag': 'trazado inválido: un punto del trazado queda dentro de un nodo', + 'canvas.route.bend': 'Codo {n} de {total}', + 'canvas.route.noBendRoom': 'Ningún tramo de esta conexión tiene espacio para un codo; no se ha cambiado nada.', 'canvas.edgeLabel.clamp': 'recorte', 'canvas.edgeLabel.clamp.title': 'retirado por el único recorte del Depósito de destino al final de la Fase 0', 'canvas.edgeLabel.blocked': 'bloqueado', diff --git a/src/i18n/locales/es-ES/inspector.ts b/src/i18n/locales/es-ES/inspector.ts index 45d85c58..e24444c7 100644 --- a/src/i18n/locales/es-ES/inspector.ts +++ b/src/i18n/locales/es-ES/inspector.ts @@ -88,7 +88,14 @@ const inspector = { 'inspector.field.route': 'Trazado', 'inspector.edge.route.curved': 'Curvo', 'inspector.edge.route.orthogonal': 'Ortogonal', - 'inspector.edge.note': 'Editar una conexión reinicia la ejecución en el paso 0 y descarta los disparadores pendientes; un resultado de Monte Carlo ya terminado se marca como desactualizado.', + 'inspector.edge.route.straight': 'Recto', + 'inspector.edge.route.auto': 'Trazado automático', + 'inspector.edge.route.manual': 'Trazado manual', + 'inspector.edge.route.reset': 'Volver al trazado automático', + 'inspector.edge.route.addBend': 'Añadir codo', + 'inspector.edge.route.addBendHint': 'Haga clic en la conexión donde va el codo o pulse Intro para añadir uno en el centro de su tramo más largo. Esc para cancelar.', + 'inspector.edge.route.editHint': 'Arrastre un codo, o selecciónelo y muévalo con las teclas de flecha (Mayús para un paso mayor). Suprimir lo elimina.', + 'inspector.edge.note': 'Cambiar la configuración de simulación de una conexión reinicia la ejecución actual. El trazado, los codos y el color solo afectan al dibujo y no la reinician.', 'inspector.field.mode': 'Modo', 'inspector.edge.mode.trigger': 'disparador: envía un pulso para que el destino actúe', 'inspector.edge.mode.activator': 'activador: habilita o deshabilita el destino', diff --git a/src/i18n/locales/es-ES/ui.ts b/src/i18n/locales/es-ES/ui.ts index dd20e523..b9edf8e5 100644 --- a/src/i18n/locales/es-ES/ui.ts +++ b/src/i18n/locales/es-ES/ui.ts @@ -398,7 +398,8 @@ const ui = { '{n, plural, one {# Parámetro añadido} many {# Parámetros añadidos} other {# Parámetros añadidos}} desde {tables}. Sus valores están en el panel de Entradas. Para usar uno en un Valor calculado, escriba @ en su expresión y elija el nombre; el campo de flujo de una conexión y un Activador ofrecen el mismo selector.', 'help.contextual.hint.import.name': 'Importación de hoja de cálculo', 'help.contextual.hint.import.desc': 'Se muestra una vez, justo después de que la primera importación de hoja de cálculo llega al lienzo.', - 'hint.frameMove.body': 'Arrastre el borde de un marco para moverlo junto con todo lo que contiene. Mantenga Alt mientras arrastra para mover solo el marco.', + 'hint.frameMove.body': + 'Arrastre el borde de un marco para moverlo junto con todo lo que contiene. Mantenga Ctrl (⌘ en Mac) mientras arrastra para mover solo el marco, o Alt para moverlo libremente, fuera de la cuadrícula.', 'help.contextual.hint.frameMove.name': 'Mover un marco', 'help.contextual.hint.frameMove.desc': 'Se muestra una vez, la primera vez que se selecciona un marco de grupo en un lienzo editable.', 'hint.focusFilter.body': '¿El grafo se está poniendo denso? Enfocar atenúa todo salvo el vecindario de un nodo; Filtros oculta tipos de nodo o de conexión.', @@ -597,6 +598,11 @@ const ui = { 'whatsNew.v0240.tiers': 'Ahora la velocidad de reproducción también cambia lo que muestra un paso: desde 0,4 s por paso, la marca en movimiento lleva su +N; más rápido, el +N aparece al llegar; por debajo de 0,2 s, el camino se ilumina un momento y la marca aparece al final.', 'whatsNew.v0240.step': 'Avanzar un paso siempre muestra todo el movimiento, sea cual sea la velocidad elegida.', 'whatsNew.v0240.phone': 'En un teléfono, el menú Más ofrece ahora la velocidad de reproducción (Lenta, Normal, Rápida o Muy rápida), y se mueven como máximo 12 marcas a la vez.', + 'whatsNew.v0250.grid': 'Edición alineada a la cuadrícula. Los nodos y los marcos se ajustan a una cuadrícula de 16 px, con guías inteligentes para los puntos de conexión, centros y bordes cercanos. Los diagramas normales existentes se alinean una vez, y Alinear a la cuadrícula vuelve a alinear el diagrama abierto en una sola acción que se puede deshacer.', + 'whatsNew.v0250.routing': 'Trazado automático más claro. Las conexiones ortogonales automáticas evitan los nodos, separan las conexiones que comparten un punto de conexión y colocan sus etiquetas en espacios más despejados.', + 'whatsNew.v0250.shapes': 'Formas de conexión editables. Elija Curvo, Recto u Ortogonal, automático o con sus propios codos: colóquelos, muévalos o quítelos en la versión de escritorio. En el teléfono, estas formas se muestran sin poder editarlas.', + 'whatsNew.v0250.outline': 'El texto queda dentro de los nodos. El contorno de un nodo ahora se ajusta a su título y sus valores, y el texto queda dentro en los 18 idiomas.', + 'whatsNew.v0250.templates': 'Plantillas renovadas. Las cinco plantillas incluidas usan la nueva cuadrícula y el nuevo trazado, con un espaciado más claro y mejores vistas iniciales.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/fr/canvas.ts b/src/i18n/locales/fr/canvas.ts index 0ac6be44..21ea57af 100644 --- a/src/i18n/locales/fr/canvas.ts +++ b/src/i18n/locales/fr/canvas.ts @@ -102,6 +102,7 @@ const canvas = { 'canvas.frame.areaName': 'Zone {n}', 'canvas.frame.dismiss': 'Ignorer ce cadre suggéré', 'canvas.frame.clearAll': 'Effacer tous les cadres', + 'canvas.tidy': 'Aligner sur la grille — place tous les nœuds et cadres sur la grille', 'canvas.frame.clearSuggested': 'Effacer les cadres suggérés', 'canvas.frame.clearSuggestedRow': 'Effacer les cadres suggérés', 'canvas.frame.colorRow': 'Couleur du cadre', @@ -118,6 +119,8 @@ const canvas = { 'canvas.activity.on': 'Calque d’activité activé — cliquez pour masquer la teinte', 'canvas.activity.rowLabel': 'Calque d’activité', 'canvas.route.invalidFlag': 'tracé invalide — un point de tracé est à l’intérieur d’un nœud', + 'canvas.route.bend': 'Coude {n} sur {total}', + 'canvas.route.noBendRoom': 'Aucun segment de cette connexion n’a la place pour un coude. Rien n’a été modifié.', 'canvas.edgeLabel.clamp': 'écrêtage', 'canvas.edgeLabel.clamp.title': 'retiré par l’unique écrêtage de fin de phase 0 du réservoir cible', diff --git a/src/i18n/locales/fr/inspector.ts b/src/i18n/locales/fr/inspector.ts index e9874101..6f31069b 100644 --- a/src/i18n/locales/fr/inspector.ts +++ b/src/i18n/locales/fr/inspector.ts @@ -91,8 +91,15 @@ const inspector = { 'inspector.field.route': 'Tracé', 'inspector.edge.route.curved': 'Courbe', 'inspector.edge.route.orthogonal': 'Orthogonal', + 'inspector.edge.route.straight': 'Droit', + 'inspector.edge.route.auto': 'Tracé automatique', + 'inspector.edge.route.manual': 'Tracé manuel', + 'inspector.edge.route.reset': 'Revenir au tracé automatique', + 'inspector.edge.route.addBend': 'Ajouter un coude', + 'inspector.edge.route.addBendHint': 'Cliquez sur la connexion à l’endroit du coude, ou appuyez sur Entrée pour en ajouter un au milieu de son plus long segment. Échap pour annuler.', + 'inspector.edge.route.editHint': 'Faites glisser un coude, ou sélectionnez-le et déplacez-le avec les flèches (Maj pour un pas plus grand). Suppr le supprime.', 'inspector.edge.note': - 'Modifier une connexion relance l’exécution au pas 0 et efface les déclenchements en attente ; un résultat Monte-Carlo terminé est marqué comme périmé.', + 'Modifier les réglages de simulation d’une connexion relance l’exécution en cours. Le tracé, les coudes et la couleur ne concernent que le dessin et ne la relancent pas.', 'inspector.field.mode': 'Mode', 'inspector.edge.mode.trigger': 'déclencheur — envoie une impulsion pour faire agir la cible', 'inspector.edge.mode.activator': 'activateur — active ou désactive la cible', diff --git a/src/i18n/locales/fr/ui.ts b/src/i18n/locales/fr/ui.ts index ea4d88af..dc366f36 100644 --- a/src/i18n/locales/fr/ui.ts +++ b/src/i18n/locales/fr/ui.ts @@ -447,7 +447,7 @@ const ui = { 'help.contextual.hint.import.desc': 'Affiché une fois, juste après le premier import de feuille de calcul sur le canevas.', 'hint.frameMove.body': - 'Faites glisser le bord d’un cadre pour le déplacer avec tout ce qu’il contient. Maintenez Alt pendant le déplacement pour ne bouger que le cadre.', + 'Faites glisser le bord d’un cadre pour le déplacer avec tout ce qu’il contient. Maintenez Ctrl (⌘ sur Mac) pendant le déplacement pour ne bouger que le cadre, ou Alt pour le déplacer librement, hors de la grille.', 'help.contextual.hint.frameMove.name': 'Déplacement de cadre', 'help.contextual.hint.frameMove.desc': 'Affiché une fois, la première fois qu’un cadre de groupe est sélectionné sur un canevas modifiable.', @@ -654,6 +654,11 @@ const ui = { 'whatsNew.v0240.tiers': 'La vitesse de lecture change désormais aussi ce qu’une étape montre. À partir de 0,4 s par étape, le repère mobile porte son +N. Plus vite, le +N apparaît à l’arrivée. Sous 0,2 s, le chemin s’allume brièvement et le repère apparaît à son extrémité.', 'whatsNew.v0240.step': 'Avancer d’un pas montre toujours tout le mouvement, quelle que soit la vitesse choisie.', 'whatsNew.v0240.phone': 'Sur un téléphone, le menu Plus propose désormais la vitesse de lecture (Lente, Normale, Rapide ou Très rapide), et 12 repères au plus se déplacent à la fois.', + 'whatsNew.v0250.grid': 'Édition alignée sur la grille. Les nœuds et les cadres s’alignent sur une grille de 16 px, avec des repères intelligents pour les points de connexion, centres et bords proches. Les diagrammes ordinaires existants sont alignés une fois, et Aligner sur la grille réaligne le diagramme ouvert en une seule action annulable.', + 'whatsNew.v0250.routing': 'Tracé automatique plus lisible. Les connexions orthogonales automatiques évitent les nœuds, séparent les connexions qui partagent un point de connexion et placent leurs étiquettes dans des espaces plus dégagés.', + 'whatsNew.v0250.shapes': 'Formes de connexion modifiables. Choisissez Courbe, Droit ou Orthogonal, automatique ou avec vos propres coudes : ajoutez-les, déplacez-les ou supprimez-les sur ordinateur. Sur téléphone, ces formes s’affichent sans pouvoir être modifiées.', + 'whatsNew.v0250.outline': 'Le texte reste dans les nœuds. Le contour d’un nœud s’adapte désormais à son titre et à ses valeurs, et le texte reste à l’intérieur dans les 18 langues.', + 'whatsNew.v0250.templates': 'Modèles retravaillés. Les cinq modèles fournis utilisent la nouvelle grille et le nouveau tracé, avec un espacement plus clair et de meilleures vues d’ouverture.', } satisfies Record export default ui diff --git a/src/i18n/locales/it/canvas.ts b/src/i18n/locales/it/canvas.ts index aab789a7..cef66079 100644 --- a/src/i18n/locales/it/canvas.ts +++ b/src/i18n/locales/it/canvas.ts @@ -108,6 +108,7 @@ const canvas = { 'canvas.frame.areaName': 'Area {n}', 'canvas.frame.dismiss': 'Ignora questo riquadro suggerito', 'canvas.frame.clearAll': 'Elimina tutti i riquadri', + 'canvas.tidy': 'Allinea alla griglia — porta tutti i nodi e i riquadri sulla griglia', 'canvas.frame.clearSuggested': 'Elimina i riquadri suggeriti', 'canvas.frame.clearSuggestedRow': 'Elimina i riquadri suggeriti', 'canvas.frame.colorRow': 'Colore del riquadro', @@ -123,6 +124,8 @@ const canvas = { 'canvas.activity.on': 'Sovrapposizione attività attiva — fai clic per nascondere il colore', 'canvas.activity.rowLabel': 'Sovrapposizione attività', 'canvas.route.invalidFlag': 'percorso non valido — un punto del percorso è dentro un nodo', + 'canvas.route.bend': 'Angolo {n} di {total}', + 'canvas.route.noBendRoom': 'Nessun tratto di questo collegamento ha spazio per un angolo: non è stato modificato nulla.', 'canvas.edgeLabel.clamp': 'limite', 'canvas.edgeLabel.clamp.title': 'rimosso dall’unico limite di fine Fase 0 del Serbatoio di destinazione', 'canvas.edgeLabel.blocked': 'bloccato', diff --git a/src/i18n/locales/it/inspector.ts b/src/i18n/locales/it/inspector.ts index 438317be..a6b33696 100644 --- a/src/i18n/locales/it/inspector.ts +++ b/src/i18n/locales/it/inspector.ts @@ -89,7 +89,14 @@ const inspector = { 'inspector.field.route': 'Percorso', 'inspector.edge.route.curved': 'Curvo', 'inspector.edge.route.orthogonal': 'Ortogonale', - 'inspector.edge.note': 'Modificare una connessione riavvia l’esecuzione dal passo 0 e cancella gli impulsi in sospeso; un risultato Monte Carlo già concluso viene contrassegnato come non aggiornato.', + 'inspector.edge.route.straight': 'Dritto', + 'inspector.edge.route.auto': 'Percorso automatico', + 'inspector.edge.route.manual': 'Percorso manuale', + 'inspector.edge.route.reset': 'Torna al percorso automatico', + 'inspector.edge.route.addBend': 'Aggiungi angolo', + 'inspector.edge.route.addBendHint': 'Fai clic sul collegamento dove va l’angolo, oppure premi Invio per aggiungerne uno a metà del suo tratto più lungo. Esc per annullare.', + 'inspector.edge.route.editHint': 'Trascina un angolo, oppure selezionalo e spostalo con i tasti freccia (con Maiusc lo spostamento è maggiore). Canc lo rimuove.', + 'inspector.edge.note': 'Modificare le impostazioni di simulazione di un collegamento riavvia l’esecuzione in corso. Percorso, angoli e colore riguardano solo il disegno e non la riavviano.', 'inspector.field.mode': 'Modalità', 'inspector.edge.mode.trigger': 'impulso — dà l’impulso alla destinazione perché scatti', 'inspector.edge.mode.activator': 'attivatore — abilita o disabilita la destinazione', diff --git a/src/i18n/locales/it/ui.ts b/src/i18n/locales/it/ui.ts index c44ab45a..acf42b42 100644 --- a/src/i18n/locales/it/ui.ts +++ b/src/i18n/locales/it/ui.ts @@ -400,7 +400,8 @@ const ui = { '{n, plural, one {# Parametro aggiunto} many {# Parametri aggiunti} other {# Parametri aggiunti}} da {tables}. I loro valori sono nel pannello Ingressi. Per usarne uno in un Valore calcolato, scrivi @ nella sua espressione e scegli il nome; il campo del flusso di una connessione e un Attivatore offrono lo stesso selettore.', 'help.contextual.hint.import.name': 'Importazione da foglio di calcolo', 'help.contextual.hint.import.desc': 'Mostrata una volta, subito dopo la prima importazione da foglio di calcolo sull’area di disegno.', - 'hint.frameMove.body': 'Trascina il bordo di un riquadro per spostarlo insieme a tutto ciò che contiene. Tieni premuto Alt mentre trascini per spostare solo il riquadro.', + 'hint.frameMove.body': + 'Trascina il bordo di un riquadro per spostarlo insieme a tutto ciò che contiene. Tieni premuto Ctrl (⌘ su Mac) mentre trascini per spostare solo il riquadro, o Alt per spostarlo liberamente, fuori dalla griglia.', 'help.contextual.hint.frameMove.name': 'Spostamento del riquadro', 'help.contextual.hint.frameMove.desc': 'Mostrata una volta, la prima volta che si seleziona un riquadro di gruppo su un’area di disegno modificabile.', 'hint.focusFilter.body': 'Il grafo si sta affollando? La messa a fuoco attenua tutto tranne i dintorni di un nodo; i filtri nascondono tipi di nodo o di connessione.', @@ -599,6 +600,11 @@ const ui = { 'whatsNew.v0240.tiers': 'Ora la velocità di riproduzione cambia anche ciò che un passo mostra: da 0,4 s per passo in su, il segno in movimento porta il suo +N; più veloce, il +N compare all’arrivo; sotto 0,2 s, il percorso si illumina per un attimo e il segno compare alla fine.', 'whatsNew.v0240.step': 'Avanzare di un passo mostra sempre tutto il movimento, qualunque sia la velocità scelta.', 'whatsNew.v0240.phone': 'Sul telefono, il menu Altro offre ora la velocità di riproduzione (Lenta, Normale, Veloce o Molto veloce), e al massimo 12 segni si spostano alla volta.', + 'whatsNew.v0250.grid': 'Modifica allineata alla griglia. Nodi e riquadri si agganciano a una griglia di 16 px, con guide intelligenti per punti di connessione, centri e bordi vicini. I diagrammi normali esistenti vengono allineati una volta, e Allinea alla griglia riallinea il diagramma aperto in un’unica azione annullabile.', + 'whatsNew.v0250.routing': 'Percorsi automatici più chiari. Le connessioni ortogonali automatiche evitano i nodi, separano le connessioni che condividono un punto di connessione e mettono le loro etichette in spazi più liberi.', + 'whatsNew.v0250.shapes': 'Forme delle connessioni modificabili. Scegli Curvo, Dritto o Ortogonale, automatico o con angoli tuoi: aggiungili, spostali o rimuovili da computer. Sul telefono queste forme vengono mostrate ma non si possono modificare.', + 'whatsNew.v0250.outline': 'Il testo resta nei nodi. Il contorno di un nodo ora si adatta al titolo e ai valori, e il testo resta dentro il contorno in tutte le 18 lingue.', + 'whatsNew.v0250.templates': 'Modelli rivisti. Tutti e cinque i modelli inclusi usano la nuova griglia e i nuovi percorsi, con spaziature più chiare e viste iniziali migliori.', } satisfies Record export default ui diff --git a/src/i18n/locales/ja/canvas.ts b/src/i18n/locales/ja/canvas.ts index b9cf9729..dfc7147c 100644 --- a/src/i18n/locales/ja/canvas.ts +++ b/src/i18n/locales/ja/canvas.ts @@ -93,6 +93,7 @@ const canvas = { 'canvas.frame.areaName': 'エリア {n}', 'canvas.frame.dismiss': 'この提案フレームを閉じる', 'canvas.frame.clearAll': 'すべてのフレームをクリア', + 'canvas.tidy': 'グリッドに整列 — すべてのノードとフレームをグリッドにそろえます', 'canvas.frame.clearSuggested': '提案フレームをクリア', 'canvas.frame.clearSuggestedRow': '提案フレームをクリア', 'canvas.frame.colorRow': 'フレームの色', @@ -108,6 +109,8 @@ const canvas = { 'canvas.activity.on': 'アクティビティ表示 オン — クリックで色を消します', 'canvas.activity.rowLabel': 'アクティビティ表示', 'canvas.route.invalidFlag': '無効な経路 — 経路の点がノードの内側にあります', + 'canvas.route.bend': '折れ点 {total} 個中 {n} 個目', + 'canvas.route.noBendRoom': 'この接続には折れ点を入れられる区間がないため、何も変更していません。', 'canvas.edgeLabel.clamp': 'クランプ', 'canvas.edgeLabel.clamp.title': 'ターゲットのプールでフェーズ 0 終了時に 1 回クランプされ取り除かれました', 'canvas.edgeLabel.blocked': 'ブロック', diff --git a/src/i18n/locales/ja/inspector.ts b/src/i18n/locales/ja/inspector.ts index a3fb21f6..ed63a760 100644 --- a/src/i18n/locales/ja/inspector.ts +++ b/src/i18n/locales/ja/inspector.ts @@ -79,8 +79,15 @@ const inspector = { 'inspector.field.route': '経路', 'inspector.edge.route.curved': '曲線', 'inspector.edge.route.orthogonal': '直交', + 'inspector.edge.route.straight': '直線', + 'inspector.edge.route.auto': '自動の経路', + 'inspector.edge.route.manual': '手動の経路', + 'inspector.edge.route.reset': '自動に戻す', + 'inspector.edge.route.addBend': '折れ点を追加', + 'inspector.edge.route.addBendHint': '折れ点を入れる位置で接続をクリックするか、Enter で最も長い区間の中央に追加します。Esc でキャンセル。', + 'inspector.edge.route.editHint': '折れ点をドラッグするか、選択して矢印キーで移動します(Shift を押しながらで大きく動きます)。Delete で削除します。', 'inspector.edge.note': - '接続を編集すると実行がステップ 0 から再開し、保留中のトリガーはクリアされます。完了済みのモンテカルロ結果は古い状態になります。', + '接続のシミュレーション設定を変えると、現在の実行が最初からやり直しになります。経路・折れ点・色は描画だけの情報なので、変えてもやり直しにはなりません。', 'inspector.field.mode': 'モード', 'inspector.edge.mode.trigger': 'トリガー — ターゲットを発火させるパルスを送る', 'inspector.edge.mode.activator': 'アクティベーター — ターゲットを有効 / 無効にする', diff --git a/src/i18n/locales/ja/ui.ts b/src/i18n/locales/ja/ui.ts index a44fcf46..982b53ab 100644 --- a/src/i18n/locales/ja/ui.ts +++ b/src/i18n/locales/ja/ui.ts @@ -402,7 +402,8 @@ const ui = { '{tables}から{n, plural, other {パラメータ#個}}を追加しました。値は入力パネルにあります。レジスタで使うには式に @ を入力して名前を選びます。接続のflowフィールドとアクティベータにも同じ選択器があります。', 'help.contextual.hint.import.name': '表計算データの読み込み', 'help.contextual.hint.import.desc': '最初の表計算データの読み込みがキャンバスに置かれた直後に一度表示されます。', - 'hint.frameMove.body': 'フレームの縁をドラッグすると、中の内容も一緒に移動します。Alt を押しながらドラッグするとフレームだけが移動します。', + 'hint.frameMove.body': + 'フレームの縁をドラッグすると、中の内容も一緒に移動します。Ctrl(Mac では ⌘)を押しながらドラッグするとフレームだけが移動し、Alt を押すとグリッドに合わせずに自由に移動します。', 'help.contextual.hint.frameMove.name': 'フレームの移動', 'help.contextual.hint.frameMove.desc': '編集可能なキャンバスでグループフレームを初めて選択したときに一度表示されます。', 'hint.focusFilter.body': @@ -604,6 +605,11 @@ const ui = { 'whatsNew.v0240.tiers': '再生速度によって、ステップの見え方も変わるようになりました。1ステップあたり0.4秒以上では動く印が+Nと一緒に進み、それより速いと+Nは到着時に表示され、0.2秒未満では経路が一瞬光ったあと印が終点に表示されます。', 'whatsNew.v0240.step': '1 ステップ進めると、速度の設定にかかわらず常に動き全体が表示されます。', 'whatsNew.v0240.phone': 'スマートフォンの「その他」メニューに再生速度(遅い・標準・速い・とても速い)が加わり、同時に動く印は最大12個になりました。', + 'whatsNew.v0250.grid': 'グリッドに沿った編集。ノードとフレームが 16 px のグリッドにそろい、近くの接続点・中心・端に合わせるスマートガイドが表示されます。既存の通常のダイアグラムは一度だけ整列され、「グリッドに整列」で開いているダイアグラムをいつでも整え直し、1 回の元に戻すで戻せます。', + 'whatsNew.v0250.routing': 'より見やすい自動経路。自動の直交接続はノードを避け、同じ接続点を使う接続を離して描き、ラベルをより空いた場所に置きます。', + 'whatsNew.v0250.shapes': '接続の形を編集可能に。曲線・直線・直交から選べ、直交は自動のままでも、デスクトップで折れ点を追加・移動・削除して自分で経路を決めることもできます。スマートフォンではこれらの形を表示しますが、編集はできません。', + 'whatsNew.v0250.outline': 'テキストがノードの中に。ノードの輪郭がタイトルと値に合わせて広がり、18 言語すべてでテキストが輪郭の中に収まります。', + 'whatsNew.v0250.templates': 'テンプレートを作り直し。同梱の 5 つのテンプレートすべてが新しいグリッドと経路を使い、間隔が見やすくなり、開いたときの表示も改善されました。', } satisfies Record export default ui diff --git a/src/i18n/locales/ko/canvas.ts b/src/i18n/locales/ko/canvas.ts index 1a06fa29..7ce757bc 100644 --- a/src/i18n/locales/ko/canvas.ts +++ b/src/i18n/locales/ko/canvas.ts @@ -78,6 +78,7 @@ const canvas = { 'canvas.frame.areaName': '구역 {n}', 'canvas.frame.dismiss': '이 제안 프레임 무시', 'canvas.frame.clearAll': '모든 프레임 지우기', + 'canvas.tidy': '그리드에 정돈 — 모든 노드와 프레임을 그리드에 맞춥니다', 'canvas.frame.clearSuggested': '제안 프레임 지우기', 'canvas.frame.clearSuggestedRow': '제안 프레임 지우기', 'canvas.frame.colorRow': '프레임 색상', @@ -93,6 +94,8 @@ const canvas = { 'canvas.activity.on': '활동 오버레이 켜짐 — 눌러서 색조 숨기기', 'canvas.activity.rowLabel': '활동 오버레이', 'canvas.route.invalidFlag': '잘못된 경로 — 경로 점이 노드 안에 있습니다', + 'canvas.route.bend': '꺾임점 {total}개 중 {n}번째', + 'canvas.route.noBendRoom': '이 연결에는 꺾임점을 넣을 구간이 없어 아무것도 바꾸지 않았습니다.', 'canvas.edgeLabel.clamp': '클램프', 'canvas.edgeLabel.clamp.title': '대상 풀의 페이즈 0 종료 시 단일 클램프로 제거됨', 'canvas.edgeLabel.blocked': '차단됨', diff --git a/src/i18n/locales/ko/inspector.ts b/src/i18n/locales/ko/inspector.ts index f3a96a9b..29ffd8dd 100644 --- a/src/i18n/locales/ko/inspector.ts +++ b/src/i18n/locales/ko/inspector.ts @@ -70,7 +70,14 @@ const inspector = { 'inspector.field.route': '경로', 'inspector.edge.route.curved': '곡선', 'inspector.edge.route.orthogonal': '직교', - 'inspector.edge.note': '연결을 편집하면 실행이 0단계에서 다시 시작되고 대기 중인 트리거가 지워집니다. 완료된 몬테카를로 결과는 오래된 것으로 표시됩니다.', + 'inspector.edge.route.straight': '직선', + 'inspector.edge.route.auto': '자동 경로', + 'inspector.edge.route.manual': '수동 경로', + 'inspector.edge.route.reset': '자동으로 되돌리기', + 'inspector.edge.route.addBend': '꺾임점 추가', + 'inspector.edge.route.addBendHint': '꺾임점을 넣을 연결선 위치를 클릭하거나, Enter를 눌러 가장 긴 구간 가운데에 추가하세요. Esc로 취소합니다.', + 'inspector.edge.route.editHint': '꺾임점을 드래그하거나, 선택한 뒤 화살표 키로 옮기세요(Shift와 함께 누르면 더 크게 움직입니다). Delete로 삭제합니다.', + 'inspector.edge.note': '연결의 시뮬레이션 설정을 바꾸면 현재 실행이 다시 시작됩니다. 경로, 꺾임점, 색상은 그리기 정보일 뿐이라 바꿔도 다시 시작되지 않습니다.', 'inspector.field.mode': '모드', 'inspector.edge.mode.trigger': 'trigger — 대상을 자극해 실행시킴', 'inspector.edge.mode.activator': 'activator — 대상을 켜거나 끔', diff --git a/src/i18n/locales/ko/ui.ts b/src/i18n/locales/ko/ui.ts index d666dc8e..3812551e 100644 --- a/src/i18n/locales/ko/ui.ts +++ b/src/i18n/locales/ko/ui.ts @@ -373,7 +373,8 @@ const ui = { '{tables}에서 {n, plural, other {파라미터 #개}}를 추가했습니다. 값은 입력 패널에 있습니다. 레지스터에서 쓰려면 수식에 @를 입력해 이름을 고르세요. 연결의 flow 필드와 활성자에도 같은 선택기가 있습니다.', 'help.contextual.hint.import.name': '스프레드시트 가져오기', 'help.contextual.hint.import.desc': '첫 스프레드시트 가져오기가 캔버스에 놓인 직후 한 번 표시됩니다.', - 'hint.frameMove.body': '프레임 가장자리를 드래그하면 안의 내용이 함께 이동합니다. Alt를 누른 채 드래그하면 프레임만 이동합니다.', + 'hint.frameMove.body': + '프레임 가장자리를 드래그하면 안의 내용이 함께 이동합니다. Ctrl(Mac에서는 ⌘)을 누른 채 드래그하면 프레임만 이동하고, Alt를 누르면 그리드에 맞추지 않고 자유롭게 이동합니다.', 'help.contextual.hint.frameMove.name': '프레임 이동', 'help.contextual.hint.frameMove.desc': '편집 가능한 캔버스에서 그룹 프레임을 처음 선택했을 때 한 번 표시됩니다.', 'hint.focusFilter.body': '그래프가 복잡해졌나요? 포커스는 선택한 노드 주변만 남기고 나머지를 흐리게 하고, 필터는 노드나 연결 유형을 숨깁니다.', @@ -572,6 +573,11 @@ const ui = { 'whatsNew.v0240.tiers': '이제 재생 속도에 따라 단계의 모습도 바뀝니다. 단계당 0.4초 이상이면 움직이는 표시가 +N과 함께 이동하고, 더 빠르면 +N이 도착할 때 나타나며, 0.2초보다 빠르면 경로가 잠깐 밝아진 뒤 표시가 끝에 나타납니다.', 'whatsNew.v0240.step': '한 단계 진행은 속도 설정과 관계없이 항상 전체 움직임을 보여 줍니다.', 'whatsNew.v0240.phone': '휴대폰의 더 보기 메뉴에 재생 속도(느리게, 보통, 빠르게, 매우 빠르게)가 생겼고, 한 번에 움직이는 표시는 최대 12개입니다.', + 'whatsNew.v0250.grid': '그리드에 맞춘 편집. 노드와 프레임이 16 px 그리드에 맞춰지고, 가까운 연결점·중심·가장자리에 맞추는 스마트 가이드가 표시됩니다. 기존의 일반 다이어그램은 열 때 한 번 정렬되며, 그리드에 정돈으로 지금 다이어그램을 다시 정렬하고 한 번에 실행 취소할 수 있습니다.', + 'whatsNew.v0250.routing': '더 깔끔한 자동 경로. 자동 직교 연결은 노드를 피해 가고, 같은 연결점을 쓰는 연결을 서로 떨어뜨리며, 라벨을 더 여유 있는 자리에 놓습니다.', + 'whatsNew.v0250.shapes': '연결 모양 편집. 곡선, 직선, 직교 중에서 고를 수 있고, 직교는 자동으로 두거나 데스크톱에서 꺾임점을 추가·이동·삭제해 직접 경로를 정할 수 있습니다. 휴대폰에서는 이 모양을 표시만 하고 편집할 수는 없습니다.', + 'whatsNew.v0250.outline': '노드 안에 들어가는 텍스트. 노드 윤곽선이 제목과 값에 맞춰 늘어나, 18개 언어 모두에서 텍스트가 윤곽선 안에 들어갑니다.', + 'whatsNew.v0250.templates': '새로 배치한 템플릿. 기본 제공 템플릿 5개 모두 새 그리드와 경로를 사용해 간격이 더 읽기 쉬워지고, 처음 열었을 때의 화면도 좋아졌습니다.', } satisfies Record export default ui diff --git a/src/i18n/locales/nl/canvas.ts b/src/i18n/locales/nl/canvas.ts index 525ab8da..8b84eb98 100644 --- a/src/i18n/locales/nl/canvas.ts +++ b/src/i18n/locales/nl/canvas.ts @@ -97,6 +97,7 @@ const canvas = { 'canvas.frame.areaName': 'Gebied {n}', 'canvas.frame.dismiss': 'Dit voorgestelde kader verwerpen', 'canvas.frame.clearAll': 'Alle kaders wissen', + 'canvas.tidy': 'Uitlijnen op raster — zet alle knooppunten en kaders op het raster', 'canvas.frame.clearSuggested': 'Voorgestelde kaders wissen', 'canvas.frame.clearSuggestedRow': 'Voorgestelde kaders wissen', 'canvas.frame.colorRow': 'Kaderkleur', @@ -112,6 +113,8 @@ const canvas = { 'canvas.activity.on': 'Activiteitsoverlay aan — klik om de kleuring te verbergen', 'canvas.activity.rowLabel': 'Activiteitsoverlay', 'canvas.route.invalidFlag': 'ongeldige route — een routepunt ligt binnen een knooppunt', + 'canvas.route.bend': 'Knik {n} van {total}', + 'canvas.route.noBendRoom': 'Geen enkel stuk van deze verbinding heeft ruimte voor een knik; er is niets gewijzigd.', 'canvas.edgeLabel.clamp': 'begrensd', 'canvas.edgeLabel.clamp.title': 'verwijderd door de enkele begrenzing van de doelvoorraad aan het einde van fase 0', 'canvas.edgeLabel.blocked': 'geblokkeerd', diff --git a/src/i18n/locales/nl/inspector.ts b/src/i18n/locales/nl/inspector.ts index fa5c53ed..de477046 100644 --- a/src/i18n/locales/nl/inspector.ts +++ b/src/i18n/locales/nl/inspector.ts @@ -86,7 +86,14 @@ const inspector = { 'inspector.field.route': 'Route', 'inspector.edge.route.curved': 'Gebogen', 'inspector.edge.route.orthogonal': 'Orthogonaal', - 'inspector.edge.note': 'Een verbinding bewerken start de run opnieuw bij stap 0 en wist alle wachtende impulsen; een afgerond Monte Carlo-resultaat wordt als verouderd gemarkeerd.', + 'inspector.edge.route.straight': 'Recht', + 'inspector.edge.route.auto': 'Automatische route', + 'inspector.edge.route.manual': 'Handmatige route', + 'inspector.edge.route.reset': 'Terug naar automatisch', + 'inspector.edge.route.addBend': 'Knik toevoegen', + 'inspector.edge.route.addBendHint': 'Klik op de verbinding waar de knik komt, of druk op Enter om er een in het midden van het langste stuk toe te voegen. Esc om te annuleren.', + 'inspector.edge.route.editHint': 'Sleep een knik, of selecteer hem en verplaats hem met de pijltoetsen (Shift voor een grotere stap). Delete verwijdert hem.', + 'inspector.edge.note': 'Het wijzigen van de simulatie-instellingen van een verbinding start de huidige run opnieuw. Route, knikken en kleur zijn alleen tekening en starten hem niet opnieuw.', 'inspector.field.mode': 'Modus', 'inspector.edge.mode.trigger': 'impuls — geef het doel een puls om te vuren', 'inspector.edge.mode.activator': 'activator — het doel in- of uitschakelen', diff --git a/src/i18n/locales/nl/ui.ts b/src/i18n/locales/nl/ui.ts index 1e28c801..cb524a9c 100644 --- a/src/i18n/locales/nl/ui.ts +++ b/src/i18n/locales/nl/ui.ts @@ -381,7 +381,8 @@ const ui = { '{n, plural, one {# Parameter} other {# Parameters}} toegevoegd uit {tables}. Hun waarden staan in het paneel Invoer. Om er een te gebruiken in een Berekende waarde, typ je @ in de expressie en kies je de naam; het stroomveld van een verbinding en een Activator bieden dezelfde keuzelijst.', 'help.contextual.hint.import.name': 'Spreadsheetimport', 'help.contextual.hint.import.desc': 'Wordt één keer getoond, direct nadat de eerste spreadsheetimport op het tekengebied is geland.', - 'hint.frameMove.body': 'Sleep de rand van een kader om het samen met alles erin te verplaatsen. Houd Alt ingedrukt tijdens het slepen om alleen het kader te verplaatsen.', + 'hint.frameMove.body': + 'Sleep de rand van een kader om het samen met alles erin te verplaatsen. Houd Ctrl (⌘ op een Mac) ingedrukt tijdens het slepen om alleen het kader te verplaatsen, of Alt om het vrij, buiten het raster te verplaatsen.', 'help.contextual.hint.frameMove.name': 'Kader verplaatsen', 'help.contextual.hint.frameMove.desc': 'Wordt één keer getoond, de eerste keer dat een groepskader op een bewerkbaar tekengebied wordt geselecteerd.', 'hint.focusFilter.body': 'Wordt het diagram druk? Focus dimt alles behalve de omgeving van één knooppunt; Filters verbergen soorten knooppunten of verbindingen.', @@ -580,6 +581,11 @@ const ui = { 'whatsNew.v0240.tiers': 'De afspeelsnelheid bepaalt nu ook wat een stap laat zien: vanaf 0,4 s per stap reist de markering met haar +N mee; sneller verschijnt +N bij aankomst; onder 0,2 s licht het pad even op en verschijnt de markering aan het eind.', 'whatsNew.v0240.step': 'Eén stap vooruit laat altijd de hele beweging zien, welke snelheid er ook is ingesteld.', 'whatsNew.v0240.phone': 'Op een telefoon biedt Meer nu een afspeelsnelheid (Langzaam, Normaal, Snel of Heel snel), en bewegen hooguit 12 markeringen tegelijk.', + 'whatsNew.v0250.grid': 'Bewerken op het raster. Knooppunten en kaders klikken vast op een raster van 16 px, met slimme hulplijnen voor verbindingspunten, middens en randen in de buurt. Bestaande gewone diagrammen worden één keer uitgelijnd, en Uitlijnen op raster lijnt het geopende diagram opnieuw uit in één actie die je ongedaan kunt maken.', + 'whatsNew.v0250.routing': 'Duidelijkere automatische routes. Automatische orthogonale verbindingen gaan om knooppunten heen, houden verbindingen met hetzelfde verbindingspunt uit elkaar en zetten hun labels op vrijere plekken.', + 'whatsNew.v0250.shapes': 'Bewerkbare verbindingsvormen. Kies Gebogen, Recht of Orthogonaal, automatisch of met je eigen knikken: voeg ze toe, verplaats of verwijder ze op de computer. Op een telefoon worden deze vormen getoond, maar niet bewerkt.', + 'whatsNew.v0250.outline': 'Tekst blijft binnen de knooppunten. De omtrek van een knooppunt groeit nu mee met de titel en de waarden, zodat de tekst in alle 18 talen erbinnen blijft.', + 'whatsNew.v0250.templates': 'Vernieuwde sjablonen. Alle vijf meegeleverde sjablonen gebruiken het nieuwe raster en de nieuwe routes, met duidelijkere tussenruimtes en betere beginweergaven.', } as const export default ui diff --git a/src/i18n/locales/pt-BR/canvas.ts b/src/i18n/locales/pt-BR/canvas.ts index 546a33d4..9b9c0255 100644 --- a/src/i18n/locales/pt-BR/canvas.ts +++ b/src/i18n/locales/pt-BR/canvas.ts @@ -109,6 +109,7 @@ const canvas = { 'canvas.frame.areaName': 'Área {n}', 'canvas.frame.dismiss': 'Dispensar este quadro sugerido', 'canvas.frame.clearAll': 'Limpar todos os quadros', + 'canvas.tidy': 'Alinhar ao quadriculado — coloca todos os nós e quadros no quadriculado', 'canvas.frame.clearSuggested': 'Limpar os quadros sugeridos', 'canvas.frame.clearSuggestedRow': 'Limpar os quadros sugeridos', 'canvas.frame.colorRow': 'Cor do quadro', @@ -124,6 +125,8 @@ const canvas = { 'canvas.activity.on': 'Camada de atividade ativada — clique para ocultar o realce', 'canvas.activity.rowLabel': 'Camada de atividade', 'canvas.route.invalidFlag': 'rota inválida — um ponto da rota está dentro de um nó', + 'canvas.route.bend': 'Dobra {n} de {total}', + 'canvas.route.noBendRoom': 'Nenhum trecho desta ligação tem espaço para uma dobra; nada foi alterado.', 'canvas.edgeLabel.clamp': 'limite', 'canvas.edgeLabel.clamp.title': 'removido pelo único limite de fim da Fase 0 do Reservatório de destino', 'canvas.edgeLabel.blocked': 'bloqueado', diff --git a/src/i18n/locales/pt-BR/inspector.ts b/src/i18n/locales/pt-BR/inspector.ts index 24bf742c..946d13a5 100644 --- a/src/i18n/locales/pt-BR/inspector.ts +++ b/src/i18n/locales/pt-BR/inspector.ts @@ -81,7 +81,14 @@ const inspector = { 'inspector.field.route': 'Rota', 'inspector.edge.route.curved': 'Curva', 'inspector.edge.route.orthogonal': 'Ortogonal', - 'inspector.edge.note': 'Editar uma conexão reinicia a execução no passo 0 e limpa os disparos pendentes; um resultado de Monte Carlo já concluído é marcado como desatualizado.', + 'inspector.edge.route.straight': 'Reta', + 'inspector.edge.route.auto': 'Rota automática', + 'inspector.edge.route.manual': 'Rota manual', + 'inspector.edge.route.reset': 'Voltar à rota automática', + 'inspector.edge.route.addBend': 'Adicionar dobra', + 'inspector.edge.route.addBendHint': 'Clique na ligação onde fica a dobra; com Enter, uma dobra entra no meio do trecho mais longo. Esc para cancelar.', + 'inspector.edge.route.editHint': 'Arraste uma dobra, ou selecione-a e mova-a com as teclas de seta (Shift para um passo maior). Delete apaga a dobra.', + 'inspector.edge.note': 'Alterar as configurações de simulação de uma conexão reinicia a execução atual. A rota, as dobras e a cor são só desenho e não a reiniciam.', 'inspector.field.mode': 'Modo', 'inspector.edge.mode.trigger': 'disparador — pulsa o destino para que ele dispare', 'inspector.edge.mode.activator': 'ativador — habilita / desabilita o destino', diff --git a/src/i18n/locales/pt-BR/ui.ts b/src/i18n/locales/pt-BR/ui.ts index fd3d4bb0..de779d10 100644 --- a/src/i18n/locales/pt-BR/ui.ts +++ b/src/i18n/locales/pt-BR/ui.ts @@ -386,7 +386,8 @@ const ui = { '{n, plural, one {# Parâmetro adicionado} many {# de Parâmetros adicionados} other {# Parâmetros adicionados}} a partir de {tables}. Os valores deles estão no painel Entradas. Para usar um em um Valor calculado, digite @ na expressão dele e escolha o nome; o campo de fluxo de uma conexão e um Ativador oferecem o mesmo seletor.', 'help.contextual.hint.import.name': 'Importação de planilha', 'help.contextual.hint.import.desc': 'Exibido uma vez, logo depois que a primeira importação de planilha chega à tela.', - 'hint.frameMove.body': 'Arraste a borda de um quadro para movê-lo junto com tudo o que está dentro dele. Segure Alt ao arrastar para mover só o quadro.', + 'hint.frameMove.body': + 'Arraste a borda de um quadro para movê-lo junto com tudo o que está dentro dele. Segure Ctrl (⌘ no Mac) ao arrastar para mover só o quadro, ou Alt para movê-lo livremente, fora do quadriculado.', 'help.contextual.hint.frameMove.name': 'Movimento de quadro', 'help.contextual.hint.frameMove.desc': 'Exibido uma vez, na primeira vez que um quadro de grupo é selecionado em uma tela editável.', 'hint.focusFilter.body': 'O grafo está ficando cheio? O Foco escurece tudo, menos a vizinhança de um nó; o Filtro oculta tipos de nó ou de conexão.', @@ -585,6 +586,11 @@ const ui = { 'whatsNew.v0240.tiers': 'A velocidade de reprodução agora também muda o que um passo mostra: a partir de 0,4 s por passo, a marca em movimento leva o seu +N; em velocidades maiores, o +N aparece na chegada; abaixo de 0,2 s, o caminho acende por um instante e a marca aparece no fim.', 'whatsNew.v0240.step': 'Avançar um passo mostra sempre todo o movimento, seja qual for a velocidade escolhida.', 'whatsNew.v0240.phone': 'Em telefones, o menu Mais oferece agora a velocidade de reprodução (Lenta, Normal, Rápida ou Muito rápida), e no máximo 12 marcas estão em movimento ao mesmo tempo.', + 'whatsNew.v0250.grid': 'Edição alinhada ao quadriculado. Os nós e os quadros encaixam num quadriculado de 16 px, com guias inteligentes para pontos de entrada e saída, centros e extremidades próximos. Os diagramas comuns já existentes são alinhados uma vez, e Alinhar ao quadriculado volta a alinhar o diagrama aberto numa única ação que pode ser desfeita.', + 'whatsNew.v0250.routing': 'Rotas automáticas mais claras. As linhas ortogonais automáticas contornam os nós, separam as linhas que usam o mesmo ponto e colocam os seus rótulos em espaços mais livres.', + 'whatsNew.v0250.shapes': 'Formas de linha editáveis. É possível escolher Curva, Reta ou Ortogonal, automática ou com dobras próprias, que se adicionam, movem ou removem no computador. No telefone, estas formas são mostradas, mas não podem ser editadas.', + 'whatsNew.v0250.outline': 'O texto fica dentro dos nós. O contorno de um nó acompanha agora o título e os valores, e o texto fica dentro dele nos 18 idiomas.', + 'whatsNew.v0250.templates': 'Templates renovados. Os cinco Templates incluídos usam o novo quadriculado e as novas rotas, com espaçamento mais claro e melhores vistas iniciais.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/pt-PT/canvas.ts b/src/i18n/locales/pt-PT/canvas.ts index bde7fb91..a47b2117 100644 --- a/src/i18n/locales/pt-PT/canvas.ts +++ b/src/i18n/locales/pt-PT/canvas.ts @@ -114,6 +114,7 @@ const canvas = { 'canvas.frame.areaName': 'Área {n}', 'canvas.frame.dismiss': 'Dispensar este quadro sugerido', 'canvas.frame.clearAll': 'Limpar todos os quadros', + 'canvas.tidy': 'Alinhar ao quadriculado — coloca todos os nós e quadros no quadriculado', 'canvas.frame.clearSuggested': 'Limpar os quadros sugeridos', 'canvas.frame.clearSuggestedRow': 'Limpar os quadros sugeridos', 'canvas.frame.colorRow': 'Cor do quadro', @@ -129,6 +130,8 @@ const canvas = { 'canvas.activity.on': 'Camada de atividade ativada — clique para ocultar o realce', 'canvas.activity.rowLabel': 'Camada de atividade', 'canvas.route.invalidFlag': 'rota inválida — um ponto da rota está dentro de um nó', + 'canvas.route.bend': 'Dobra {n} de {total}', + 'canvas.route.noBendRoom': 'Nenhum trecho desta ligação tem espaço para uma dobra; nada foi alterado.', 'canvas.edgeLabel.clamp': 'limite', 'canvas.edgeLabel.clamp.title': 'removido pelo único limite de fim da Fase 0 do Reservatório de destino', 'canvas.edgeLabel.blocked': 'bloqueado', diff --git a/src/i18n/locales/pt-PT/inspector.ts b/src/i18n/locales/pt-PT/inspector.ts index a844437d..01d3077e 100644 --- a/src/i18n/locales/pt-PT/inspector.ts +++ b/src/i18n/locales/pt-PT/inspector.ts @@ -81,7 +81,14 @@ const inspector = { 'inspector.field.route': 'Rota', 'inspector.edge.route.curved': 'Curva', 'inspector.edge.route.orthogonal': 'Ortogonal', - 'inspector.edge.note': 'Editar uma ligação reinicia a execução no passo 0 e limpa os disparos pendentes; um resultado de Monte Carlo já concluído é marcado como desatualizado.', + 'inspector.edge.route.straight': 'Reta', + 'inspector.edge.route.auto': 'Rota automática', + 'inspector.edge.route.manual': 'Rota manual', + 'inspector.edge.route.reset': 'Voltar à rota automática', + 'inspector.edge.route.addBend': 'Adicionar dobra', + 'inspector.edge.route.addBendHint': 'Clique na ligação onde fica a dobra; com Enter, uma dobra entra no meio do trecho mais longo. Esc para cancelar.', + 'inspector.edge.route.editHint': 'Arraste uma dobra, ou selecione-a e mova-a com as teclas de seta (Shift para um passo maior). Delete apaga a dobra.', + 'inspector.edge.note': 'Alterar as definições de simulação de uma ligação reinicia a execução atual. A rota, as dobras e a cor são só desenho e não a reiniciam.', 'inspector.field.mode': 'Modo', 'inspector.edge.mode.trigger': 'disparador — pulsa o destino para que ele dispare', 'inspector.edge.mode.activator': 'ativador — habilita / desabilita o destino', diff --git a/src/i18n/locales/pt-PT/ui.ts b/src/i18n/locales/pt-PT/ui.ts index bb47945b..c4229a12 100644 --- a/src/i18n/locales/pt-PT/ui.ts +++ b/src/i18n/locales/pt-PT/ui.ts @@ -400,7 +400,8 @@ const ui = { '{n, plural, one {# Parâmetro adicionado} many {# de Parâmetros adicionados} other {# Parâmetros adicionados}} a partir de {tables}. Os valores deles estão no painel Entradas. Para usar um num Valor calculado, digite @ na expressão dele e escolha o nome; o campo de fluxo de uma ligação e um Ativador oferecem o mesmo seletor.', 'help.contextual.hint.import.name': 'Importação de folha de cálculo', 'help.contextual.hint.import.desc': 'Exibido uma vez, logo depois que a primeira importação de folha de cálculo chega à tela.', - 'hint.frameMove.body': 'Arraste a borda de um quadro para movê-lo junto com tudo o que está dentro dele. Segure Alt ao arrastar para mover só o quadro.', + 'hint.frameMove.body': + 'Arraste a borda de um quadro para movê-lo junto com tudo o que está dentro dele. Segure Ctrl (⌘ no Mac) ao arrastar para mover só o quadro, ou Alt para movê-lo livremente, fora do quadriculado.', 'help.contextual.hint.frameMove.name': 'Movimento de quadro', 'help.contextual.hint.frameMove.desc': 'Exibido uma vez, na primeira vez que um quadro de grupo é selecionado numa tela editável.', 'hint.focusFilter.body': 'O grafo está a ficar cheio? O Foco escurece tudo, menos a vizinhança de um nó; o Filtro oculta tipos de nó ou de ligação.', @@ -599,6 +600,11 @@ const ui = { 'whatsNew.v0240.tiers': 'A velocidade de reprodução agora também muda o que um passo mostra: a partir de 0,4 s por passo, a marca em movimento leva o seu +N; em velocidades maiores, o +N aparece na chegada; abaixo de 0,2 s, o caminho acende por um instante e a marca aparece no fim.', 'whatsNew.v0240.step': 'Avançar um passo mostra sempre todo o movimento, seja qual for a velocidade escolhida.', 'whatsNew.v0240.phone': 'Em telefones, o menu Mais oferece agora a velocidade de reprodução (Lenta, Normal, Rápida ou Muito rápida), e no máximo 12 marcas estão em movimento ao mesmo tempo.', + 'whatsNew.v0250.grid': 'Edição alinhada ao quadriculado. Os nós e os quadros encaixam num quadriculado de 16 px, com guias inteligentes para pontos de entrada e saída, centros e extremidades próximos. Os diagramas comuns já existentes são alinhados uma vez, e Alinhar ao quadriculado volta a alinhar o diagrama aberto numa única ação que pode ser desfeita.', + 'whatsNew.v0250.routing': 'Rotas automáticas mais claras. As linhas ortogonais automáticas contornam os nós, separam as linhas que usam o mesmo ponto e colocam os seus rótulos em espaços mais livres.', + 'whatsNew.v0250.shapes': 'Formas de linha editáveis. É possível escolher Curva, Reta ou Ortogonal, automática ou com dobras próprias, que se adicionam, movem ou removem no computador. No telefone, estas formas são mostradas, mas não podem ser editadas.', + 'whatsNew.v0250.outline': 'O texto fica dentro dos nós. O contorno de um nó acompanha agora o título e os valores, e o texto fica dentro dele nos 18 idiomas.', + 'whatsNew.v0250.templates': 'Templates renovados. Os cinco Templates incluídos usam o novo quadriculado e as novas rotas, com espaçamento mais claro e melhores vistas iniciais.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/ru/canvas.ts b/src/i18n/locales/ru/canvas.ts index ec37bc7f..48c1e59e 100644 --- a/src/i18n/locales/ru/canvas.ts +++ b/src/i18n/locales/ru/canvas.ts @@ -114,6 +114,7 @@ const canvas = { 'canvas.frame.areaName': 'Область {n}', 'canvas.frame.dismiss': 'Убрать эту предложенную рамку', 'canvas.frame.clearAll': 'Убрать все рамки', + 'canvas.tidy': 'Выровнять по сетке — поставить все узлы и рамки на сетку', 'canvas.frame.clearSuggested': 'Убрать предложенные рамки', 'canvas.frame.clearSuggestedRow': 'Убрать предложенные рамки', 'canvas.frame.colorRow': 'Цвет рамки', @@ -130,6 +131,8 @@ const canvas = { 'canvas.activity.on': 'Слой активности включён — нажмите, чтобы убрать подсветку', 'canvas.activity.rowLabel': 'Слой активности', 'canvas.route.invalidFlag': 'некорректный маршрут — точка маршрута находится внутри узла', + 'canvas.route.bend': 'Излом {n} из {total}', + 'canvas.route.noBendRoom': 'Ни на одном отрезке этой связи нет места для излома; ничего не изменено.', 'canvas.edgeLabel.clamp': 'обрезано', 'canvas.edgeLabel.clamp.title': 'убрано единственным ограничением принимающего Накопителя в конце фазы 0', diff --git a/src/i18n/locales/ru/inspector.ts b/src/i18n/locales/ru/inspector.ts index 2a428ddc..5e9f2ca7 100644 --- a/src/i18n/locales/ru/inspector.ts +++ b/src/i18n/locales/ru/inspector.ts @@ -92,8 +92,15 @@ const inspector = { 'inspector.field.route': 'Маршрут', 'inspector.edge.route.curved': 'Кривая', 'inspector.edge.route.orthogonal': 'Прямоугольный', + 'inspector.edge.route.straight': 'Прямая', + 'inspector.edge.route.auto': 'Автоматический маршрут', + 'inspector.edge.route.manual': 'Ручной маршрут', + 'inspector.edge.route.reset': 'Вернуть автоматический маршрут', + 'inspector.edge.route.addBend': 'Добавить излом', + 'inspector.edge.route.addBendHint': 'Нажмите на связь там, где нужен излом, или нажмите Enter, чтобы добавить излом в середину самого длинного отрезка. Esc для отмены.', + 'inspector.edge.route.editHint': 'Перетащите излом или выделите его и сдвиньте стрелками (Shift — более крупным шагом). Delete удаляет излом.', 'inspector.edge.note': - 'Изменение связи перезапускает выполнение с шага 0 и снимает отложенные триггеры; завершённый результат Monte Carlo помечается как устаревший.', + 'Изменение настроек моделирования связи перезапускает текущее выполнение. Маршрут, изломы и цвет относятся только к рисунку и его не перезапускают.', 'inspector.field.mode': 'Режим', 'inspector.edge.mode.trigger': 'триггер — импульс, запускающий получателя', 'inspector.edge.mode.activator': 'активатор — включает или выключает получателя', diff --git a/src/i18n/locales/ru/ui.ts b/src/i18n/locales/ru/ui.ts index 7bffe3c5..e8c0d221 100644 --- a/src/i18n/locales/ru/ui.ts +++ b/src/i18n/locales/ru/ui.ts @@ -447,7 +447,7 @@ const ui = { 'help.contextual.hint.import.desc': 'Показывается один раз, сразу после первого импорта таблицы на холст.', 'hint.frameMove.body': - 'Тяните край рамки, чтобы переместить её вместе со всем содержимым. Удерживайте Alt при перетаскивании, чтобы переместить только рамку.', + 'Тяните край рамки, чтобы переместить её вместе со всем содержимым. Удерживайте Ctrl (⌘ на Mac) при перетаскивании, чтобы переместить только рамку, или Alt, чтобы двигать рамку свободно, вне сетки.', 'help.contextual.hint.frameMove.name': 'Перемещение рамки', 'help.contextual.hint.frameMove.desc': 'Показывается один раз, при первом выделении рамки группы на редактируемом холсте.', @@ -653,6 +653,11 @@ const ui = { 'whatsNew.v0240.tiers': 'Скорость воспроизведения теперь меняет и то, что показывает шаг: от 0,4 с на шаг отметка движется со своим +N; быстрее +N появляется при прибытии; меньше 0,2 с путь ненадолго подсвечивается, а отметка появляется в его конце.', 'whatsNew.v0240.step': 'Один шаг всегда показывает движение целиком, какая бы скорость ни была выбрана.', 'whatsNew.v0240.phone': 'На телефоне в меню появилась скорость воспроизведения (Медленно, Обычно, Быстро или Очень быстро), и одновременно движутся не больше 12 отметок.', + 'whatsNew.v0250.grid': 'Редактирование по сетке. Узлы и рамки привязываются к сетке 16 px, а умные направляющие показывают соседние точки подключения, центры и края. Существующие обычные диаграммы выравниваются один раз, а «Выровнять по сетке» заново выравнивает открытую диаграмму одним действием, которое можно отменить.', + 'whatsNew.v0250.routing': 'Более понятные автоматические маршруты. Автоматические прямоугольные связи обходят узлы, разводят связи с общей точкой подключения и ставят подписи в более свободные места.', + 'whatsNew.v0250.shapes': 'Редактируемая форма связей. Можно выбрать кривую, прямую или прямоугольную линию — автоматическую или со своими изломами, которые на компьютере можно добавлять, перемещать и удалять. На телефоне эти формы отображаются, но не редактируются.', + 'whatsNew.v0250.outline': 'Текст остаётся внутри узлов. Контур узла теперь подстраивается под название и значения, и текст остаётся внутри на всех 18 языках.', + 'whatsNew.v0250.templates': 'Обновлённые шаблоны. Все пять встроенных шаблонов используют новую сетку и маршруты, с более понятными отступами и удобными начальными видами.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/th/canvas.ts b/src/i18n/locales/th/canvas.ts index 9e6a3e5e..b3f54a20 100644 --- a/src/i18n/locales/th/canvas.ts +++ b/src/i18n/locales/th/canvas.ts @@ -112,6 +112,7 @@ const canvas = { 'canvas.frame.areaName': 'พื้นที่ที่ {n}', 'canvas.frame.dismiss': 'ปิดกรอบที่แนะนำนี้', 'canvas.frame.clearAll': 'ล้างกรอบทั้งหมด', + 'canvas.tidy': 'จัดให้ตรงตาราง — วางโหนดและกรอบทั้งหมดให้ตรงตาราง', 'canvas.frame.clearSuggested': 'ล้างกรอบที่แนะนำ', 'canvas.frame.clearSuggestedRow': 'ล้างกรอบที่แนะนำ', 'canvas.frame.colorRow': 'สีกรอบ', @@ -127,6 +128,8 @@ const canvas = { 'canvas.activity.on': 'ชั้นแสดงกิจกรรมเปิดอยู่ — คลิกเพื่อซ่อนสี', 'canvas.activity.rowLabel': 'ชั้นแสดงกิจกรรม', 'canvas.route.invalidFlag': 'เส้นทางไม่ถูกต้อง — มีจุดของเส้นทางอยู่ในโหนด', + 'canvas.route.bend': 'จุดหักมุม {n} จาก {total}', + 'canvas.route.noBendRoom': 'เส้นเชื่อมนี้ไม่มีช่วงใดที่มีที่ว่างสำหรับจุดหักมุม จึงไม่ได้เปลี่ยนแปลงอะไร', 'canvas.edgeLabel.clamp': 'ถูกตัด', 'canvas.edgeLabel.clamp.title': 'ถูกตัดโดยการจำกัดครั้งเดียวของถังพักปลายทางเมื่อจบเฟส 0', 'canvas.edgeLabel.blocked': 'ถูกขวาง', diff --git a/src/i18n/locales/th/inspector.ts b/src/i18n/locales/th/inspector.ts index 05d1c106..9c174e1c 100644 --- a/src/i18n/locales/th/inspector.ts +++ b/src/i18n/locales/th/inspector.ts @@ -85,8 +85,15 @@ const inspector = { 'inspector.field.route': 'เส้นทาง', 'inspector.edge.route.curved': 'โค้ง', 'inspector.edge.route.orthogonal': 'หักมุมฉาก', + 'inspector.edge.route.straight': 'ตรง', + 'inspector.edge.route.auto': 'เส้นทางอัตโนมัติ', + 'inspector.edge.route.manual': 'เส้นทางที่กำหนดเอง', + 'inspector.edge.route.reset': 'กลับเป็นเส้นทางอัตโนมัติ', + 'inspector.edge.route.addBend': 'เพิ่มจุดหักมุม', + 'inspector.edge.route.addBendHint': 'คลิกบนเส้นเชื่อมตรงตำแหน่งที่ต้องการจุดหักมุม หรือกด Enter เพื่อเพิ่มจุดกึ่งกลางของช่วงที่ยาวที่สุด กด Esc เพื่อยกเลิก', + 'inspector.edge.route.editHint': 'ลากจุดหักมุม หรือเลือกแล้วย้ายด้วยปุ่มลูกศร (กด Shift ค้างเพื่อก้าวทีละมาก) Delete ลบจุดนั้น', 'inspector.edge.note': - 'การแก้ไขเส้นเชื่อมจะเริ่มการรันใหม่ที่ขั้นที่ 0 และล้างทริกเกอร์ที่ค้างอยู่ ผลลัพธ์ Monte Carlo ที่เสร็จแล้วจะถูกทำเครื่องหมายว่าล้าสมัย', + 'การเปลี่ยนการตั้งค่าการจำลองของเส้นเชื่อมจะเริ่มการรันปัจจุบันใหม่ ส่วนเส้นทาง จุดหักมุม และสีเป็นเพียงการวาดและไม่ทำให้เริ่มใหม่', 'inspector.field.mode': 'โหมด', 'inspector.edge.mode.trigger': 'ทริกเกอร์ — ส่งจังหวะให้ปลายทางทำงาน', 'inspector.edge.mode.activator': 'ตัวกระตุ้น — เปิด / ปิดการทำงานของปลายทาง', diff --git a/src/i18n/locales/th/ui.ts b/src/i18n/locales/th/ui.ts index bb7057a3..a495a731 100644 --- a/src/i18n/locales/th/ui.ts +++ b/src/i18n/locales/th/ui.ts @@ -442,7 +442,7 @@ const ui = { 'help.contextual.hint.import.name': 'การนำเข้าตาราง', 'help.contextual.hint.import.desc': 'แสดงครั้งเดียว ทันทีหลังการนำเข้าตารางครั้งแรกลงบนผืนผ้าใบ', 'hint.frameMove.body': - 'ลากขอบของกรอบเพื่อย้ายกรอบพร้อมทุกอย่างที่อยู่ข้างใน กด Alt ค้างไว้ขณะลากเพื่อย้ายเฉพาะกรอบ', + 'ลากขอบของกรอบเพื่อย้ายกรอบพร้อมทุกอย่างที่อยู่ข้างใน กด Ctrl (⌘ บน Mac) ค้างไว้ขณะลากเพื่อย้ายเฉพาะกรอบ หรือกด Alt เพื่อย้ายได้อย่างอิสระโดยไม่ยึดตาราง', 'help.contextual.hint.frameMove.name': 'การย้ายกรอบ', 'help.contextual.hint.frameMove.desc': 'แสดงครั้งเดียว เมื่อเลือกกรอบกลุ่มเป็นครั้งแรกบนผืนผ้าใบที่แก้ไขได้', @@ -645,6 +645,11 @@ const ui = { 'whatsNew.v0240.tiers': 'ตอนนี้ความเร็วในการเล่นยังเปลี่ยนสิ่งที่แต่ละขั้นแสดงด้วย ตั้งแต่ 0.4 วินาทีต่อขั้นขึ้นไป เครื่องหมายเคลื่อนไหวจะพา +N ไปด้วย ถ้าเร็วกว่านั้น +N จะปรากฏเมื่อไปถึง และถ้าต่ำกว่า 0.2 วินาที เส้นทางจะสว่างวาบแล้วเครื่องหมายจะปรากฏที่ปลายทาง', 'whatsNew.v0240.step': 'การเดินหน้าหนึ่งขั้นจะแสดงการเคลื่อนไหวทั้งหมดเสมอ ไม่ว่าจะตั้งความเร็วไว้เท่าใด', 'whatsNew.v0240.phone': 'บนโทรศัพท์ เมนูเพิ่มเติมมีความเร็วในการเล่นแล้ว (ช้า ปกติ เร็ว หรือเร็วมาก) และเครื่องหมายจะเคลื่อนที่พร้อมกันได้ไม่เกิน 12 อัน', + 'whatsNew.v0250.grid': 'แก้ไขให้ตรงตาราง โหนดและกรอบจะยึดกับตารางขนาด 16 px พร้อมเส้นนำทางอัจฉริยะที่ช่วยจัดให้ตรงกับจุดเชื่อมต่อ จุดกึ่งกลาง และขอบที่อยู่ใกล้ ไดอะแกรมทั่วไปที่มีอยู่จะถูกจัดให้ตรงหนึ่งครั้ง และ จัดให้ตรงตาราง จะจัดไดอะแกรมที่เปิดอยู่ใหม่ได้ในการกระทำเดียวที่เลิกทำได้', + 'whatsNew.v0250.routing': 'เส้นทางอัตโนมัติที่ชัดเจนขึ้น เส้นเชื่อมหักมุมฉากแบบอัตโนมัติจะหลบโหนด แยกเส้นเชื่อมที่ใช้จุดเชื่อมต่อเดียวกันออกจากกัน และวางป้ายกำกับไว้ในที่ที่โล่งกว่า', + 'whatsNew.v0250.shapes': 'แก้ไขรูปทรงของเส้นเชื่อมได้ เลือกได้ระหว่าง โค้ง ตรง หรือ หักมุมฉาก จะให้เป็นแบบอัตโนมัติหรือกำหนดจุดหักมุมเองก็ได้ โดยเพิ่ม ย้าย หรือลบจุดหักมุมบนเดสก์ท็อป บนโทรศัพท์จะแสดงรูปทรงเหล่านี้แต่แก้ไขไม่ได้', + 'whatsNew.v0250.outline': 'ข้อความอยู่ในโหนด เส้นขอบของโหนดจะขยายตามชื่อและค่า ข้อความจึงอยู่ภายในเส้นขอบในทั้ง 18 ภาษา', + 'whatsNew.v0250.templates': 'เทมเพลตที่จัดใหม่ เทมเพลตทั้ง 5 แบบที่มาพร้อมแอปใช้ตารางและเส้นทางแบบใหม่ ระยะห่างชัดเจนขึ้น และมุมมองตอนเปิดดีขึ้น', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/tr/canvas.ts b/src/i18n/locales/tr/canvas.ts index 35135de5..c2aab5e1 100644 --- a/src/i18n/locales/tr/canvas.ts +++ b/src/i18n/locales/tr/canvas.ts @@ -111,6 +111,7 @@ const canvas = { 'canvas.frame.areaName': '{n}. alan', 'canvas.frame.dismiss': 'Bu önerilen çerçeveyi kaldır', 'canvas.frame.clearAll': 'Tüm çerçeveleri kaldır', + 'canvas.tidy': 'Izgaraya hizala — tüm düğümleri ve çerçeveleri ızgaraya yerleştirir', 'canvas.frame.clearSuggested': 'Önerilen çerçeveleri kaldır', 'canvas.frame.clearSuggestedRow': 'Önerilen çerçeveleri kaldır', 'canvas.frame.colorRow': 'Çerçeve rengi', @@ -127,6 +128,8 @@ const canvas = { 'canvas.activity.on': 'Etkinlik katmanı açık — rengi kaldırmak için tıklayın', 'canvas.activity.rowLabel': 'Etkinlik katmanı', 'canvas.route.invalidFlag': 'geçersiz güzergâh — bir güzergâh noktası düğümün içinde', + 'canvas.route.bend': 'Kırılma noktası {n} / {total}', + 'canvas.route.noBendRoom': 'Bu bağlantının hiçbir parçasında kırılma noktası için yer yok; hiçbir şey değiştirilmedi.', 'canvas.edgeLabel.clamp': 'kırpıldı', 'canvas.edgeLabel.clamp.title': 'alıcı Havuzun Faz 0 sonundaki tek sınırlaması tarafından kaldırıldı', diff --git a/src/i18n/locales/tr/inspector.ts b/src/i18n/locales/tr/inspector.ts index a87db05a..9b036d31 100644 --- a/src/i18n/locales/tr/inspector.ts +++ b/src/i18n/locales/tr/inspector.ts @@ -84,8 +84,15 @@ const inspector = { 'inspector.field.route': 'Güzergâh', 'inspector.edge.route.curved': 'Eğri', 'inspector.edge.route.orthogonal': 'Dik açılı', + 'inspector.edge.route.straight': 'Düz', + 'inspector.edge.route.auto': 'Otomatik güzergâh', + 'inspector.edge.route.manual': 'Elle çizilen güzergâh', + 'inspector.edge.route.reset': 'Otomatik güzergâha dön', + 'inspector.edge.route.addBend': 'Kırılma noktası ekle', + 'inspector.edge.route.addBendHint': 'Kırılma noktasının geleceği yerde bağlantıya tıklayın ya da en uzun parçanın ortasına eklemek için Enter tuşuna basın. İptal için Esc.', + 'inspector.edge.route.editHint': 'Bir kırılma noktasını sürükleyin ya da seçip ok tuşlarıyla taşıyın (Shift daha büyük adım atar). Delete onu siler.', 'inspector.edge.note': - 'Bir bağlantıyı düzenlemek çalıştırmayı 0. adımdan başlatır ve bekleyen tetiklemeleri siler; biten bir Monte Carlo sonucu eskimiş olarak işaretlenir.', + 'Bir bağlantının simülasyon ayarlarını değiştirmek geçerli çalıştırmayı yeniden başlatır. Güzergâh, kırılma noktaları ve renk yalnızca çizimdir ve onu yeniden başlatmaz.', 'inspector.field.mode': 'Kip', 'inspector.edge.mode.trigger': 'tetikleyici — alıcıyı çalıştırmak için darbe verir', 'inspector.edge.mode.activator': 'etkinleştirici — alıcıyı açar / kapatır', diff --git a/src/i18n/locales/tr/ui.ts b/src/i18n/locales/tr/ui.ts index 89b71305..4b090dbc 100644 --- a/src/i18n/locales/tr/ui.ts +++ b/src/i18n/locales/tr/ui.ts @@ -433,7 +433,7 @@ const ui = { 'help.contextual.hint.import.desc': 'İlk tablo içe aktarımı tuvale düştükten hemen sonra bir kez gösterilir.', 'hint.frameMove.body': - 'Bir çerçeveyi içindeki her şeyle birlikte taşımak için kenarından sürükleyin. Yalnızca çerçeveyi taşımak için sürüklerken Alt tuşunu basılı tutun.', + 'Bir çerçeveyi içindeki her şeyle birlikte taşımak için kenarından sürükleyin. Yalnızca çerçeveyi taşımak için sürüklerken Ctrl (Mac’te ⌘) tuşunu, ızgaradan bağımsız serbestçe taşımak için Alt tuşunu basılı tutun.', 'help.contextual.hint.frameMove.name': 'Çerçeve taşıma', 'help.contextual.hint.frameMove.desc': 'Düzenlenebilir bir tuvalde bir grup çerçevesi ilk kez seçildiğinde bir kez gösterilir.', @@ -639,6 +639,11 @@ const ui = { 'whatsNew.v0240.tiers': 'Oynatma hızı artık bir adımın gösterdiğini de değiştirir: adım başına 0,4 sn ve üzerinde işaret +N ile birlikte hareket eder; daha hızlıda +N varışta görünür; 0,2 sn altında yol kısa bir süre parlar ve işaret sonunda belirir.', 'whatsNew.v0240.step': 'Bir adım ilerletmek, hız ayarı ne olursa olsun her zaman hareketin tamamını gösterir.', 'whatsNew.v0240.phone': 'Telefonda Diğer menüsünde artık oynatma hızı var (Yavaş, Normal, Hızlı veya Çok hızlı) ve aynı anda en fazla 12 işaret hareket eder.', + 'whatsNew.v0250.grid': 'Izgaraya hizalı düzenleme. Düğümler ve çerçeveler 16 px ızgaraya oturur; akıllı kılavuzlar yakındaki bağlantı noktalarını, merkezleri ve kenarları gösterir. Mevcut sıradan diyagramlar bir kez hizalanır, Izgaraya hizala ise açık diyagramı istediğiniz zaman tek bir geri alınabilir adımla yeniden hizalar.', + 'whatsNew.v0250.routing': 'Daha net otomatik güzergâhlar. Otomatik dik açılı bağlantılar düğümlerin etrafından dolaşır, aynı bağlantı noktasını paylaşan bağlantıları ayırır ve etiketlerini daha boş yerlere koyar.', + 'whatsNew.v0250.shapes': 'Düzenlenebilir bağlantı şekilleri. Eğri, Düz veya Dik açılı seçilebilir; dik açılı bağlantı otomatik kalabilir ya da masaüstünde kırılma noktaları eklenip taşınarak ve silinerek elle çizilebilir. Telefonda bu şekiller gösterilir ama düzenlenemez.', + 'whatsNew.v0250.outline': 'Metin düğümün içinde kalır. Düğümün dış çizgisi artık başlığına ve değerlerine göre genişler, böylece metin 18 dilin hepsinde içeride kalır.', + 'whatsNew.v0250.templates': 'Yenilenen şablonlar. Hazır gelen beş şablonun hepsi yeni ızgarayı ve güzergâhları kullanır; aralıklar daha net, açılış görünümleri daha iyidir.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/vi/canvas.ts b/src/i18n/locales/vi/canvas.ts index 497d0497..703c4d24 100644 --- a/src/i18n/locales/vi/canvas.ts +++ b/src/i18n/locales/vi/canvas.ts @@ -104,6 +104,7 @@ const canvas = { 'canvas.frame.areaName': 'Vùng {n}', 'canvas.frame.dismiss': 'Bỏ nhóm gợi ý này', 'canvas.frame.clearAll': 'Xóa tất cả nhóm', + 'canvas.tidy': 'Căn theo lưới — đặt mọi nút và nhóm vào lưới', 'canvas.frame.clearSuggested': 'Xóa các nhóm gợi ý', 'canvas.frame.clearSuggestedRow': 'Xóa các nhóm gợi ý', 'canvas.frame.colorRow': 'Màu nhóm', @@ -119,6 +120,8 @@ const canvas = { 'canvas.activity.on': 'Lớp phủ hoạt động đang bật — nhấn để ẩn màu tô', 'canvas.activity.rowLabel': 'Lớp phủ hoạt động', 'canvas.route.invalidFlag': 'đường đi không hợp lệ — một điểm định tuyến nằm trong nút', + 'canvas.route.bend': 'Điểm gấp {n} trên {total}', + 'canvas.route.noBendRoom': 'Không đoạn nào của liên kết này còn chỗ cho điểm gấp; không có gì thay đổi.', 'canvas.edgeLabel.clamp': 'cắt bớt', 'canvas.edgeLabel.clamp.title': 'bị bỏ bởi một lần cắt duy nhất ở cuối Pha 0 của Bể chứa đích', 'canvas.edgeLabel.blocked': 'bị chặn', diff --git a/src/i18n/locales/vi/inspector.ts b/src/i18n/locales/vi/inspector.ts index 2a7470c3..856c42ca 100644 --- a/src/i18n/locales/vi/inspector.ts +++ b/src/i18n/locales/vi/inspector.ts @@ -80,7 +80,14 @@ const inspector = { 'inspector.field.route': 'Đường đi', 'inspector.edge.route.curved': 'Cong', 'inspector.edge.route.orthogonal': 'Vuông góc', - 'inspector.edge.note': 'Sửa một liên kết sẽ khởi động lại lần chạy từ bước 0 và xóa mọi kích hoạt đang chờ; kết quả Monte-Carlo đã hoàn tất bị đánh dấu là cũ.', + 'inspector.edge.route.straight': 'Thẳng', + 'inspector.edge.route.auto': 'Đường đi tự động', + 'inspector.edge.route.manual': 'Đường đi thủ công', + 'inspector.edge.route.reset': 'Trở về đường đi tự động', + 'inspector.edge.route.addBend': 'Thêm điểm gấp', + 'inspector.edge.route.addBendHint': 'Nhấn vào liên kết tại chỗ cần điểm gấp, hoặc nhấn Enter để thêm một điểm ở giữa đoạn dài nhất. Esc để hủy.', + 'inspector.edge.route.editHint': 'Kéo một điểm gấp, hoặc chọn nó rồi di chuyển bằng các phím mũi tên (giữ Shift để bước lớn hơn). Delete xóa nó.', + 'inspector.edge.note': 'Thay đổi thiết lập mô phỏng của một liên kết sẽ khởi động lại lần chạy hiện tại. Đường đi, điểm gấp và màu chỉ là phần vẽ và không khởi động lại nó.', 'inspector.field.mode': 'Chế độ', 'inspector.edge.mode.trigger': 'kích phát — phát xung để đích hoạt động', 'inspector.edge.mode.activator': 'bộ kích hoạt — bật / tắt đích', diff --git a/src/i18n/locales/vi/ui.ts b/src/i18n/locales/vi/ui.ts index 2a151275..6b18af50 100644 --- a/src/i18n/locales/vi/ui.ts +++ b/src/i18n/locales/vi/ui.ts @@ -386,7 +386,8 @@ const ui = { '{n, plural, other {Đã thêm # tham số}} từ {tables}. Giá trị của chúng nằm ở bảng Đầu vào. Để dùng một cái trong Giá trị tính toán, gõ @ trong biểu thức rồi chọn tên; ô lượng chảy của liên kết và một bộ kích hoạt cũng có cùng bộ chọn đó.', 'help.contextual.hint.import.name': 'Nhập bảng tính', 'help.contextual.hint.import.desc': 'Hiện một lần, ngay sau khi lần nhập bảng tính đầu tiên đưa dữ liệu lên khung vẽ.', - 'hint.frameMove.body': 'Kéo cạnh của một nhóm để di chuyển nó cùng mọi thứ bên trong. Giữ Alt khi kéo để chỉ di chuyển riêng nhóm.', + 'hint.frameMove.body': + 'Kéo cạnh của một nhóm để di chuyển nó cùng mọi thứ bên trong. Giữ Ctrl (⌘ trên Mac) khi kéo để chỉ di chuyển riêng nhóm, hoặc Alt để di chuyển tự do, không theo lưới.', 'help.contextual.hint.frameMove.name': 'Di chuyển nhóm', 'help.contextual.hint.frameMove.desc': 'Hiện một lần, lần đầu tiên một nhóm được chọn trên khung vẽ có thể chỉnh sửa.', 'hint.focusFilter.body': 'Đồ thị đang rối? Tiêu điểm làm mờ mọi thứ trừ vùng lân cận của một nút; Bộ lọc ẩn bớt loại nút hoặc loại liên kết.', @@ -585,6 +586,11 @@ const ui = { 'whatsNew.v0240.tiers': 'Tốc độ phát giờ cũng thay đổi những gì một bước hiển thị: từ 0,4 giây mỗi bước trở lên, dấu chuyển động mang theo +N; nhanh hơn thì +N hiện khi tới nơi; dưới 0,2 giây thì đường đi lóe sáng rồi dấu hiện ở cuối.', 'whatsNew.v0240.step': 'Tiến một bước luôn hiển thị toàn bộ chuyển động, dù tốc độ được đặt thế nào.', 'whatsNew.v0240.phone': 'Trên điện thoại, mục Thêm giờ có tốc độ phát (Chậm, Bình thường, Nhanh hoặc Rất nhanh), và cùng lúc chỉ có tối đa 12 dấu di chuyển.', + 'whatsNew.v0250.grid': 'Chỉnh sửa theo lưới. Nút và nhóm bám theo lưới 16 px, kèm đường gióng thông minh cho các điểm nối, tâm và cạnh ở gần. Sơ đồ thông thường có sẵn được căn một lần, và Căn theo lưới căn lại sơ đồ đang mở trong một thao tác có thể hoàn tác.', + 'whatsNew.v0250.routing': 'Đường đi tự động rõ ràng hơn. Liên kết vuông góc tự động tránh các nút, tách các liên kết dùng chung một điểm nối và đặt nhãn của chúng vào chỗ thoáng hơn.', + 'whatsNew.v0250.shapes': 'Hình dạng liên kết có thể chỉnh. Chọn Cong, Thẳng hoặc Vuông góc, tự động hoặc với điểm gấp của riêng bạn: thêm, di chuyển hoặc xóa chúng trên máy tính. Trên điện thoại, các hình dạng này chỉ được hiển thị, không chỉnh sửa được.', + 'whatsNew.v0250.outline': 'Chữ nằm gọn trong nút. Đường viền của nút giờ giãn theo tiêu đề và giá trị, giữ chữ nằm bên trong ở cả 18 ngôn ngữ.', + 'whatsNew.v0250.templates': 'Mẫu được làm lại. Cả năm mẫu có sẵn đều dùng lưới và đường đi mới, khoảng cách rõ ràng hơn và khung nhìn khi mở tốt hơn.', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hans/canvas.ts b/src/i18n/locales/zh-Hans/canvas.ts index 2cb38879..45d2f7ca 100644 --- a/src/i18n/locales/zh-Hans/canvas.ts +++ b/src/i18n/locales/zh-Hans/canvas.ts @@ -87,6 +87,7 @@ const canvas = { 'canvas.frame.areaName': '区域 {n}', 'canvas.frame.dismiss': '忽略这个推荐分组', 'canvas.frame.clearAll': '清除所有分组框', + 'canvas.tidy': '对齐网格——把所有节点和分组框对齐到网格', 'canvas.frame.clearSuggested': '清除推荐的分组框', 'canvas.frame.clearSuggestedRow': '清除推荐的分组框', 'canvas.frame.colorRow': '分组框颜色', @@ -102,6 +103,8 @@ const canvas = { 'canvas.activity.on': '活动叠加已开启——点击可隐藏着色', 'canvas.activity.rowLabel': '活动叠加', 'canvas.route.invalidFlag': '路径无效——有路径点落在节点内部', + 'canvas.route.bend': '拐点,共 {total} 个,第 {n} 个', + 'canvas.route.noBendRoom': '这条连线没有可加拐点的线段,未做任何更改。', 'canvas.edgeLabel.clamp': '限幅', 'canvas.edgeLabel.clamp.title': '被目标资源池在阶段 0 结束时的一次限幅移除', 'canvas.edgeLabel.blocked': '受阻', diff --git a/src/i18n/locales/zh-Hans/inspector.ts b/src/i18n/locales/zh-Hans/inspector.ts index 5f404200..0c5ea232 100644 --- a/src/i18n/locales/zh-Hans/inspector.ts +++ b/src/i18n/locales/zh-Hans/inspector.ts @@ -76,7 +76,14 @@ const inspector = { 'inspector.field.route': '走线', 'inspector.edge.route.curved': '曲线', 'inspector.edge.route.orthogonal': '直角', - 'inspector.edge.note': '编辑连线会让运行从第 0 步重新开始并清除待处理的触发;已完成的蒙特卡洛结果会被标记为过期。', + 'inspector.edge.route.straight': '直线', + 'inspector.edge.route.auto': '自动走线', + 'inspector.edge.route.manual': '手动走线', + 'inspector.edge.route.reset': '恢复自动走线', + 'inspector.edge.route.addBend': '添加拐点', + 'inspector.edge.route.addBendHint': '在连线上要加拐点的位置点击,或按 Enter 在最长一段的中点添加,按 Esc 取消。', + 'inspector.edge.route.editHint': '拖动拐点,或选中后用方向键移动(按住 Shift 幅度更大)。按 Delete 删除。', + 'inspector.edge.note': '修改连线的模拟设置会让当前运行重新开始。走线、拐点和颜色只影响绘制,修改它们不会重新开始。', 'inspector.field.mode': '模式', 'inspector.edge.mode.trigger': '触发——向目标发出脉冲,使其执行', 'inspector.edge.mode.activator': '激活器——启用或禁用目标', diff --git a/src/i18n/locales/zh-Hans/ui.ts b/src/i18n/locales/zh-Hans/ui.ts index effbada9..ed0714a3 100644 --- a/src/i18n/locales/zh-Hans/ui.ts +++ b/src/i18n/locales/zh-Hans/ui.ts @@ -378,7 +378,8 @@ const ui = { '已从 {tables} 添加 {n, plural, other {# 个参数}}。它们的数值在“输入”面板里。要在计算值中使用某个参数,在表达式里输入 @ 再选择名称;连线的流量字段和激活器也提供同样的选择器。', 'help.contextual.hint.import.name': '电子表格导入', 'help.contextual.hint.import.desc': '第一次把电子表格导入到画布之后显示一次。', - 'hint.frameMove.body': '拖动分组框的边缘,可以连同里面的内容一起移动。拖动时按住 Alt,则只移动分组框本身。', + 'hint.frameMove.body': + '拖动分组框的边缘,可以连同里面的内容一起移动。拖动时按住 Ctrl(Mac 上为 ⌘),则只移动分组框本身;按住 Alt 则不对齐网格、自由移动。', 'help.contextual.hint.frameMove.name': '分组框移动', 'help.contextual.hint.frameMove.desc': '在可编辑的画布上第一次选中分组框时显示一次。', 'hint.focusFilter.body': '图变复杂了?“聚焦”会把某个节点邻域之外的内容调暗;“筛选”可以隐藏某些节点或连线类型。', @@ -577,6 +578,11 @@ const ui = { 'whatsNew.v0240.tiers': '播放速度现在也会改变一步的显示方式:每步 0.4 秒及以上时,移动标记带着 +N 一起移动;更快时,+N 在到达时出现;快于 0.2 秒时,路径先短暂亮起,标记随后出现在终点。', 'whatsNew.v0240.step': '前进一步时,无论速度如何设置,始终显示完整的移动过程。', 'whatsNew.v0240.phone': '在手机上,“更多”菜单新增了播放速度(慢、正常、快、很快),同时移动的标记最多 12 个。', + 'whatsNew.v0250.grid': '按网格编辑。节点和分组框会吸附到 16 px 网格,并有智能参考线对齐附近的连接点、中心和边缘。已有的普通图表会在打开时对齐一次,“对齐网格”可随时重新对齐当前图表,并可一步撤销。', + 'whatsNew.v0250.routing': '更清晰的自动走线。自动直角连线会避开节点,把共用同一连接点的连线分开,并把标签放在更空的位置。', + 'whatsNew.v0250.shapes': '可编辑的连线形状。可选择曲线、直线或直角;直角连线可以保持自动,也可以在桌面端添加、移动或删除拐点来自行走线。手机上会显示这些形状,但不能编辑。', + 'whatsNew.v0250.outline': '文字留在节点内。节点轮廓现在会随标题和数值变宽,在全部 18 种语言中文字都留在轮廓内。', + 'whatsNew.v0250.templates': '重新编排的模板。全部 5 个内置模板都采用新的网格和走线,间距更清晰,打开时的视图也有改进。', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hant/canvas.ts b/src/i18n/locales/zh-Hant/canvas.ts index f6d1dfe3..54ab4256 100644 --- a/src/i18n/locales/zh-Hant/canvas.ts +++ b/src/i18n/locales/zh-Hant/canvas.ts @@ -90,6 +90,7 @@ const canvas = { 'canvas.frame.areaName': '區域 {n}', 'canvas.frame.dismiss': '忽略這個建議的群組框', 'canvas.frame.clearAll': '清除所有群組框', + 'canvas.tidy': '對齊格線——把所有節點和群組框對齊到格線', 'canvas.frame.clearSuggested': '清除建議的群組框', 'canvas.frame.clearSuggestedRow': '清除建議的群組框', 'canvas.frame.colorRow': '群組框顏色', @@ -105,6 +106,8 @@ const canvas = { 'canvas.activity.on': '活動疊層已開啟——點一下可隱藏上色', 'canvas.activity.rowLabel': '活動疊層', 'canvas.route.invalidFlag': '路徑無效——有路徑點落在節點裡面', + 'canvas.route.bend': '拐點,共 {total} 個,第 {n} 個', + 'canvas.route.noBendRoom': '這條連線沒有可加拐點的線段,未做任何變更。', 'canvas.edgeLabel.clamp': '限幅', 'canvas.edgeLabel.clamp.title': '被目標資源池在階段 0 結束時的那一次限幅移除', 'canvas.edgeLabel.blocked': '受阻', diff --git a/src/i18n/locales/zh-Hant/inspector.ts b/src/i18n/locales/zh-Hant/inspector.ts index 9cd223f4..e644e2b9 100644 --- a/src/i18n/locales/zh-Hant/inspector.ts +++ b/src/i18n/locales/zh-Hant/inspector.ts @@ -81,8 +81,15 @@ const inspector = { 'inspector.field.route': '走線', 'inspector.edge.route.curved': '曲線', 'inspector.edge.route.orthogonal': '直角', + 'inspector.edge.route.straight': '直線', + 'inspector.edge.route.auto': '自動走線', + 'inspector.edge.route.manual': '手動走線', + 'inspector.edge.route.reset': '恢復自動走線', + 'inspector.edge.route.addBend': '新增拐點', + 'inspector.edge.route.addBendHint': '在連線上要加拐點的位置點一下,或按 Enter 在最長一段的中點新增,按 Esc 取消。', + 'inspector.edge.route.editHint': '拖曳拐點,或選取後用方向鍵移動(按住 Shift 幅度更大)。按 Delete 刪除。', 'inspector.edge.note': - '編輯連線會讓執行從第 0 步重新開始並清除待處理的觸發;已完成的蒙地卡羅結果會標記為過期。', + '修改連線的模擬設定會讓目前的執行重新開始。走線、拐點和顏色只影響繪製,修改它們不會重新開始。', 'inspector.field.mode': '模式', 'inspector.edge.mode.trigger': '觸發——向目標送出脈衝,使其執行', 'inspector.edge.mode.activator': '啟動器——啟用或停用目標', diff --git a/src/i18n/locales/zh-Hant/ui.ts b/src/i18n/locales/zh-Hant/ui.ts index 0b2df990..3b0691e4 100644 --- a/src/i18n/locales/zh-Hant/ui.ts +++ b/src/i18n/locales/zh-Hant/ui.ts @@ -402,7 +402,7 @@ const ui = { 'help.contextual.hint.import.name': '試算表匯入', 'help.contextual.hint.import.desc': '第一次把試算表匯入到畫布之後顯示一次。', 'hint.frameMove.body': - '拖曳群組框的邊緣,可以連同裡面的內容一起移動。拖曳時按住 Alt,則只移動群組框本身。', + '拖曳群組框的邊緣,可以連同裡面的內容一起移動。拖曳時按住 Ctrl(Mac 上為 ⌘),則只移動群組框本身;按住 Alt 則不對齊格線、自由移動。', 'help.contextual.hint.frameMove.name': '群組框移動', 'help.contextual.hint.frameMove.desc': '在可編輯的畫布上第一次選取群組框時顯示一次。', 'hint.focusFilter.body': @@ -603,6 +603,11 @@ const ui = { 'whatsNew.v0240.tiers': '播放速度現在也會改變一步的顯示方式:每步 0.4 秒以上時,移動標記帶著 +N 一起移動;更快時,+N 在抵達時出現;快於 0.2 秒時,路徑先短暫亮起,標記隨後出現在終點。', 'whatsNew.v0240.step': '前進一步時,無論速度如何設定,始終顯示完整的移動過程。', 'whatsNew.v0240.phone': '在手機上,「更多」選單新增了播放速度(慢、正常、快、很快),同時移動的標記最多 12 個。', + 'whatsNew.v0250.grid': '依格線編輯。節點和群組框會吸附到 16 px 格線,並有智慧參考線對齊附近的連接點、中心和邊緣。既有的一般圖表會在開啟時對齊一次,「對齊格線」可隨時重新對齊目前的圖表,並可一步復原。', + 'whatsNew.v0250.routing': '更清楚的自動走線。自動直角連線會避開節點,把共用同一連接點的連線分開,並把標籤放在更空的位置。', + 'whatsNew.v0250.shapes': '可編輯的連線形狀。可選擇曲線、直線或直角;直角連線可以保持自動,也可以在桌面版新增、移動或刪除拐點來自行走線。手機上會顯示這些形狀,但無法編輯。', + 'whatsNew.v0250.outline': '文字留在節點內。節點輪廓現在會隨標題和數值變寬,在全部 18 種語言中文字都留在輪廓內。', + 'whatsNew.v0250.templates': '重新編排的範本。全部 5 個內建範本都採用新的格線和走線,間距更清楚,開啟時的畫面也有改善。', } satisfies Record export default ui diff --git a/src/i18n/nlCopy.test.ts b/src/i18n/nlCopy.test.ts index 198ef79f..5b0a36c3 100644 --- a/src/i18n/nlCopy.test.ts +++ b/src/i18n/nlCopy.test.ts @@ -119,10 +119,12 @@ const SURVIVING: ReadonlyArray = [ ['product, company and place names, never translated', [ 'loop', 'studio', 'github', 'google', 'excel', 'numbers', 'machinations', 'io', 'alex', 'inspector', 'portugal', 'thai', + 'mac', // the frame-move hint (issue #344) ]], ['file formats, units, metrics and version tokens', [ 'csv', 'tsv', 'json', 'xlsx', 'kg', 'xp', 'p10', 'p50', 'p90', 'montecarloresult', 'v', 'v1', 'v2', 'g1', 'mc', 'id', 'min', 'max', 'rev', 'build', 'mmo', 'gacha', + 'px', // the grid size in the 0.25.0 release note (issue #344) ]], ['Monte Carlo — a place name to begin with', ['monte', 'carlo']], ['platform names, and `contrast`, which Dutch spells as English does — the 0.15.3 release note (issue #298)', [ @@ -261,15 +263,15 @@ const IDENTICAL_TO_EN: ReadonlyArray = [ // ------------------------------------------------------------------ shape describe('nl copy — the surfaces exist and are complete', () => { it('has exactly the base key set', () => { - expect(KEYS).toHaveLength(1064) // issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1079) // issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) expect(Object.keys(NL).sort()).toEqual([...KEYS].sort()) }) it('covers all four runtime surfaces, at the measured sizes', () => { const per: Record = {} for (const r of RUNTIME) per[r.surface.split('/')[0]!] = (per[r.surface.split('/')[0]!] ?? 0) + 1 - expect(per).toEqual({ catalog: 1064, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1286) + expect(per).toEqual({ catalog: 1079, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1301) }) it('EVERY row has a non-empty ENGLISH side — the vacuity guard', () => { diff --git a/src/i18n/ptPtCopy.test.ts b/src/i18n/ptPtCopy.test.ts index 2fb464c8..9e8924b4 100644 --- a/src/i18n/ptPtCopy.test.ts +++ b/src/i18n/ptPtCopy.test.ts @@ -58,7 +58,7 @@ describe('pt-PT copy — the region audit over pt-BR', () => { // ---------------------------------------------------------------- shape it('has exactly the same key set as pt-BR', () => { expect(Object.keys(ptValues).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(1064) // issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1079) // issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) }) it('differs from pt-BR on exactly the audited keys', () => { diff --git a/src/i18n/ruCopy.test.ts b/src/i18n/ruCopy.test.ts index 161c6983..2ee54ffc 100644 --- a/src/i18n/ruCopy.test.ts +++ b/src/i18n/ruCopy.test.ts @@ -43,7 +43,7 @@ describe('ru copy — the first Cyrillic catalog', () => { // ------------------------------------------------------------------ shape it('has exactly the base key set', () => { expect(Object.keys(ruValues).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(1064) // issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1079) // issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) }) // ----------------------------------------------------------------- script @@ -217,7 +217,6 @@ describe('ru copy — the first Cyrillic catalog', () => { 'ещё', 'её', 'ждёт', - 'завершённый', 'задаёт', 'задаётся', 'заменён', @@ -239,6 +238,8 @@ describe('ru copy — the first Cyrillic catalog', () => { 'неё', 'обведён', 'обновлён', + // issue #344: the 0.25.0 release note (`Обновлённые шаблоны`) + 'обновлённые', 'объединённые', 'остаётся', 'передаёт', diff --git a/src/i18n/thCopy.test.ts b/src/i18n/thCopy.test.ts index 285ed7b0..fdceb5fe 100644 --- a/src/i18n/thCopy.test.ts +++ b/src/i18n/thCopy.test.ts @@ -88,11 +88,11 @@ const englishFor = (surface: string, id: string): string => { describe('th copy — the first no-space script', () => { it('has exactly the base key set', () => { expect(Object.keys(TH).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(1064) // issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1079) // issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) }) it('covers all three runtime surfaces', () => { - expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1064) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1079) expect(RUNTIME.filter(([s]) => s.startsWith('template/'))).toHaveLength(196) expect(RUNTIME.filter(([s]) => s.startsWith('frame/'))).toHaveLength(7) expect(RUNTIME.filter(([s]) => s.startsWith('module/'))).toHaveLength(19) @@ -167,6 +167,7 @@ const LATIN_BRAND = [ 'loop', 'studio', 'machinations', 'io', 'github', 'google', 'sheets', 'excel', 'numbers', 'monte', 'carlo', 'mc', 'mmo', 'windows', 'iphone', 'chrome', // the storage gate (issue #297) + 'mac', // the frame-move hint (issue #344) ] /** File formats and the export commands named after them. `Graph JSON` and * `Runs CSV` are the names of the files the product writes. */ @@ -197,6 +198,7 @@ const LATIN_WIRE = [ const LATIN_EXAMPLE = [ 'alex', 'gold', 'energy', 'xp', 'player', 'free', 'general', 'premium', 'standard', 'r', 'sr', 'ssr', + 'px', // the grid size in the 0.25.0 release note (issue #344) ] const LATIN_GROUPS: [string, string[]][] = [ ['brand', LATIN_BRAND], @@ -278,6 +280,9 @@ const LATIN_KEYS = [ 'catalog:import.issue.ragged-row', 'catalog:whatsNew.v0220.badge', 'catalog:whatsNew.v0240.tiers', + 'catalog:whatsNew.v0250.grid', + 'catalog:inspector.edge.route.addBendHint', + 'catalog:inspector.edge.route.editHint', 'catalog:import.parseError', 'catalog:import.parseErrorsBlockValidation', 'catalog:import.pasteAria', @@ -295,7 +300,6 @@ const LATIN_KEYS = [ 'catalog:import.roleHelp.ignored', 'catalog:inspector.edge.flowParam.hint', 'catalog:inspector.edge.flowPlaceholder', - 'catalog:inspector.edge.note', 'catalog:inspector.edge.type.resource', 'catalog:inspector.edge.type.state', 'catalog:inspector.expr.labelPlaceholder', @@ -433,7 +437,7 @@ describe('a Latin run is declared, twice over — by key and by word', () => { it('the strings carrying a Latin run are exactly the declared ones', () => { expect(withLatin.slice().sort()).toEqual(LATIN_KEYS.slice().sort()) - expect(LATIN_KEYS).toHaveLength(172) // issue #330 PR 3: +1 (the `+N` of one 0.24.0 release-note line); issue #330 PR 1: +1 (the `+N` of one 0.22.0 release-note line); issue #307: +2 (two 0.18.1 release-note lines); issue #308: +1 (one 0.18.2 release-note line); issue #325: +1 (one 0.19.0 release-note line) + expect(LATIN_KEYS).toHaveLength(174) // issue #344 Step 3: +2 (Esc / Shift / Delete in the two bend hints), -1 (the edge note no longer names Monte Carlo); issue #344 Step 1: +1 (Alt / Ctrl / Mac in one 0.25.0 release-note line); issue #330 PR 3: +1 (the `+N` of one 0.24.0 release-note line); issue #330 PR 1: +1 (the `+N` of one 0.22.0 release-note line); issue #307: +2 (two 0.18.1 release-note lines); issue #308: +1 (one 0.18.2 release-note line); issue #325: +1 (one 0.19.0 release-note line) }) it('the Latin vocabulary is exactly the declared one', () => { @@ -441,14 +445,14 @@ describe('a Latin run is declared, twice over — by key and by word', () => { for (const [, , v] of RUNTIME) for (const w of latinWords(v)) seen.add(w) const declared = new Set([...LATIN_GLOBAL, ...Object.keys(LATIN_KEY_SCOPED)]) expect([...seen].sort()).toEqual([...declared].sort()) - expect(declared.size).toBe(80) // issue #307: +3 (home, end, tab) + expect(declared.size).toBe(82) // issue #344: +1 (px, the 0.25.0 release note); issue #344 Step 1: +1 (mac); issue #307: +3 (home, end, tab) }) it('the groups are disjoint, and they add up', () => { // Otherwise a word could be quietly moved between groups, and the reason // it is allowed — which is the whole content of this list — would rot. const counts = LATIN_GROUPS.map(([, g]) => g.length) - expect(counts).toEqual([16, 11, 14, 24, 12]) + expect(counts).toEqual([17, 11, 14, 24, 13]) const all = LATIN_GROUPS.flatMap(([, g]) => g) expect(all).toHaveLength(LATIN_GLOBAL.size) for (const w of Object.keys(LATIN_KEY_SCOPED)) expect(LATIN_GLOBAL.has(w)).toBe(false) diff --git a/src/i18n/trCopy.test.ts b/src/i18n/trCopy.test.ts index bce2841b..5ba57540 100644 --- a/src/i18n/trCopy.test.ts +++ b/src/i18n/trCopy.test.ts @@ -30,7 +30,7 @@ const KEYS = Object.keys(EN) describe('tr copy — the first Turkish catalog', () => { it('has exactly the base key set', () => { expect(Object.keys(TR).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(1064) // issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #300: +33; issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1079) // issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #300: +33; issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) }) }) diff --git a/src/i18n/viCopy.test.ts b/src/i18n/viCopy.test.ts index ebd70cae..a39082a0 100644 --- a/src/i18n/viCopy.test.ts +++ b/src/i18n/viCopy.test.ts @@ -132,11 +132,11 @@ const englishFor = (surface: string, id: string): string => { describe('vi copy — the first locale English hides inside', () => { it('has exactly the base key set', () => { expect(Object.keys(VI).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(1064) // issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) + expect(KEYS).toHaveLength(1079) // issue #344: +2 (the 0.25.0 release note is five lines, was three); issue #344 Step 3: +9 (the connection shape and bend-point strings and the keyboard bend notice); issue #344 Step 1: +3 (three 0.25.0 release-note lines); issue #344 Step 1: +1 (canvas.tidy, the Tidy to grid rail button); issue #330 PR 3: +3 (three 0.24.0 release-note lines); issue #330 PR 3: +9 (the phone Playback speed sheet); issue #330 PR 2: +3 (three 0.23.0 release-note lines); issue #330 PR 1: +5 (five 0.22.0 release-note lines); issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines); the session chip: +3 (the three 0.17.2 release-note lines); the licence screen: +12 (nine strings of the view and the three 0.18.0 release-note lines); the menu keyboard: +3 (the three 0.18.1 release-note lines); the tour announcement: +4 (tour.nav.announce and the three 0.18.2 release-note lines); the flow colour: +20 (the sixteen strings of the Colour section and the four 0.19.0 release-note lines); the flow colour views: +4 (the four 0.20.0 release-note lines); compact nodes: +3 (the three 0.21.0 release-note lines); Focus dimming: +3 (the three 0.21.1 release-note lines); value rows: +3 (the three 0.21.2 release-note lines); the edit lock: +4 (canvas.lock.name, its fixed accessible name, and the three 0.21.3 release-note lines); the locked rail and the phone search: +4 (the four 0.21.4 release-note lines) }) it('covers all four runtime surfaces', () => { - expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1064) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(1079) expect(RUNTIME.filter(([s]) => s.startsWith('template/'))).toHaveLength(196) expect(RUNTIME.filter(([s]) => s.startsWith('frame/'))).toHaveLength(7) expect(RUNTIME.filter(([s]) => s.startsWith('module/'))).toHaveLength(19) @@ -265,6 +265,7 @@ const ASCII_PROPER = [ 'excel', 'numbers', 'monte', 'carlo', 'mc', 'mmo', 'brazil', 'windows', 'iphone', // the 0.15.3 release note (issue #298) 'chrome', // the storage gate (issue #297) + 'mac', // the frame-move hint (issue #344) ] /** File formats and the export commands named after them. */ const ASCII_FILE_FORMAT = ['json', 'csv', 'tsv', 'xlsx', 'graph', 'workspace', 'montecarloresult', 'html'] @@ -303,6 +304,7 @@ const ASCII_LOANWORD = ['tab', 'web', 'banner', 'gacha', 'menu'] const ASCII_EXAMPLE = [ 'alex', 'gold', 'energy', 'xp', 'player', 'item', 'premium', 'standard', 'r', 'sr', 'ssr', 'kg', + 'px', // the grid size in the 0.25.0 release note (issue #344) ] /** Vietnamese words that happen to be pure ASCII AND to spell an English word * present in the same English original: `so sánh` (compare) against "so", and @@ -401,6 +403,9 @@ const ASCII_KEYS = [ 'catalog:import.issue.ragged-row', 'catalog:whatsNew.v0220.badge', 'catalog:whatsNew.v0240.tiers', + 'catalog:whatsNew.v0250.grid', + 'catalog:inspector.edge.route.addBendHint', + 'catalog:inspector.edge.route.editHint', 'catalog:import.parseError', 'catalog:import.parseErrorsBlockValidation', 'catalog:import.pasteAria', @@ -422,7 +427,6 @@ const ASCII_KEYS = [ 'catalog:import.roleHelp.key', 'catalog:inspector.edge.flowParam.hint', 'catalog:inspector.edge.flowPlaceholder', - 'catalog:inspector.edge.note', 'catalog:inspector.edge.type.resource', 'catalog:inspector.edge.type.state', 'catalog:inspector.expr.labelPlaceholder', @@ -580,7 +584,7 @@ describe('a kept English word is declared, twice over — by key and by word', ( ([s, id]) => s + ':' + id, ) expect(actual.slice().sort()).toEqual(ASCII_KEYS.slice().sort()) - expect(ASCII_KEYS).toHaveLength(196) // issue #330 PR 3: +1 (the `+N` of one 0.24.0 release-note line); issue #330 PR 1: +1 (the `+N` of one 0.22.0 release-note line); issue #307: +3 (the three 0.18.1 release-note lines); issue #308: +1 (one 0.18.2 release-note line); issue #325: +1 (one 0.19.0 release-note line) + expect(ASCII_KEYS).toHaveLength(198) // issue #344 Step 3: +2 (Esc / Shift / Delete in the two bend hints), -1 (the edge note no longer names Monte Carlo); issue #344 Step 1: +1 (Alt / Ctrl / Mac in one 0.25.0 release-note line); issue #330 PR 3: +1 (the `+N` of one 0.24.0 release-note line); issue #330 PR 1: +1 (the `+N` of one 0.22.0 release-note line); issue #307: +3 (the three 0.18.1 release-note lines); issue #308: +1 (one 0.18.2 release-note line); issue #325: +1 (one 0.19.0 release-note line) }) it('the kept vocabulary is exactly the declared one', () => { @@ -588,13 +592,13 @@ describe('a kept English word is declared, twice over — by key and by word', ( for (const [s, id, v] of RUNTIME) for (const w of sharedWith(s, id, v)) seen.add(w) const declared = new Set([...ASCII_GLOBAL, ...Object.keys(ASCII_KEY_SCOPED)]) expect([...seen].sort()).toEqual([...declared].sort()) - expect(declared.size).toBe(83) // issue #307: +2 (home, end) + expect(declared.size).toBe(85) // issue #344: +1 (px, the 0.25.0 release note); issue #344 Step 1: +1 (mac); issue #307: +2 (home, end) }) it('the groups are disjoint, and they add up', () => { // Otherwise a word could be quietly moved between groups, and the reason it // is kept — which is the whole content of this list — would rot. - expect(ASCII_GROUPS.map(([, g]) => g.length)).toEqual([17, 8, 14, 18, 4, 5, 12, 2]) + expect(ASCII_GROUPS.map(([, g]) => g.length)).toEqual([18, 8, 14, 18, 4, 5, 13, 2]) const all = ASCII_GROUPS.flatMap(([, g]) => g) expect(all).toHaveLength(ASCII_GLOBAL.size) for (const w of Object.keys(ASCII_KEY_SCOPED)) expect(ASCII_GLOBAL.has(w)).toBe(false) diff --git a/src/index.css b/src/index.css index 94fac3f3..7c65bddb 100644 --- a/src/index.css +++ b/src/index.css @@ -1613,6 +1613,74 @@ a.sheet__row { fill: var(--warning); font: 700 11px var(--font-sans); } +/* issue #344 §DL3.6 — smart guides (SmartGuideLayer.tsx): a dashed placement + line through where the drag lands, a solid line to every node it is exactly + aligned with. 1 screen px whatever the zoom; faint while Alt frees the move. */ +.smart-guides { + position: absolute; + left: 0; + top: 0; + width: 1px; + height: 1px; + overflow: visible; + pointer-events: none; + z-index: 4; +} +.smart-guide { + stroke: var(--line-focus); + stroke-width: 1; + vector-effect: non-scaling-stroke; + stroke-dasharray: 4 4; + opacity: 0.55; +} +.smart-guide.is-strong { + stroke-dasharray: none; + opacity: 0.95; +} +.smart-guides.is-faint .smart-guide { + opacity: 0.3; +} +@media (forced-colors: active) { + .smart-guide { + stroke: Highlight; + } +} +/* issue #344 step 3 (docs/edge-routing.md §ER16) — a bend point of the selected + Manual orthogonal connection (RouteBends.tsx): a small square on the route, + draggable and focusable. A position it cannot keep (inside a node, on the + port stub) is shown with a DASHED border — the non-colour tell — and the + point goes back on release. */ +.route-bend { + position: absolute; + width: 12px; + height: 12px; + padding: 0; + margin: 0; + border: 2px solid var(--line-focus); + border-radius: 2px; + background: var(--surface-raised); + pointer-events: all; + cursor: move; + z-index: 1; +} +.route-bend:focus-visible, +.route-bend.is-focused { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} +.route-bend.is-invalid { + border-style: dashed; + border-color: var(--warning); +} +@media (forced-colors: active) { + .route-bend { + border-color: Highlight; + background: Canvas; + } + .route-bend.is-invalid { + border-color: CanvasText; + } +} /* Under `forced-colors: active` the UA force-adjusts these SVG `fill` / `stroke` values to system colours automatically (the same path the Canvas Visual Refresh relies on — no `@media` block, see scripts/check-mobile-query.mjs). @@ -2184,7 +2252,26 @@ a.sheet__row { .nodef--register .nodef__sub { margin-left: var(--vra-sub-start, clamp(0px, calc(11.6667% - 11.5px), 17px)); } - +/* issue #337 — the Pool, Source, Drain, Converter and Gate outlines keep a fixed + px depth at every width (./components/nodes/silhouette), and + ./components/nodes/outlineFit places what is painted inside them: the head + (chip + title) moves in by `--of-head-shift` where the outline needs it, a + Source / Drain / Converter mode row starts at the title like #332's rows, + and the node's width is set inline. The shift is a physical `margin-left` + for the reason #332's row starts are: the canvas is left-to-right in every + language. The Gate keeps its lines centred, so only a mode row's max width + reaches it. */ +:is(.nodef--pool, .nodef--source, .nodef--drain, .nodef--converter) .nodef__head { + margin-left: var(--of-head-shift, 0px); +} +:is(.nodef--source, .nodef--drain, .nodef--converter) .nodef__sub { + align-self: flex-start; + margin-left: var(--vra-sub-start, 0px); + max-width: var(--vra-sub-max, calc(100% - var(--vra-sub-start, 0px))); +} +.nodef--gate .nodef__sub { + max-width: var(--vra-sub-max, 100%); +} .nodef__head { display: flex; align-items: center; @@ -4465,6 +4552,33 @@ a.sheet__row { justify-content: space-between; margin-bottom: 12px; } +/* issue #344 step 3 — the route state, Add bend and Reset to automatic */ +.inspector__route { + margin: -4px 0 12px; +} +.inspector__route .inspector__note { + margin-bottom: 6px; +} +.inspector__route-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +/* the armed state: the rail toggles' pressed treatment, never an `outline` + (that is the focus ring, see §LGR9 below) */ +.inspector__route-actions .btn[aria-pressed='true'], +.inspector__route-actions .btn[aria-pressed='true']:hover { + color: var(--signal-primary); + background: var(--signal-primary-soft); + box-shadow: inset 0 0 0 2px var(--signal-primary); +} +@media (forced-colors: active) { + .inspector__route-actions .btn[aria-pressed='true'], + .inspector__route-actions .btn[aria-pressed='true']:hover { + background: Highlight; + color: HighlightText; + } +} .inspector__kind { font-size: 11px; font-weight: 700; diff --git a/src/main.tsx b/src/main.tsx index ff1d53f1..d86d32f7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -43,6 +43,11 @@ if (__PWA_ENABLED__) { } async function start(): Promise { + // issue #344 §DL2.8 — a legacy autosave record is re-placed on the grid once, + // here, before `startApp` evaluates the stores that read it (no store is + // imported by this module, and the session is open by now) + const { convertAutosaveLayout } = await import('./store/layoutBoot') + convertAutosaveLayout() const { startApp } = await import('./startApp') await startApp() } diff --git a/src/model/dataImportCommit.test.ts b/src/model/dataImportCommit.test.ts index b6aaf80e..9277343f 100644 --- a/src/model/dataImportCommit.test.ts +++ b/src/model/dataImportCommit.test.ts @@ -4,6 +4,7 @@ import { createTableDraft, setColumnRole, validateDrafts, type DraftColumnRole, import { canonicalContent, digestOfCanonical } from './revision' import { deserialize, serialize, type ImportSourceTable, type SavedFrame } from './serialize' import type { LoopEdge, LoopNode } from './types' +import { nodeOnGrid } from './layout/grid' // docs/data-import.md -- Phase 1B. `buildImportCommit` is the pure // candidate builder: label composition (the resolved join rule), the three @@ -120,7 +121,9 @@ describe('buildImportCommit -- placement', () => { expect(built.ok).toBe(true) if (!built.ok) return expect(built.createdFrames).toEqual([]) - expect(built.createdNodes[0].position).toEqual({ x: 100, y: 200 }) + // issue #344 §DL3 — on the 16 px grid (left edge 96, port row 196 + 28 = 224) + expect(built.createdNodes[0].position).toEqual({ x: 96, y: 196 }) + for (const n of built.createdNodes) expect(nodeOnGrid(n.position)).toBe(true) }) it('"framePerTable": a lookup-only table (zero number columns) gets NO frame', () => { diff --git a/src/model/dataImportCommit.ts b/src/model/dataImportCommit.ts index d160335e..3a2692df 100644 --- a/src/model/dataImportCommit.ts +++ b/src/model/dataImportCommit.ts @@ -15,6 +15,7 @@ import { import { validateResultGraph } from './revision' import type { ImportSourceTable, SavedFrame } from './serialize' import type { LoopEdge, LoopNode } from './types' +import { snapNodePosition, snapRectOutward } from './layout/grid' export type PlacementChoice = | { kind: 'none'; origin: { x: number; y: number } } @@ -65,7 +66,7 @@ const GRID_COLS = 4 export type Rect = { x: number; y: number; w: number; h: number } type Point = { x: number; y: number } -function rectsOverlap(a: Rect, b: Rect): boolean { +export function rectsOverlap(a: Rect, b: Rect): boolean { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y } export function nodeRect(pos: Point): Rect { @@ -353,6 +354,11 @@ export function buildImportCommit( } positions = candidate } + // issue #344 §DL3 — every imported node lands on the grid (≤ 8 px: inside + // the 24 px frame padding and the 300 / 144 px pitch, so no overlap and no + // node outside its frame); a created frame snaps outward to the grid + positions = positions.map(snapNodePosition) + for (let i = 0; i < createdFrames.length; i++) createdFrames[i] = { ...createdFrames[i], rect: snapRectOutward(createdFrames[i].rect) } // -- build the Parameter nodes ----------------------------------------- const createdNodes: LoopNode[] = cells.map((cell, i) => { diff --git a/src/model/dataImportRefresh.ts b/src/model/dataImportRefresh.ts index 054412d9..c408cb33 100644 --- a/src/model/dataImportRefresh.ts +++ b/src/model/dataImportRefresh.ts @@ -7,7 +7,8 @@ // discipline for the first import. import { nextId } from './factory' -import { NODE_H, NODE_W, gridPositions, nodeRect, shiftUntilClear, type Rect } from './dataImportCommit' +import { NODE_H, NODE_W, gridPositions, nodeRect, rectsOverlap, shiftUntilClear, type Rect } from './dataImportCommit' +import { snapNodePosition } from './layout/grid' import { composeFullRowLabel } from './dataImportValidate' import type { ValidatedRefreshSnapshot } from './dataImportRefreshValidate' import { findReferences, type NodeReference } from './dataImportReferences' @@ -399,7 +400,11 @@ function placeNewNodes(count: number, origin: Point, hostNodes: readonly LoopNod if (!shifted) return null const dx = shifted.x - rect.x const dy = shifted.y - rect.y - return raw.map((p) => ({ x: p.x + dx, y: p.y + dy })) + const placed = raw.map((p) => ({ x: p.x + dx, y: p.y + dy })) + // issue #344 §DL3 — on the grid (≤ 8 px), unless that would touch something + // the scan kept clear: then the scan's own positions stand (never into a frame) + const snapped = placed.map(snapNodePosition) + return snapped.some((p) => obstacles.some((o) => rectsOverlap(nodeRect(p), o))) ? placed : snapped } export function buildRefreshCommit( diff --git a/src/model/edgeRouting.ts b/src/model/edgeRouting.ts index 3299495a..36107358 100644 --- a/src/model/edgeRouting.ts +++ b/src/model/edgeRouting.ts @@ -10,8 +10,9 @@ export const MAX_WAYPOINTS_PER_EDGE = 64 export type EdgeWaypoint = { x: number; y: number } export type EdgeRoutingRead = { - /** present iff a valid `"orthogonal"` mode survived */ - route?: 'orthogonal' + /** present iff a valid `"orthogonal"` or (loop-revision/10) `"straight"` + * mode survived */ + route?: 'orthogonal' | 'straight' /** present iff `route` is `"orthogonal"` AND 1..64 finite points survived */ waypoints?: EdgeWaypoint[] } @@ -33,11 +34,11 @@ export function readRoutingPayload(raw: unknown, onWarn?: (msg: string) => void) const d = raw as { route?: unknown; waypoints?: unknown } // ── route ── - let route: 'orthogonal' | undefined + let route: 'orthogonal' | 'straight' | undefined if (d.route === undefined || d.route === 'bezier') { route = undefined // absent / explicit-bezier both normalise to absent - } else if (d.route === 'orthogonal') { - route = 'orthogonal' + } else if (d.route === 'orthogonal' || d.route === 'straight') { + route = d.route } else { onWarn?.('edge routing dropped — unrecognised `route` value') return {} // whole payload @@ -50,6 +51,12 @@ export function readRoutingPayload(raw: unknown, onWarn?: (msg: string) => void) onWarn?.('edge routing dropped — `waypoints` is not an array') return {} } + if (route === 'straight') { + // SEMANTICS-R10.md §R10-1.1 — a straight line takes no bend points: the + // shape is kept, the waypoints are dropped + if (rawWp.length > 0) onWarn?.('edge `waypoints` dropped — a `route: "straight"` connection takes none') + return { route } + } if (route !== 'orthogonal') { if (rawWp.length > 0) onWarn?.('edge `waypoints` dropped — no `route: "orthogonal"`') return {} // waypoints meaningless without orthogonal; route was absent anyway @@ -84,6 +91,12 @@ export function edgeHasRoutingIntent(data: unknown): boolean { return d.route === 'orthogonal' || (Array.isArray(d.waypoints) && d.waypoints.length > 0) } +/** SEMANTICS-R10.md §R10-1 — true iff the edge carries a surviving + * `route: "straight"`, evaluated on already-normalised data. */ +export function edgeIsStraight(data: unknown): boolean { + return !!data && typeof data === 'object' && (data as { route?: unknown }).route === 'straight' +} + /** * §R3-1.1 warning list for the import path — a deterministic message per edge * whose *raw* routing payload would be quarantined, in ascending edge `id` diff --git a/src/model/edgeShape.test.ts b/src/model/edgeShape.test.ts new file mode 100644 index 00000000..5f5ce6c3 --- /dev/null +++ b/src/model/edgeShape.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest' +import { defaultData } from './factory' +import { readRoutingPayload, routingReadIssues } from './edgeRouting' +import { + buildSelectiveApply, + canonicalContent, + computeRevisionDiff, + computeThreeWay, + digestOfCanonical, + fieldTag, + fullContentDigest, + readRevisionSide, +} from './revision' +import { deserialize, serialize } from './serialize' +import type { LoopEdge, LoopNode, NodeKind } from './types' +import { semanticDigest } from './workspace' + +// SEMANTICS-R10.md — the connection shapes through the reader, the file, the +// digests and the revision layer (issue #344 step 3, docs/diagram-layout.md §DL4). + +function node(id: string, kind: NodeKind, x: number): LoopNode { + const data = { ...(defaultData(kind) as unknown as Record), label: id } + return { id, type: kind, position: { x, y: 0 }, data: data as LoopNode['data'] } +} + +type Shape = Record + +/** a flow-only graph (so the shape is the only reason for a version above /1): + * a resource edge `r` and a state edge `s`, each with the given routing keys */ +function graph(r: Shape = {}, s: Shape = {}) { + const nodes = [node('source', 'source', 0), node('pool', 'pool', 200), node('gate', 'gate', 400)] + const edges: LoopEdge[] = [ + { id: 'r', type: 'loop', source: 'source', target: 'pool', sourceHandle: 'out', targetHandle: 'in', data: { kind: 'resource', flow: '1', ...r } as LoopEdge['data'] }, + { id: 's', type: 'loop', source: 'pool', target: 'gate', sourceHandle: 'state-source', targetHandle: 'state-target', data: { kind: 'state', mode: 'trigger', expr: '', ...s } as LoopEdge['data'] }, + ] + return { nodes, edges } +} + +const STRAIGHT = { route: 'straight' } +const AUTO = { route: 'orthogonal' } +const MANUAL = { route: 'orthogonal', waypoints: [{ x: 96, y: 48 }, { x: 96, y: 112 }] } + +const routeOf = (e: LoopEdge | undefined) => (e?.data as { route?: unknown } | undefined)?.route +const wpOf = (e: LoopEdge | undefined) => (e?.data as { waypoints?: unknown } | undefined)?.waypoints +const roundTrip = (g: { nodes: LoopNode[]; edges: LoopEdge[] }) => deserialize(serialize(g.nodes, g.edges)) + +describe('the reader — §R10-1.1', () => { + it('keeps "straight" and drops bend points beside it, with a warning', () => { + expect(readRoutingPayload({ route: 'straight' })).toEqual({ route: 'straight' }) + const warns: string[] = [] + expect(readRoutingPayload({ route: 'straight', waypoints: [{ x: 1, y: 2 }] }, (m) => warns.push(m))).toEqual({ route: 'straight' }) + expect(warns).toEqual(['edge `waypoints` dropped — a `route: "straight"` connection takes none']) + expect(readRoutingPayload({ route: 'straight', waypoints: [] })).toEqual({ route: 'straight' }) + }) + + it('Curved stays absent, "bezier" is read as absent, an unknown value drops the payload', () => { + expect(readRoutingPayload({})).toEqual({}) + expect(readRoutingPayload({ route: 'bezier' })).toEqual({}) + const warns: string[] = [] + expect(readRoutingPayload({ route: 'diagonal', waypoints: [{ x: 1, y: 1 }] }, (m) => warns.push(m))).toEqual({}) + expect(warns).toEqual(['edge routing dropped — unrecognised `route` value']) + expect(routingReadIssues([{ id: 'b', data: { route: 'straight', waypoints: [{ x: 0, y: 0 }] } }, { id: 'a', data: { waypoints: [{ x: 0, y: 0 }] } }])).toEqual([ + 'edge "a": edge `waypoints` dropped — no `route: "orthogonal"`', + 'edge "b": edge `waypoints` dropped — a `route: "straight"` connection takes none', + ]) + }) + + it('Auto and Manual orthogonal read as before', () => { + expect(readRoutingPayload(AUTO)).toEqual(AUTO) + expect(readRoutingPayload(MANUAL)).toEqual(MANUAL) + }) +}) + +describe('the file', () => { + it('every shape survives a round trip on both edge kinds; Curved gains no key', () => { + for (const shape of [{}, STRAIGHT, AUTO, MANUAL]) { + const g = roundTrip(graph(shape, shape)) + for (const id of ['r', 's']) { + const e = g.edges.find((x) => x.id === id) + expect(routeOf(e), `${id} ${JSON.stringify(shape)}`).toBe((shape as { route?: unknown }).route) + expect(wpOf(e)).toEqual((shape as { waypoints?: unknown }).waypoints) + } + } + expect(serialize(graph().nodes, graph().edges)).not.toContain('"route"') + }) + + it('bend points stored beside Straight are dropped on reading, the shape kept', () => { + const g = roundTrip(graph({ route: 'straight', waypoints: [{ x: 1, y: 2 }] })) + const r = g.edges.find((x) => x.id === 'r') + expect(routeOf(r)).toBe('straight') + expect(wpOf(r)).toBeUndefined() + }) +}) + +describe('the digests — R10-INV-1 / R10-INV-2', () => { + it('the engine digest is the same for every shape', async () => { + const plain = await semanticDigest(graph()) + for (const shape of [STRAIGHT, AUTO, MANUAL]) expect(await semanticDigest(graph(shape, shape))).toBe(plain) + }) + + it('the full digest tells every shape apart and returns with the shape', async () => { + const ds = await Promise.all([{}, STRAIGHT, AUTO, MANUAL].map((s) => fullContentDigest(graph(s)))) + expect(new Set(ds).size).toBe(4) + expect(await fullContentDigest(graph())).toBe(ds[0]) + }) + + it('a document with no straight connection projects exactly as before', () => { + for (const shape of [{}, AUTO, MANUAL]) expect(JSON.stringify(canonicalContent(graph(shape)))).not.toContain('straight') + const r = canonicalContent(graph(STRAIGHT)).edges.find((e) => e.id === 'r')! + expect((r.data as Record).route).toBe('straight') + expect('waypoints' in r.data).toBe(false) + }) + + it('the literal loop-revision/1 projection never emits it', () => { + expect(JSON.stringify(canonicalContent(graph(STRAIGHT, STRAIGHT), { modelLayer: false }))).not.toContain('route') + }) +}) + +describe('the revision layer — SEMANTICS-R10.md', () => { + it('a side with a straight connection is loop-revision/10; without one it is what it was', () => { + const straight = readRevisionSide(graph(STRAIGHT)) + expect(straight.ok && straight.version).toBe('loop-revision/10') + const auto = readRevisionSide(graph(AUTO)) + expect(auto.ok && auto.version).toBe('loop-revision/3') + const curved = readRevisionSide(graph()) + expect(curved.ok && curved.version).toBe('loop-revision/1') + const invalid = readRevisionSide(graph({ route: 'diagonal' })) + expect(invalid.ok && invalid.version).toBe('loop-revision/1') + }) + + it('a stored digest over the /10 projection verifies', () => { + const g = graph(STRAIGHT, MANUAL) + const side = readRevisionSide(g, digestOfCanonical(canonicalContent(g))) + expect(side.ok && side.digestVerified).toBe(true) + }) + + it('a digest written before /10 still verifies (no straight connection, any other shape)', () => { + for (const shape of [{}, AUTO, MANUAL]) { + const g = graph(shape) + const side = readRevisionSide(g, digestOfCanonical(canonicalContent(g))) + expect(side.ok && side.digestVerified, JSON.stringify(shape)).toBe(true) + } + }) + + it('the shape and the bend points are cosmetic fields', () => { + expect(fieldTag('edge', 'data.route')).toBe('cosmetic') + expect(fieldTag('edge', 'data.waypoints')).toBe('cosmetic') + }) + + it('a shape change is a real change in Review, never an engine or advisory one', () => { + const pairs: [Shape, Shape][] = [[{}, STRAIGHT], [STRAIGHT, AUTO], [AUTO, MANUAL], [MANUAL, {}]] + for (const [a, b] of pairs) { + const d = computeRevisionDiff(canonicalContent(graph(a)), canonicalContent(graph(b))) + expect(d.summary.empty, `${JSON.stringify(a)} -> ${JSON.stringify(b)}`).toBe(false) + expect(d.summary.engineAffecting).toBe(false) + expect(d.summary.advisoryAffecting).toBe(false) + } + }) + + it('a selective Apply moves between every pair of shapes', () => { + const shapes: Shape[] = [{}, STRAIGHT, AUTO, MANUAL] + for (const from of shapes) { + for (const to of shapes) { + if (from === to) continue + const base = graph(from) + const proposed = graph(to) + const plan = computeThreeWay(canonicalContent(base), canonicalContent(base), canonicalContent(proposed)) + const hunk = plan.hunks.find((h) => h.id === 'r' && h.kind === 'change')! + const fieldChoices = Object.fromEntries(hunk.fields!.map((f) => [f.field, 'proposed' as const])) + const applied = buildSelectiveApply({ target: base, proposedFull: proposed, plan, selection: { accept: {}, fieldChoices: { r: fieldChoices } } }) + const label = `${JSON.stringify(from)} -> ${JSON.stringify(to)}` + expect(applied.ok, label).toBe(true) + if (!applied.ok) continue + const r = applied.edges.find((e) => e.id === 'r') + expect(routeOf(r), label).toBe((to as { route?: unknown }).route) + expect(wpOf(r), label).toEqual((to as { waypoints?: unknown }).waypoints) + } + } + }) +}) diff --git a/src/model/layout/dragSnap.ts b/src/model/layout/dragSnap.ts new file mode 100644 index 00000000..7e3a7df5 --- /dev/null +++ b/src/model/layout/dragSnap.ts @@ -0,0 +1,123 @@ +// docs/diagram-layout.md §DL3 (issue #344) — snapping a pointer drag. +// +// React Flow's own `snapToGrid` stays off: it snaps every dragged node on its +// own, which would change a selection's relative positions, and it knows +// nothing of the port row. Instead the canvas tells this module which node the +// user grabbed, which nodes move, the zoom and whether Alt is held, and the +// graph store moves every dragged node by the ONE correction the smart guides +// choose (§DL3.6: another node's port row, then its centre or edges, then the +// 16 px grid — for the grabbed node, or the selection's bounding box). Alt held +// = a free move for that drag (the guides are shown faint, nothing is pulled; +// releasing it mid-drag snaps again). Outside a pointer drag (an arrow-key +// move) the correction is the grid's, and the guides are only shown. No React, +// no store: plain module state the canvas writes and `graphStore.onNodesChange` +// reads. + +import type { NodeChange } from '@xyflow/react' +import type { LoopNode } from '../types' +import { snapNodePosition } from './grid' +import { ALIGN_TOL_PX, BoxIndex, type GBox, type Guide, settledGuides, smartSnap } from './smartGuides' + +let anchorId: string | null = null +let free = false +/** a pointer drag is under way (begin … end) */ +let active = false +let index: BoxIndex | null = null +let tol = ALIGN_TOL_PX + +/** the nodes with no resource port row */ +const NO_PORT = new Set(['parameter', 'register']) +/** a node's box for the guides: its rendered size (the default before React + * Flow measures it) */ +export function guideBox(n: LoopNode, at = n.position): GBox { + return { + id: n.id, + x: at.x, + y: at.y, + w: n.measured?.width ?? n.width ?? 130, + h: n.measured?.height ?? n.height ?? 56, + port: !NO_PORT.has(String(n.type)), + } +} + +export const dragSnap = { + /** a pointer drag starts: the grabbed node (null = the first moved one), + * the ids that move, every node of the graph, the zoom and Alt. The other + * nodes are indexed once, here. */ + begin(id: string | null, alt: boolean, moving?: readonly string[], nodes?: readonly LoopNode[], zoom = 1): void { + anchorId = id + free = alt + active = true + tol = ALIGN_TOL_PX / (zoom > 0 ? zoom : 1) + const ids = new Set(moving ?? (id ? [id] : [])) + index = nodes ? new BoxIndex(nodes.filter((n) => !ids.has(n.id) && !n.hidden).map((n) => guideBox(n))) : null + }, + /** every pointer move reports Alt again */ + update(alt: boolean): void { + free = alt + }, + end(): void { + anchorId = null + free = false + active = false + index = null + }, + get free(): boolean { + return free + }, + get active(): boolean { + return active + }, +} + +/** §DL3.6 — after an arrow-key move (the canvas moves the nodes itself): the + * row and column the moved nodes now sit on, and every node aligned with them */ +export function keyMoveGuides(nodes: readonly LoopNode[], movedIds: readonly string[], anchorId: string | null): Guide[] { + const ids = new Set(movedIds) + const moving = nodes.filter((n) => ids.has(n.id)).map((n) => guideBox(n)) + const idx = new BoxIndex(nodes.filter((n) => !ids.has(n.id) && !n.hidden).map((n) => guideBox(n))) + return settledGuides(moving, anchorId, idx) +} + +type PositionChange = Extract, { type: 'position' }> + +export type SnappedDrag = { + changes: NodeChange[] + /** the guides to show now; null = leave them as they are */ + guides: Guide[] | null + faint: boolean +} + +/** the drag's position changes moved by the one correction; `nodes` are the + * graph's current nodes (their sizes). An arrow-key move (no pointer drag) + * lands on the grid and reports the row and column it now sits on. */ +export function snapDrag(changes: NodeChange[], nodes: readonly LoopNode[]): SnappedDrag { + const moves = changes.filter((c): c is PositionChange => c.type === 'position' && c.position != null) + if (moves.length === 0) return { changes, guides: null, faint: false } + const byId = new Map(nodes.map((n) => [n.id, n])) + const shift = (dx: number, dy: number) => + dx === 0 && dy === 0 + ? changes + : changes.map((c) => (c.type === 'position' && c.position != null ? { ...c, position: { x: c.position.x + dx, y: c.position.y + dy } } : c)) + const boxes = moves.flatMap((c) => { + const n = byId.get(c.id) + return n ? [guideBox(n, c.position!)] : [] + }) + + if (active && index) { + const r = smartSnap(boxes, anchorId, index, tol, free) + return { changes: shift(r.dx, r.dy), guides: r.guides, faint: free } + } + + // no pointer drag: the grid only (an arrow-key move, §DL3.4), then the + // guides of where it now sits + const anchor = moves.find((c) => c.id === anchorId) ?? moves[0] + const p = anchor.position! + const s = snapNodePosition(p) + const out = shift(s.x - p.x, s.y - p.y) + const ids = new Set(moves.map((c) => c.id)) + const settled = boxes.map((b) => ({ ...b, x: b.x + s.x - p.x, y: b.y + s.y - p.y })) + const idx = new BoxIndex(nodes.filter((n) => !ids.has(n.id) && !n.hidden).map((n) => guideBox(n))) + return { changes: out, guides: settledGuides(settled, anchor.id, idx), faint: false } +} + diff --git a/src/model/layout/frameRebuild.ts b/src/model/layout/frameRebuild.ts new file mode 100644 index 00000000..c50ff386 --- /dev/null +++ b/src/model/layout/frameRebuild.ts @@ -0,0 +1,53 @@ +// docs/diagram-layout.md §DL2.9 / §DL5.3 (issue #344) — the clearance a +// re-placement keeps between nodes, and how a saved frame is rebuilt around the +// nodes it shows once they have moved. Shared by the bundled Templates +// (`placeTemplate`), the one-time conversion of an older document and Tidy to +// grid. Pure and deterministic. + +import { GRID } from './grid' + +/** the clearance kept between two nodes, flow px: two port stubs and a lane + * across, and a row of routes between two node rows */ +export const LAYOUT_CLEARANCE = { x: 48, y: 32 } as const +/** the margin a frame keeps around each node it shows (docs/example-coffee-roastery.md §CR17) */ +export const FRAME_MARGIN = 24 + +export type Rect = { x: number; y: number; w: number; h: number } +export type Box = { x: number; y: number; w: number; h: number } + +const bbox = (boxes: Box[]) => ({ + x: Math.min(...boxes.map((b) => b.x)), + y: Math.min(...boxes.map((b) => b.y)), + x1: Math.max(...boxes.map((b) => b.x + b.w)), + y1: Math.max(...boxes.map((b) => b.y + b.h)), +}) + +/** A frame rebuilt around `members` at their new place: on each side it keeps + * the padding it had around them before (`before`: each member's box as it + * was), at least `FRAME_MARGIN`, and covers each member's box as it is now + * (`after`); snapped outward to the grid. No member: `fallback`. */ +export function rebuildFrameRect(rect: Rect, members: readonly string[], before: (id: string) => Box, after: (id: string) => Box, fallback: Rect): Rect { + if (members.length === 0) return fallback + const b0 = bbox(members.map(before)) + const b1 = bbox(members.map(after)) + const pad = { + l: Math.max(FRAME_MARGIN, b0.x - rect.x), + t: Math.max(FRAME_MARGIN, b0.y - rect.y), + r: Math.max(FRAME_MARGIN, rect.x + rect.w - b0.x1), + b: Math.max(FRAME_MARGIN, rect.y + rect.h - b0.y1), + } + const x0 = Math.floor((b1.x - pad.l) / GRID) * GRID + const y0 = Math.floor((b1.y - pad.t) / GRID) * GRID + const x1 = Math.ceil((b1.x1 + pad.r) / GRID) * GRID + const y1 = Math.ceil((b1.y1 + pad.b) / GRID) * GRID + return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } +} + +/** whether a box lies fully inside a rect */ +export const fullyInside = (b: Box, r: Rect): boolean => b.x >= r.x && b.y >= r.y && b.x + b.w <= r.x + r.w && b.y + b.h <= r.y + r.h +/** whether a box's centre lies inside a rect */ +export const centreInside = (b: Box, r: Rect): boolean => { + const cx = b.x + b.w / 2 + const cy = b.y + b.h / 2 + return cx >= r.x && cx <= r.x + r.w && cy >= r.y && cy <= r.y + r.h +} diff --git a/src/model/layout/grid.test.ts b/src/model/layout/grid.test.ts new file mode 100644 index 00000000..d3ece6a6 --- /dev/null +++ b/src/model/layout/grid.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { GRID, nodeOnGrid, PORT_ROW, snapNodePosition, snapPoint, snapPortY, snapRectOutward, snapX } from './grid' + +// issue #344 §DL1 — the grid and the port row + +describe('the layout grid', () => { + it('is 16 px with the port row 28 px below the top', () => { + expect(GRID).toBe(16) + expect(PORT_ROW).toBe(28) + }) + + it('snaps a left edge to the nearest grid line, never to -0', () => { + expect(snapX(7.9)).toBe(0) + expect(snapX(8)).toBe(16) + expect(snapX(-7.9)).toBe(0) + expect(Object.is(snapX(-7.9), -0)).toBe(false) + expect(snapX(1240)).toBe(1248) + }) + + it('snaps the PORT ROW, so a stored y on the grid is 16k − 28', () => { + for (const y of [-40, -1, 0, 3.5, 4, 11.9, 12, 100, 1530]) { + const s = snapPortY(y) + expect(Math.abs((s + PORT_ROW) % GRID)).toBe(0) + expect(Math.abs(s - y)).toBeLessThanOrEqual(GRID / 2) + } + expect(snapPortY(4)).toBe(4) // 4 + 28 = 32 + expect(snapNodePosition({ x: 41, y: 40 })).toEqual({ x: 48, y: 36 }) + }) + + it('tells a node position on the grid from one off it', () => { + expect(nodeOnGrid({ x: 32, y: 4 })).toBe(true) + expect(nodeOnGrid({ x: 32, y: 0 })).toBe(false) + expect(nodeOnGrid({ x: 33, y: 4 })).toBe(false) + }) + + it('snaps a point to the grid and grows a rect outward to it', () => { + expect(snapPoint({ x: 9, y: 23 })).toEqual({ x: 16, y: 16 }) + expect(snapRectOutward({ x: 5, y: 17, w: 30, h: 30 })).toEqual({ x: 0, y: 16, w: 48, h: 32 }) + expect(snapRectOutward({ x: 16, y: 32, w: 64, h: 48 })).toEqual({ x: 16, y: 32, w: 64, h: 48 }) + }) +}) diff --git a/src/model/layout/grid.ts b/src/model/layout/grid.ts new file mode 100644 index 00000000..7c345b55 --- /dev/null +++ b/src/model/layout/grid.ts @@ -0,0 +1,50 @@ +// docs/diagram-layout.md §DL1 (issue #344) — the layout grid and the port row. +// +// The canvas grid is 16 px in flow coordinates (the dots the canvas already +// draws). A node's resource ports (`in` / `out`) sit on a fixed row 28 px below +// its top, whatever its height, text or language, so a stored position alone +// keeps a connection straight in every language; a taller node grows downward. +// "On the grid" means the node's left edge and its port row are multiples of +// 16, so the stored `y` of a node on the grid is `16k − 28`. + +/** the grid step, flow px */ +export const GRID = 16 + +/** the resource port row, flow px below a node's top (half the 56 px floor, so + * a one-line node draws its ports where it always did) */ +export const PORT_ROW = 28 + +const round = (v: number): number => { + const r = Math.round(v / GRID) * GRID + return r === 0 ? 0 : r // never -0 +} + +/** the nearest grid x for a node's left edge */ +export const snapX = (x: number): number => round(x) + +/** the stored `y` whose port row (`y + PORT_ROW`) is the nearest grid line */ +export const snapPortY = (y: number): number => round(y + PORT_ROW) - PORT_ROW + +/** a node position moved to the grid: left edge and port row snapped */ +export const snapNodePosition = (p: { x: number; y: number }): { x: number; y: number } => ({ + x: snapX(p.x), + y: snapPortY(p.y), +}) + +/** the nearest grid point (a frame's corner, a waypoint) */ +export const snapPoint = (p: { x: number; y: number }): { x: number; y: number } => ({ x: round(p.x), y: round(p.y) }) + +/** a rect grown outward to the grid (left / top down, right / bottom up), so + * everything it contained stays inside (a frame) */ +export const snapRectOutward = (r: { x: number; y: number; w: number; h: number }): { x: number; y: number; w: number; h: number } => { + const x0 = Math.floor(r.x / GRID) * GRID + const y0 = Math.floor(r.y / GRID) * GRID + const x1 = Math.ceil((r.x + r.w) / GRID) * GRID + const y1 = Math.ceil((r.y + r.h) / GRID) * GRID + return { x: x0 === 0 ? 0 : x0, y: y0 === 0 ? 0 : y0, w: x1 - x0, h: y1 - y0 } +} + +const on = (v: number): boolean => Math.abs(v / GRID - Math.round(v / GRID)) < 1e-9 + +/** whether a node position is on the grid (left edge and port row) */ +export const nodeOnGrid = (p: { x: number; y: number }): boolean => on(p.x) && on(p.y + PORT_ROW) diff --git a/src/model/layout/place.ts b/src/model/layout/place.ts new file mode 100644 index 00000000..1ab5a6b8 --- /dev/null +++ b/src/model/layout/place.ts @@ -0,0 +1,41 @@ +// docs/diagram-layout.md §DL2 / §DL3 (issue #344) — the nearest free grid spot. +// +// Grid positions are tried in rings of growing distance from the wanted one, +// each ring in a fixed order (distance, then y, then x), so the answer is +// deterministic. `fits(p)` decides whether a candidate is free (no overlap, the +// caller's order rules); the wanted position itself is tried first. + +import { GRID } from './grid' + +export type Box = { x: number; y: number; w: number; h: number } + +export const boxesOverlap = (a: Box, b: Box, clearance = 0): boolean => + a.x < b.x + b.w + clearance && b.x < a.x + a.w + clearance && a.y < b.y + b.h + clearance && b.y < a.y + a.h + clearance + +const ringCache = new Map() +function ring(r: number): [number, number][] { + let cells = ringCache.get(r) + if (!cells) { + cells = [] + for (let i = -r; i <= r; i++) for (let j = -r; j <= r; j++) if (Math.max(Math.abs(i), Math.abs(j)) === r) cells.push([i, j]) + cells.sort((a, b) => Math.hypot(a[0], a[1]) - Math.hypot(b[0], b[1]) || a[1] - b[1] || a[0] - b[0]) + ringCache.set(r, cells) + } + return cells +} + +/** the nearest grid offset of `want` for which `fits` holds, within `maxRing` + * grid steps; null when none does */ +export function nearestFree( + want: { x: number; y: number }, + fits: (p: { x: number; y: number }) => boolean, + maxRing = 64, +): { x: number; y: number } | null { + for (let r = 0; r <= maxRing; r++) { + for (const [i, j] of ring(r)) { + const p = { x: want.x + i * GRID, y: want.y + j * GRID } + if (fits(p)) return p + } + } + return null +} diff --git a/src/model/layout/replace.test.ts b/src/model/layout/replace.test.ts new file mode 100644 index 00000000..de1499fa --- /dev/null +++ b/src/model/layout/replace.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import type { LoopEdge, LoopNode } from '../types' +import { nodeOnGrid, PORT_ROW } from './grid' +import { replaceOnGrid, type FrameLike } from './replace' + +// issue #344 §DL2 — re-placing a graph on the grid: deterministic, idempotent, +// order-keeping, never creating an overlap or a frame cut + +const N = (id: string, x: number, y: number): LoopNode => ({ id, type: 'pool', position: { x, y }, data: { kind: 'pool', label: id } }) as unknown as LoopNode +const E = (id: string, source: string, target: string, data: Record = {}): LoopEdge => + ({ id, source, target, sourceHandle: 'out', targetHandle: 'in', type: 'loop', data: { kind: 'resource', flow: '1', ...data } }) as unknown as LoopEdge +const size = () => ({ w: 120, h: 58 }) +const apply = (nodes: LoopNode[], r: ReturnType): LoopNode[] => nodes.map((n) => ({ ...n, position: r.positions[n.id] })) +const overlap = (a: { x: number; y: number }, b: { x: number; y: number }) => a.x < b.x + 120 && b.x < a.x + 120 && a.y < b.y + 58 && b.y < a.y + 58 + +describe('replaceOnGrid', () => { + const nodes = [N('a', 3, 7), N('b', 205, 11), N('c', 410, 260.4), N('d', 7, 140), N('e', 129, 145)] + const edges = [E('e1', 'a', 'b'), E('e2', 'b', 'c'), E('e3', 'd', 'e')] + + it('puts every node on the grid (left edge and port row)', () => { + const r = replaceOnGrid(nodes, edges, [], size) + for (const p of Object.values(r.positions)) expect(nodeOnGrid(p)).toBe(true) + }) + + it('is deterministic: the same input gives byte-identical output, in any input order', () => { + const a = JSON.stringify(replaceOnGrid(nodes, edges, [], size)) + const b = JSON.stringify(replaceOnGrid([...nodes].reverse(), [...edges].reverse(), [], size)) + expect(b).toBe(a) + }) + + it('is idempotent: re-placing the result moves nothing', () => { + const once = apply(nodes, replaceOnGrid(nodes, edges, [], size)) + const twice = replaceOnGrid(once, edges, [], size) + for (const n of once) expect(twice.positions[n.id]).toEqual(n.position) + }) + + it('merges the rows of two connected nodes less than 16 px apart', () => { + // port rows 28 → 32 and 41 → 48: 13 px apart, connected ⇒ one row + const r = replaceOnGrid([N('p', 0, 0), N('q', 200, 13)], [E('x', 'p', 'q')], [], size) + expect(r.positions.q.y + PORT_ROW).toBe(r.positions.p.y + PORT_ROW) + }) + + it('keeps two UNRELATED neighbours 8–16 px apart on different rows', () => { + // port rows 28 and 37 both round to 32; unrelated and 30 px apart ⇒ the + // lower one takes the next row + const r = replaceOnGrid([N('p', 0, 0), N('q', 150, 9)], [], [], size) + expect(r.positions.q.y - r.positions.p.y).toBe(16) + }) + + it('leaves those two on one row when a flow connects them', () => { + const r = replaceOnGrid([N('p', 0, 0), N('q', 150, 9)], [E('x', 'p', 'q')], [], size) + expect(r.positions.q.y).toBe(r.positions.p.y) + }) + + it('never creates an overlap, and keeps the left / right order', () => { + const tight = [N('a', 0, 0), N('b', 124, 0), N('c', 246, 2)] + const r = replaceOnGrid(tight, [], [], size) + const ps = tight.map((n) => r.positions[n.id]) + for (let i = 0; i < ps.length; i++) for (let j = i + 1; j < ps.length; j++) expect(overlap(ps[i], ps[j])).toBe(false) + expect(ps[0].x).toBeLessThan(ps[1].x) + expect(ps[1].x).toBeLessThan(ps[2].x) + }) + + it('grows a frame outward to the grid and keeps every node it held inside', () => { + const f: FrameLike = { id: 'f', rect: { x: -9, y: -21, w: 330, h: 100 } } + const r = replaceOnGrid([N('a', 3, 7), N('b', 195, 11)], [], [f], size) + const fr = r.frames.f + expect(Math.abs(fr.x % 16)).toBe(0) + expect(Math.abs(fr.y % 16)).toBe(0) + for (const id of ['a', 'b']) { + const p = r.positions[id] + expect(p.x >= fr.x && p.y >= fr.y && p.x + 120 <= fr.x + fr.w && p.y + 58 <= fr.y + fr.h).toBe(true) + } + }) + + it('holds only the nodes fully inside by default: one reaching past the edge is not taken in', () => { + // `b` (120 wide) ends 30 px past the frame's right edge + const f: FrameLike = { id: 'f', rect: { x: 0, y: 0, w: 290, h: 90 } } + const r = replaceOnGrid([N('a', 16, 16), N('b', 200, 16)], [], [f], size) + expect(r.frames.f.x + r.frames.f.w).toBeLessThan(r.positions.b.x + 120) + }) + + it("a caller's held set decides the members: the frame grows to hold each of them whole, and only them", () => { + // the migration's case: `b` was inside as drawn when saved, its box is wider now + const f: FrameLike = { id: 'f', rect: { x: 0, y: 0, w: 290, h: 90 } } + const r = replaceOnGrid([N('a', 16, 16), N('b', 200, 16), N('c', 16, 200)], [], [f], size, { held: { f: ['a', 'b'] } }) + const fr = r.frames.f + for (const id of ['a', 'b']) { + const p = r.positions[id] + expect(p.x >= fr.x && p.y >= fr.y && p.x + 120 <= fr.x + fr.w && p.y + 58 <= fr.y + fr.h, id).toBe(true) + } + expect(r.positions.c.y).toBeGreaterThanOrEqual(fr.y + fr.h) + }) + + it('snaps waypoints and drops one that lands inside a node', () => { + const e = E('w', 'a', 'b', { route: 'orthogonal', waypoints: [{ x: 101, y: 203 }, { x: 260, y: 30 }] }) + const r = replaceOnGrid([N('a', 0, 0), N('b', 220, 0)], [e], [], size) + expect(r.waypoints.w).toEqual([{ x: 96, y: 208 }]) + }) +}) diff --git a/src/model/layout/replace.ts b/src/model/layout/replace.ts new file mode 100644 index 00000000..ee4b5cca --- /dev/null +++ b/src/model/layout/replace.ts @@ -0,0 +1,196 @@ +// docs/diagram-layout.md §DL2 (issue #344) — re-placing a graph on the grid. +// +// Pure and deterministic: the result depends only on the document (positions, +// connections, frames, waypoints) and the canonical size of each node, which +// the caller passes in (`canonicalBox`, never a DOM or font measurement), so it +// is the same in every language, browser and platform. Ties break by node id. +// Running it on its own result changes nothing (idempotent). +// +// 1. every node: left edge and port row to the nearest grid line +// 2. rows: two RELATED nodes (a flow connects them, or they share a direct +// flow neighbour) whose port rows were less than 16 px apart share one +// row; two UNRELATED neighbours whose rows were 8–16 px apart never do +// (the lower one takes the next grid row) +// 3. overlaps: space is INSERTED — an overlapping pair is separated along +// the axis its centres are further apart on, by moving every node on the +// far side (by original centre) by the same grid step, until nothing +// overlaps; no left/right or above/below order between any two nodes is +// ever reversed (a row move in step 2 is made only when it reverses none) +// 4. frames: snapped outward, and grown to keep every node they held (fully +// inside before the move, or the caller's held set) +// 5. waypoints: snapped; one that lands inside a node is dropped + +import type { LoopEdge, LoopNode } from '../types' +import { GRID, PORT_ROW, snapNodePosition, snapPoint, snapRectOutward } from './grid' +import { boxesOverlap, type Box } from './place' + +export type FrameLike = { id: string; rect: { x: number; y: number; w: number; h: number } } +export type SizeOf = (node: LoopNode) => { w: number; h: number } + +export type ReplaceResult = { + positions: Record + frames: Record + /** edge id → its waypoints after the move ([] = every waypoint dropped) */ + waypoints: Record +} + +const byId = (a: { id: string }, b: { id: string }): number => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0) +const isResource = (e: LoopEdge): boolean => !(e.sourceHandle ?? '').startsWith('state') && !(e.targetHandle ?? '').startsWith('state') + +export type ReplaceOptions = { + /** frame id → the ids of the nodes it holds, when the caller decided it */ + held?: Readonly> + /** a clearance every node keeps from the next (x to its right, y below it): + * the placement treats each box as this much larger; frames and waypoints + * still use the real box */ + gap?: { x: number; y: number } +} + +export function replaceOnGrid(nodes: readonly LoopNode[], edges: readonly LoopEdge[], frames: readonly FrameLike[], sizeOf: SizeOf, opts: ReplaceOptions = {}): ReplaceResult { + const sorted = [...nodes].sort(byId) + const real = new Map(sorted.map((n) => [n.id, sizeOf(n)])) + const gx = opts.gap?.x ?? 0 + const gy = opts.gap?.y ?? 0 + const size = new Map(sorted.map((n) => [n.id, { w: real.get(n.id)!.w + gx, h: real.get(n.id)!.h + gy }])) + const orig = new Map(sorted.map((n) => [n.id, { x: n.position.x, y: n.position.y }])) + const pos = new Map(sorted.map((n) => [n.id, snapNodePosition(n.position)])) + const box = (id: string, p = pos.get(id)!): Box => ({ x: p.x, y: p.y, w: size.get(id)!.w, h: size.get(id)!.h }) + /** the node's own box, without the clearance */ + const realBox = (id: string, p = pos.get(id)!): Box => ({ x: p.x, y: p.y, w: real.get(id)!.w, h: real.get(id)!.h }) + const row = (p: { y: number }): number => p.y + PORT_ROW + + // 2. related pairs: a resource connection between them, or a shared direct + // flow neighbour (the same source, or the same target) + const related = new Set() + const key = (a: string, b: string): string => (a < b ? `${a}|${b}` : `${b}|${a}`) + const flows = edges.filter(isResource).filter((e) => e.source !== e.target && pos.has(e.source) && pos.has(e.target)) + for (const e of flows) related.add(key(e.source, e.target)) + const outs = new Map(), ins = new Map() + for (const e of flows) { + ;(outs.get(e.source) ?? outs.set(e.source, []).get(e.source)!).push(e.target) + ;(ins.get(e.target) ?? ins.set(e.target, []).get(e.target)!).push(e.source) + } + for (const group of [...outs.values(), ...ins.values()]) { + const g = [...new Set(group)].sort() + for (let i = 0; i < g.length; i++) for (let j = i + 1; j < g.length; j++) related.add(key(g[i], g[j])) + } + const near = (a: string, b: string): boolean => { + const A = box(a), B = box(b) + return A.x < B.x + B.w + 4 * GRID && B.x < A.x + A.w + 4 * GRID // within four grid steps sideways + } + const ids = sorted.map((n) => n.id) + const centre = (id: string, p: { x: number; y: number }) => ({ x: p.x + size.get(id)!.w / 2, y: p.y + size.get(id)!.h / 2 }) + const oc = new Map(ids.map((id) => [id, centre(id, orig.get(id)!)])) + /** a row move of `id` to `target` reverses no above/below order (centres + * >= 8 px apart before) with any other node */ + const keepsRows = (id: string, target: { x: number; y: number }): boolean => { + const c = centre(id, target) + return ids.every((j) => { + if (j === id) return true + const dy = oc.get(id)!.y - oc.get(j)!.y + if (Math.abs(dy) < 8) return true + const ny = c.y - centre(j, pos.get(j)!).y + return ny === 0 || Math.sign(ny) === Math.sign(dy) + }) + } + const free = (id: string, target: { x: number; y: number }): boolean => !ids.some((j) => j !== id && boxesOverlap(box(id, target), box(j))) + // related pairs first (sorted), so a merge is decided before a split + for (const pair of [...related].sort()) { + const [a, b] = pair.split('|') + const d0 = Math.abs(row(orig.get(a)!) - row(orig.get(b)!)) + if (d0 < 0.5 || d0 >= GRID) continue + const ra = row(pos.get(a)!), rb = row(pos.get(b)!) + if (ra === rb) continue + // the lower-sorted id keeps its row; the other joins it if that is free + // and reverses no other order + const target = { x: pos.get(b)!.x, y: ra - PORT_ROW } + if (free(b, target) && keepsRows(b, target)) pos.set(b, target) + } + for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) { + const a = ids[i], b = ids[j] + if (related.has(key(a, b)) || !near(a, b)) continue + const d0 = row(orig.get(b)!) - row(orig.get(a)!) + if (Math.abs(d0) < GRID / 2 || Math.abs(d0) >= GRID) continue + if (row(pos.get(a)!) !== row(pos.get(b)!)) continue + const lower = d0 > 0 ? b : a + const target = { x: pos.get(lower)!.x, y: pos.get(lower)!.y + GRID } + if (free(lower, target) && keepsRows(lower, target)) pos.set(lower, target) + } + + // 3. overlaps, by INSERTING SPACE: the first overlapping pair (in a fixed + // order) is separated along the axis its centres are further apart on, + // by moving EVERY node on the far side of it (by original centre) by the + // same grid-rounded distance. A set defined by the original centres only + // ever moves away from the rest, so no left/right or above/below order + // between any two nodes is reversed. Repeats until nothing overlaps. + const CELL = 128 + const ceilGrid = (v: number): number => Math.ceil(v / GRID) * GRID + const firstOverlap = (): [string, string] | null => { + const cells = new Map() + for (const id of ids) { + const b = box(id) + for (let cx = Math.floor(b.x / CELL); cx <= Math.floor((b.x + b.w) / CELL); cx++) + for (let cy = Math.floor(b.y / CELL); cy <= Math.floor((b.y + b.h) / CELL); cy++) (cells.get(`${cx},${cy}`) ?? cells.set(`${cx},${cy}`, []).get(`${cx},${cy}`)!).push(id) + } + let best: [string, string] | null = null + for (const list of cells.values()) for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) { + const [p, q] = list[i] < list[j] ? [list[i], list[j]] : [list[j], list[i]] + if (!boxesOverlap(box(p), box(q))) continue + if (!best || p < best[0] || (p === best[0] && q < best[1])) best = [p, q] + } + return best + } + for (let iter = 0, pair = firstOverlap(); pair && iter < ids.length * 8; iter++, pair = firstOverlap()) { + const [p, q] = pair + const dx = oc.get(q)!.x - oc.get(p)!.x + const dy = oc.get(q)!.y - oc.get(p)!.y + const horizontal = Math.abs(dx) >= Math.abs(dy) + // `near` is the one staying, `far` the one the space opens in front of + const ahead = horizontal ? dx > 0 || (dx === 0 && q > p) : dy > 0 || (dy === 0 && q > p) + const [stay, far] = ahead ? [p, q] : [q, p] + const t = horizontal ? oc.get(far)!.x : oc.get(far)!.y + const inFar = (id: string): boolean => { + const v = horizontal ? oc.get(id)!.x : oc.get(id)!.y + return v > t || (v === t && id >= far) + } + const S = box(stay), F = box(far) + const d = ceilGrid(horizontal ? S.x + S.w - F.x : S.y + S.h - F.y) + if (d <= 0) break // cannot happen for an overlap; never loop on it + for (const id of ids) if (inFar(id)) { + const cur = pos.get(id)! + pos.set(id, horizontal ? { x: cur.x + d, y: cur.y } : { x: cur.x, y: cur.y + d }) + } + } + + // 4. frames: outward to the grid, grown to keep the nodes they held — fully + // inside before the move, or the caller's own held set (`opts.held`: the + // layout migration decides membership by the boxes the document was + // drawn with, before the move changes any node's size) + const outFrames: ReplaceResult['frames'] = {} + for (const f of [...frames].sort(byId)) { + const given = opts.held?.[f.id] + const held = given ? ids.filter((id) => given.includes(id)) : ids.filter((id) => { + const b = realBox(id, orig.get(id)!) + return b.x >= f.rect.x && b.y >= f.rect.y && b.x + b.w <= f.rect.x + f.rect.w && b.y + b.h <= f.rect.y + f.rect.h + }) + let x0 = f.rect.x, y0 = f.rect.y, x1 = f.rect.x + f.rect.w, y1 = f.rect.y + f.rect.h + for (const id of held) { + const b = realBox(id) + x0 = Math.min(x0, b.x); y0 = Math.min(y0, b.y); x1 = Math.max(x1, b.x + b.w); y1 = Math.max(y1, b.y + b.h) + } + outFrames[f.id] = snapRectOutward({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 }) + } + + // 5. waypoints + const outWps: ReplaceResult['waypoints'] = {} + for (const e of [...edges].sort(byId)) { + const wps = (e.data as { waypoints?: { x: number; y: number }[] } | undefined)?.waypoints + if (!Array.isArray(wps) || wps.length === 0) continue + outWps[e.id] = wps.map(snapPoint).filter((p) => !ids.some((id) => { + const b = realBox(id) + return p.x > b.x && p.x < b.x + b.w && p.y > b.y && p.y < b.y + b.h + })) + } + + return { positions: Object.fromEntries(ids.map((id) => [id, pos.get(id)!])), frames: outFrames, waypoints: outWps } +} diff --git a/src/model/layout/smartGuides.test.ts b/src/model/layout/smartGuides.test.ts new file mode 100644 index 00000000..616590a5 --- /dev/null +++ b/src/model/layout/smartGuides.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { BoxIndex, GUIDE_REACH, settledGuides, smartSnap, type GBox } from './smartGuides' +import { PORT_ROW } from './grid' + +// docs/diagram-layout.md §DL3.6 — smart guides: the correction and the lines, +// by priority port row → centre / edges → 16 px grid; independent of where the +// node was grabbed; Alt moves nothing; only nearby nodes are looked at. + +const box = (id: string, x: number, y: number, w = 130, h = 56, port = true): GBox => ({ id, x, y, w, h, port }) + +describe('smartSnap', () => { + it('lines the port row up with a nearby node’s before the grid (priority 1)', () => { + // the other node's port row is at 100 + 28 = 128 — off the 16 px grid + const idx = new BoxIndex([box('o', 400, 100)]) + const r = smartSnap([box('m', 47, 103)], 'm', idx, 6, false) + expect(r.dy).toBe(-3) // 103 + 28 → 128 + const port = r.guides.find((g) => g.axis === 'y' && g.kind === 'port')! + expect(port.at).toBe(128) + expect(port.strong).toBe(true) + expect(port.to).toBeGreaterThanOrEqual(530) // reaches the other node + }) + + it('aligns centres or edges before the grid, and falls back to the grid beyond the tolerance', () => { + const idx = new BoxIndex([box('o', 403, 600)]) + // x: the other node's left edge 403 is 4 px away → aligned (not the grid's 400) + const a = smartSnap([box('m', 399, 200)], 'm', idx, 6, false) + expect(a.dx).toBe(4) + expect(a.guides.some((g) => g.axis === 'x' && g.kind === 'edge' && g.at === 403 && g.strong)).toBe(true) + // 12 px away: the grid + const b = smartSnap([box('m', 391, 200)], 'm', idx, 6, false) + expect(b.dx).toBe(384 - 391) // the nearest grid line + expect(b.guides.filter((g) => g.axis === 'x' && g.strong)).toEqual([]) + }) + + it('the port row beats a closer centre / edge match', () => { + // centre-y of `o` is 128, its port row 128 too here; make them differ: + // o at y 90, h 120 → centre 150, port 118. m at y 92 (port 120, centre 120 for h 56) + const idx = new BoxIndex([box('o', 600, 90, 130, 120)]) + const r = smartSnap([box('m', 100, 92)], 'm', idx, 6, false) + expect(r.dy).toBe(-2) // port 120 → 118 + }) + + it('where the node was grabbed never changes where it lands: the result depends on its position only', () => { + const idx = new BoxIndex([box('o', 400, 100)]) + const p = { x: 61, y: 97 } + const a = smartSnap([box('m', p.x, p.y)], 'm', idx, 6, false) + const b = smartSnap([box('m', p.x, p.y)], 'm', idx, 6, false) + expect(a).toEqual(b) + }) + + it('a selection is referenced by its bounding box (centre and edges) and the grabbed node’s port row', () => { + const idx = new BoxIndex([box('o', 1000, 0, 100, 400)]) // centre-y 200 + const sel = [box('a', 0, 120), box('b', 200, 220)] // bbox y 120 … 276, centre-y 198 + const r = smartSnap(sel, 'a', idx, 6, false) + expect(r.dy).toBe(2) + expect(r.guides.some((g) => g.axis === 'y' && g.kind === 'center' && g.at === 200)).toBe(true) + }) + + it('Alt finds the guides but moves nothing (faint)', () => { + const idx = new BoxIndex([box('o', 400, 100)]) + const r = smartSnap([box('m', 47, 103)], 'm', idx, 6, true) + expect(r.dx).toBe(0) + expect(r.dy).toBe(0) + expect(r.guides.every((g) => !g.strong)).toBe(true) + }) + + it('a node far beyond the reach is not looked at', () => { + const idx = new BoxIndex([box('far', 400 + GUIDE_REACH * 3, 100)]) + const r = smartSnap([box('m', 47, 103)], 'm', idx, 6, false) + expect(r.guides.filter((g) => g.strong)).toEqual([]) + }) + + it('a node without a port row (Parameter / Register) is never a port-row target', () => { + const idx = new BoxIndex([box('p', 400, 100, 130, 56, false)]) + const r = smartSnap([box('m', 47, 100 + 1)], 'm', idx, 6, false) + expect(r.guides.some((g) => g.kind === 'port')).toBe(false) + }) +}) + +describe('settledGuides (keyboard)', () => { + it('shows the row and column the node sits on and the nodes exactly aligned, moving nothing', () => { + const idx = new BoxIndex([box('o', 400, 100)]) + const g = settledGuides([box('m', 48, 100)], 'm', idx) + expect(g.some((x) => x.axis === 'y' && x.kind === 'port' && x.at === 100 + PORT_ROW && x.strong)).toBe(true) + expect(g.some((x) => x.kind === 'place')).toBe(true) + }) +}) + +describe('BoxIndex', () => { + it('returns only the boxes meeting the query, each once, in id order', () => { + const boxes = Array.from({ length: 400 }, (_, i) => box(`n${String(i).padStart(3, '0')}`, (i % 20) * 300, Math.floor(i / 20) * 200, 200, 120)) + const idx = new BoxIndex(boxes) + const got = idx.query(0, 0, 650, 450) + const want = boxes.filter((b) => b.x <= 650 && b.x + b.w >= 0 && b.y <= 450 && b.y + b.h >= 0).map((b) => b.id).sort() + expect(got.map((b) => b.id)).toEqual(want) + expect(got.length).toBeLessThan(boxes.length / 10) + }) +}) diff --git a/src/model/layout/smartGuides.ts b/src/model/layout/smartGuides.ts new file mode 100644 index 00000000..fbb95068 --- /dev/null +++ b/src/model/layout/smartGuides.ts @@ -0,0 +1,176 @@ +// docs/diagram-layout.md §DL3.6 (issue #344, Lumi 2026-10-10) — smart guides: +// where a dragged node (or selection) will land, and what it lines up with. +// +// Pure and deterministic. The reference is the dragged geometry, never the +// pointer: the grabbed node's position (React Flow keeps the grab offset) and +// its port row, and for a selection its bounding box — so where a node was +// grabbed can never change where it lands. Per axis, the first of +// +// 1. the port row of another node (y only) +// 2. another node's centre, or its left / right (top / bottom) edge +// 3. the 16 px grid +// +// within `tol` (screen px over the zoom) wins; ties go to the smaller +// distance, then the node id. Alt = a free move: the guides are found the same +// way and drawn faint, and nothing is moved. +// +// Only the nodes near the dragged geometry are looked at (`BoxIndex`, grid +// buckets built once per drag), never every node per frame. Display and input +// only: no document field, digest or simulation result depends on it. + +import { PORT_ROW, snapPortY, snapX } from './grid' + +export type GBox = { + id: string + x: number + y: number + w: number + h: number + /** the node has a resource port row (`PORT_ROW` below its top) */ + port: boolean +} + +export type GuideKind = 'place' | 'port' | 'center' | 'edge' +export type Guide = { + /** a vertical line at x = `at` (axis 'x') or a horizontal one at y = `at` */ + axis: 'x' | 'y' + at: number + /** the extent along the line */ + from: number + to: number + kind: GuideKind + /** another node lies exactly on it (drawn darker); false: the placement + * line through the drop position, or a faint free-move hint */ + strong: boolean +} +export type SmartSnap = { dx: number; dy: number; guides: Guide[] } + +/** the alignment pull, in SCREEN px (divided by the zoom for flow px) */ +export const ALIGN_TOL_PX = 6 +/** how far around the dragged geometry other nodes are considered, flow px */ +export const GUIDE_REACH = 1200 +const CELL = 256 + +/** grid buckets over the nodes that do not move during a gesture */ +export class BoxIndex { + private cells = new Map() + constructor(boxes: readonly GBox[]) { + for (const b of boxes) { + for (let cx = Math.floor(b.x / CELL); cx <= Math.floor((b.x + b.w) / CELL); cx++) + for (let cy = Math.floor(b.y / CELL); cy <= Math.floor((b.y + b.h) / CELL); cy++) { + const k = `${cx},${cy}` + ;(this.cells.get(k) ?? this.cells.set(k, []).get(k)!).push(b) + } + } + } + /** every box meeting the rectangle, each once, in id order */ + query(x0: number, y0: number, x1: number, y1: number): GBox[] { + const seen = new Map() + for (let cx = Math.floor(x0 / CELL); cx <= Math.floor(x1 / CELL); cx++) + for (let cy = Math.floor(y0 / CELL); cy <= Math.floor(y1 / CELL); cy++) + for (const b of this.cells.get(`${cx},${cy}`) ?? []) if (b.x <= x1 && b.x + b.w >= x0 && b.y <= y1 && b.y + b.h >= y0) seen.set(b.id, b) + return [...seen.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + } +} + +type Feature = { kind: Exclude; v: number; tag: string } +type Rect = { x: number; y: number; w: number; h: number } + +const bboxOf = (bs: readonly GBox[]): Rect => { + const x0 = Math.min(...bs.map((b) => b.x)) + const y0 = Math.min(...bs.map((b) => b.y)) + const x1 = Math.max(...bs.map((b) => b.x + b.w)) + const y1 = Math.max(...bs.map((b) => b.y + b.h)) + return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } +} + +const yFeatures = (r: Rect, portY: number | null): Feature[] => [ + ...(portY != null ? [{ kind: 'port' as const, v: portY, tag: 'port' }] : []), + { kind: 'center', v: r.y + r.h / 2, tag: 'cy' }, + { kind: 'edge', v: r.y, tag: 'top' }, + { kind: 'edge', v: r.y + r.h, tag: 'bottom' }, +] +const xFeatures = (r: Rect): Feature[] => [ + { kind: 'center', v: r.x + r.w / 2, tag: 'cx' }, + { kind: 'edge', v: r.x, tag: 'left' }, + { kind: 'edge', v: r.x + r.w, tag: 'right' }, +] +const PRI: Record = { port: 0, center: 1, edge: 1 } + +type Match = { pri: number; d: number; id: string; target: number; mine: Feature } + +/** the best alignment on one axis within `tol`, or null */ +function bestMatch(mine: Feature[], others: GBox[], axis: 'x' | 'y', tol: number): Match | null { + let best: Match | null = null + for (const o of others) { + const theirs = axis === 'y' ? yFeatures(o, o.port ? o.y + PORT_ROW : null) : xFeatures(o) + for (const m of mine) { + for (const t of theirs) { + if (t.tag !== m.tag) continue // port↔port, centre↔centre, top↔top … + const d = t.v - m.v + if (Math.abs(d) > tol) continue + const cand: Match = { pri: PRI[m.kind], d, id: o.id, target: t.v, mine: m } + if (!best || cand.pri < best.pri || (cand.pri === best.pri && (Math.abs(d) < Math.abs(best.d) || (Math.abs(d) === Math.abs(best.d) && o.id < best.id)))) best = cand + } + } + } + return best +} + +/** the guides of one axis after the move: the placement line through the + * dragged geometry, and one line per feature of it (port row, centre, each + * edge) that now lies exactly on the same feature of other nodes, out to the + * farthest of them */ +function axisGuides(axis: 'x' | 'y', r: Rect, place: number, mine: Feature[], others: GBox[], strong: boolean): Guide[] { + const along0 = axis === 'y' ? r.x : r.y + const along1 = axis === 'y' ? r.x + r.w : r.y + r.h + const out: Guide[] = [{ axis, at: place, from: along0 - GUIDE_REACH, to: along1 + GUIDE_REACH, kind: 'place', strong: false }] + for (const m of mine) { + let lo = along0 + let hi = along1 + let any = false + for (const o of others) { + const theirs = axis === 'y' ? yFeatures(o, o.port ? o.y + PORT_ROW : null) : xFeatures(o) + if (!theirs.some((t) => t.tag === m.tag && Math.abs(t.v - m.v) < 0.5)) continue + any = true + lo = Math.min(lo, axis === 'y' ? o.x : o.y) + hi = Math.max(hi, axis === 'y' ? o.x + o.w : o.y + o.h) + } + if (any) out.push({ axis, at: m.v, from: lo, to: hi, kind: m.kind, strong }) + } + return out +} + +/** §DL3.6 — the correction for a pointer drag and its guides. `moving` are the + * dragged nodes at their proposed positions, `anchor` the grabbed one; `tol` + * is in flow px; `free` (Alt) finds the guides but moves nothing. */ +export function smartSnap(moving: readonly GBox[], anchorId: string | null, index: BoxIndex, tol: number, free: boolean): SmartSnap { + if (moving.length === 0) return { dx: 0, dy: 0, guides: [] } + const anchor = moving.find((b) => b.id === anchorId) ?? moving[0] + const r = moving.length === 1 ? anchor : bboxOf(moving) + const portY = anchor.port ? anchor.y + PORT_ROW : null + const ids = new Set(moving.map((b) => b.id)) + const others = index.query(r.x - GUIDE_REACH, r.y - GUIDE_REACH, r.x + r.w + GUIDE_REACH, r.y + r.h + GUIDE_REACH).filter((b) => !ids.has(b.id)) + const my = bestMatch(yFeatures(r, portY), others, 'y', tol) + const mx = bestMatch(xFeatures(r), others, 'x', tol) + const dy = free ? 0 : my ? my.d : snapPortY(anchor.y) - anchor.y + const dx = free ? 0 : mx ? mx.d : snapX(anchor.x) - anchor.x + const moved: Rect = { x: r.x + dx, y: r.y + dy, w: r.w, h: r.h } + const yPlace = (portY ?? r.y) + dy + const xPlace = (mx ? mx.mine.v : anchor.x) + dx + // free: the guides of the alignment that WOULD apply (drawn faint) + const shown: Rect = free ? { x: r.x + (mx ? mx.d : 0), y: r.y + (my ? my.d : 0), w: r.w, h: r.h } : moved + const shownPort = portY != null ? portY + (free ? (my ? my.d : 0) : dy) : null + const guides = [ + ...axisGuides('y', shown, yPlace, yFeatures(shown, shownPort), others, !free), + ...axisGuides('x', shown, xPlace, xFeatures(shown), others, !free), + ] + return { dx, dy, guides } +} + +/** §DL3.6 — after a keyboard move: the row and column the node now sits on, + * and every other node exactly aligned with it. Nothing is moved. */ +export function settledGuides(moving: readonly GBox[], anchorId: string | null, index: BoxIndex): Guide[] { + return smartSnap(moving, anchorId, index, 0.5, true).guides.map((g) => (g.kind === 'place' ? g : { ...g, strong: true })) +} + diff --git a/src/model/layout/templateBoxes.json b/src/model/layout/templateBoxes.json new file mode 100644 index 00000000..944490d0 --- /dev/null +++ b/src/model/layout/templateBoxes.json @@ -0,0 +1,208 @@ +{ + "equilibrium": { + "tpl-consume": [158, 56], + "tpl-conv": [172, 56], + "tpl-gate": [201, 62], + "tpl-prod": [192, 74], + "tpl-spill": [158, 56], + "tpl-src": [187, 72], + "tpl-vault": [185, 90] + }, + "deadlock": { + "tpl-conv": [172, 56], + "tpl-gate": [201, 62], + "tpl-prod": [192, 74], + "tpl-spill": [158, 56], + "tpl-src": [187, 72], + "tpl-vault": [185, 90] + }, + "coffee-roastery": { + "cafe_retail": [187, 72], + "cafe_retail_demand_kg": [179, 118], + "daily_roast_kg": [179, 102], + "dessert_prep": [179, 102], + "dessert_prep_margin": [179, 118], + "dessert_prep_src": [190, 72], + "dessert_sales": [183, 72], + "dessert_stock": [183, 74], + "dessert_wrapup": [186, 72], + "green_delivery": [187, 72], + "green_stock": [196, 90], + "green_wholesale": [183, 72], + "green_wholesale_kg": [179, 102], + "online_orders": [179, 102], + "online_sales": [187, 72], + "planned_cost": [260, 102], + "projected_operating_margin": [246, 118], + "projected_revenue": [260, 102], + "roast_loss": [186, 72], + "roasted_bleed": [187, 88], + "roasted_stock": [194, 74], + "roasted_supply_margin": [260, 118], + "roasting": [201, 62] + }, + "gacha-banner-zones": { + "all_zones_done": [185, 56], + "ceiling_hits_pickup": [186, 74], + "ceiling_hits_standard": [186, 74], + "cmp1_hit_rate_free": [234, 118], + "cmp2_hit_rate_standard": [260, 102], + "cmp3_hit_rate_pickup": [249, 102], + "cmp4_pickup_rate_pickup": [260, 118], + "forced_ssr_standard": [201, 62], + "fund_free": [191, 56], + "fund_pickup": [191, 56], + "fund_standard": [191, 56], + "missed_pickup_pickup": [193, 74], + "pickup_count_pickup": [188, 58], + "pickup_hit_pickup": [189, 56], + "pity_pickup": [146, 58], + "pity_standard": [146, 58], + "pulls_made_free": [194, 58], + "pulls_made_pickup": [194, 58], + "pulls_made_standard": [194, 58], + "pulls_per_zone": [179, 102], + "r_count_free": [170, 58], + "r_count_pickup": [170, 58], + "r_count_standard": [170, 58], + "r_hit_free": [134, 56], + "r_hit_pickup": [134, 56], + "r_hit_standard": [134, 56], + "roll_forced_open_pickup": [201, 78], + "roll_forced_owed_pickup": [201, 78], + "roll_gate_free": [134, 56], + "roll_gate_standard": [134, 56], + "roll_normal_open_pickup": [201, 62], + "roll_normal_owed_pickup": [201, 62], + "sr_count_free": [178, 58], + "sr_count_pickup": [178, 58], + "sr_count_standard": [178, 58], + "sr_hit_free": [134, 56], + "sr_hit_pickup": [134, 56], + "sr_hit_standard": [134, 56], + "ssr_count_free": [174, 58], + "ssr_count_pickup": [188, 58], + "ssr_count_standard": [187, 58], + "ssr_hit_free": [134, 56], + "ssr_hit_standard": [134, 56], + "ssr_split_open_pickup": [201, 62], + "standard_count_pickup": [190, 74], + "standard_hit_pickup": [201, 62], + "termination_fuel": [197, 90], + "ticket_free": [118, 58], + "ticket_pickup": [118, 58], + "ticket_standard": [118, 58], + "zone1_free_w_r": [179, 102], + "zone1_free_w_sr": [179, 102], + "zone1_free_w_ssr": [179, 102], + "zone2_standard_hard_pity": [179, 102], + "zone2_standard_w_r": [179, 102], + "zone2_standard_w_sr": [179, 102], + "zone2_standard_w_ssr": [179, 102], + "zone3_pickup_hard_pity": [179, 102], + "zone3_pickup_w_pickup": [179, 102], + "zone3_pickup_w_r": [179, 102], + "zone3_pickup_w_sr": [179, 102], + "zone3_pickup_w_ssr": [179, 102], + "zone3_pickup_w_standard": [179, 102] + }, + "mmo-progression": { + "active_char": [186, 90], + "bucket_consumable": [196, 90], + "bucket_equip": [190, 90], + "bucket_rare": [166, 74], + "bucket_vendor": [184, 90], + "char_creation": [187, 72], + "clock": [167, 56], + "combat_fails": [195, 58], + "combat_wins": [191, 74], + "completion": [166, 74], + "completion_src": [187, 72], + "consumable_conv": [193, 72], + "death_conv": [193, 72], + "death_pool": [184, 90], + "deaths": [194, 74], + "drop": [145, 74], + "elapsed": [193, 58], + "end15": [185, 56], + "equip_conv": [143, 56], + "fail_conv": [203, 72], + "fail_pool": [172, 74], + "food": [192, 58], + "food_bought": [196, 74], + "food_consumed": [196, 74], + "food_upkeep": [202, 72], + "gear_score": [192, 74], + "gear_wear": [193, 106], + "gold": [118, 58], + "gold_earned": [176, 58], + "hunt_payout": [204, 72], + "hunt_xp": [156, 58], + "items_consumed": [193, 74], + "items_equipped": [197, 74], + "items_looted": [192, 74], + "items_sold": [183, 74], + "level": [118, 58], + "loot_category": [201, 62], + "loot_dispatch": [201, 72], + "loot_feed": [195, 90], + "quest_payout": [200, 72], + "quest_xp": [161, 58], + "r_burned": [253, 102], + "r_efflevel": [179, 102], + "r_expense": [260, 86], + "r_huntshare": [260, 102], + "r_income": [167, 102], + "r_items_acct": [260, 102], + "r_netgold": [260, 102], + "rare_conv": [196, 72], + "repair_gold": [204, 56], + "repair_spend": [192, 74], + "repair_wear": [199, 72], + "resupply": [189, 72], + "resupply_spend": [185, 74], + "reward": [146, 74], + "reward_router": [201, 62], + "training_spend": [190, 74], + "vendor_conv": [198, 72], + "vendor_revenue": [191, 74], + "void": [158, 56], + "water": [184, 58], + "water_bought": [195, 74], + "water_consumed": [196, 74], + "water_upkeep": [189, 72], + "wear_cleared": [186, 74], + "xp": [118, 58], + "xp_earned": [156, 58], + "z1_combat": [201, 62], + "z1_enc": [196, 90], + "z1_enc_src": [187, 72], + "z1_loot": [201, 62], + "z1_lootroll": [194, 90], + "z1_training": [206, 72], + "z1_win": [186, 90], + "z1_winamp": [205, 72], + "z1_xp2lvl": [200, 72], + "z1_xp_meter": [201, 62], + "z2_combat": [201, 62], + "z2_enc": [196, 90], + "z2_enc_src": [188, 72], + "z2_loot": [201, 62], + "z2_lootroll": [192, 90], + "z2_training": [201, 72], + "z2_win": [194, 90], + "z2_winamp": [206, 72], + "z2_xp2lvl": [206, 72], + "z2_xp_meter": [201, 62], + "z3_combat": [201, 62], + "z3_enc": [196, 90], + "z3_enc_src": [187, 72], + "z3_loot": [201, 62], + "z3_lootroll": [192, 90], + "z3_training": [206, 72], + "z3_win": [184, 90], + "z3_winamp": [206, 72], + "z3_xp2lvl": [205, 72], + "z3_xp_meter": [201, 62] + } +} diff --git a/src/model/layout/templatePlacement.test.ts b/src/model/layout/templatePlacement.test.ts new file mode 100644 index 00000000..a87719a2 --- /dev/null +++ b/src/model/layout/templatePlacement.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import coffee from '../../../examples/coffee-roastery.json' +import deadlock from '../../../examples/deadlock.json' +import equilibrium from '../../../examples/equilibrium.json' +import gacha from '../../../examples/gacha-banner-zones.json' +import mmo from '../../../examples/mmo-progression.json' +import BOXES from './templateBoxes.json' +import { FRAME_GAP, FRAME_MARGIN, TEMPLATE_GAP, placeTemplate, type TemplateName } from './templatePlacement' +import type { LoopEdge, LoopNode } from '../types' + +// docs/diagram-layout.md §DL5 (issue #344 step 4) — what every shipped Template +// holds once placed: by each node's widest box over the 18 languages, no two +// nodes closer than the clearance on both axes; every saved frame holds each of +// its nodes with ≥ FRAME_MARGIN and two frames keep ≥ FRAME_GAP; every connection is +// Auto orthogonal (no Manual exception is needed); and placing a shipped file +// again changes nothing it has already settled. + +type Doc = { nodes: LoopNode[]; edges: LoopEdge[]; frames?: { id: string; rect: { x: number; y: number; w: number; h: number } }[] } +const SHIPPED: [TemplateName, Doc][] = [ + ['equilibrium', equilibrium as unknown as Doc], + ['deadlock', deadlock as unknown as Doc], + ['coffee-roastery', coffee as unknown as Doc], + ['gacha-banner-zones', gacha as unknown as Doc], + ['mmo-progression', mmo as unknown as Doc], +] +const size = (tpl: TemplateName, id: string) => { + const b = (BOXES[tpl] as Record)[id]! + return { w: b[0]!, h: b[1]! } +} + +describe.each(SHIPPED)('%s as shipped', (tpl, doc) => { + it('every node has a measured widest box', () => { + for (const n of doc.nodes) expect((BOXES[tpl] as Record)[n.id], n.id).toBeDefined() + }) + + it('no two nodes are closer than the clearance on both axes (by their widest boxes)', () => { + const bad: string[] = [] + for (let i = 0; i < doc.nodes.length; i++) + for (let j = i + 1; j < doc.nodes.length; j++) { + const a = doc.nodes[i]! + const b = doc.nodes[j]! + const A = size(tpl, a.id) + const B = size(tpl, b.id) + const gapX = Math.max(b.position.x - (a.position.x + A.w), a.position.x - (b.position.x + B.w)) + const gapY = Math.max(b.position.y - (a.position.y + A.h), a.position.y - (b.position.y + B.h)) + if (gapX < TEMPLATE_GAP.x && gapY < TEMPLATE_GAP.y) bad.push(`${a.id}~${b.id}`) + } + expect(bad).toEqual([]) + }) + + it('every connection is Auto orthogonal: no bend points, no other shape', () => { + for (const e of doc.edges) { + expect((e.data as { route?: unknown }).route, e.id).toBe('orthogonal') + expect((e.data as { waypoints?: unknown }).waypoints, e.id).toBeUndefined() + } + }) + + it('every frame holds each node whose centre is in it with ≥ FRAME_MARGIN; two frames keep ≥ FRAME_GAP between them', () => { + const frames = doc.frames ?? [] + for (const f of frames) { + for (const n of doc.nodes) { + const s = size(tpl, n.id) + const cx = n.position.x + s.w / 2 + const cy = n.position.y + s.h / 2 + if (cx < f.rect.x || cx > f.rect.x + f.rect.w || cy < f.rect.y || cy > f.rect.y + f.rect.h) continue + expect(n.position.x - f.rect.x, `${f.id} ${n.id} left`).toBeGreaterThanOrEqual(FRAME_MARGIN) + expect(n.position.y - f.rect.y, `${f.id} ${n.id} top`).toBeGreaterThanOrEqual(FRAME_MARGIN) + expect(f.rect.x + f.rect.w - (n.position.x + s.w), `${f.id} ${n.id} right`).toBeGreaterThanOrEqual(FRAME_MARGIN) + expect(f.rect.y + f.rect.h - (n.position.y + s.h), `${f.id} ${n.id} bottom`).toBeGreaterThanOrEqual(FRAME_MARGIN) + } + } + for (let i = 0; i < frames.length; i++) + for (let j = i + 1; j < frames.length; j++) { + const a = frames[i]!.rect + const b = frames[j]!.rect + const gap = Math.max(b.x - (a.x + a.w), a.x - (b.x + b.w), b.y - (a.y + a.h), a.y - (b.y + b.h)) + expect(gap, `${frames[i]!.id} / ${frames[j]!.id}`).toBeGreaterThanOrEqual(FRAME_GAP) + } + }) + + it('placing it again keeps every route Auto and every node in its order', () => { + const again = placeTemplate(tpl, structuredClone(doc)) + for (const e of again.edges) expect((e.data as { route?: unknown }).route).toBe('orthogonal') + // no left/right or above/below order reversed (ties may stay ties) + const pos = new Map(again.nodes.map((n) => [n.id, n.position])) + for (const a of doc.nodes) + for (const b of doc.nodes) { + if (a.position.x < b.position.x) expect(pos.get(a.id)!.x <= pos.get(b.id)!.x, `${a.id} left of ${b.id}`).toBe(true) + if (a.position.y < b.position.y) expect(pos.get(a.id)!.y <= pos.get(b.id)!.y, `${a.id} above ${b.id}`).toBe(true) + } + }) +}) diff --git a/src/model/layout/templatePlacement.ts b/src/model/layout/templatePlacement.ts new file mode 100644 index 00000000..2682c162 --- /dev/null +++ b/src/model/layout/templatePlacement.ts @@ -0,0 +1,109 @@ +// docs/diagram-layout.md §DL5 (issue #344 step 4) — how a bundled Template is +// placed. Every Template's source (its builder, its generator script, or for the +// two small ones the file itself) goes through this ONE function, so the +// shipped file is always reproducible from its source: +// +// 1. `replaceOnGrid` (§DL2: order-keeping, space is inserted, no left/right or +// above/below order between two nodes is ever reversed), with each node +// sized by its WIDEST real box over the 18 languages (`templateBoxes.json`, +// measured on the running app) plus a clearance (`TEMPLATE_GAP`) — room +// for the 16 px port stubs on both sides and a fan's branch after them, so +// no language puts a node over another's port; +// 2. every saved frame grown to hold each node whose centre is inside it, +// with `FRAME_MARGIN` to spare on every side (snapped outward); two frames +// that stood apart keep `FRAME_GAP` between them (space inserted, order +// kept); +// 3. every connection Auto orthogonal (`route: "orthogonal"`, no waypoints) — +// except a listed Manual exception (`manual`: edge id → bend points), +// which is kept only where the router cannot resolve a case by placement. +// +// Pure and deterministic. + +import type { LoopEdge, LoopEdgeData, LoopNode } from '../types' +import { GRID } from './grid' +import { centreInside, FRAME_MARGIN, LAYOUT_CLEARANCE, rebuildFrameRect } from './frameRebuild' +import { replaceOnGrid, type FrameLike } from './replace' +import BOXES from './templateBoxes.json' + +export type TemplateName = keyof typeof BOXES + +/** the clearance kept between two nodes (the same as every re-placement's, §DL2.9) */ +export const TEMPLATE_GAP = LAYOUT_CLEARANCE +export { FRAME_MARGIN } +/** the space kept between two frames that stood apart: room for a frame's title + * between it and its neighbour (two frames' margins alone would make them touch) */ +export const FRAME_GAP = 32 + +type Doc = { nodes: LoopNode[]; edges: LoopEdge[]; frames?: F[] } + +export function placeTemplate(name: TemplateName, doc: Doc, manual: Record = {}): Doc { + const boxes = BOXES[name] as Record + const size = (id: string): { w: number; h: number } => { + const b = boxes[id] + if (!b) throw new Error(`placeTemplate(${name}): no measured box for node ${id}`) + return { w: b[0], h: b[1] } + } + const r = replaceOnGrid(doc.nodes, doc.edges, doc.frames ?? [], (n) => { + const s = size(n.id) + return { w: s.w + TEMPLATE_GAP.x, h: s.h + TEMPLATE_GAP.y } + }) + // 2. a frame travels with the nodes it holds — those whose centre was inside + // it BEFORE the move (by their real size): it is rebuilt around where they + // now are, keeping its own padding on each side (at least `FRAME_MARGIN`), + // snapped outward (`rebuildFrameRect`). A frame that holds no node keeps + // the grid's rect. + const origAt = new Map(doc.nodes.map((n) => [n.id, n.position])) + const at = new Map(doc.nodes.map((n) => [n.id, r.positions[n.id] ?? n.position])) + const boxAt = (where: Map) => (id: string) => ({ ...where.get(id)!, ...size(id) }) + const build = () => doc.frames?.map((f) => { + const members = doc.nodes.filter((n) => centreInside(boxAt(origAt)(n.id), f.rect)).map((n) => n.id) + return { ...f, rect: rebuildFrameRect(f.rect, members, boxAt(origAt), boxAt(at), r.frames[f.id] ?? f.rect) } + }) + + // 2b. two frames that stood apart keep `FRAME_GAP` between them (the lower or + // right one's title sits in that gap): space is INSERTED as in §DL2 — + // every node whose centre lies beyond the nearer frame's far edge moves + // by the same grid step, so no order between two nodes is reversed and + // no clearance shrinks + const apart = (a: FrameLike['rect'], b: FrameLike['rect']) => ({ + x: Math.max(b.x - (a.x + a.w), a.x - (b.x + b.w)), + y: Math.max(b.y - (a.y + a.h), a.y - (b.y + b.h)), + }) + const sortedFrames = [...(doc.frames ?? [])].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + let frames = build() + for (let iter = 0; frames && iter < sortedFrames.length * sortedFrames.length; iter++) { + const rect = new Map(frames.map((f) => [f.id, f.rect])) + let shift: { axis: 'x' | 'y'; t: number; d: number } | null = null + for (let i = 0; i < sortedFrames.length && !shift; i++) + for (let j = i + 1; j < sortedFrames.length && !shift; j++) { + const g0 = apart(sortedFrames[i]!.rect, sortedFrames[j]!.rect) + if (g0.x < 0 && g0.y < 0) continue // they overlapped before: not this rule's case + const axis = g0.x >= g0.y ? 'x' : 'y' + const A = rect.get(sortedFrames[i]!.id)!, B = rect.get(sortedFrames[j]!.id)! + const gap = apart(A, B)[axis] + if (gap >= FRAME_GAP) continue + const [near] = axis === 'x' ? (A.x <= B.x ? [A, B] : [B, A]) : A.y <= B.y ? [A, B] : [B, A] + shift = { axis, t: axis === 'x' ? near.x + near.w : near.y + near.h, d: Math.ceil((FRAME_GAP - gap) / GRID) * GRID } + } + if (!shift) break + for (const n of doc.nodes) { + const p = at.get(n.id)! + const c = shift.axis === 'x' ? p.x + size(n.id).w / 2 : p.y + size(n.id).h / 2 + if (c > shift.t) at.set(n.id, shift.axis === 'x' ? { x: p.x + shift.d, y: p.y } : { x: p.x, y: p.y + shift.d }) + } + frames = build() + } + const placed = doc.nodes.map((n) => ({ ...n, position: { ...at.get(n.id)! } })) + + // 3. Auto orthogonal, or a listed Manual exception + const edges = doc.edges.map((e) => { + if (!e.data) return e + const { route: _r, waypoints: _w, accent, ...rest } = e.data as LoopEdgeData & { accent?: unknown } + const wps = manual[e.id] + return { + ...e, + data: { ...rest, route: 'orthogonal', ...(wps ? { waypoints: wps.map((p) => ({ x: p.x, y: p.y })) } : {}), ...(accent !== undefined ? { accent } : {}) } as LoopEdgeData, + } + }) + return { ...doc, nodes: placed, edges, ...(frames ? { frames } : {}) } +} diff --git a/src/model/revision.ts b/src/model/revision.ts index bd45de46..60f0139d 100644 --- a/src/model/revision.ts +++ b/src/model/revision.ts @@ -6,7 +6,7 @@ // (§R2.1 / §R6). NO store, NO autosave, NO UI, NO download side effects — the // lifecycle that consumes these lands in Slice 1B. -import { edgeHasRoutingIntent } from './edgeRouting' +import { edgeHasRoutingIntent, edgeIsStraight } from './edgeRouting' import { defaultData } from './factory' import { normalizeResourceType, @@ -150,8 +150,27 @@ export type ProjectPayload = { appliedProposal?: AppliedProposal lineage?: string[] meta?: ProjectMeta + /** SEMANTICS-R10.md §R10-6 — the revision semantics the writer recorded the + * file with (`loop-revision/N`). Written by every build from `/10` on; + * absent in older files. Not content: outside every digest. */ + semantics?: string } +/** the revision semantics this build writes into a header (§R10-6) */ +export const REVISION_SEMANTICS = 'loop-revision/10' +const SEMANTICS_RE = /^loop-revision\/([1-9][0-9]{0,3})$/ + +/** §R10-6 — the `N` a header declares, or 0 when it declares none (a file + * written before `/10`, or an unreadable value) */ +export function declaredSemantics(project: { semantics?: unknown } | null | undefined): number { + const m = typeof project?.semantics === 'string' ? SEMANTICS_RE.exec(project.semantics) : null + return m ? Number(m[1]) : 0 +} + +/** §R10-6 / docs/edge-routing.md §ER15.1 — a record whose header declares + * semantics before `/10` keeps its Curved labels where they were recorded */ +export const recordKeepsLabels = (project: { semantics?: unknown } | null | undefined): boolean => declaredSemantics(project) < 10 + // ── id validation & secure mint (§R11) ───────────────────────────────────── export const isProjectId = (s: unknown): s is string => @@ -425,6 +444,8 @@ function projectEdge(e: LoopEdge, modelLayer: boolean): CanonicalEdge { // projection, and only when `route === "orthogonal"` survived. if (!modelLayer) continue if (src?.route === 'orthogonal') data.route = 'orthogonal' + // SEMANTICS-R10.md §R10-2 — the same slot, the new value + else if (src?.route === 'straight') data.route = 'straight' } else if (f === 'waypoints') { // §R3-2.2 — array in stored order, NOT deduped, each {x, y} verbatim @@ -630,6 +651,7 @@ export type SideVersion = | 'loop-revision/6' // SEMANTICS-R6.md — the side carries CSU (loop-state/3) `timing` / `when` content | 'loop-revision/8' // SEMANTICS-R8.md — the side carries data-import provenance content (§R8-1) | 'loop-revision/9' // SEMANTICS-R9.md — the side carries ≥ 1 flow colour (`data.accent`) + | 'loop-revision/10' // SEMANTICS-R10.md — the side carries ≥ 1 straight connection (`route: "straight"`) export type RevisionSideOk = { ok: true version: SideVersion @@ -801,7 +823,13 @@ export function readRevisionSide( const hasAccent = g.nodes.some((n) => readAccent((n.data as { accent?: unknown } | undefined)?.accent) !== undefined) || g.edges.some((e) => readAccent((e.data as { accent?: unknown } | undefined)?.accent) !== undefined) - const version: SideVersion = hasAccent + // SEMANTICS-R10.md §R10-1 — ≥ 1 SURVIVING `route: "straight"` (the + // defensive read keeps it; a build before /10 drops it). The newest + // extension, so the highest label precedence, on the same projection. + const hasStraight = g.edges.some((e) => edgeIsStraight(e.data)) + const version: SideVersion = hasStraight + ? 'loop-revision/10' + : hasAccent ? 'loop-revision/9' : hasDataImport ? 'loop-revision/8' @@ -1900,6 +1928,8 @@ export function readProject(raw: unknown, loadedContent?: CanonicalContent): Rea role, ...(lineage ? { lineage } : {}), ...(readMeta(o.meta) ? { meta: readMeta(o.meta) } : {}), + // §R10-6 — kept only in its exact form; anything else reads as absent + ...(typeof o.semantics === 'string' && SEMANTICS_RE.test(o.semantics) ? { semantics: o.semantics } : {}), } if (isAppliedProposal(o.appliedProposal)) project.appliedProposal = o.appliedProposal @@ -2111,6 +2141,7 @@ export function planRevisionExport(input: { contentDigest: baselineDigest, // §R10 integrity — the file's own content lineage, meta, + semantics: REVISION_SEMANTICS, // SEMANTICS-R10.md §R10-6 } const file = buildFile(input.doc, project, input.modelVersion) const text = JSON.stringify(file, null, 2) @@ -2214,6 +2245,7 @@ export function planProposalExport(input: { base, lineage: [base.revisionId, ...input.project.lineage].slice(0, LINEAGE_MAX), meta: { ...input.meta, createdAt: input.now }, + semantics: REVISION_SEMANTICS, // SEMANTICS-R10.md §R10-6 } const file = buildFile(input.doc, project, input.modelVersion) const text = JSON.stringify(file, null, 2) diff --git a/src/model/serialize.ts b/src/model/serialize.ts index f4f39264..ce9a8ecb 100644 --- a/src/model/serialize.ts +++ b/src/model/serialize.ts @@ -14,6 +14,10 @@ export const STORAGE_KEY = 'loop-studio:graph:v1' export const SCHEMA_V1 = 'loop-studio/graph' export const SCHEMA_V2 = 'loop-studio/graph/2' const SCHEMA_VERSION = 1 +/** issue #344, docs/diagram-layout.md §DL2.8 — the current layout rules: 1 = + * the 16 px grid with the 28 px port row. A document with a lower (or no) + * `layoutVersion` is re-placed once when it is opened. */ +export const LAYOUT_VERSION = 1 export type ModelSemanticsVersion = 1 | 2 @@ -201,6 +205,11 @@ export const DI_HEADER_MAX = 200 export type GraphDoc = { schema: string version: number + /** issue #344, docs/diagram-layout.md §DL2.8 — which layout rules the + * positions follow. Written on every save (`LAYOUT_VERSION`); absent ⇒ 0, a + * layout from before the grid, converted once when the document is opened. + * Not part of either digest (the positions themselves are). */ + layoutVersion?: number nodes: LoopNode[] edges: LoopEdge[] recommendedRunConfig?: RecommendedRunConfig @@ -662,6 +671,7 @@ export function serialize( const doc: GraphDoc = { schema: schemaForModelVersion(modelVersion), version: SCHEMA_VERSION, + layoutVersion: LAYOUT_VERSION, // issue #344 — every save follows the current rules nodes: nodes.map(toDocNode), edges: edges.map(toDocEdge), } @@ -703,6 +713,8 @@ export function deserialize(text: string): { edges: LoopEdge[] /** loop-model/2 — the model-semantics version this file declares (from `schema`). */ modelVersion: ModelSemanticsVersion + /** issue #344 — the file's `layoutVersion` (0 when absent or unreadable) */ + layoutVersion: number recommendedRunConfig?: RecommendedRunConfig /** LGR Slice 5 — the saved manual frames, already run through `readSavedFrames` * (bad entries dropped, ids resolved, labels capped). `[]` when the file has @@ -766,6 +778,7 @@ export function deserialize(text: string): { return { ...normalizeGraph({ nodes: obj.nodes as LoopNode[], edges: obj.edges as LoopEdge[] }), modelVersion, + layoutVersion: Number.isInteger(obj.layoutVersion) && (obj.layoutVersion as number) >= 0 ? (obj.layoutVersion as number) : 0, frames: readSavedFrames(obj.frames), // §R5-1.1 — [] when absent / all-bad dataImports: readDataImports(obj.dataImports), // §R8-1.1 — [] when absent / all-bad hasRawDataImportSignal, @@ -838,6 +851,8 @@ export function loadFromStorage(): nodes: LoopNode[] edges: LoopEdge[] modelVersion: ModelSemanticsVersion + /** issue #344 — 0 for an autosave written before the grid */ + layoutVersion: number recommendedRunConfig?: RecommendedRunConfig frames: SavedFrame[] dataImports: ImportSourceTable[] diff --git a/src/model/sha256Baseline.test.ts b/src/model/sha256Baseline.test.ts index f410126d..134196b4 100644 --- a/src/model/sha256Baseline.test.ts +++ b/src/model/sha256Baseline.test.ts @@ -73,9 +73,14 @@ const COLOURED_SINCE = new Set(['coffee-roastery.json', 'gacha-banner-zones.json // (scripts/gen-gacha-banner-zones-example.ts) so its `+1` label no longer meets // `e_pickup_34`'s on the shorter nodes. Routing is never engine data, so the // engine digest still matches; the legacy projection removes exactly this one -// waypoint, and fails if it is not exactly what PR 3 added. +// waypoint, and fails if it is not exactly what PR 3 added. Issue #344 step 1 +// (layout round 8) added one more each to `e_pickup_17` and `e_pickup_20`. const ADDED_WAYPOINTS: Record> = { - 'gacha-banner-zones.json': { e_pickup_36: [{ x: 3200, y: 650 }] }, + 'gacha-banner-zones.json': { + e_pickup_17: [{ x: 3700, y: 549 }], + e_pickup_20: [{ x: 3360, y: 548 }], + e_pickup_36: [{ x: 3200, y: 650 }], + }, } /** a document's text without what was added after the #301 baseline: the flow @@ -91,6 +96,26 @@ const legacyProjection = (file: string, text: string): string => { return JSON.stringify(doc) } +// issue #344 step 4 (docs/diagram-layout.md §DL5) - the five bundled Templates +// were placed for the grid: positions, frames and routes moved, which are +// content but never engine data. Their engine digest still equals the +// recorded one; their content and full-content digests changed and are pinned +// at their current values (the three coloured ones in +// src/engine/templateFlowColours.test.ts, the other two below). The recorded +// #301 chain is checked on the files as they stood before that step, +// `test/fixtures/templates-before-step4/`. +const PLACED_SINCE = new Set(['equilibrium.json', 'deadlock.json', ...COLOURED_SINCE]) +const PLACED_PINS: Record = { + 'deadlock.json': { contentDigest: '7ec1004c804aa53414da260df9f3ba375c42aff3569bb16981cd3e9236b72c5b', fullContentDigest: '7ec1004c804aa53414da260df9f3ba375c42aff3569bb16981cd3e9236b72c5b' }, + 'equilibrium.json': { contentDigest: '373f7deae9c99c7f324e92cabf90fdb23146a6bb79c6e8a02cd3553d34320a7d', fullContentDigest: '373f7deae9c99c7f324e92cabf90fdb23146a6bb79c6e8a02cd3553d34320a7d' }, +} +const BEFORE_STEP4 = import.meta.glob('../../test/fixtures/templates-before-step4/*.json', { query: '?raw', import: 'default', eager: true }) as Record +const readBefore = (file: string): string => { + const text = BEFORE_STEP4['../../test/fixtures/templates-before-step4/' + file] + if (text === undefined) throw new Error('missing pre-step-4 copy ' + file) + return text +} + const docOf = (p: ReturnType) => ({ nodes: p.nodes, edges: p.edges, @@ -111,6 +136,27 @@ describe('the digests of real documents are unchanged', () => { }) continue } + if (PLACED_SINCE.has(g.file)) { + it(`examples/${g.file}: placed for the grid (#344 step 4) - the engine digest is the recorded one, content pinned anew`, async () => { + const p = deserialize(read(g.file)) + const doc = docOf(p) + expect(p.modelVersion).toBe(g.modelVersion) + expect(await semanticDigest({ nodes: p.nodes, edges: p.edges }, p.modelVersion)).toBe(g.semanticDigest) + const content = digestOfCanonical(canonicalContent(doc, { modelVersion: p.modelVersion })) + const full = await fullContentDigest(doc, p.modelVersion) + expect(content, g.file).toBe(PLACED_PINS[g.file]!.contentDigest) + expect(full, g.file).toBe(PLACED_PINS[g.file]!.fullContentDigest) + expect(content).not.toBe(g.contentDigest) + }) + it(`examples/${g.file} before step 4: semantic, content and full-content digests as recorded`, async () => { + const p = deserialize(readBefore(g.file)) + const doc = docOf(p) + expect(await semanticDigest({ nodes: p.nodes, edges: p.edges }, p.modelVersion)).toBe(g.semanticDigest) + expect(digestOfCanonical(canonicalContent(doc, { modelVersion: p.modelVersion }))).toBe(g.contentDigest) + expect(await fullContentDigest(doc, p.modelVersion)).toBe(g.fullContentDigest) + }) + continue + } it(`examples/${g.file}: semantic, content and full-content digests`, async () => { const p = deserialize(read(g.file)) const doc = docOf(p) @@ -121,11 +167,11 @@ describe('the digests of real documents are unchanged', () => { }) } - it('legacy (#301 baseline): the three coloured Templates without their flow colours (PR 2) and the gacha e_pickup_36 waypoint (PR 3) give the recorded digests', async () => { + it('legacy (#301 baseline): the three coloured Templates as they stood before #344 step 4, without their flow colours (PR 2) and the gacha waypoints added since (PR 3, #344 step 1), give the recorded digests', async () => { const legacy = EXAMPLE_GRAPH_DIGESTS.filter((g) => COLOURED_SINCE.has(g.file)) expect(legacy.map((g) => g.file).sort()).toEqual([...COLOURED_SINCE].sort()) for (const g of legacy) { - const p = deserialize(legacyProjection(g.file, read(g.file))) + const p = deserialize(legacyProjection(g.file, readBefore(g.file))) const doc = docOf(p) expect(p.modelVersion, g.file).toBe(g.modelVersion) expect(await semanticDigest({ nodes: p.nodes, edges: p.edges }, p.modelVersion), g.file).toBe(g.semanticDigest) diff --git a/src/model/templates.initialView.test.ts b/src/model/templates.initialView.test.ts new file mode 100644 index 00000000..92e76160 --- /dev/null +++ b/src/model/templates.initialView.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import BOXES from './layout/templateBoxes.json' +import { TEMPLATES } from './templates' + +// issue #344 (docs/diagram-layout.md §DL5.6) — the MMO opening view's `keep` +// rect holds the core start nodes at their widest box over the 18 languages +// (`layout/templateBoxes.json`), so a wider translation or a moved node cannot +// silently fall outside it. + +const CORE = ['char_creation', 'active_char', 'z1_enc_src', 'z1_enc'] + +describe('MMO initialView keep', () => { + const tpl = TEMPLATES.find((t) => t.id === 'mmo-progression')! + const keep = tpl.initialView!.keep! + const boxes = BOXES['mmo-progression'] as Record + + it.each(CORE)('%s is inside keep at its widest box', (id) => { + const n = tpl.graph.nodes.find((m) => m.id === id)! + const [w, h] = boxes[id]! + expect(n.position.x).toBeGreaterThanOrEqual(keep.x) + expect(n.position.y).toBeGreaterThanOrEqual(keep.y) + expect(n.position.x + w!).toBeLessThanOrEqual(keep.x + keep.width) + expect(n.position.y + h!).toBeLessThanOrEqual(keep.y + keep.height) + }) + + it('keep is the tight union of the core boxes (no slack that would lower the zoom)', () => { + const b = CORE.map((id) => { + const n = tpl.graph.nodes.find((m) => m.id === id)! + const [w, h] = boxes[id]! + return { l: n.position.x, t: n.position.y, r: n.position.x + w!, b: n.position.y + h! } + }) + expect({ x: keep.x, y: keep.y, right: keep.x + keep.width, bottom: keep.y + keep.height }).toEqual({ + x: Math.min(...b.map((v) => v.l)), + y: Math.min(...b.map((v) => v.t)), + right: Math.max(...b.map((v) => v.r)), + bottom: Math.max(...b.map((v) => v.b)), + }) + }) +}) diff --git a/src/model/templates.ts b/src/model/templates.ts index 89fa1a0b..2d555909 100644 --- a/src/model/templates.ts +++ b/src/model/templates.ts @@ -25,6 +25,12 @@ import type { LoopEdge, LoopNode } from './types' export type InitialView = { rect: { x: number; y: number; width: number; height: number } minZoom: number + /** issue #344 (docs/diagram-layout.md §DL5.6) — the graph rect that must + * open WHOLE (the core start nodes at their widest box over the 18 + * languages, `layout/templateBoxes.json`): the zoom is capped so it fits the + * pane, clear of the minimap only where the two would overlap on both axes. + * Above `minZoom` only; absent ⇒ the plain `rect` fit. */ + keep?: { x: number; y: number; width: number; height: number } } export type Template = { @@ -110,6 +116,12 @@ export const TEMPLATES: Template[] = [ recommendedRunConfig: (coffeeRoasteryDoc as { recommendedRunConfig?: RecommendedRunConfig }) .recommendedRunConfig, modelVersion: modelVersionForSchema((coffeeRoasteryDoc as { schema?: unknown }).schema) ?? 1, + // issue #344 step 5 (docs/diagram-layout.md §DL5.5) — the operating flow + // first: the five daily levers, the supply and the roasting & sales frames, + // sized so a 1280 x 800 window opens at about 0.5 (L1 with room, never at + // the 0.45 edge); the forecast metrics may start off screen (Reset view and + // the minimap show the whole graph) + initialView: { rect: { x: 8, y: -12, width: 1344, height: 912 }, minZoom: 0.46 }, }, // The "Early MMO progression (levels 1–15)" demo — a connected play economy. // The canonical graph is examples/mmo-progression.json (built + verified by @@ -133,7 +145,20 @@ export const TEMPLATES: Template[] = [ // threshold `LOD_L1_MIN` = 0.45); tuned by eye at 1280 / 820 px. The rest // of the graph is one pan / minimap away. Fixed graph coords, no locale // branch. - initialView: { rect: { x: 0, y: 0, width: 880, height: 360 }, minZoom: 0.6 }, + // issue #344 steps 4–6 (docs/diagram-layout.md §DL5.5–§DL5.6) — + // readability first: the rect is sized for a zoom of about 1.05 at + // 1600 x 1000 (node text >= 12 px on screen) inside the Controls rail's + // reserved column; `keep` holds the core start nodes — Character creation, + // Active character, Starter encounters and Starter Lv 1–5 — at their + // widest box over the 18 languages (Thai Starter Lv 1–5, 196 px, ends at + // 1236), so they open whole at any pane size and with the minimap + // collapsed (1.040 at 1600 x 1000); later paths may start off screen + // (Reset view and the minimap show the whole graph) + initialView: { + rect: { x: 44, y: -4, width: 927, height: 368 }, + minZoom: 0.6, + keep: { x: 48, y: 36, width: 1188, height: 200 }, + }, }, // docs/gacha-banner-zones.md (GZ) — the 3-zone gacha banner comparison. The // canonical graph is examples/gacha-banner-zones.json, built by @@ -180,6 +205,8 @@ export const TEMPLATES: Template[] = [ // exact class of overflow if the layout shifts slightly in the future. // Fixed graph coords, no locale branch (comparison row 0-1720 + Free // zone 0-950 x 0-810). - initialView: { rect: { x: 0, y: 0, width: 1760, height: 850 }, minZoom: 0.45 }, + // issue #344 step 4 (§DL5.5) — the comparison frame + the Free zone frame + // at their placed coordinates, 40 px margin + initialView: { rect: { x: -40, y: -56, width: 2080, height: 1136 }, minZoom: 0.45 }, }, ] diff --git a/src/model/types.ts b/src/model/types.ts index 3d499438..ad54a301 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -132,9 +132,10 @@ export type StateMode = 'label' | 'node' | 'trigger' | 'activator' * `route` ⇒ Bézier (the writer never emits `"bezier"`). `waypoints` is * meaningful only with `route: "orthogonal"`; each point is world-space finite, * kept verbatim, in user order (§R3-1.1 / §R3-2). The computed path is NOT - * stored. */ + * stored. loop-revision/10 (SEMANTICS-R10.md) adds `"straight"`, a direct + * line that takes no waypoints. */ export type EdgeRoutingData = { - route?: 'orthogonal' + route?: 'orthogonal' | 'straight' waypoints?: { x: number; y: number }[] } diff --git a/src/releaseNotes/releaseNotes.ts b/src/releaseNotes/releaseNotes.ts index c6f1a2de..bfe9a9ff 100644 --- a/src/releaseNotes/releaseNotes.ts +++ b/src/releaseNotes/releaseNotes.ts @@ -38,6 +38,23 @@ export const releaseNoteIdFor = (version: string): ReleaseNoteId => `release:${v * mechanics, no test counts, no pull-request numbers. */ export const RELEASE_NOTES: readonly ReleaseNote[] = [ + { + // issue #344 (Diagram Readability, one migration) - grid-aligned editing + // with smart guides and the one-time re-placement, automatic orthogonal + // routing, the connection shapes and bend points, text inside the node + // outline, and the re-placed Templates; the date is the day it is + // deployed, set right before the merge + id: 'release:0.25.0', + version: '0.25.0', + date: '2026-10-10', + items: [ + 'whatsNew.v0250.grid', + 'whatsNew.v0250.routing', + 'whatsNew.v0250.shapes', + 'whatsNew.v0250.outline', + 'whatsNew.v0250.templates', + ], + }, { // issue #330 PR 3 - the speed tiers, Step always full, the phone's four // speeds and its fewer moving markers; the date is the day it is deployed, diff --git a/src/startApp.tsx b/src/startApp.tsx index b28f53a5..cbc623d3 100644 --- a/src/startApp.tsx +++ b/src/startApp.tsx @@ -12,7 +12,7 @@ import { useProjectStore } from './store/projectStore' import { usePwaStore } from './store/pwaStore' import { useReviewStore } from './store/reviewStore' import * as revisionIO from './store/revisionIO' -import { __resetRouteCache, __routeGenCount, currentRouteMap } from './store/routeMap' +import { __resetProvisionalPeak, __resetRouteCache, __routeGenCount, __syncFreeSlots, __syncFullGeneration, currentRouteGeneration, currentRouteMap, routeDiagnostics, setRecordLabels, useRouteInputs } from './store/routeMap' import * as shareLink from './store/shareLink' import { useDataImportStore } from './store/dataImportStore' import { useFilterStore } from './store/filterStore' @@ -51,6 +51,11 @@ export async function startApp(): Promise { // value means `system`. In a temporary session the port holds nothing, so // this is the system theme, and it also clears whatever the gate showed. applyStoredTheme() + // issue #344 step 3 (§ER15.1) — the route map follows the document's label + // rule; subscribed before the first render, so a document switch reaches it + // before any edge renders + setRecordLabels(useGraphStore.getState().recordLabels) + useGraphStore.subscribe((s) => setRecordLabels(s.recordLabels)) // Dev-only store bridge for browser E2E (never in the production / portable // build — `import.meta.env.DEV` is statically false there and tree-shaken out). @@ -89,6 +94,32 @@ export async function startApp(): Promise { const g = useGraphStore.getState() return currentRouteMap(g.nodes, g.edges).get(id) ?? null }, + // issue #344 §ER14.4–5 — the last full generation's classes and fans, + // the last provisional map and job, and whether a job is still running + diagnostics: routeDiagnostics, + pending: () => routeDiagnostics().pending, + resetProvisionalPeak: __resetProvisionalPeak, + // the map on screen, and the synchronous generation it must equal once + // nothing is pending (computed aside; the cache is untouched) + all: () => { + const g = useGraphStore.getState() + return currentRouteMap(g.nodes, g.edges) + }, + syncFull: () => { + const g = useGraphStore.getState() + return __syncFullGeneration(g.nodes, g.edges) + }, + // §ER15 — the Curved / Straight label slots on screen, the synchronous + // ones they must equal, and whether anything drawn is still waiting + free: () => { + const g = useGraphStore.getState() + return currentRouteGeneration(g.nodes, g.edges).free + }, + syncFree: () => { + const g = useGraphStore.getState() + return __syncFreeSlots(g.nodes, g.edges) + }, + busy: () => useRouteInputs.getState().busy, }, // issue #332 — how many node row-fit measurements have run, so the e2e // can assert none runs per animation frame diff --git a/src/store/graphStore.edgeRouting.test.ts b/src/store/graphStore.edgeRouting.test.ts new file mode 100644 index 00000000..716e0c58 --- /dev/null +++ b/src/store/graphStore.edgeRouting.test.ts @@ -0,0 +1,215 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import './editPolicy' +import { deserialize, serialize } from '../model/serialize' +import { useGraphStore } from './graphStore' +import { useUiStore } from './uiStore' + +// issue #344 step 3 (docs/diagram-layout.md §DL4, SEMANTICS-R10.md) — a shape or +// bend-point edit is ONE undo entry, never merged with the next, never a +// simulation change, and refused while the canvas is locked. + +const S = () => useGraphStore.getState() +const rev = () => S().simulationRev +const dataOf = (id: string) => S().edges.find((e) => e.id === id)!.data as Record + +function two() { + S().newGraph() + S().addNodeAt('source', { x: 0, y: 0 }) + S().addNodeAt('pool', { x: 320, y: 0 }) + const [a, b] = S().nodes + S().onConnect({ source: a!.id, target: b!.id, sourceHandle: 'out', targetHandle: 'in' }) + return S().edges[0]!.id +} + +const WP = [{ x: 224, y: 32 }] + +beforeEach(() => { + useUiStore.getState().setCanvasLocked(false) + S().newGraph() +}) + +describe('setEdgeRouting', () => { + it('a new connection is Auto orthogonal; every shape writes the stored form of SEMANTICS-R10.md', () => { + const e = two() + expect(dataOf(e).route).toBe('orthogonal') + expect('waypoints' in dataOf(e)).toBe(false) + S().setEdgeRouting(e, { route: 'orthogonal', waypoints: WP }) + expect(dataOf(e).waypoints).toEqual(WP) + S().setEdgeRouting(e, {}) + expect('route' in dataOf(e)).toBe(false) + expect('waypoints' in dataOf(e)).toBe(false) + S().setEdgeRouting(e, { route: 'straight', waypoints: WP }) + expect(dataOf(e).route).toBe('straight') + expect('waypoints' in dataOf(e), 'Straight takes no bend points').toBe(false) + }) + + it('each edit is one undo entry, even two in quick succession, and none bumps the run', () => { + const e = two() + const r = rev() + const past = S().past.length + S().setEdgeRouting(e, { route: 'orthogonal', waypoints: WP }) + S().setEdgeRouting(e, { route: 'orthogonal', waypoints: [{ x: 240, y: 32 }] }) + S().setEdgeRouting(e, { route: 'orthogonal' }) // Reset to automatic + expect(S().past.length).toBe(past + 3) + expect(rev()).toBe(r) + S().undo() + expect(dataOf(e).waypoints).toEqual([{ x: 240, y: 32 }]) + S().undo() + expect(dataOf(e).waypoints).toEqual(WP) + S().undo() + expect('waypoints' in dataOf(e)).toBe(false) + S().redo() + expect(dataOf(e).waypoints).toEqual(WP) + expect(rev()).toBe(r) + }) + + it('an edit that changes nothing adds no entry', () => { + const e = two() + const past = S().past.length + S().setEdgeRouting(e, { route: 'orthogonal' }) + S().setEdgeRouting(e, { route: 'orthogonal', waypoints: [] }) + expect(S().past.length).toBe(past) + }) + + it('the stored key order is the reader’s, so a save and reload is byte-identical', () => { + const e = two() + S().setAccent([], [e], '#638EA5') + S().setEdgeRouting(e, { route: 'orthogonal', waypoints: WP }) + expect(Object.keys(dataOf(e)).slice(-3)).toEqual(['route', 'waypoints', 'accent']) + const file = serialize(S().nodes, S().edges) + const back = deserialize(file) + expect(serialize(back.nodes, back.edges)).toBe(file) + }) + + it('is refused while the canvas is locked, and so is the silent gesture form', () => { + const e = two() + const before = JSON.stringify(S().edges) + const past = S().past.length + useUiStore.getState().setCanvasLocked(true) + S().setEdgeRouting(e, { route: 'straight' }) + S().setEdgeRoutingSilently(e, { route: 'orthogonal', waypoints: WP }) + expect(JSON.stringify(S().edges)).toBe(before) + expect(S().past.length).toBe(past) + }) + + it('the silent form changes the edge with no entry; the gesture entry restores the start in one undo', () => { + const e = two() + const past = S().past.length + const snap = S().captureGestureSnapshot() + S().setEdgeRoutingSilently(e, { route: 'orthogonal', waypoints: WP }) + S().setEdgeRoutingSilently(e, { route: 'orthogonal', waypoints: [{ x: 256, y: 48 }] }) + expect(S().past.length).toBe(past) + S().pushGestureEntry(snap) + expect(S().past.length).toBe(past + 1) + S().undo() + expect('waypoints' in dataOf(e)).toBe(false) + }) +}) + +describe('the record label rule (§ER15.1)', () => { + const loadRecord = (e: string) => { + const g = S() + S().loadDoc({ nodes: g.nodes, edges: g.edges.map((x) => (x.id === e ? { ...x, data: { kind: 'resource', flow: '1' } as never } : x)) }, { mode: 'document-boundary', canvasLocked: false, recordLabels: true }) + } + + it('a record keeps its rule until a shape edit, which ends it in the same undo entry', () => { + const e = two() + loadRecord(e) + expect(S().recordLabels).toBe(true) + S().setEdgeRouting(e, { route: 'straight' }) + expect(S().recordLabels).toBe(false) + S().undo() + expect(S().recordLabels).toBe(true) + expect('route' in dataOf(e)).toBe(false) + S().redo() + expect(S().recordLabels).toBe(false) + }) + + it('Tidy to grid ends it, even on a record already on the grid; undo brings it back', () => { + const e = two() + loadRecord(e) + S().tidyToGrid() + expect(S().recordLabels).toBe(false) + S().undo() + expect(S().recordLabels).toBe(true) + }) + + it('another document, New and a Template start on the current rule', () => { + const e = two() + loadRecord(e) + S().newGraph() + expect(S().recordLabels).toBe(false) + }) + + it('the autosaved Project header carries the rule as labelLayoutVersion (0 record, 1 current); layoutVersion stays the layout', async () => { + const { flushAutosave, setAutosaveProjectHeader } = await import('./graphStore') + const { STORAGE_KEY, LAYOUT_VERSION } = await import('../model/serialize') + const m = new Map() + vi.stubGlobal('localStorage', { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => void m.set(k, String(v)), + removeItem: (k: string) => void m.delete(k), + clear: () => m.clear(), + key: (i: number) => [...m.keys()][i] ?? null, + get length() { return m.size }, + }) + try { + const e = two() + loadRecord(e) + setAutosaveProjectHeader({ schema: 'loop-revision/1', version: 1, role: 'revision' }) + S().setAccent([], [e], '#638EA5') // any edit schedules a write + flushAutosave() + const rec = JSON.parse(m.get(STORAGE_KEY)!) + expect(rec.project.labelLayoutVersion).toBe(0) + expect(rec.layoutVersion).toBe(LAYOUT_VERSION) + S().setEdgeRouting(e, { route: 'orthogonal' }) + flushAutosave() + const cur = JSON.parse(m.get(STORAGE_KEY)!) + expect(cur.project.labelLayoutVersion).toBe(1) + expect(cur.layoutVersion).toBe(LAYOUT_VERSION) + // an ordinary document has no header, so no key + setAutosaveProjectHeader(null) + S().newGraph() + flushAutosave() + expect(JSON.parse(m.get(STORAGE_KEY)!).project).toBeUndefined() + } finally { + setAutosaveProjectHeader(null) + vi.unstubAllGlobals() + } + }) + + it('a reload restores the rule from that header: absent (an older autosave) or 0 = record, 1 = current; no header or a proposal = current', async () => { + const { bootRecordLabels } = await import('./graphStore') + expect(bootRecordLabels({ schema: 'loop-revision/1', role: 'revision' })).toBe(true) + expect(bootRecordLabels({ schema: 'loop-revision/1', role: 'revision', labelLayoutVersion: 0 })).toBe(true) + expect(bootRecordLabels({ schema: 'loop-revision/1', role: 'revision', labelLayoutVersion: 1 })).toBe(false) + expect(bootRecordLabels(null)).toBe(false) + expect(bootRecordLabels({ schema: 'loop-revision/1', role: 'proposal' })).toBe(false) + }) +}) + +describe('the header semantics decide a record (§R10-6)', () => { + it('a header declaring /10 or later is current; absent, earlier or malformed is a record', async () => { + const { declaredSemantics, recordKeepsLabels } = await import('../model/revision') + expect(recordKeepsLabels({ semantics: 'loop-revision/10' })).toBe(false) + expect(recordKeepsLabels({ semantics: 'loop-revision/11' })).toBe(false) + expect(recordKeepsLabels({ semantics: 'loop-revision/9' })).toBe(true) + expect(recordKeepsLabels({})).toBe(true) + expect(recordKeepsLabels({ semantics: 'loop-revision/x' })).toBe(true) + expect(declaredSemantics({ semantics: ' loop-revision/10' })).toBe(0) + }) + + it('every revision and proposal this build writes declares loop-revision/10, outside the digest; a /10 record without a straight connection is still /10', async () => { + const { readProject, REVISION_SEMANTICS } = await import('../model/revision') + const e = two() + expect(dataOf(e).route).toBe('orthogonal') // no straight connection anywhere + const { useProjectStore } = await import('./projectStore') + const r = useProjectStore.getState().planRevision() + expect(r.ok).toBe(true) + if (!r.ok) return + const file = JSON.parse(r.text) + expect(file.project.semantics).toBe(REVISION_SEMANTICS) + const read = readProject(file.project) + expect(read.ok && read.project.semantics).toBe('loop-revision/10') + }) +}) diff --git a/src/store/graphStore.simrev.test.ts b/src/store/graphStore.simrev.test.ts index 4dfe095b..b40d27c2 100644 --- a/src/store/graphStore.simrev.test.ts +++ b/src/store/graphStore.simrev.test.ts @@ -83,6 +83,8 @@ describe('graphStore.simulationRev', () => { it('does NOT bump: a routing-only edge edit (route / waypoints are cosmetic — loop-revision/3 §R3-3)', () => { const { edgeId } = base() const set = (d: Record) => useGraphStore.getState().setEdgeData(edgeId, d as never) + // a new connection is orthogonal (issue #344 §ER14.1): start from a curve + set({ kind: 'resource', flow: '1' }) // route only expect(bumped(() => set({ kind: 'resource', flow: '1', route: 'orthogonal' }))).toBe(0) // waypoints only (route already present) diff --git a/src/store/graphStore.ts b/src/store/graphStore.ts index dd9a32dc..3bbb4d20 100644 --- a/src/store/graphStore.ts +++ b/src/store/graphStore.ts @@ -13,6 +13,16 @@ import { defaultNodeLabel } from '../i18n/nodeDefaults' import { relabelFramesForLocale, relabelNodesForLocale } from '../i18n/templateLabels/relabel' import { relabelModuleNodesForLocale } from '../i18n/moduleLabelSync' import { createNode, defaultData, nextId } from '../model/factory' +import { dragSnap, snapDrag } from '../model/layout/dragSnap' +import { useGuideStore } from './guideStore' +import { convertLayout, isLegacyLayout, migrateDocument } from './layoutConvert' +import { GRID, snapNodePosition } from '../model/layout/grid' +import { boxesOverlap, nearestFree } from '../model/layout/place' +import { BASE_NODE_H } from '../components/nodes/silhouette' + +/** a not-yet-measured node's width for the palette's free-spot search (the + * router's own default, `routeMap.ts`) */ +const NEW_NODE_W = 130 import { uniqueNodeLabel } from '../model/nodeLabel' import { insertGraph, type GraphDocLike } from '../model/moduleGraph' import { @@ -68,7 +78,18 @@ export type LockedRefusal = { ok: false; reason: 'locked' } * - `revision-apply`: the open document is edited in place (SEMANTICS-R.md * R-INV-8 — one undo entry); refused while the edit lock is on. */ export type LoadDocOptions = ( - | { mode: 'document-boundary'; canvasLocked: boolean } + | { + mode: 'document-boundary' + canvasLocked: boolean + /** issue #344 §DL2.8 — the opened file's `layoutVersion`: older than + * `LAYOUT_VERSION` ⇒ re-placed on the grid once, before the history + * starts. Absent ⇒ the caller's graph is already current (no conversion). */ + layoutVersion?: number + /** issue #344 step 3 — the document is a revision or proposal recorded + * before `loop-revision/10`: its Curved labels keep their recorded + * (Bézier middle) place (see `recordLabels`) */ + recordLabels?: boolean + } | { mode: 'revision-apply' } ) & { modelVersion?: ModelSemanticsVersion @@ -106,6 +127,8 @@ type HistoryEntry = { nodes: LoopNode[] edges: LoopEdge[] modelVersion: ModelSemanticsVersion + /** issue #344 step 3 — the record label rule in force (`recordLabels`) */ + recordLabels?: boolean sidecar: unknown } /** docs/large-graph-readability-saved-frames.md §SF11.1 "Move" — an opaque @@ -164,6 +187,15 @@ type GraphStore = { * per document: never returns to `1` except on a full new / v1 load. */ modelVersion: ModelSemanticsVersion + /** issue #344 step 3 (docs/edge-routing.md §ER15.1) — the document came + * from a revision or proposal whose header declares semantics before + * `loop-revision/10`, and its Curved labels are drawn where they were + * recorded (the Bézier middle), not in a free slot, so the record and its + * Review look as they did. Ends with the first shape or bend-point edit, or + * Tidy to grid (each undoable); the autosaved Project header carries it as + * `labelLayoutVersion` (0 = this rule, 1 = the current placement). */ + recordLabels: boolean + past: HistoryEntry[] future: HistoryEntry[] canUndo: boolean @@ -203,7 +235,15 @@ type GraphStore = { onEdgesChange: (changes: EdgeChange[]) => void onConnect: (conn: Connection) => void - addNodeAt: (kind: NodeKind, position: XY) => void + /** issue #344 §DL3 — the node lands on the grid unless `free` (Alt held at + * the drop); `findFree` steps on the grid to the nearest spot no node covers + * (the palette click, which has no drop point of its own) */ + addNodeAt: (kind: NodeKind, position: XY, opts?: { free?: boolean; findFree?: boolean }) => void + /** issue #344 §DL2.10 — re-place the current document on the grid by the + * same rules as the one-time conversion: ONE undo entry (graph + saved + * frames), cosmetic (no simulation change). Returns how many nodes moved; + * 0 ⇒ nothing changed and no entry was made. Refused while locked. */ + tidyToGrid: () => number /** docs/module-system.md §MS3 — merge a module (a plain graph fragment) into * the open graph as ONE atomic history entry: every module node/edge id is * re-issued, every `register` expr `@ref` / v2 `@param` flow is rewritten, @@ -216,7 +256,7 @@ type GraphStore = { * never touches `mcStore` or `frameStore` (§MS4a-B2 / B3). */ insertModule: ( module: GraphDocLike, - opts: { at: XY; confirmedPromotion?: boolean; bundledModuleId?: string }, + opts: { at: XY; confirmedPromotion?: boolean; bundledModuleId?: string; /** issue #344 §DL3 — Alt at the drop: the anchor is not snapped */ free?: boolean }, ) => InsertModuleResult /** docs/data-import.md §DI16 Phase 1B — commit a validated multi-table * import as ONE atomic history entry: every generated Parameter, every @@ -248,6 +288,15 @@ type GraphStore = { renameDataImportTable: (id: string, newLabel: string) => { ok: true } | { ok: false; reason: 'empty-table-name' | 'label-too-long' } | LockedRefusal updateNodeData: (id: string, patch: Record) => void setEdgeData: (id: string, data: LoopEdgeData) => void + /** issue #344 step 3 (docs/diagram-layout.md §DL4, SEMANTICS-R10.md) — set a + * connection's shape and bend points: `route` absent = Curved, `"straight"`, + * `"orthogonal"` with no `waypoints` = Auto, with 1 … 64 = Manual. ONE history + * entry per call, never merged with the next edit; nothing when unchanged or + * while the edit lock is on. Cosmetic: the run is not reset. */ + setEdgeRouting: (id: string, routing: EdgeRouting) => void + /** the same change with no history entry, for a bend-point gesture between + * `captureGestureSnapshot` and `pushGestureEntry` */ + setEdgeRoutingSilently: (id: string, routing: EdgeRouting) => void /** docs/flow-colour-and-compact-nodes.md FC-2.4 — set (`#RRGGBB` in any * form `readAccent` takes) or remove (`null`) the flow colour of these * nodes and edges as ONE undo entry. Elements that already have it are @@ -448,7 +497,7 @@ function writeAutosaveNow(): void { saveToStorage( s.nodes, s.edges, - autosaveProjectHeader, + withLabelLayout(autosaveProjectHeader, s.recordLabels), autosaveTimelineSeries, s.modelVersion, liveFrames(), @@ -457,6 +506,22 @@ function writeAutosaveNow(): void { ) } +/** issue #344 step 3 (§ER15.1) — the Project header of the autosave carries the + * label rule explicitly: `labelLayoutVersion` 0 = the record's own (legacy + * Curved labels), 1 = the current placement. Only a document with a header can + * hold the record rule; an ordinary document never gets the key. */ +export const LABEL_LAYOUT_VERSION = 1 +export function bootRecordLabels(header: unknown): boolean { + if (!header || typeof header !== 'object') return false + const h = header as { role?: unknown; labelLayoutVersion?: unknown } + if (h.role === 'proposal') return false + return h.labelLayoutVersion !== LABEL_LAYOUT_VERSION +} +function withLabelLayout(header: unknown, recordLabels: boolean): unknown { + if (!header || typeof header !== 'object') return header + return { ...(header as Record), labelLayoutVersion: recordLabels ? 0 : LABEL_LAYOUT_VERSION } +} + /** Write the pending debounced autosave RIGHT NOW, if there is one. Called on * `pagehide` and when the tab goes hidden: the 400 ms debounce otherwise * loses an edit made just before a close / reload / app switch (reproduced: @@ -485,6 +550,29 @@ if (typeof window !== 'undefined' && typeof document !== 'undefined') { }) } +// ── connection shapes (issue #344 step 3, SEMANTICS-R10.md) ──────────────── + +export type EdgeRouting = { route?: 'orthogonal' | 'straight'; waypoints?: { x: number; y: number }[] } + +/** the edges with `id`'s routing replaced, keys trailing as the reader writes + * them; null when nothing changes. Bend points are kept only beside + * `"orthogonal"` (R10-INV-3). */ +function withRouting(edges: LoopEdge[], id: string, r: EdgeRouting): LoopEdge[] | null { + const e = edges.find((x) => x.id === id) + if (!e || !e.data) return null + const { route: _r, waypoints: _w, accent, ...rest } = e.data as LoopEdgeData & EdgeRouting & { accent?: unknown } + const wps = r.route === 'orthogonal' && r.waypoints && r.waypoints.length > 0 ? r.waypoints.map((p) => ({ x: p.x, y: p.y })) : undefined + // `normalizeEdge`'s order: … route, waypoints, accent + const data = { + ...rest, + ...(r.route ? { route: r.route } : {}), + ...(wps ? { waypoints: wps } : {}), + ...(accent !== undefined ? { accent } : {}), + } as LoopEdgeData + if (JSON.stringify(data) === JSON.stringify(e.data)) return null + return edges.map((x) => (x.id === id ? { ...x, data } : x)) +} + // ── save boundary (SEMANTICS of an undo step) ─────────────────────────────── // One history entry per discrete action. Continuous actions coalesce: a node // drag is one entry; rapid edits to the same field within COALESCE_MS are one @@ -507,6 +595,33 @@ export function onlyAccentsDiffer( a: { nodes: LoopNode[]; edges: LoopEdge[]; modelVersion: ModelSemanticsVersion }, b: { nodes: LoopNode[]; edges: LoopEdge[]; modelVersion: ModelSemanticsVersion }, ): boolean { + return onlyCosmeticsDiffer(a, b, ['accent']) +} + +/** issue #344 step 3 — the same test with a connection's shape and bend points + * (`route`, `waypoints`) also counted as decoration: undoing or redoing a + * shape or bend-point edit does not reset the run either (SEMANTICS-R10.md + * R10-INV-2) */ +export function onlyDecorationDiffers( + a: { nodes: LoopNode[]; edges: LoopEdge[]; modelVersion: ModelSemanticsVersion }, + b: { nodes: LoopNode[]; edges: LoopEdge[]; modelVersion: ModelSemanticsVersion }, +): boolean { + return onlyCosmeticsDiffer(a, b, ['accent', 'route', 'waypoints']) +} + +function onlyCosmeticsDiffer( + a: { nodes: LoopNode[]; edges: LoopEdge[]; modelVersion: ModelSemanticsVersion }, + b: { nodes: LoopNode[]; edges: LoopEdge[]; modelVersion: ModelSemanticsVersion }, + edgeKeys: readonly string[], +): boolean { + const withoutEdgeKeys = (data: unknown): string => { + if (!data || typeof data !== 'object') return JSON.stringify(data) + const rest = { ...(data as Record) } + for (const k of edgeKeys) delete rest[k] + return JSON.stringify(rest) + } + const edgeKeysOf = (data: unknown): string => + JSON.stringify(edgeKeys.map((k) => (data as Record | undefined)?.[k] ?? null)) if (a.modelVersion !== b.modelVersion) return false if (a.nodes.length !== b.nodes.length || a.edges.length !== b.edges.length) return false let accentChanged = false @@ -526,8 +641,8 @@ export function onlyAccentsDiffer( (p.sourceHandle ?? null) !== (q.sourceHandle ?? null) || (p.targetHandle ?? null) !== (q.targetHandle ?? null) ) return false if (p.data === q.data) continue - if (withoutAccent(p.data) !== withoutAccent(q.data)) return false - if (accentOf(p.data) !== accentOf(q.data)) accentChanged = true + if (withoutEdgeKeys(p.data) !== withoutEdgeKeys(q.data)) return false + if (edgeKeysOf(p.data) !== edgeKeysOf(q.data)) accentChanged = true } return accentChanged } @@ -639,9 +754,9 @@ export const useGraphStore = create((set, get) => { if (tag === 'remove') queueMicrotask(() => { if (lastTag === 'remove') lastTag = '' }) clearPristine() // any edit / load / template — even a coalesced one — ends "pristine" if (coalesce) return - const { nodes, edges, modelVersion } = get() + const { nodes, edges, modelVersion, recordLabels } = get() set({ - past: [...get().past, { nodes, edges, modelVersion, sidecar: sidecarNow(framesOverride) }].slice(-HISTORY_MAX), + past: [...get().past, { nodes, edges, modelVersion, recordLabels, sidecar: sidecarNow(framesOverride) }].slice(-HISTORY_MAX), future: [], // a fresh action discards the redo branch AND its sidecars canUndo: true, canRedo: false, @@ -672,8 +787,8 @@ export const useGraphStore = create((set, get) => { notifyFrameChange: () => persist(), captureGestureSnapshot: () => { - const { nodes, edges, modelVersion } = get() - const entry: HistoryEntry = { nodes, edges, modelVersion, sidecar: sidecarNow() } + const { nodes, edges, modelVersion, recordLabels } = get() + const entry: HistoryEntry = { nodes, edges, modelVersion, recordLabels, sidecar: sidecarNow() } return entry as unknown as GestureSnapshot }, pushGestureEntry: (snapshot) => { @@ -721,6 +836,11 @@ export const useGraphStore = create((set, get) => { pendingInitialView: null, pristineSample: stored == null, modelVersion: bootModelVersion, + // issue #344 step 3 (§ER15.1) — the autosaved Project header says which + // label rule the document had; a header written before the key existed is + // a record from before `loop-revision/10`. A proposal session is not + // restored (its header is dropped at boot), so it never keeps the rule. + recordLabels: bootRecordLabels(stored?.project), past: [], future: [], canUndo: false, @@ -728,7 +848,7 @@ export const useGraphStore = create((set, get) => { undo: () => { if (editGuard()) return // #334 — refused while the edit lock is on - const { past, future, nodes, edges, modelVersion } = get() + const { past, future, nodes, edges, modelVersion, recordLabels } = get() if (!past.length) return const prev = past[past.length - 1] lastTag = '' @@ -736,15 +856,16 @@ export const useGraphStore = create((set, get) => { nodes: prev.nodes, edges: prev.edges, modelVersion: prev.modelVersion, + recordLabels: prev.recordLabels ?? false, past: past.slice(0, -1), - future: [{ nodes, edges, modelVersion, sidecar: sidecarNow() }, ...future].slice(0, HISTORY_MAX), + future: [{ nodes, edges, modelVersion, recordLabels, sidecar: sidecarNow() }, ...future].slice(0, HISTORY_MAX), canUndo: past.length > 1, canRedo: true, selectedNodeId: null, selectedEdgeId: null, }) - // FC-2.4 — undoing a flow colour is not a model change - if (onlyAccentsDiffer({ nodes, edges, modelVersion }, prev)) clearPristine() + // FC-2.4 / R10-INV-2 — undoing a flow colour, a shape or a bend point is not a model change + if (onlyDecorationDiffers({ nodes, edges, modelVersion }, prev)) clearPristine() else bump() persist() restoreSidecar(prev.sidecar) // restore the project header + saved frames this entry carried @@ -752,7 +873,7 @@ export const useGraphStore = create((set, get) => { redo: () => { if (editGuard()) return // #334 — refused while the edit lock is on - const { past, future, nodes, edges, modelVersion } = get() + const { past, future, nodes, edges, modelVersion, recordLabels } = get() if (!future.length) return const next = future[0] lastTag = '' @@ -760,15 +881,16 @@ export const useGraphStore = create((set, get) => { nodes: next.nodes, edges: next.edges, modelVersion: next.modelVersion, - past: [...past, { nodes, edges, modelVersion, sidecar: sidecarNow() }].slice(-HISTORY_MAX), + recordLabels: next.recordLabels ?? false, + past: [...past, { nodes, edges, modelVersion, recordLabels, sidecar: sidecarNow() }].slice(-HISTORY_MAX), future: future.slice(1), canUndo: true, canRedo: future.length > 1, selectedNodeId: null, selectedEdgeId: null, }) - // FC-2.4 — redoing a flow colour is not a model change - if (onlyAccentsDiffer({ nodes, edges, modelVersion }, next)) clearPristine() + // FC-2.4 / R10-INV-2 — redoing a flow colour, a shape or a bend point is not a model change + if (onlyDecorationDiffers({ nodes, edges, modelVersion }, next)) clearPristine() else bump() persist() restoreSidecar(next.sidecar) // restore the project header + saved frames this entry carried @@ -779,12 +901,23 @@ export const useGraphStore = create((set, get) => { // a move, add, remove or replace does not (a `replace` that only flips // `selected` — React Flow's `setNodes` from a panel's reveal — is a // selection, and passes) - const changes = editGuard() + let changes = editGuard() ? all.filter((c) => c.type === 'select' || c.type === 'dimensions' || selectionOnlyReplace(c, get().nodes)) : all if (changes.length === 0 && all.length > 0) return const dragging = changes.some((c) => c.type === 'position' && c.dragging) const settled = changes.some((c) => c.type === 'position' && c.dragging === false) + // issue #344 §DL3 — a pointer drag lands by the smart guides' one + // correction (§DL3.6: port row, centre / edges, grid; Alt = free), an + // arrow-key move on the grid; the guides show where it went + if (dragging || settled) { + const s = snapDrag(changes, get().nodes) + changes = s.changes + if (s.guides) { + if (dragSnap.active) useGuideStore.getState().show(s.guides, s.faint) + else useGuideStore.getState().flash(s.guides) + } + } const removed = changes.some((c) => c.type === 'remove') // 'remove' tag: a node deletion and the connected-edge deletions React Flow // cascades arrive as separate calls in the same tick — coalesce them into @@ -824,7 +957,7 @@ export const useGraphStore = create((set, get) => { ? conn.targetHandle : 'state-target', type: 'loop', - data: { kind: 'state', mode: 'trigger', expr: '' }, + data: { kind: 'state', mode: 'trigger', expr: '', route: 'orthogonal' }, // issue #344 §ER14.1 — new connections are routed } : { id: nextId('e'), @@ -834,7 +967,7 @@ export const useGraphStore = create((set, get) => { sourceHandle: 'out', targetHandle: 'in', type: 'loop', - data: { kind: 'resource', flow: '1' }, + data: { kind: 'resource', flow: '1', route: 'orthogonal' }, // issue #344 §ER14.1 } commit('') set({ edges: addEdge(edge, get().edges) }) @@ -842,8 +975,35 @@ export const useGraphStore = create((set, get) => { persist() }, - addNodeAt: (kind, position) => { + tidyToGrid: () => { + if (editGuard()) return 0 // #334 — refused while the edit lock is on + const g = get() + const frames = (frameSidecar?.get() as SavedFrame[] | null | undefined) ?? [] + const c = convertLayout({ nodes: g.nodes, edges: g.edges, frames: [...frames] }) + const moved = c.nodes.filter((n, i) => n !== g.nodes[i]).length + const edgesChanged = c.edges.some((e, i) => e !== g.edges[i]) + const framesChanged = (c.frames ?? []).some((f, i) => f !== frames[i]) + // a record already on the grid still leaves its recorded label rule + if (!moved && !edgesChanged && !framesChanged && !g.recordLabels) return 0 + commit('') // the pre-tidy graph + saved frames, ONE entry + lastTag = '' + set({ nodes: c.nodes, edges: c.edges, recordLabels: false }) // §ER15.1 — so does a tidied record + if (framesChanged) frameSidecar?.set(c.frames) + clearPristine() + persist() + return moved + }, + + addNodeAt: (kind, position, opts) => { if (editGuard()) return // #334 — refused while the edit lock is on + if (!opts?.free) { + const want = snapNodePosition(position) + if (opts?.findFree) { + const boxes = get().nodes.map((n) => ({ x: n.position.x, y: n.position.y, w: n.measured?.width ?? NEW_NODE_W, h: n.measured?.height ?? BASE_NODE_H })) + const w = kind === 'gate' ? 134 : NEW_NODE_W + position = nearestFree(want, (p) => !boxes.some((b) => boxesOverlap({ x: p.x, y: p.y, w, h: BASE_NODE_H }, b, GRID))) ?? want + } else position = want + } commit('') // docs/localization.md §L3.4a — the name is resolved for the CURRENT UI // language at placement, then de-duplicated against the graph's display @@ -969,6 +1129,23 @@ export const useGraphStore = create((set, get) => { persist() }, + setEdgeRouting: (id, routing) => { + if (editGuard()) return // #334 — refused while the edit lock is on + const next = withRouting(get().edges, id, routing) + if (!next) return + commit('') + lastTag = '' // the next edit is its own entry, whatever it is + set({ edges: next, recordLabels: false }) // §ER15.1 — an edited shape takes the current label rule + persist() + }, + setEdgeRoutingSilently: (id, routing) => { + if (editGuard()) return // #334 — refused while the edit lock is on + const next = withRouting(get().edges, id, routing) + if (!next) return + set({ edges: next, recordLabels: false }) + persist() + }, + removeNode: (id) => { if (editGuard()) return // #334 — refused while the edit lock is on commit('') @@ -1002,6 +1179,7 @@ export const useGraphStore = create((set, get) => { selectedNodeId: null, selectedEdgeId: null, modelVersion: 1, + recordLabels: false, loadRev: get().loadRev + 1, pendingInitialView: null, // §MML3 — no menu framing survives a New // `newGraph` does NOT bump `fitRev`: an empty canvas has nothing to fit, @@ -1028,6 +1206,7 @@ export const useGraphStore = create((set, get) => { selectedNodeId: null, selectedEdgeId: null, modelVersion, + recordLabels: false, loadRev: get().loadRev + 1, fitRev: get().fitRev + 1, // §MML3 — a menu-opened Template's framing hint; `null` for a paste @@ -1044,10 +1223,10 @@ export const useGraphStore = create((set, get) => { }, loadJSON: (text) => { - const { nodes, edges, recommendedRunConfig, modelVersion, frames, dataImports } = deserialize(text) + const { nodes, edges, recommendedRunConfig, modelVersion, layoutVersion, frames, dataImports } = deserialize(text) get().loadDoc( { nodes, edges }, - { mode: 'document-boundary', canvasLocked: recommendedRunConfig?.canvasLocked === true, modelVersion, frames, dataImports }, + { mode: 'document-boundary', canvasLocked: recommendedRunConfig?.canvasLocked === true, modelVersion, layoutVersion, frames, dataImports }, ) return recommendedRunConfig }, @@ -1058,8 +1237,18 @@ export const useGraphStore = create((set, get) => { * `simulationRev` step (SEMANTICS-W.md §W5.1). `opts.mode` (#334) says * whether this is another document (empty history, its own lock) or a * revision Apply on this one (one undo entry, refused while locked). */ - loadDoc: ({ nodes, edges }, opts) => { - const { modelVersion = 1, frames, dataImports } = opts + loadDoc: (doc, opts) => { + const { modelVersion = 1, dataImports } = opts + let { nodes, edges } = doc + let frames = opts.frames + // issue #344 §DL2.8 — an older layout is re-placed once, here, before + // `enterDocument` starts the history: never an undo entry + if (opts.mode === 'document-boundary' && opts.layoutVersion != null && isLegacyLayout(opts.layoutVersion)) { + const c = migrateDocument({ nodes, edges, frames: frames ? [...frames] : undefined }) + nodes = c.nodes + edges = c.edges + frames = c.frames + } let history: ReturnType | null = null if (opts.mode === 'document-boundary') history = enterDocument(opts.canvasLocked) else { @@ -1076,6 +1265,9 @@ export const useGraphStore = create((set, get) => { selectedNodeId: null, selectedEdgeId: null, modelVersion, + // issue #344 step 3 — another document says which label rule it takes; + // a revision Apply edits this one and keeps its rule + recordLabels: opts.mode === 'document-boundary' ? opts.recordLabels === true : get().recordLabels, loadRev: get().loadRev + 1, pendingInitialView: null, // §MML3 — file / Share / Workspace keeps its own camera }) @@ -1091,7 +1283,7 @@ export const useGraphStore = create((set, get) => { const built = insertGraph( { nodes: g.nodes, edges: g.edges, modelVersion: g.modelVersion }, mod, - { at: opts.at }, + { at: opts.free ? opts.at : snapNodePosition(opts.at) }, // issue #344 §DL3 — the anchor on the grid ) if (!built.ok) return { ok: false, reason: built.reason } // §MS3.4 / MS7-2 — a v1 host + v2 module promotion is never silent. Bail diff --git a/src/store/guideStore.ts b/src/store/guideStore.ts new file mode 100644 index 00000000..1ad8ac9f --- /dev/null +++ b/src/store/guideStore.ts @@ -0,0 +1,44 @@ +// docs/diagram-layout.md §DL3.6 (issue #344) — the smart guides on screen: set +// by the drag snap (graphStore) during a pointer drag, briefly after a keyboard +// move, cleared when the gesture ends. Display only: nothing here reaches a +// document, the history or a simulation. + +import { create } from 'zustand' +import type { Guide } from '../model/layout/smartGuides' + +/** how long the row and column stay up after a keyboard move, ms */ +export const KEY_GUIDE_MS = 1200 + +type GuideState = { + guides: Guide[] + /** a free (Alt) move: drawn faint */ + faint: boolean + show: (guides: Guide[], faint: boolean) => void + /** shown, then cleared after `KEY_GUIDE_MS` unless something replaced them */ + flash: (guides: Guide[]) => void + clear: () => void +} + +let timer: ReturnType | undefined + +const same = (a: Guide[], b: Guide[]): boolean => + a.length === b.length && a.every((g, i) => g.axis === b[i].axis && g.at === b[i].at && g.from === b[i].from && g.to === b[i].to && g.kind === b[i].kind && g.strong === b[i].strong) + +export const useGuideStore = create((set, get) => ({ + guides: [], + faint: false, + show: (guides, faint) => { + clearTimeout(timer) + if (get().faint === faint && same(get().guides, guides)) return + set({ guides, faint }) + }, + flash: (guides) => { + clearTimeout(timer) + set({ guides, faint: false }) + timer = setTimeout(() => set({ guides: [] }), KEY_GUIDE_MS) + }, + clear: () => { + clearTimeout(timer) + if (get().guides.length) set({ guides: [] }) + }, +})) diff --git a/src/store/layoutBoot.ts b/src/store/layoutBoot.ts new file mode 100644 index 00000000..b81fe918 --- /dev/null +++ b/src/store/layoutBoot.ts @@ -0,0 +1,19 @@ +// docs/diagram-layout.md §DL2.8 (issue #344) — the autosave record's one-time +// layout conversion, run by the boot module (`src/main.tsx`) after the storage +// session is open and BEFORE `startApp` evaluates the stores, which read the +// record as they are created (the graph store its nodes, the frame store its +// frames). Converting the record in place keeps the two consistent and puts the +// conversion before any history exists. Imports no store. + +import { loadFromStorage, saveToStorage } from '../model/serialize' +import { isLegacyLayout, migrateDocument } from './layoutConvert' + +/** convert a legacy autosave record once; a current one (or none) is untouched, + * and so is one that carries a Project header — a revision's layout is never + * re-placed automatically (§DL2.8), only by Tidy to grid. */ +export function convertAutosaveLayout(): void { + const stored = loadFromStorage() + if (!stored || !isLegacyLayout(stored.layoutVersion) || stored.project != null) return + const c = migrateDocument({ nodes: stored.nodes, edges: stored.edges, frames: stored.frames }) + saveToStorage(c.nodes, c.edges, stored.project, stored.recommendedRunConfig?.timelineSeries, stored.modelVersion, c.frames, stored.dataImports) +} diff --git a/src/store/layoutConvert.test.ts b/src/store/layoutConvert.test.ts new file mode 100644 index 00000000..f0d6e9a2 --- /dev/null +++ b/src/store/layoutConvert.test.ts @@ -0,0 +1,257 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import './editPolicy' +import { useGraphStore } from './graphStore' +import { useFrameStore } from './frameStore' +import { useUiStore } from './uiStore' +import { nodeOnGrid } from '../model/layout/grid' +import { deserialize, LAYOUT_VERSION, loadFromStorage, saveToStorage, serialize, STORAGE_KEY } from '../model/serialize' +import { semanticDigest } from '../model/workspace' +import { convertAutosaveLayout } from './layoutBoot' +import { convertLayout, coveredBeforeConversion, heldBeforeConversion, migrateDocument } from './layoutConvert' +import { canonicalBox } from '../components/nodes/canonicalBox' +import type { LoopNode } from '../model/types' + +// issue #344 §DL2.8 / §DL2.10 — the one-time conversion of an older layout +// and Tidy to grid + +const g = () => useGraphStore.getState() +const pool = (id: string, x: number, y: number) => + ({ id, type: 'pool', position: { x, y }, data: { kind: 'pool', label: id, initial: 0, capacity: null, mode: 'pullAny', activation: 'passive' } }) as never +const flow = (id: string, s: string, t: string) => + ({ id, source: s, target: t, sourceHandle: 'out', targetHandle: 'in', type: 'loop', data: { kind: 'resource', flow: '1' } }) as never + +/** a file as written before the grid: no `layoutVersion` */ +const legacyFile = (): string => { + const doc = JSON.parse(serialize([pool('a', 3, 7), pool('b', 205, 11), pool('c', 418, 260.4)], [flow('e1', 'a', 'b'), flow('e2', 'b', 'c')])) + delete doc.layoutVersion + return JSON.stringify(doc) +} + +beforeEach(() => { + useUiStore.getState().setCanvasLocked(false) + g().newGraph() +}) + +describe('layoutVersion in the file', () => { + it('is written on every save, and an older file reads as 0', () => { + expect(JSON.parse(serialize([], [])).layoutVersion).toBe(LAYOUT_VERSION) + expect(deserialize(legacyFile()).layoutVersion).toBe(0) + expect(deserialize(serialize([], [])).layoutVersion).toBe(LAYOUT_VERSION) + }) +}) + +describe('the one-time conversion', () => { + it('re-places a legacy file on the grid when it is opened, with no undo entry', () => { + g().loadJSON(legacyFile()) + for (const n of g().nodes) expect(nodeOnGrid(n.position)).toBe(true) + expect(g().canUndo).toBe(false) + }) + + it('never changes the engine digest', async () => { + const before = deserialize(legacyFile()) + g().loadJSON(legacyFile()) + expect(await semanticDigest({ nodes: g().nodes, edges: g().edges }, 1)).toBe(await semanticDigest({ nodes: before.nodes, edges: before.edges }, 1)) + }) + + it('runs once: the saved document reopens unchanged', () => { + g().loadJSON(legacyFile()) + const saved = serialize(g().nodes, g().edges) + const positions = g().nodes.map((n) => n.position) + g().loadJSON(saved) + expect(g().nodes.map((n) => n.position)).toEqual(positions) + }) + + it('keeps a free (off-grid) position in a CURRENT document', () => { + const current = serialize([pool('a', 3, 7)], []) + g().loadJSON(current) + expect(g().nodes[0].position).toEqual({ x: 3, y: 7 }) + }) + + it('does not move a Template (loadGraph)', () => { + g().loadGraph({ nodes: [pool('t', 3, 7)], edges: [] }, { canvasLocked: false }) + expect(g().nodes[0].position).toEqual({ x: 3, y: 7 }) + }) + + it('is deterministic and idempotent', () => { + const d = deserialize(legacyFile()) + const a = convertLayout({ nodes: d.nodes, edges: d.edges, frames: [] }) + const b = convertLayout({ nodes: d.nodes, edges: d.edges, frames: [] }) + expect(JSON.stringify(b)).toBe(JSON.stringify(a)) + const again = convertLayout(a) + expect(again.nodes.map((n) => n.position)).toEqual(a.nodes.map((n) => n.position)) + }) +}) + +describe("the conversion keeps a saved frame's members (#337)", () => { + // a Converter whose box as drawn when saved fit the frame, and which #337 + // draws wider: 12 + 14 + 135 + 12 = 173 px then, wider now + const conv = (id: string, x: number, y: number) => + ({ id, type: 'converter', position: { x, y }, data: { kind: 'converter', label: 'Gekaufte Nahrung (Einheiten)', mode: 'pullAny' } }) as unknown as LoopNode + const frame = { id: 'f', label: 'F', rect: { x: 0, y: 0, w: 220, h: 120 } } + const inside = (n: { position: { x: number; y: number } }, w: number, h: number, r: { x: number; y: number; w: number; h: number }) => + n.position.x >= r.x && n.position.y >= r.y && n.position.x + w <= r.x + r.w && n.position.y + h <= r.y + r.h + + it('decides membership by the boxes of that time: a member #337 widened is still held whole', () => { + const doc = { nodes: [conv('c', 24, 24)], edges: [], frames: [frame] } + expect(heldBeforeConversion(doc)).toEqual({ f: ['c'] }) + const m = migrateDocument(doc) + const now = canonicalBox(m.nodes[0]) + expect(now.w).toBeGreaterThan(173) + expect(inside(m.nodes[0], now.w, now.h, m.frames![0].rect)).toBe(true) + }) + + it('a node that only reached into the frame is not held, but the frame now covers it (its centre was inside)', () => { + // 120 + 173 / 2 = 206.5 < 220: the centre was inside, the box was not + const doc = { nodes: [conv('c', 120, 24)], edges: [], frames: [frame] } + expect(heldBeforeConversion(doc)).toEqual({ f: [] }) + expect(coveredBeforeConversion(doc)).toEqual({ f: ['c'] }) + const m = migrateDocument(doc) + expect(inside(m.nodes[0], canonicalBox(m.nodes[0]).w, canonicalBox(m.nodes[0]).h, m.frames![0].rect)).toBe(true) + }) + + it('a node whose centre was outside stays outside: the frame is not grown for it', () => { + const doc = { nodes: [conv('a', 24, 24), conv('c', 200, 24)], edges: [], frames: [{ ...frame, rect: { x: 0, y: 0, w: 230, h: 120 } }] } + expect(coveredBeforeConversion(doc)).toEqual({ f: ['a'] }) + const m = migrateDocument(doc) + const c = m.nodes.find((n) => n.id === 'c')! + expect(m.frames![0].rect.x + m.frames![0].rect.w).toBeLessThanOrEqual(c.position.x) + }) + + it('keeps every frame side at least 24 px from the nodes it shows, and its own padding where it had more', () => { + const doc = { nodes: [conv('c', 64, 40)], edges: [], frames: [{ ...frame, rect: { x: 0, y: 0, w: 400, h: 160 } }] } + const m = migrateDocument(doc) + const r = m.frames![0].rect + const n = m.nodes[0] + const b = canonicalBox(n) + expect(n.position.x - r.x).toBeGreaterThanOrEqual(64 - 16) // its 64 px, give or take the grid + expect(r.x + r.w - (n.position.x + b.w)).toBeGreaterThanOrEqual(24) + expect(r.y + r.h - (n.position.y + b.h)).toBeGreaterThanOrEqual(24) + }) + + it('Tidy to grid reads the CURRENT boxes for the same rule', () => { + const doc = { nodes: [conv('c', 24, 24)], edges: [], frames: [frame] } + const t = convertLayout(doc) + expect(inside(t.nodes[0], canonicalBox(t.nodes[0]).w, canonicalBox(t.nodes[0]).h, t.frames![0].rect)).toBe(true) + }) + + it('keeps the 48 x 32 clearance between nodes (one-time conversion and Tidy alike)', () => { + const doc = { nodes: [conv('a', 0, 0), conv('b', 200, 0)], edges: [], frames: [] } + for (const out of [migrateDocument(doc), convertLayout(doc)]) { + const [a, b] = out.nodes + expect(b.position.x - (a.position.x + canonicalBox(a).w)).toBeGreaterThanOrEqual(48) + } + }) +}) + +describe('the route migration (§ER14.1)', () => { + const routeOf = (id: string) => (g().edges.find((e) => e.id === id)!.data as { route?: string }).route + + it('an ordinary pre-grid document gets route "orthogonal" written on every connection', () => { + g().loadJSON(legacyFile()) + expect(routeOf('e1')).toBe('orthogonal') + expect(routeOf('e2')).toBe('orthogonal') + // and it is saved that way: the file now says so explicitly + const saved = JSON.parse(serialize(g().nodes, g().edges)) as { edges: { data: { route?: string } }[] } + expect(saved.edges.every((e) => e.data.route === 'orthogonal')).toBe(true) + }) + + it('keeps the engine digest; only cosmetic content changes', async () => { + const before = deserialize(legacyFile()) + g().loadJSON(legacyFile()) + expect(await semanticDigest({ nodes: g().nodes, edges: g().edges }, 1)).toBe(await semanticDigest({ nodes: before.nodes, edges: before.edges }, 1)) + }) + + it('a CURRENT document keeps an absent route: it is still a curve', () => { + g().loadJSON(serialize([pool('a', 0, 4), pool('b', 208, 4)], [flow('e1', 'a', 'b')])) + expect(routeOf('e1')).toBeUndefined() + }) + + it('Tidy to grid re-places positions only: it never writes a route', () => { + g().loadJSON(serialize([pool('a', 3, 7), pool('b', 205, 11)], [flow('e1', 'a', 'b')])) + g().tidyToGrid() + expect(routeOf('e1')).toBeUndefined() + }) + + it('a new connection is created orthogonal', () => { + g().loadJSON(serialize([pool('a', 0, 4), pool('b', 208, 4)], [])) + g().onConnect({ source: 'a', target: 'b', sourceHandle: 'out', targetHandle: 'in' }) + expect((g().edges[0].data as { route?: string }).route).toBe('orthogonal') + }) +}) + +describe('the autosave record at boot', () => { + class MemStorage { + m = new Map() + getItem(k: string) { return this.m.has(k) ? this.m.get(k)! : null } + setItem(k: string, v: string) { this.m.set(k, String(v)) } + removeItem(k: string) { this.m.delete(k) } + clear() { this.m.clear() } + key(i: number) { return [...this.m.keys()][i] ?? null } + get length() { return this.m.size } + } + beforeEach(() => { + vi.stubGlobal('localStorage', new MemStorage()) + }) + afterEach(() => { + vi.unstubAllGlobals() + }) + + /** write a pre-grid autosave record, optionally with a Project header */ + const writeLegacyRecord = (project?: unknown) => { + saveToStorage([pool('a', 3, 7), pool('b', 205, 11)], [flow('e1', 'a', 'b')], project) + const rec = JSON.parse(localStorage.getItem(STORAGE_KEY)!) + delete rec.layoutVersion + localStorage.setItem(STORAGE_KEY, JSON.stringify(rec)) + } + + it('is re-placed once, and stamped current', () => { + writeLegacyRecord() + convertAutosaveLayout() + const stored = loadFromStorage()! + for (const n of stored.nodes) expect(nodeOnGrid(n.position)).toBe(true) + expect(stored.layoutVersion).toBe(LAYOUT_VERSION) + expect((stored.edges[0].data as { route?: string }).route).toBe('orthogonal') + }) + + it('keeps its layout when it carries a Project header — a revision is never re-placed automatically', () => { + writeLegacyRecord({ projectId: 'p', revisionId: 'r', role: 'revision' }) + const before = localStorage.getItem(STORAGE_KEY) + convertAutosaveLayout() + expect(localStorage.getItem(STORAGE_KEY)).toBe(before) + }) +}) + +describe('Tidy to grid', () => { + it('re-places the current document as ONE undo step', () => { + g().loadJSON(serialize([pool('a', 3, 7), pool('b', 205, 11)], [])) + expect(g().canUndo).toBe(false) + expect(g().tidyToGrid()).toBe(2) + for (const n of g().nodes) expect(nodeOnGrid(n.position)).toBe(true) + expect(g().canUndo).toBe(true) + g().undo() + expect(g().nodes.map((n) => n.position)).toEqual([{ x: 3, y: 7 }, { x: 205, y: 11 }]) + expect(g().canUndo).toBe(false) + }) + + it('moves frames with it in the same step', () => { + g().loadJSON(serialize([pool('a', 3, 7)], [], undefined, undefined, undefined, 1, [{ id: 'f', label: 'F', rect: { x: -9, y: -21, w: 200, h: 120 } }])) + g().tidyToGrid() + const rect = useFrameStore.getState().frames[0].rect + expect(Math.abs(rect.x % 16)).toBe(0) + g().undo() + expect(useFrameStore.getState().frames[0].rect).toEqual({ x: -9, y: -21, w: 200, h: 120 }) + }) + + it('makes no entry when nothing would move', () => { + g().loadJSON(serialize([pool('a', 32, 4)], [])) + expect(g().tidyToGrid()).toBe(0) + expect(g().canUndo).toBe(false) + }) + + it('is refused while the canvas is locked', () => { + g().loadJSON(serialize([pool('a', 3, 7)], [])) + useUiStore.getState().setCanvasLocked(true) + expect(g().tidyToGrid()).toBe(0) + expect(g().nodes[0].position).toEqual({ x: 3, y: 7 }) + }) +}) diff --git a/src/store/layoutConvert.ts b/src/store/layoutConvert.ts new file mode 100644 index 00000000..700d1c0a --- /dev/null +++ b/src/store/layoutConvert.ts @@ -0,0 +1,100 @@ +// docs/diagram-layout.md §DL2.8 (issue #344) — the one-time layout conversion. +// +// A document whose `layoutVersion` is older than `LAYOUT_VERSION` is re-placed +// on the grid ONCE, when it is opened, before its history starts: never an +// undo entry. The result follows only from the document and the canonical node +// boxes (`canonicalBox`), so it is the same in every language and on every +// platform. The next save writes the current `layoutVersion`, so it never runs +// again for that document; Templates and bundled modules are never converted +// (their sources are re-placed by hand). No store is imported here: the boot +// module runs this on the autosave record before any store reads it. + +import type { LoopEdge, LoopEdgeData, LoopNode } from '../model/types' +import { LAYOUT_VERSION, type SavedFrame } from '../model/serialize' +import { replaceOnGrid } from '../model/layout/replace' +import { centreInside, fullyInside, LAYOUT_CLEARANCE, rebuildFrameRect } from '../model/layout/frameRebuild' +import { canonicalBox, type CanonicalBox, legacyCanonicalBoxBeforeOutlineContainment } from '../components/nodes/canonicalBox' + +export type ConvertibleDoc = { nodes: LoopNode[]; edges: LoopEdge[]; frames?: SavedFrame[] } +type BoxOf = (node: LoopNode) => CanonicalBox + +/** whether a document's layout predates the current rules */ +export const isLegacyLayout = (layoutVersion: number | undefined): boolean => (layoutVersion ?? 0) < LAYOUT_VERSION + +const framesOf = (doc: ConvertibleDoc, inFrame: (b: { x: number; y: number; w: number; h: number }, r: SavedFrame['rect']) => boolean, boxOf: BoxOf): Record => { + const out: Record = {} + for (const f of doc.frames ?? []) out[f.id] = doc.nodes.filter((n) => inFrame({ ...n.position, ...boxOf(n) }, f.rect)).map((n) => n.id).sort() + return out +} + +/** which nodes each saved frame HELD as the document was drawn when it was + * saved: fully inside, by the boxes of that time + * (`legacyCanonicalBoxBeforeOutlineContainment`), decided BEFORE any node's + * size or place changes — so a node #337 draws wider never falls out of its + * frame, and a node that only reached into a frame is never taken in. */ +export const heldBeforeConversion = (doc: ConvertibleDoc, boxOf: BoxOf = legacyCanonicalBoxBeforeOutlineContainment): Record => + framesOf(doc, fullyInside, boxOf) + +/** which nodes each saved frame SHOWS: their centre inside it, by the same + * boxes. It never decides membership; the rebuilt frame only covers them, so + * no frame edge runs through a node that sat in it (§DL2.9) */ +export const coveredBeforeConversion = (doc: ConvertibleDoc, boxOf: BoxOf = legacyCanonicalBoxBeforeOutlineContainment): Record => + framesOf(doc, centreInside, boxOf) + +/** the document re-placed on the grid (positions, frames, waypoints); every + * other field untouched. Deterministic and idempotent. Every node keeps the + * Templates' clearance (`LAYOUT_CLEARANCE`, 48 x 32) from the next; each saved + * frame is rebuilt around the nodes it held and the nodes it showed, at their + * new place and current size, keeping its padding on each side (at least + * 24 px). `boxBefore` is each node's box as the document was drawn before + * (the one-time conversion: the frozen pre-#337 box; Tidy to grid: the + * current one). */ +export function convertLayout(doc: T, boxBefore: BoxOf = canonicalBox): T { + const r = replaceOnGrid(doc.nodes, doc.edges, doc.frames ?? [], canonicalBox, { gap: LAYOUT_CLEARANCE }) + const held = heldBeforeConversion(doc, boxBefore) + const covered = coveredBeforeConversion(doc, boxBefore) + const byId = new Map(doc.nodes.map((n) => [n.id, n])) + const before = (id: string) => ({ ...byId.get(id)!.position, ...boxBefore(byId.get(id)!) }) + const after = (id: string) => ({ ...(r.positions[id] ?? byId.get(id)!.position), ...canonicalBox(byId.get(id)!) }) + const nodes = doc.nodes.map((n) => { + const p = r.positions[n.id] + return p && (p.x !== n.position.x || p.y !== n.position.y) ? { ...n, position: { x: p.x, y: p.y } } : n + }) + const edges = doc.edges.map((e) => { + const wps = r.waypoints[e.id] + if (!wps || !e.data) return e + if (wps.length === 0) { + // §ER5.1 — the writer never emits `waypoints: []` + const { waypoints: _drop, ...rest } = e.data as LoopEdgeData & { waypoints?: unknown } + return { ...e, data: rest as LoopEdgeData } + } + return { ...e, data: { ...e.data, waypoints: wps } as LoopEdgeData } + }) + const frames = doc.frames?.map((f) => { + const members = [...new Set([...(held[f.id] ?? []), ...(covered[f.id] ?? [])])].sort() + const rect = rebuildFrameRect(f.rect, members, before, after, r.frames[f.id] ?? f.rect) + const same = rect.x === f.rect.x && rect.y === f.rect.y && rect.w === f.rect.w && rect.h === f.rect.h + return same ? f : { ...f, rect } + }) + return { ...doc, nodes, edges, ...(doc.frames ? { frames } : {}) } +} + +/** docs/edge-routing.md §ER14.1 — the migration of an ordinary pre-grid + * document: its layout re-placed (`convertLayout`) and every connection that + * has no `route` (a Bézier) given `route: "orthogonal"` explicitly, so it is + * drawn by the router from now on. An absent `route` keeps meaning Bézier + * everywhere else — a revision or proposal is never migrated, and Tidy to grid + * re-places positions only. A connection already `orthogonal` keeps its + * waypoints (Manual) or has none (Auto). */ +export function migrateDocument(doc: T): T { + // the frames' nodes as the document was drawn when it was saved (Tidy to + // grid, on a document already drawn by the current rules, reads the current + // boxes) + const placed = convertLayout(doc, legacyCanonicalBoxBeforeOutlineContainment) + const edges = placed.edges.map((e) => + e.data && (e.data as { route?: unknown }).route === undefined + ? { ...e, data: { ...e.data, route: 'orthogonal' } as LoopEdgeData } + : e, + ) + return { ...placed, edges } +} diff --git a/src/store/projectStore.test.ts b/src/store/projectStore.test.ts index 14436da5..2c1021fb 100644 --- a/src/store/projectStore.test.ts +++ b/src/store/projectStore.test.ts @@ -341,6 +341,8 @@ describe('projectStore — routing is loop-revision/3 cosmetic revision content useGraphStore.getState().setEdgeData(eid, d as never) vi.advanceTimersByTime(700) // past COALESCE_MS ⇒ each edit is its own undo entry } + // a new connection is orthogonal (issue #344 §ER14.1): start from a curve + set({ kind: 'resource', flow: '1' }) promote('2026-09-09T00:00:00Z') const baseline = useProjectStore.getState().open!.baselineDigest diff --git a/src/store/projectStore.ts b/src/store/projectStore.ts index c6308be3..124a6ece 100644 --- a/src/store/projectStore.ts +++ b/src/store/projectStore.ts @@ -16,6 +16,7 @@ import { mintId, planProposalExport, planRevisionExport, + recordKeepsLabels, truncBytes, type AppliedProposal, type CanonicalContent, @@ -523,6 +524,9 @@ export const useProjectStore = create((set, get) => { { mode: 'document-boundary', canvasLocked: false, + // issue #344 step 3 — a proposal whose header declares semantics + // before `loop-revision/10` keeps its recorded label places (§ER15.1) + recordLabels: recordKeepsLabels(project), modelVersion: proposed.modelVersion ?? 1, frames: proposed.frames, dataImports: proposed.dataImports, diff --git a/src/store/revisionIO.test.ts b/src/store/revisionIO.test.ts index 5771933c..d5a7a29c 100644 --- a/src/store/revisionIO.test.ts +++ b/src/store/revisionIO.test.ts @@ -3,6 +3,7 @@ import { STORAGE_KEY } from '../model/serialize' import { useGraphStore } from './graphStore' import { useProjectStore } from './projectStore' import { routeImport } from './revisionIO' +import baseRevisionDoc from '../../examples/revision/base.revision.json' const recordProject = () => { const raw = localStorage.getItem(STORAGE_KEY) @@ -158,3 +159,31 @@ describe('routeImport (§R10 — Import ≠ Apply)', () => { expect(useGraphStore.getState().nodes.some((n) => n.id === 'bad')).toBe(true) }) }) + +describe('issue #344 step 3 — opening a record writes nothing of /10 into it (SEMANTICS-R10.md §R10-6)', () => { + it('a revision from before /10 opens as a record: the header keeps no semantics, the autosave keeps the rule only as labelLayoutVersion', async () => { + const { flushAutosave } = await import('./graphStore') + const text = JSON.stringify(baseRevisionDoc, null, 2) + const original = JSON.parse(text) + expect(original.project.semantics, 'the fixture is a pre-/10 record').toBeUndefined() + const r = await routeImport(text) + expect(r.kind).toBe('revision') + expect(useGraphStore.getState().recordLabels).toBe(true) + expect('semantics' in (useProjectStore.getState().open as object)).toBe(false) + flushAutosave() + const saved = recordProject() + expect(saved.revisionId).toBe(original.project.revisionId) + expect('semantics' in saved, 'no /10 header added by opening').toBe(false) + expect(saved.labelLayoutVersion).toBe(0) + expect(JSON.parse(text), 'the opened text itself is untouched').toEqual(original) + }) + + it('a record exported by this build declares /10; opening it takes the current rule', async () => { + const text = makeRevisionFile() + expect(JSON.parse(text).project.semantics).toBe('loop-revision/10') + useGraphStore.getState().newGraph() + useProjectStore.setState({ open: null, dirty: false, activePlanId: null }) + await routeImport(text) + expect(useGraphStore.getState().recordLabels).toBe(false) + }) +}) diff --git a/src/store/revisionIO.ts b/src/store/revisionIO.ts index bb0b01ed..79c93061 100644 --- a/src/store/revisionIO.ts +++ b/src/store/revisionIO.ts @@ -4,6 +4,7 @@ import { digestOfCanonical, graphStructureIssues, readRevisionSideAndProject, + recordKeepsLabels, type HunkSelection, type ProjectPayload, type ProposalBase, @@ -155,7 +156,11 @@ export async function routeImport(text: string): Promise { // PROVED (the declared one, or v2 for a recovered legacy file), then adopt // the header. `loaded` is that same projection, so the adopted baseline // digest equals what `projectStore.liveDigest()` computes right after. - const outcome = await importFile(text, { modelVersion: read.modelVersion }) + // issue #344 §DL2.8 — a revision opens exactly as recorded (no layout + // re-placement), or it would differ from its own revision on open. + // issue #344 step 3 — a revision whose header declares semantics before + // `loop-revision/10` keeps its recorded label places too (§ER15.1, §R10-6) + const outcome = await importFile(text, { modelVersion: read.modelVersion, keepLayout: true, recordLabels: recordKeepsLabels(read.project) }) useProjectStore.getState().openRevisionFromFile(read.project, digestOfCanonical(loaded)) return { kind: 'revision', outcome, project: read.project, legacyV2Recovered: read.legacyV2Recovered } } diff --git a/src/store/routeEditStore.ts b/src/store/routeEditStore.ts new file mode 100644 index 00000000..08d0bb77 --- /dev/null +++ b/src/store/routeEditStore.ts @@ -0,0 +1,45 @@ +// issue #344 step 3 (docs/diagram-layout.md §DL4, docs/edge-routing.md §ER16) — +// the route-editing session state: whether "Add bend" is armed for a +// connection, which bend point has the keyboard, and the last notice of the +// armed tool. UI state only: nothing here reaches a document, the history or a +// simulation. + +import { create } from 'zustand' + +/** the notice the armed tool shows when Enter finds no segment for a bend */ +export type RouteEditNotice = 'canvas.route.noBendRoom' + +type RouteEditState = { + /** the connection "Add bend" is armed for: the next click on its line (or + * Enter) inserts one bend point */ + addBendFor: string | null + /** the bend point that has the focus ring and the arrow keys */ + bend: { edgeId: string; index: number } | null + /** shown under the Add bend hint until the tool is armed or disarmed again */ + notice: RouteEditNotice | null + armAddBend: (edgeId: string | null) => void + selectBend: (bend: { edgeId: string; index: number } | null) => void + setNotice: (notice: RouteEditNotice | null) => void + /** all cleared (a selection change, the lock, another document) */ + clear: () => void +} + +export const useRouteEditStore = create((set, get) => ({ + addBendFor: null, + bend: null, + notice: null, + armAddBend: (edgeId) => { + if (get().addBendFor !== edgeId || get().notice !== null) set({ addBendFor: edgeId, notice: null }) + }, + selectBend: (bend) => { + const cur = get().bend + if (cur?.edgeId === bend?.edgeId && cur?.index === bend?.index) return + set({ bend }) + }, + setNotice: (notice) => { + if (get().notice !== notice) set({ notice }) + }, + clear: () => { + if (get().addBendFor !== null || get().bend !== null || get().notice !== null) set({ addBendFor: null, bend: null, notice: null }) + }, +})) diff --git a/src/store/routeMap.test.ts b/src/store/routeMap.test.ts index 4d22ff92..cb5eb882 100644 --- a/src/store/routeMap.test.ts +++ b/src/store/routeMap.test.ts @@ -174,3 +174,323 @@ describe('currentRouteMap — a reused generation equals a cold rebuild', () => expect(routes(currentRouteMap(moved, edges))).toEqual(routes(warm)) }) }) + +// issue #344 step 2 — docs/edge-routing.md §ER14 +describe('currentRouteMap — the guarded generation (§ER14)', () => { + /** a row of three sources feeding two targets, with crossing traffic */ + const busy = () => ({ + nodes: [node('a', 0, 0), node('b', 0, 160), node('c', 0, 320), node('x', 500, 80), node('y', 500, 240), node('mid', 250, 140)], + edges: [edge('e1', 'a', 'y'), edge('e2', 'c', 'x'), edge('e3', 'b', 'x'), edge('e4', 'b', 'y'), edge('e5', 'y', 'a')], + }) + + it('is independent of node and edge input order (edges are routed in id order)', () => { + const { nodes, edges } = busy() + const m1 = routes(currentRouteMap(nodes, edges)) + __resetRouteCache() + const m2 = routes(currentRouteMap([...nodes].reverse(), [...edges].reverse())) + expect(new Map(m2.map((r) => [r[0], r]))).toEqual(new Map(m1.map((r) => [r[0], r]))) + }) + + it('a label sits in a free slot clear of every node box', async () => { + const { setRouteLabelSize } = await import('./routeMap') + const { nodes, edges } = busy() + for (const e of edges) setRouteLabelSize(e.id, 24, 18) + const m = currentRouteMap(nodes, edges) + for (const [id, r] of m) { + expect(r.label, id).not.toBeNull() + const L = { x: r.label!.x - 12, y: r.label!.y - 9, w: 24, h: 18 } + for (const n of nodes) { + const b = { x: n.position.x - 2, y: n.position.y - 2, w: 134, h: 60 } + const overlap = L.x < b.x + b.w && b.x < L.x + L.w && L.y < b.y + b.h && b.y < L.y + L.h + // the midpoint is the fallback when no slot is free; here every route has one + expect(overlap, `${id} label on ${n.id}`).toBe(false) + } + } + }) + + /** a scheduler the test drives by hand: `run()` runs every queued slice */ + const manual = () => { + const q: (() => void)[] = [] + return { + schedule: (fn: () => void) => void q.push(fn), + step: () => q.shift()?.(), + run: () => { + let n = 0 + while (q.length && n++ < 10000) q.shift()!() + return n + }, + size: () => q.length, + } + } + + it('a live gesture re-routes only what the moved node touches; the drop commits the cold-load generation', async () => { + const { beginLiveLayout, endLiveLayout, __setRouteScheduler } = await import('./routeMap') + const s = manual() + __setRouteScheduler(s.schedule) + try { + const { nodes, edges } = busy() + currentRouteMap(nodes, edges) // cold: sliced too + s.run() + const before = currentRouteMap(nodes, edges) + beginLiveLayout() + // drag `a` (incident: e1, e5) a little + const moved = nodes.map((n) => (n.id === 'a' ? { ...n, position: { x: 0, y: 16 } } : n)) + const during = currentRouteMap(moved, edges) + for (const id of ['e2', 'e3', 'e4']) expect(during.get(id), `${id} untouched while live`).toBe(before.get(id)) + expect(during.get('e1')!.d).not.toBe(before.get('e1')!.d) + expect(s.size(), 'no full generation while the gesture lasts').toBe(0) + const g = __routeGenCount() + endLiveLayout() + s.run() + expect(__routeGenCount()).toBe(g + 1) + const dropped = currentRouteMap(moved, edges) + __setRouteScheduler(null) + __resetRouteCache() + const cold = currentRouteMap(moved, edges) + expect(routes(dropped)).toEqual(routes(cold)) + } finally { + __setRouteScheduler(null) + } + }) + + it('a sliced full generation shows the provisional map until it is complete, then the synchronous result in one swap', async () => { + const { __setRouteScheduler } = await import('./routeMap') + const s = manual() + __setRouteScheduler(s.schedule) + try { + const { nodes, edges } = busy() + currentRouteMap(nodes, edges) // cold: sliced too + s.run() + const moved = nodes.map((n) => (n.id === 'mid' ? { ...n, position: { x: 250, y: 60 } } : n)) + const shown = currentRouteMap(moved, edges) + // until the job commits, every render gets the SAME provisional map + expect(currentRouteMap(moved, edges)).toBe(shown) + s.run() + const final = currentRouteMap(moved, edges) + __setRouteScheduler(null) + __resetRouteCache() + expect(routes(final)).toEqual(routes(currentRouteMap(moved, edges))) + } finally { + __setRouteScheduler(null) + } + }) + + it('a cold start draws no routed edge until its sliced generation commits; busy meanwhile; a layout seen again is shown at once', async () => { + const { __setRouteScheduler, useRouteInputs } = await import('./routeMap') + const s = manual() + __setRouteScheduler(s.schedule) + try { + const { nodes, edges } = busy() + expect(currentRouteMap(nodes, edges).size, 'nothing routed is drawn yet').toBe(0) + await Promise.resolve() // `busy` is published right after the render + expect(useRouteInputs.getState().busy).toBe(true) + s.run() + const full = currentRouteMap(nodes, edges) + expect(full.size).toBe(edges.length) + await Promise.resolve() + expect(useRouteInputs.getState().busy).toBe(false) + // another layout, then back: the kept generation, with no job + const moved = nodes.map((n) => (n.id === 'mid' ? { ...n, position: { x: 250, y: 60 } } : n)) + currentRouteMap(moved, edges) + s.run() + const g = __routeGenCount() + expect(currentRouteMap(nodes, edges)).toBe(full) + expect(s.size()).toBe(0) + expect(__routeGenCount()).toBe(g) + // and it equals the synchronous generation + __setRouteScheduler(null) + __resetRouteCache() + expect(routes(full)).toEqual(routes(currentRouteMap(nodes, edges))) + } finally { + __setRouteScheduler(null) + } + }) + + it('a newer layout cancels a running job: the cancelled generation never commits', async () => { + const { __setRouteScheduler, routeDiagnostics } = await import('./routeMap') + const s = manual() + __setRouteScheduler(s.schedule) + try { + const { nodes, edges } = busy() + currentRouteMap(nodes, edges) + s.run() + const g = __routeGenCount() + const m1 = nodes.map((n) => (n.id === 'mid' ? { ...n, position: { x: 250, y: 60 } } : n)) + currentRouteMap(m1, edges) // the first job is queued, not finished + const m2 = nodes.map((n) => (n.id === 'mid' ? { ...n, position: { x: 250, y: 200 } } : n)) + currentRouteMap(m2, edges) // cancels it + s.run() + expect(__routeGenCount()).toBe(g + 1) // only the second commits + expect(routeDiagnostics().job.cancelled).toBeGreaterThan(0) + const final = currentRouteMap(m2, edges) + __setRouteScheduler(null) + __resetRouteCache() + expect(routes(final)).toEqual(routes(currentRouteMap(m2, edges))) + } finally { + __setRouteScheduler(null) + } + }) + + it('a port fan shares only its stub: no two connections of one port run together past it, and no label sits on it', async () => { + const { routeDiagnostics, setRouteLabelSize } = await import('./routeMap') + // one source port feeding four targets in the same direction + const nodes = [node('s', 0, 160), node('t1', 400, 0), node('t2', 400, 120), node('t3', 400, 240), node('t4', 400, 360)] + const edges = [edge('f1', 's', 't1'), edge('f2', 's', 't2'), edge('f3', 's', 't3'), edge('f4', 's', 't4')] + for (const e of edges) setRouteLabelSize(e.id, 24, 18) + const m = currentRouteMap(nodes, edges) + expect(routeDiagnostics().fanOverlaps).toEqual([]) + for (const [id, r] of m) { + const [p0, p1] = r.stubs + const L = { x: r.label!.x - 12, y: r.label!.y - 9, w: 24, h: 18 } + const onStub = Math.max(p0.x, p1.x) > L.x && Math.min(p0.x, p1.x) < L.x + L.w && Math.max(p0.y, p1.y) > L.y && Math.min(p0.y, p1.y) < L.y + L.h + expect(onStub, `${id} label on the shared stub`).toBe(false) + } + }) + + it('reports how many routes each rung produced, and names every outer / blocked one', async () => { + const { routeDiagnostics } = await import('./routeMap') + const { nodes, edges } = busy() + currentRouteMap(nodes, edges) + const d = routeDiagnostics() + expect(Object.values(d.counts).reduce((a, b) => a + b, 0)).toBe(edges.length) + expect(d.flagged.every((f) => f.routeClass === 'outer' || f.routeClass === 'blocked')).toBe(true) + }) +}) + +describe('currentRouteGeneration — Curved and Straight label slots (§ER15)', () => { + const LS = { w: 24, h: 18 } + /** s → t straight with a node `o` on the middle of the line; a → b curved + * with `o2` on its middle; plus one routed edge r → u */ + const scene = () => ({ + nodes: [node('s', 0, 0), node('t', 400, 0), node('o', 230, 0), node('a', 0, 240), node('b', 400, 240), node('o2', 230, 240), node('r', 0, 480), node('u', 400, 480)], + edges: [edge('st', 's', 't', { route: 'straight' }), edge('cv', 'a', 'b', { route: undefined }), edge('or', 'r', 'u')], + }) + const boxes = (nodes: LoopNode[]) => nodes.map((n) => ({ id: n.id, x: n.position.x - 2, y: n.position.y - 2, w: 134, h: 60 })) + const hits = (c: { x: number; y: number }, b: { x: number; y: number; w: number; h: number }) => + c.x - LS.w / 2 < b.x + b.w && b.x < c.x + LS.w / 2 && c.y - LS.h / 2 < b.y + b.h && b.y < c.y + LS.h / 2 + + it('a label takes the first slot on its own line that is clear of every node, not the middle', async () => { + const { currentRouteGeneration, setRouteLabelSize } = await import('./routeMap') + const { nodes, edges } = scene() + for (const e of edges) setRouteLabelSize(e.id, LS.w, LS.h) + const g = currentRouteGeneration(nodes, edges) + expect([...g.free.keys()].sort()).toEqual(['cv', 'st']) + for (const id of ['st', 'cv']) { + const s = g.free.get(id)! + expect(s.f, id).not.toBe(0.5) + for (const b of boxes(nodes)) expect(hits(s.at, b), `${id} label on ${b.id}`).toBe(false) + } + // both lines are horizontal here (ports on one row), so the slot is on the row + expect(g.free.get('st')!.at.y).toBe(28) + expect(g.free.get('cv')!.at.y).toBeCloseTo(268, 6) + }) + + it('a label whose size is not known yet has no slot', async () => { + const { currentRouteGeneration, setRouteLabelSize } = await import('./routeMap') + const { nodes, edges } = scene() + setRouteLabelSize('st', LS.w, LS.h) + expect([...currentRouteGeneration(nodes, edges).free.keys()]).toEqual(['st']) + }) + + it('Curved and Straight connections never change a route', async () => { + const { currentRouteGeneration } = await import('./routeMap') + const { nodes, edges } = scene() + const withFree = routes(currentRouteGeneration(nodes, edges).map) + __resetRouteCache() + expect(routes(currentRouteGeneration(nodes, edges.filter((e) => e.id === 'or')).map)).toEqual(withFree) + }) + + it('the sliced generation gives the synchronous slots; a moved node re-places only the labels of its own edges until the drop', async () => { + const { currentRouteGeneration, setRouteLabelSize, __setRouteScheduler, beginLiveLayout, endLiveLayout } = await import('./routeMap') + const q: (() => void)[] = [] + __setRouteScheduler((fn) => void q.push(fn)) + try { + const { nodes, edges } = scene() + for (const e of edges) setRouteLabelSize(e.id, LS.w, LS.h) + expect(currentRouteGeneration(nodes, edges).free.size, 'no slot before the first generation commits').toBe(0) + while (q.length) q.shift()!() + const sliced = currentRouteGeneration(nodes, edges) + beginLiveLayout() + const moved = nodes.map((n) => (n.id === 's' ? { ...n, position: { x: 0, y: 16 } } : n)) + const during = currentRouteGeneration(moved, edges) + expect(during.free.get('cv'), 'an edge the move does not touch keeps its slot').toBe(sliced.free.get('cv')) + expect(during.free.get('st')).not.toBe(sliced.free.get('st')) + endLiveLayout() + while (q.length) q.shift()!() + const dropped = currentRouteGeneration(moved, edges) + __setRouteScheduler(null) + __resetRouteCache() + for (const e of edges) setRouteLabelSize(e.id, LS.w, LS.h) + expect([...dropped.free]).toEqual([...currentRouteGeneration(moved, edges).free]) + __resetRouteCache() + for (const e of edges) setRouteLabelSize(e.id, LS.w, LS.h) + expect([...sliced.free]).toEqual([...currentRouteGeneration(nodes, edges).free]) + } finally { + __setRouteScheduler(null) + } + }) +}) + +describe('bend points from the keyboard and on the line (§ER16.2)', () => { + const two = () => ({ nodes: [node('a', 0, 0), node('b', 480, 192)], edges: [edge('e', 'a', 'b')] }) + + it('Enter takes the middle of the longest editable segment, snapped along it and kept on its line', async () => { + const { keyboardBend } = await import('./routeMap') + const { nodes, edges } = two() + const r = currentRouteMap(nodes, edges).get('e')! + const p = keyboardBend(nodes, edges, 'e', r)! + expect(p).not.toBeNull() + // on the route: one coordinate is a segment's own line, the other on the grid + const onLine = r.points.some((a, i) => { + const b = r.points[i + 1] + return !!b && ((a.x === b.x && p.x === a.x && p.y % 16 === 0) || (a.y === b.y && p.y === a.y && p.x % 16 === 0)) + }) + expect(onLine).toBe(true) + // deterministic + expect(keyboardBend(nodes, edges, 'e', r)).toEqual(p) + }) + + it('equal segments go to the one nearer the start; a refused point gives way to the next segment', async () => { + const { keyboardBend } = await import('./routeMap') + // a symmetric Z: two equal horizontal legs; the first one wins + const { nodes, edges } = { nodes: [node('a', 0, 0), node('b', 448, 320)], edges: [edge('e', 'a', 'b')] } + const r = currentRouteMap(nodes, edges).get('e')! + const p = keyboardBend(nodes, edges, 'e', r)! + expect(p).not.toBeNull() + // a node over that point refuses it: another segment is used + const blocked = [...nodes, node('x', p.x - 20, p.y - 20, { width: 40, height: 40, measured: { width: 40, height: 40 } })] + __resetRouteCache() + const r2 = currentRouteMap(blocked, edges).get('e')! + const q = keyboardBend(blocked, edges, 'e', r2) + if (q) expect(q).not.toEqual(p) + }) + + it('a click puts the bend on the nearest segment, snapped along it (Alt: exactly at the click)', async () => { + const { bendAtClick } = await import('./routeMap') + const pts = [{ x: 0, y: 28 }, { x: 200, y: 28 }, { x: 200, y: 300 }] + expect(bendAtClick(pts, { x: 75, y: 33 }, false)).toEqual({ x: 80, y: 28 }) + expect(bendAtClick(pts, { x: 75, y: 33 }, true)).toEqual({ x: 75, y: 28 }) + expect(bendAtClick(pts, { x: 205, y: 151 }, false)).toEqual({ x: 200, y: 144 }) + // a segment with no grid line inside it takes no snapped bend + expect(bendAtClick([{ x: 1, y: 28 }, { x: 15, y: 28 }], { x: 8, y: 28 }, false)).toBeNull() + }) +}) + +describe('the record label rule (§ER15.1)', () => { + it('a record places no Curved / Straight label, and its routed labels are the ones without them', async () => { + const { currentRouteGeneration, setRecordLabels, setRouteLabelSize } = await import('./routeMap') + const nodes = [node('s', 0, 0), node('t', 400, 0), node('mid', 200, 0), node('r', 0, 240), node('u', 400, 240)] + const edges = [edge('cv', 's', 't', { route: undefined }), edge('or', 'r', 'u')] + for (const e of edges) setRouteLabelSize(e.id, 24, 18) + setRecordLabels(true) + const rec = currentRouteGeneration(nodes, edges) + expect(rec.free.size).toBe(0) + const recRoutes = [...rec.map].map(([id, x]) => [id, x.d, x.label]) + __resetRouteCache() + for (const e of edges) setRouteLabelSize(e.id, 24, 18) + setRecordLabels(true) + expect([...currentRouteGeneration(nodes, edges.filter((e) => e.id === 'or')).map].map(([id, x]) => [id, x.d, x.label])).toEqual(recRoutes) + setRecordLabels(false) + expect(currentRouteGeneration(nodes, edges).free.has('cv'), 'the current rule places it').toBe(true) + }) +}) diff --git a/src/store/routeMap.ts b/src/store/routeMap.ts index 1fb1c430..e5c214d2 100644 --- a/src/store/routeMap.ts +++ b/src/store/routeMap.ts @@ -1,44 +1,151 @@ -// docs/edge-routing.md §ER3.8–§ER3.9 — the atomic orthogonal-route map. +// docs/edge-routing.md §ER3.8–§ER3.9, §ER14 — the atomic orthogonal-route map. // -// One `Map` for every `route: "orthogonal"` edge, rebuilt -// as a whole generation whenever the routing inputs change, and swapped in -// atomically (a render never mixes an edge's stale route with another's fresh -// one). Uses the module-identity-cache pattern (like `src/store/registers.ts`): +// One `Map` for every `route: "orthogonal"` edge. A FULL +// generation routes every edge with the guarded router (`computeGuardedRoute`: +// own end nodes are obstacles, no fill is crossed, unrelated routes pay to share +// a trunk or to cross), in id order, places the labels and runs the guarded +// label rounds; it never depends on input order and is the same however it is +// run. +// +// Uses the module-identity-cache pattern (like `src/store/registers.ts`): // Zustand keeps `nodes` / `edges` referentially stable until they actually -// change, so within one render every `LoopEdge` gets the SAME generation with -// no recompute; a graph edit swaps a reference ⇒ exactly one full recompute. +// change, so within one render every `LoopEdge` gets the SAME map with no +// recompute. The cache key is the routing INPUT (§ER3.8): the `(nodes ref, +// edges ref, input revision)` identity first, then the layout signature — every +// value a generation reads, serialised. A `select` change or a label edit keeps +// the map; zoom / pan / hover / theme / sim never touch the inputs at all. // -// The cache key is the routing INPUT (§ER3.8), checked in two steps: the -// `(nodes ref, edges ref, ROUTER_VERSION)` identity first (free), then the -// layout signature — every value `rebuild` reads, serialised. React Flow's -// `applyNodeChanges` returns a NEW `nodes` array for a `select` change (and -// other non-geometric edits do the same), which used to rebuild the whole map -// on every click; the same signature now keeps the generation and adopts the -// new identities. Zoom / pan / hover / theme / sim never touch -// `graphStore.nodes` / `.edges` at all. +// §ER14.5 — the cost. A layout change first shows a PROVISIONAL map: the last +// full generation with only the routes the change touches routed again (within +// a small time budget). The full generation for the new layout then runs as a +// job in slices of about `SLICE_MS`, each its own task, and is committed +// in one swap when it is complete — never a partial map. A newer layout, or a +// drag starting, cancels a running job; a cancelled job never commits. During a +// node / selection / frame drag (`beginLiveLayout` … `endLiveLayout`) no job +// runs; the drop starts one. Without a task scheduler (tests, +// non-browser runs) the full generation is computed at once — the same steps, +// so the result is byte-identical. +import { create } from 'zustand' import { Position } from '@xyflow/react' -import type { LoopEdge, LoopNode } from '../model/types' +import type { LoopEdge, LoopNode, NodeKind } from '../model/types' +import { GRID, PORT_ROW } from '../model/layout/grid' import { type Box, - computeOrthogonalRoute, - type RouteResult, - ROUTER_VERSION, + computeGuardedRoute, + computeSoftReroute, + guardedRouteSteps, + type GuardedClass, + type GuardedInput, + type FanEnd, + type GuardedResult, + type Occupied, + PARALLEL_GAP, + ROUTE_STUB, + SOFT_PADS, + type Pt, } from '../components/edges/orthogonalRoute' -import { BASE_NODE_H } from '../components/nodes/silhouette' +import { BASE_NODE_H, portInsetFraction } from '../components/nodes/silhouette' +import { freeLinePoints, pointAlong } from '../components/edges/freeLine' // the size of a node React Flow has not measured yet; the height is the node's // own floor, so a one-line node routes the same before and after it is measured const DEFAULT_W = 130 const DEFAULT_H = BASE_NODE_H -type Key = { nodes: LoopNode[]; edges: LoopEdge[]; v: number; sig: string } -let cacheKey: Key | null = null -let cacheMap: ReadonlyMap = new Map() -let genCount = 0 +/** the route map's own version: part of the cache key, so a router change + * never reuses a generation computed by the previous rules */ +export const ROUTE_MAP_VERSION = 2 + +/** guarded label rounds after the first label placement */ +export const LABEL_ROUNDS = 2 +/** label slots along a route, as fractions of its length, in order of preference */ +export const LABEL_SLOTS = [0.5, 0.4, 0.6, 0.3, 0.7, 0.25, 0.75, 0.2, 0.8] as const +/** the margin kept between a label and a node box */ +const LABEL_NODE_MARGIN = 2 +/** a full generation's work per animation frame, ms */ +export const SLICE_MS = 8 +/** the provisional map's routing budget, ms (a new edge over it is not drawn + * until the full generation commits) */ +export const PROVISIONAL_MS = 5 + +export type RoutedEdge = GuardedResult & { + /** where the edge's label is centred; null when its size is not known yet + * (the label then sits at `mid`) */ + label: Pt | null +} + +/** §ER15 — a Curved or Straight edge's label slot: the fraction of its line's + * length where the label is centred (the line `LoopEdge` draws, sampled the + * same way), and that point on the route map's own copy of the line */ +export type FreeSlot = { f: number; at: Pt } + +/** a generation: the routes, and the label slots of the other edges */ +type GenOut = { map?: Map; free?: Map } + +// ── the routing inputs that are not graph data: label sizes, the live flag ── + +type LabelSize = { w: number; h: number } +const labelSizes = new Map() + +/** bumped whenever the map to show changes outside a graph edit (a label size, + * a committed full generation), so every `LoopEdge` re-reads it */ +export const useRouteInputs = create<{ rev: number; busy: boolean; record: boolean }>(() => ({ rev: 0, busy: false, record: false })) + +/** §ER15.1 — the document is a record shown by its recorded label rule + * (`graphStore.recordLabels`): Curved and Straight labels are not placed, they + * sit at their line's middle as recorded, and nothing else keeps off their + * lines — the routed edges get exactly the generation they had before step 3 */ +let recordLabels = false +export function setRecordLabels(on: boolean): void { + if (recordLabels === on) return + recordLabels = on + useRouteInputs.setState((s) => ({ rev: s.rev + 1, record: on })) +} +const bump = () => useRouteInputs.setState((s) => ({ rev: s.rev + 1 })) + +/** §ER14.5 — `busy` while a routed edge is not drawn because its first route + * is still being computed (the canvas carries `aria-busy`). Decided during a + * render, so it is published right after it. */ +let wantBusy = false +function setBusy(b: boolean): void { + wantBusy = b + if (useRouteInputs.getState().busy === b) return + queueMicrotask(() => { + if (useRouteInputs.getState().busy !== wantBusy) useRouteInputs.setState({ busy: wantBusy }) + }) +} + +/** a label's REST size (visible, not selected, no run value), in flow px; the + * last known size stays while the label is hidden, so zooming never moves a + * route */ +export function setRouteLabelSize(edgeId: string, w: number, h: number): void { + const prev = labelSizes.get(edgeId) + if (prev && prev.w === w && prev.h === h) return + labelSizes.set(edgeId, { w, h }) + bump() +} + +let live = false +/** a node / selection / frame drag starts: until `endLiveLayout`, a layout + * change shows only provisional maps, and a running full generation stops */ +export function beginLiveLayout(): void { + live = true + cancelJob() +} +/** the gesture ends: the full generation for the last layout starts */ +export function endLiveLayout(): void { + if (!live) return + live = false + if (lastInput && committed && committed.sig !== base?.sig) startFull(lastInput.nodes, lastInput.edges, committed.sig) +} + +// ── geometry ────────────────────────────────────────────────────────────── const isOrtho = (e: LoopEdge): boolean => (e.data as { route?: unknown } | undefined)?.route === 'orthogonal' +const isStraight = (e: LoopEdge): boolean => + (e.data as { route?: unknown } | undefined)?.route === 'straight' const handlePos = (handleId: string | null | undefined, fallback: Position): Position => { // resource ports: `in` = Left, `out` = Right; state ports: top / bottom. @@ -49,71 +156,637 @@ const handlePos = (handleId: string | null | undefined, fallback: Position): Pos return fallback } -/** centre of a node's handle in flow coords */ -function handlePoint(n: LoopNode, pos: Position): { x: number; y: number } { - const w = n.measured?.width ?? n.width ?? DEFAULT_W - const h = n.measured?.height ?? n.height ?? DEFAULT_H +const sizeOf = (n: LoopNode): { w: number; h: number } => ({ + w: n.measured?.width ?? n.width ?? DEFAULT_W, + h: n.measured?.height ?? n.height ?? DEFAULT_H, +}) + +/** centre of a node's handle in flow coords. issue #344 §DL1 — a resource + * port is on the fixed row `PORT_ROW` below the top, drawn on the outline + * there (`portInsetFraction`, the same rule `nodes.tsx` draws it with), so a + * route starts where the port is; state ports stay at the top / bottom centre */ +function handlePoint(n: LoopNode, pos: Position): Pt { + const { w, h } = sizeOf(n) const x = n.position.x const y = n.position.y + const kind = n.type as NodeKind switch (pos) { - case Position.Left: return { x, y: y + h / 2 } - case Position.Right: return { x: x + w, y: y + h / 2 } + case Position.Left: return { x: x + portInsetFraction(kind, h, 'in', PORT_ROW, w) * w, y: y + PORT_ROW } + case Position.Right: return { x: x + w - portInsetFraction(kind, h, 'out', PORT_ROW, w) * w, y: y + PORT_ROW } case Position.Top: return { x: x + w / 2, y } default: return { x: x + w / 2, y: y + h } } } -function rebuild(nodes: LoopNode[], edges: LoopEdge[]): ReadonlyMap { - const byId = new Map(nodes.map((n) => [n.id, n])) - const boxOf = (n: LoopNode): Box => ({ - id: n.id, - x: n.position.x, - y: n.position.y, - w: n.measured?.width ?? n.width ?? DEFAULT_W, - h: n.measured?.height ?? n.height ?? DEFAULT_H, - }) +const boxOf = (n: LoopNode): Box => ({ id: n.id, x: n.position.x, y: n.position.y, ...sizeOf(n) }) - // parallel sets — unordered endpoint key, reversed pairs included (§ER3.7) - const groups = new Map() - const pkey = (e: LoopEdge): string => { - const a = `${e.source}:${e.sourceHandle ?? ''}` - const b = `${e.target}:${e.targetHandle ?? ''}` - return a < b ? `${a}|${b}` : `${b}|${a}` +const hit = (a: Box, b: Box): boolean => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h + +/** does the polyline enter box `b`'s interior? */ +function polyHits(pts: Pt[], b: Box): boolean { + for (let i = 1; i < pts.length; i++) { + const a = pts[i - 1] + const c = pts[i] + if (Math.max(a.x, c.x) > b.x && Math.min(a.x, c.x) < b.x + b.w && Math.max(a.y, c.y) > b.y && Math.min(a.y, c.y) < b.y + b.h) return true } + return false +} + +/** the point at fraction `f` of a polyline's length */ +const pointAt = pointAlong + +/** the segments of `routes`, indexed by ruler line for the trunk / crossing cost */ +function occupiedOf(routes: Pt[][]): Occupied { + const v = new Map() + const h = new Map() + for (const pts of routes) { + for (let i = 1; i < pts.length; i++) { + const a = pts[i - 1] + const b = pts[i] + if (a.x === b.x) (v.get(a.x) ?? v.set(a.x, []).get(a.x)!).push([Math.min(a.y, b.y), Math.max(a.y, b.y)]) + else if (a.y === b.y) (h.get(a.y) ?? h.set(a.y, []).get(a.y)!).push([Math.min(a.x, b.x), Math.max(a.x, b.x)]) + } + } + const num = (p: number, q: number) => p - q + return { v, h, vx: [...v.keys()].sort(num), hy: [...h.keys()].sort(num) } +} + +const CLASS_RANK: Record = { orthogonal: 0, degenerate: 0, tight: 1, outer: 2, blocked: 3 } + +// ── ports, fans and the shared stub (§ER14.3) ───────────────────────────── + +const endKey = (node: string, handle: string | null | undefined) => `${node}:${handle ?? ''}` +const srcKey = (e: LoopEdge) => endKey(e.source, e.sourceHandle) +const tgtKey = (e: LoopEdge) => endKey(e.target, e.targetHandle) +const pkey = (e: LoopEdge): string => { + const a = srcKey(e) + const b = tgtKey(e) + return a < b ? `${a}|${b}` : `${b}|${a}` +} + +/** a polyline from the end of its stub at `start`: the first segment cut where + * the stub ends (or where the route already turned, if earlier) */ +function fromStub(pts: Pt[], stubEnd: Pt): Pt[] { + if (pts.length < 2) return pts + const a = pts[0] + const b = pts[1] + const dB = Math.abs(b.x - a.x) + Math.abs(b.y - a.y) + const dS = Math.abs(stubEnd.x - a.x) + Math.abs(stubEnd.y - a.y) + return dB <= dS ? pts.slice(1) : [stubEnd, ...pts.slice(1)] +} + +/** what two connections of one port may NOT share: everything past the port's + * stub. `atSource` / `atTarget` say which end is the shared port. */ +function pastSharedStub(r: GuardedResult, atSource: boolean, atTarget: boolean): Pt[] { + let pts = r.points + if (atSource) pts = fromStub(pts, r.stubs[1]) + if (atTarget) pts = [...fromStub([...pts].reverse(), r.stubs[2])].reverse() + return pts +} + +/** the stretch of a route on its shared stub(s): where no label may sit */ +function sharedStubSegments(r: GuardedResult, atSource: boolean, atTarget: boolean): Pt[][] { + const out: Pt[][] = [] + if (atSource) out.push([r.stubs[0], r.stubs[1]]) + if (atTarget) out.push([r.stubs[2], r.stubs[3]]) + return out +} + +// ── one generation ──────────────────────────────────────────────────────── + +type Plan = { + ortho: LoopEdge[] + /** §ER15 — the Curved and Straight connections with both ends present, in id + * order, and each one's line (from the same port anchors the routes use): + * not routed, but their labels take a free slot and every other label keeps + * off their lines */ + free: LoopEdge[] + lines: Map + byId: Map + boxes: Box[] + /** parallel sets — unordered endpoint key, reversed pairs included (§ER3.7) */ + groups: Map + /** how many routed edges use each port */ + portUse: Map + /** every routed end's reserved stretch, known before any route: its stub + * (port → branch point) and, at a fan, its first leg after the branch. A + * route pays `FAN_COST` to run along any of them but its own ports' stubs. */ + reserved: { edgeId: string; port: string; stub: Pt[]; leg: Pt[] | null }[] + /** each fan member's branch at its source / target port (§ER14.3) */ + fans: Map +} + +/** §ER14.3 — the branch points of one port's fan. Each member's far end is + * measured across the stub; members on the negative side leave toward it, + * members on the positive side toward that one, and at most one member goes + * straight on (the lowest id; other level members alternate sides). Within a + * side, the member reaching furthest out branches nearest the node, so + * siblings never cross; members on opposite sides may share an offset. The + * offsets split the 16 px stub evenly. */ +function assignFan(members: { id: string; delta: number }[]): Map { + const out = new Map() + const byId = [...members].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + const neg: { id: string; delta: number }[] = [] + const pos: { id: string; delta: number }[] = [] + let straight: string | null = null + let alt = 0 + for (const m of byId) { + if (m.delta < -1) neg.push(m) + else if (m.delta > 1) pos.push(m) + else if (straight == null) straight = m.id + else (alt++ % 2 === 0 ? neg : pos).push(m) + } + const tie = (a: { id: string }, b: { id: string }) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0) + neg.sort((a, b) => a.delta - b.delta || tie(a, b)) + pos.sort((a, b) => b.delta - a.delta || tie(a, b)) + const place = (list: { id: string }[], side: -1 | 1) => + list.forEach((m, k) => out.set(m.id, { len: (ROUTE_STUB * (k + 1)) / list.length, side })) + place(neg, -1) + place(pos, 1) + if (straight != null) out.set(straight, { len: ROUTE_STUB, side: 0 }) + return out +} + +function plan(nodes: LoopNode[], edges: LoopEdge[]): Plan { const ortho = edges.filter(isOrtho).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + const byId = new Map(nodes.map((n) => [n.id, n])) + const groups = new Map() + const portUse = new Map() + const atPort = new Map() for (const e of ortho) { const k = pkey(e) ;(groups.get(k) ?? groups.set(k, []).get(k)!).push(e.id) + portUse.set(srcKey(e), (portUse.get(srcKey(e)) ?? 0) + 1) + portUse.set(tgtKey(e), (portUse.get(tgtKey(e)) ?? 0) + 1) + ;(atPort.get(srcKey(e)) ?? atPort.set(srcKey(e), []).get(srcKey(e))!).push({ e, asSource: true }) + ;(atPort.get(tgtKey(e)) ?? atPort.set(tgtKey(e), []).get(tgtKey(e))!).push({ e, asSource: false }) } - - const out = new Map() + const fans = new Map() + for (const list of atPort.values()) { + if (list.length < 2) continue + const { e: e0, asSource } = list[0] + const portNode = byId.get(asSource ? e0.source : e0.target) + if (!portNode) continue + const pos = asSource ? handlePos(e0.sourceHandle, Position.Right) : handlePos(e0.targetHandle, Position.Left) + const at = handlePoint(portNode, pos) + const horiz = pos === Position.Left || pos === Position.Right + const members: { id: string; delta: number }[] = [] + for (const { e, asSource: s } of list) { + const far = byId.get(s ? e.target : e.source) + if (!far) continue + const p = handlePoint(far, s ? handlePos(e.targetHandle, Position.Left) : handlePos(e.sourceHandle, Position.Right)) + members.push({ id: e.id, delta: horiz ? p.y - at.y : p.x - at.x }) + } + for (const [id, f] of assignFan(members)) { + const cur = fans.get(id) ?? {} + if (asSource) cur.src = f + else cur.tgt = f + fans.set(id, cur) + } + } + const reserved: Plan['reserved'] = [] for (const e of ortho) { + for (const asSource of [true, false]) { + const n = byId.get(asSource ? e.source : e.target) + if (!n) continue + const pos = asSource ? handlePos(e.sourceHandle, Position.Right) : handlePos(e.targetHandle, Position.Left) + const f = asSource ? fans.get(e.id)?.src : fans.get(e.id)?.tgt + const port = handlePoint(n, pos) + const nrm = pos === Position.Left ? { x: -1, y: 0 } : pos === Position.Right ? { x: 1, y: 0 } : pos === Position.Top ? { x: 0, y: -1 } : { x: 0, y: 1 } + const b = boxOf(n) + const side = nrm.x > 0 ? b.x + b.w - port.x : nrm.x < 0 ? port.x - b.x : nrm.y > 0 ? b.y + b.h - port.y : port.y - b.y + const len = Math.max(0, side) + (f?.len ?? ROUTE_STUB) + const branch = { x: port.x + nrm.x * len, y: port.y + nrm.y * len } + let leg: Pt[] | null = null + if (f) { + const across = pos === Position.Left || pos === Position.Right ? { x: 0, y: 1 } : { x: 1, y: 0 } + const dir = f.side === 0 ? nrm : { x: across.x * f.side, y: across.y * f.side } + leg = [branch, { x: branch.x + dir.x * ROUTE_STUB, y: branch.y + dir.y * ROUTE_STUB }] + } + reserved.push({ edgeId: e.id, port: asSource ? srcKey(e) : tgtKey(e), stub: [port, branch], leg }) + } + } + const free: LoopEdge[] = [] + const lines = new Map() + for (const e of recordLabels ? [] : [...edges].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) { + if (isOrtho(e)) continue const s = byId.get(e.source) const t = byId.get(e.target) if (!s || !t) continue const sPos = handlePos(e.sourceHandle, Position.Right) const tPos = handlePos(e.targetHandle, Position.Left) - const set = groups.get(pkey(e))! - const route = computeOrthogonalRoute({ - edgeId: e.id, - source: handlePoint(s, sPos), - target: handlePoint(t, tPos), - sourcePosition: sPos, - targetPosition: tPos, - obstacles: nodes.filter((n) => n.id !== e.source && n.id !== e.target).map(boxOf), - waypoints: Array.isArray((e.data as { waypoints?: { x: number; y: number }[] }).waypoints) - ? (e.data as { waypoints: { x: number; y: number }[] }).waypoints - : [], - parallelIndex: set.indexOf(e.id), - parallelCount: set.length, - selfLoop: e.source === e.target, - }) - out.set(e.id, route) + free.push(e) + lines.set(e.id, freeLinePoints(handlePoint(s, sPos), sPos, handlePoint(t, tPos), tPos, isStraight(e))) + } + return { ortho, free, lines, byId, boxes: nodes.map(boxOf), groups, portUse, fans, reserved } +} + +/** one edge, routed against the current layout and the given placed routes: + * every other route costs to run along or cross; one sharing a port only past + * that port's stub, and its lines ± `PARALLEL_GAP` are offered as lanes */ +function routeInput(p: Plan, e: LoopEdge, others: ReadonlyMap, soft: Box[]): GuardedInput | null { + const s = p.byId.get(e.source) + const t = p.byId.get(e.target) + if (!s || !t) return null + const sPos = handlePos(e.sourceHandle, Position.Right) + const tPos = handlePos(e.targetHandle, Position.Left) + const set = p.groups.get(pkey(e))! + const busy: Pt[][] = [] + const fanRoutes: Pt[][] = [] + const laneX: number[] = [] + const laneY: number[] = [] + for (const o of p.ortho) { + if (o.id === e.id) continue + const r = others.get(o.id) + if (!r) continue + // a parallel set (same two ports) fans out by its own offset (§ER3.7) + if (pkey(o) === pkey(e)) continue + const atS = srcKey(o) === srcKey(e) + const atT = tgtKey(o) === tgtKey(e) + if (!atS && !atT) { + busy.push(r.points) + continue + } + const rest = pastSharedStub(r, atS, atT) + fanRoutes.push(rest) + // lanes beside the sibling's first two segments past the shared stub — where + // the split is decided — at the shared end(s) + const near = (seg: Pt[]) => { + for (let i = 1; i < seg.length; i++) { + if (seg[i].x === seg[i - 1].x) laneX.push(seg[i].x - PARALLEL_GAP, seg[i].x + PARALLEL_GAP) + else if (seg[i].y === seg[i - 1].y) laneY.push(seg[i].y - PARALLEL_GAP, seg[i].y + PARALLEL_GAP) + } + } + if (atS) near(rest.slice(0, 3)) + if (atT) near(rest.slice(-3)) + } + const input: GuardedInput = { + edgeId: e.id, + source: handlePoint(s, sPos), + target: handlePoint(t, tPos), + sourcePosition: sPos, + targetPosition: tPos, + sourceBox: boxOf(s), + targetBox: boxOf(t), + obstacles: p.boxes.filter((b) => b.id !== e.source && b.id !== e.target), + soft, + waypoints: Array.isArray((e.data as { waypoints?: Pt[] }).waypoints) ? (e.data as { waypoints: Pt[] }).waypoints : [], + parallelIndex: set.indexOf(e.id), + parallelCount: set.length, + occupied: occupiedOf(busy), + fan: occupiedOf([ + ...fanRoutes, + ...p.reserved.flatMap((r) => + r.edgeId === e.id ? [] : [...(r.port === srcKey(e) || r.port === tgtKey(e) ? [] : [r.stub]), ...(r.leg ? [r.leg] : [])], + ), + ]), + lanes: { x: laneX, y: laneY }, + sourceFan: p.fans.get(e.id)?.src, + targetFan: p.fans.get(e.id)?.tgt, + } + return input +} + +/** one edge, routed at once (the provisional map); a label round (soft boxes) + * tries one rung of `SOFT_PADS` per call */ +function routeOne(p: Plan, e: LoopEdge, others: ReadonlyMap, soft: Box[], softPad?: number): GuardedResult | null { + const input = routeInput(p, e, others, soft) + if (!input) return null + return softPad != null ? computeSoftReroute(input, softPad) : computeGuardedRoute(input) +} + +/** the same, as steps: it pauses inside its searches (§ER14.5) */ +function* routeOneSteps(p: Plan, e: LoopEdge, others: ReadonlyMap, soft: Box[], softPad?: number): Generator { + const input = routeInput(p, e, others, soft) + if (!input) return null + return yield* guardedRouteSteps(softPad != null ? { ...input, softPad } : input) +} + +const labelBoxAt = (c: Pt, s: LabelSize, id: string): Box => ({ id: `label:${id}`, x: c.x - s.w / 2, y: c.y - s.h / 2, w: s.w, h: s.h }) + +/** every stretch of the given routes that sits on a shared port stub */ +function sharedStubsOf(p: Plan, routes: ReadonlyMap): Pt[][] { + const out: Pt[][] = [] + for (const e of p.ortho) { + const r = routes.get(e.id) + if (!r) continue + const atS = (p.portUse.get(srcKey(e)) ?? 0) > 1 + const atT = (p.portUse.get(tgtKey(e)) ?? 0) > 1 + out.push(...sharedStubSegments(r, atS, atT)) } return out } -/** §ER3.8 — the layout signature: exactly what `rebuild` reads, in input +/** the first free slot on the edge's own line: never on a shared port stub; + * then clear of every node box, every label already placed and every other + * line (routed, Curved or Straight); failing that, clear of nodes and labels + * only; the midpoint last. Returns the slot's centre and its fraction. */ +function placeLabelOn( + p: Plan, + id: string, + points: Pt[], + mid: Pt, + placed: Map, + routes: ReadonlyMap, + stubs: Pt[][], +): { c: Pt; f: number } | null { + const size = labelSizes.get(id) + if (!size) return null + const nodes = p.boxes.map((b) => ({ ...b, x: b.x - LABEL_NODE_MARGIN, y: b.y - LABEL_NODE_MARGIN, w: b.w + 2 * LABEL_NODE_MARGIN, h: b.h + 2 * LABEL_NODE_MARGIN })) + const candidates = LABEL_SLOTS.map((f) => ({ c: f === 0.5 ? mid : pointAt(points, f), f })) + const offStub = (box: Box) => !stubs.some((s) => polyHits(s, box)) + const clear = (box: Box): boolean => { + if (nodes.some((n) => hit(n, box))) return false + for (const [oid, ob] of placed) if (oid !== id && hit(ob, box)) return false + return true + } + const offLines = (box: Box): boolean => { + for (const [oid, o] of routes) if (oid !== id && polyHits(o.points, box)) return false + for (const [oid, l] of p.lines) if (oid !== id && polyHits(l, box)) return false + return true + } + for (const s of candidates) { + const box = labelBoxAt(s.c, size, id) + if (offStub(box) && clear(box) && offLines(box)) return s + } + for (const s of candidates) { + const box = labelBoxAt(s.c, size, id) + if (offStub(box) && clear(box)) return s + } + for (const s of candidates) if (offStub(labelBoxAt(s.c, size, id))) return s + return { c: mid, f: 0.5 } +} + +/** a routed edge's label slot */ +function placeLabel( + p: Plan, + id: string, + r: GuardedResult, + placed: Map, + routes: ReadonlyMap, + stubs: Pt[][], +): Pt | null { + return placeLabelOn(p, id, r.points, r.mid, placed, routes, stubs)?.c ?? null +} + +/** a Curved or Straight edge's label slot, as a fraction of its line */ +function placeFreeLabel(p: Plan, e: LoopEdge, placed: Map, routes: ReadonlyMap, stubs: Pt[][]): FreeSlot | null { + const line = p.lines.get(e.id)! + const s = placeLabelOn(p, e.id, line, pointAt(line, 0.5), placed, routes, stubs) + if (!s) return null + placed.set(e.id, labelBoxAt(s.c, labelSizes.get(e.id)!, e.id)) + return { f: s.f, at: s.c } +} + +/** a full generation, as steps: each `yield` is a point where a sliced run may + * pause. Run to the end in one go it IS the synchronous generation. */ +function* fullSteps(nodes: LoopNode[], edges: LoopEdge[], out: GenOut): Generator { + const p = plan(nodes, edges) + yield 'plan' + const routes = new Map() + for (const e of p.ortho) { + const r = yield* routeOneSteps(p, e, routes, []) + if (r) routes.set(e.id, r) + yield `route ${e.id}` + } + // labels: first free slot, in id order + let stubs = sharedStubsOf(p, routes) + yield 'stubs' + const labels = new Map() + const placed = new Map() + for (const e of p.ortho) { + const r = routes.get(e.id) + if (!r) continue + const c = placeLabel(p, e.id, r, placed, routes, stubs) + labels.set(e.id, c) + if (c) placed.set(e.id, labelBoxAt(c, labelSizes.get(e.id)!, e.id)) + yield `label ${e.id}` + } + // guarded rounds: an edge running through other edges' labels is routed again + // with those labels as soft obstacles, kept only when it crosses fewer of them + // and is not a lower rung + for (let round = 0; round < LABEL_ROUNDS; round++) { + let changed = 0 + for (const e of p.ortho) { + const r = routes.get(e.id) + if (!r) continue + const crossed = [...placed].filter(([id, b]) => id !== e.id && polyHits(r.points, b)).map(([, b]) => b) + if (crossed.length === 0) continue + // one rung of `SOFT_PADS` per step, until one is free + let r2: GuardedResult | null = null + for (const pad of SOFT_PADS) { + r2 = yield* routeOneSteps(p, e, routes, crossed, pad) + yield `round ${e.id} @${pad}` + if (r2) break + } + if (!r2 || r2.d === r.d || CLASS_RANK[r2.routeClass] > CLASS_RANK[r.routeClass]) continue + if (crossed.filter((b) => polyHits(r2.points, b)).length >= crossed.length) continue + routes.set(e.id, r2) + stubs = sharedStubsOf(p, routes) + placed.delete(e.id) + const c = placeLabel(p, e.id, r2, placed, routes, stubs) + labels.set(e.id, c) + if (c) placed.set(e.id, labelBoxAt(c, labelSizes.get(e.id)!, e.id)) + changed++ + } + if (changed === 0) break + } + // §ER15 — then the Curved and Straight labels, in id order, among every + // label and line already placed + const free = new Map() + for (const e of p.free) { + const s = placeFreeLabel(p, e, placed, routes, stubs) + if (s) free.set(e.id, s) + yield `free ${e.id}` + } + const map = new Map() + for (const e of p.ortho) { + const r = routes.get(e.id) + if (r) map.set(e.id, { ...r, label: labels.get(e.id) ?? null }) + } + out.map = map + out.free = free +} + +/** the synchronous full generation */ +function rebuildFull(nodes: LoopNode[], edges: LoopEdge[]): Required { + const out: GenOut = {} + for (const _ of fullSteps(nodes, edges, out)) { + // run every step at once + } + return { map: out.map!, free: out.free! } +} + +/** each routed edge's own routing input, to tell which changed */ +const edgeKey = (e: LoopEdge): string => + JSON.stringify([e.source, e.sourceHandle ?? null, e.target, e.targetHandle ?? null, (e.data as { route?: unknown }).route ?? null, (e.data as { waypoints?: unknown }).waypoints ?? null, labelSizes.get(e.id) ?? null]) + +/** the provisional map: the last full generation, with the routes this change + * touches routed again — an edge that is new or whose own input changed, one + * incident to a moved / resized node, one whose route now runs into a moved + * node — incident and new ones first, within `PROVISIONAL_MS` (a new edge left + * over is not drawn until the full generation commits; a moved one keeps its + * last route). Their labels take a free slot among the labels already placed; + * then so do the labels of the Curved and Straight edges that are new, changed + * or incident to a moved node (no routing: always within the budget). Never + * committed as a full generation. */ +function provisional(nodes: LoopNode[], edges: LoopEdge[], from: Base): Required { + const tStart = now() + const p = plan(nodes, edges) + const moved = new Set() + for (const n of nodes) { + const b = from.geo.get(n.id) + const s = sizeOf(n) + if (!b || b.x !== n.position.x || b.y !== n.position.y || b.w !== s.w || b.h !== s.h) moved.add(n.id) + } + const movedBoxes = p.boxes.filter((b) => moved.has(b.id)) + const out = new Map() + for (const e of p.ortho) { + const r = from.map.get(e.id) + if (r) out.set(e.id, r) + } + const first: LoopEdge[] = [] + const then: LoopEdge[] = [] + for (const e of p.ortho) { + const r = from.map.get(e.id) + if (!r || from.keys.get(e.id) !== edgeKey(e) || moved.has(e.source) || moved.has(e.target)) first.push(e) + else if (movedBoxes.some((b) => polyHits(r.points, b))) then.push(e) + } + lastProvisional = { moved: moved.size, affected: first.length + then.length, routed: 0 } + const placed = new Map() + const redo = new Set([...first, ...then].map((e) => e.id)) + for (const [id, r] of out) { + const s = labelSizes.get(id) + if (r.label && s && !redo.has(id)) placed.set(id, labelBoxAt(r.label, s, id)) + } + const free = new Map() + const freeRedo: LoopEdge[] = [] + for (const e of p.free) { + const s = from.free.get(e.id) + if (!s || from.keys.get(e.id) !== edgeKey(e) || moved.has(e.source) || moved.has(e.target)) { + freeRedo.push(e) + continue + } + free.set(e.id, s) + const size = labelSizes.get(e.id) + if (size) placed.set(e.id, labelBoxAt(s.at, size, e.id)) + } + const t0 = now() + let longest = 0 + for (const e of [...first, ...then]) { + // a route is started only when the longest one so far still fits + if (now() - t0 + longest > PROVISIONAL_MS) continue + const r0 = now() + const r = routeOne(p, e, out, []) + longest = Math.max(longest, now() - r0) + lastProvisional.routed++ + if (!r) { + out.delete(e.id) + continue + } + const c = placeLabel(p, e.id, r, placed, out, []) + if (c) placed.set(e.id, labelBoxAt(c, labelSizes.get(e.id)!, e.id)) + out.set(e.id, { ...r, label: c }) + } + for (const e of freeRedo) { + const s = placeFreeLabel(p, e, placed, out, []) + if (s) free.set(e.id, s) + } + lastProvisional.ms = +(now() - tStart).toFixed(2) + provisionalPeakMs = Math.max(provisionalPeakMs, lastProvisional.ms) + return { map: out, free } +} + +const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()) + +// ── diagnostics (never hidden): §ER14.4 ─────────────────────────────────── + +type Report = { + counts: Record + flagged: { id: string; routeClass: GuardedClass }[] + /** two connections of one port running together past its stub (must be 0) */ + fanOverlaps: { a: string; b: string; length: number }[] + /** fan members whose branch was blocked by obstacles (routed without it) */ + fanBlocked: string[] +} +let lastReport: Report = { counts: { orthogonal: 0, tight: 0, outer: 0, blocked: 0, degenerate: 0 }, flagged: [], fanOverlaps: [], fanBlocked: [] } +/** the last provisional map: moved nodes, routes it had to redo, routes redone */ +let lastProvisional: { moved: number; affected: number; routed: number; ms?: number } | null = null +/** the longest provisional map since the last `__resetProvisionalPeak` (it runs inside a render) */ +let provisionalPeakMs = 0 +export function __resetProvisionalPeak(): void { + provisionalPeakMs = 0 +} +/** the last full generation's job: slices and the longest one, ms */ +let lastJob: { slices: number; maxSliceMs: number; maxStepMs: number; slowestStep: string; cancelled: number } = { slices: 0, maxSliceMs: 0, maxStepMs: 0, slowestStep: '', cancelled: 0 } +const warned = new Set() + +/** the collinear length two polylines share */ +function sharedLength(a: Pt[], b: Pt[]): number { + let s = 0 + for (let i = 1; i < a.length; i++) { + for (let j = 1; j < b.length; j++) { + const p0 = a[i - 1] + const p1 = a[i] + const q0 = b[j - 1] + const q1 = b[j] + if (p0.x === p1.x && q0.x === q1.x && p0.x === q0.x) { + const o = Math.min(Math.max(p0.y, p1.y), Math.max(q0.y, q1.y)) - Math.max(Math.min(p0.y, p1.y), Math.min(q0.y, q1.y)) + if (o > 0) s += o + } else if (p0.y === p1.y && q0.y === q1.y && p0.y === q0.y) { + const o = Math.min(Math.max(p0.x, p1.x), Math.max(q0.x, q1.x)) - Math.max(Math.min(p0.x, p1.x), Math.min(q0.x, q1.x)) + if (o > 0) s += o + } + } + } + return s +} + +function report(nodes: LoopNode[], edges: LoopEdge[], map: Map): void { + const counts: Record = { orthogonal: 0, tight: 0, outer: 0, blocked: 0, degenerate: 0 } + const flagged: Report['flagged'] = [] + for (const [id, r] of map) { + counts[r.routeClass]++ + if (r.routeClass === 'outer' || r.routeClass === 'blocked') { + flagged.push({ id, routeClass: r.routeClass }) + const k = `${id}|${r.routeClass}` + if (import.meta.env.DEV && !warned.has(k)) { + warned.add(k) + console.warn(`[route] ${id}: no route inside the clearance ladder — drawn as "${r.routeClass}"`) + } + } + } + const fanOverlaps: Report['fanOverlaps'] = [] + const p = plan(nodes, edges) + for (let i = 0; i < p.ortho.length; i++) { + for (let j = i + 1; j < p.ortho.length; j++) { + const a = p.ortho[i] + const b = p.ortho[j] + if (pkey(a) === pkey(b)) continue + const atS = srcKey(a) === srcKey(b) + const atT = tgtKey(a) === tgtKey(b) + if (!atS && !atT) continue + const ra = map.get(a.id) + const rb = map.get(b.id) + if (!ra || !rb) continue + const len = sharedLength(pastSharedStub(ra, atS, atT), pastSharedStub(rb, atS, atT)) + if (len > 0.5) fanOverlaps.push({ a: a.id, b: b.id, length: +len.toFixed(1) }) + } + } + lastReport = { counts, flagged, fanOverlaps, fanBlocked: [...map].filter(([, r]) => r.fanBlocked).map(([id]) => id) } +} + +/** the last full generation's route classes, for the collision check and the + * development tools: how many routes each rung produced, every edge drawn on + * the outer corridor or blocked, and every port fan that runs together past + * its stub; plus the last provisional map and the last job's slices */ +export function routeDiagnostics(): Report & { provisional: typeof lastProvisional; provisionalPeakMs: number; job: typeof lastJob; pending: boolean } { + return { ...lastReport, provisional: lastProvisional, provisionalPeakMs, job: lastJob, pending: job != null } +} + +// ── the cache, the provisional map and the full-generation job ───────────── + +/** §ER3.8 — the layout signature: exactly what a generation reads, in input * order, plus each node's `hidden` flag (not read by the router today, but a * visibility change is a layout change by contract and must never reuse a * generation if the router ever starts skipping hidden obstacles). Structured @@ -121,48 +794,370 @@ function rebuild(nodes: LoopNode[], edges: LoopEdge[]): ReadonlyMap { - if (cacheKey && cacheKey.nodes === nodes && cacheKey.edges === edges && cacheKey.v === ROUTER_VERSION) { - return cacheMap +/** a committed full generation, and what it was computed from */ +type Base = { + sig: string + map: Map + free: Map + geo: Map + keys: Map +} +const baseOf = (nodes: LoopNode[], edges: LoopEdge[], sig: string, gen: Required): Base => ({ + sig, + map: gen.map, + free: gen.free, + geo: new Map(nodes.map((n) => [n.id, { ...n.position, ...sizeOf(n) }])), + keys: new Map(edges.map((e) => [e.id, edgeKey(e)])), +}) + +/** the map shown now, for the signature it answers */ +let committed: { sig: string; map: ReadonlyMap; free: ReadonlyMap } | null = null +/** the last FULL generation (exact): what a provisional map starts from */ +let base: Base | null = null +let lastInput: { nodes: LoopNode[]; edges: LoopEdge[] } | null = null +let idKey: { nodes: LoopNode[]; edges: LoopEdge[]; rev: number } | null = null +let genCount = 0 + +type Scheduler = (run: () => void) => void +/** the slice scheduler: in a browser each slice is its own task (a + * `MessageChannel` message), so between two slices the browser handles input + * and draws a frame whenever one is due; none (synchronous full generations) + * elsewhere */ +function taskScheduler(): Scheduler | null { + if (typeof MessageChannel !== 'function' || typeof window === 'undefined') return null + const queue: (() => void)[] = [] + const ch = new MessageChannel() + ch.port1.onmessage = () => queue.shift()?.() + return (run) => { + queue.push(run) + ch.port2.postMessage(0) + } +} +let scheduler: Scheduler | null = taskScheduler() + +let job: { token: number; sig: string; nodes: LoopNode[]; edges: LoopEdge[]; steps: Generator; out: GenOut; slices: number; maxSlice: number; lastStep: number; maxStep: number; slowest: string } | null = null +let jobToken = 0 + +function cancelJob(): void { + if (job) lastJob = { ...lastJob, cancelled: lastJob.cancelled + 1 } + job = null + jobToken++ +} + +function startFull(nodes: LoopNode[], edges: LoopEdge[], sig: string): void { + cancelJob() + if (!scheduler) return + const out: GenOut = {} + const token = ++jobToken + job = { token, sig, nodes, edges, steps: fullSteps(nodes, edges, out), out, slices: 0, maxSlice: 0, lastStep: 0, maxStep: 0, slowest: '' } + const runSlice = () => { + const j = job + if (!j || j.token !== token) return // cancelled: never commits + const t0 = now() + let done = false + // at least one step; another only when the last step's length still fits + for (;;) { + const s0 = now() + const step = j.steps.next() + if (step.done) { + done = true + break + } + j.lastStep = now() - s0 + if (j.lastStep > j.maxStep) j.slowest = step.value + j.maxStep = Math.max(j.maxStep, j.lastStep) + if (now() - t0 + Math.max(j.lastStep, j.maxStep) > SLICE_MS) break + } + j.slices++ + j.maxSlice = Math.max(j.maxSlice, now() - t0) + scheduler!(done ? commitSlice : runSlice) } - const sig = layoutSignature(nodes, edges) - if (cacheKey && cacheKey.sig === sig && cacheKey.v === ROUTER_VERSION) { - // same layout under new array identities (a select change, a label edit …): - // keep the generation, adopt the identities for the fast path - cacheKey = { nodes, edges, v: ROUTER_VERSION, sig } - return cacheMap + // the commit (diagnostics, the kept generation) is a task of its own, counted + // like a slice + const commitSlice = () => { + const j = job + if (!j || j.token !== token) return + const t0 = now() + job = null + // only the generation for the layout on screen is committed + if (committed && committed.sig === j.sig && !live) { + commitFull(j.nodes, j.edges, j.sig, { map: j.out.map!, free: j.out.free! }) + bump() + } + j.slices++ + j.maxSlice = Math.max(j.maxSlice, now() - t0) + lastJob = { slices: j.slices, maxSliceMs: +j.maxSlice.toFixed(2), maxStepMs: +j.maxStep.toFixed(2), slowestStep: j.slowest, cancelled: lastJob.cancelled } } - cacheMap = rebuild(nodes, edges) - cacheKey = { nodes, edges, v: ROUTER_VERSION, sig } - genCount += 1 - return cacheMap + scheduler(runSlice) } -/** test hook — how many full route-map generations have been built. */ +/** the last few committed full generations, by signature: a layout seen again + * (undo, redo, reopening the same document) is shown at once */ +const generations = new Map() +const GENERATIONS_KEPT = 4 + +function commitFull(nodes: LoopNode[], edges: LoopEdge[], sig: string, gen: Required): void { + base = baseOf(nodes, edges, sig, gen) + committed = { sig, map: gen.map, free: gen.free } + genCount++ + report(nodes, edges, gen.map) + generations.delete(sig) + generations.set(sig, base) + while (generations.size > GENERATIONS_KEPT) generations.delete(generations.keys().next().value!) + setBusy(false) +} + +/** what is waiting: a routed edge with no route yet, or a Curved / Straight + * edge whose label size is known but has no slot yet */ +function waiting(nodes: LoopNode[], edges: LoopEdge[], gen: { map: ReadonlyMap; free: ReadonlyMap }): boolean { + const ids = new Set(nodes.map((n) => n.id)) + for (const e of edges) { + if (isOrtho(e)) { + if (!gen.map.has(e.id)) return true + } else if (!recordLabels && ids.has(e.source) && ids.has(e.target) && labelSizes.has(e.id) && !gen.free.has(e.id)) return true + } + return false +} + +const NO_SLOTS: ReadonlyMap = new Map() + +/** The generation to draw for the current routing input — the routes and the + * Curved / Straight label slots — shared by every `LoopEdge` in the render. */ +export function currentRouteGeneration(nodes: LoopNode[], edges: LoopEdge[]): { map: ReadonlyMap; free: ReadonlyMap } { + const rev = useRouteInputs.getState().rev + if (committed && idKey && idKey.nodes === nodes && idKey.edges === edges && idKey.rev === rev) return committed + lastInput = { nodes, edges } + const sig = layoutSignature(nodes, edges) + `|v${ROUTE_MAP_VERSION}${recordLabels ? '|record' : ''}` + idKey = { nodes, edges, rev } + if (committed && committed.sig === sig) return committed // same layout, new identities + const known = generations.get(sig) + if (known) { + // a full generation of exactly this layout is kept: show it at once + cancelJob() + base = known + committed = { sig, map: known.map, free: known.free } + report(nodes, edges, known.map) + setBusy(false) + return committed + } + if (!scheduler) { + // tests and the offline judge: the full generation at once + cancelJob() + commitFull(nodes, edges, sig, rebuildFull(nodes, edges)) + return committed! + } + const total = edges.filter(isOrtho).length + // a cold start, or another document (most routed edges are new): nothing + // routed, and no Curved / Straight label, is drawn until the sliced full + // generation commits + const fresh = base ? edges.filter((e) => isOrtho(e) && !base!.map.has(e.id)).length : total + if (!base || fresh > Math.max(8, total / 2)) { + committed = { sig, map: new Map(), free: NO_SLOTS } + setBusy(waiting(nodes, edges, committed)) + if (!live) startFull(nodes, edges, sig) + return committed + } + const gen = provisional(nodes, edges, base) + committed = { sig, ...gen } + setBusy(waiting(nodes, edges, committed)) + if (!live) startFull(nodes, edges, sig) + return committed +} + +/** the routes of the current generation (see `currentRouteGeneration`) */ +export function currentRouteMap(nodes: LoopNode[], edges: LoopEdge[]): ReadonlyMap { + return currentRouteGeneration(nodes, edges).map +} + +// ── bend-point editing (issue #344 step 3, docs/edge-routing.md §ER16) ───── + +/** how close to a port stub a bend point may not be, flow px */ +export const BEND_STUB_TOL = 8 + +/** why a bend point of `edgeId` cannot be at `p`: inside a node, or on one of + * the connection's own port stubs (the stretch from a port to `ROUTE_STUB` + * past its node's side, which the router owns); null when it can. A refused + * edit is undone, never adjusted. */ +export function bendRejection(nodes: LoopNode[], edges: LoopEdge[], edgeId: string, p: Pt): 'node' | 'stub' | null { + for (const n of nodes) { + const b = boxOf(n) + if (p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h) return 'node' + } + const e = edges.find((x) => x.id === edgeId) + if (!e) return null + const byId = new Map(nodes.map((n) => [n.id, n])) + for (const asSource of [true, false]) { + const n = byId.get(asSource ? e.source : e.target) + if (!n) continue + const pos = asSource ? handlePos(e.sourceHandle, Position.Right) : handlePos(e.targetHandle, Position.Left) + const port = handlePoint(n, pos) + const nrm = pos === Position.Left ? { x: -1, y: 0 } : pos === Position.Right ? { x: 1, y: 0 } : pos === Position.Top ? { x: 0, y: -1 } : { x: 0, y: 1 } + const b = boxOf(n) + const side = nrm.x > 0 ? b.x + b.w - port.x : nrm.x < 0 ? port.x - b.x : nrm.y > 0 ? b.y + b.h - port.y : port.y - b.y + const len = Math.max(0, side) + ROUTE_STUB + const end = { x: port.x + nrm.x * len, y: port.y + nrm.y * len } + if (distToSegment(p, port, end) < BEND_STUB_TOL) return 'stub' + } + return null +} + +function distToSegment(p: Pt, a: Pt, b: Pt): number { + const dx = b.x - a.x + const dy = b.y - a.y + const l2 = dx * dx + dy * dy + const t = l2 > 0 ? Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2)) : 0 + return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)) +} + +/** where along a polyline the point nearest to `p` lies, as a length from its start */ +function lengthAt(pts: Pt[], p: Pt): number { + let best = Infinity + let at = 0 + let acc = 0 + for (let i = 1; i < pts.length; i++) { + const a = pts[i - 1] + const b = pts[i] + const seg = Math.hypot(b.x - a.x, b.y - a.y) + const d = distToSegment(p, a, b) + if (d < best) { + best = d + const dx = b.x - a.x + const dy = b.y - a.y + const t = seg > 0 ? Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / (seg * seg))) : 0 + at = acc + t * seg + } + acc += seg + } + return at +} + +/** the point of segment a→b (axis-aligned) nearest to `p`, snapped to the grid + * ALONG the segment and kept on its line ACROSS it, so a bend point added + * there leaves the route where it is (a port row is off the grid: a full snap + * would make the route jog). `free` (Alt) skips the snap. Null when no grid + * line falls inside the segment. */ +function onSegment(a: Pt, b: Pt, p: Pt, free: boolean): Pt | null { + const horiz = Math.abs(a.y - b.y) <= Math.abs(a.x - b.x) + const lo = horiz ? Math.min(a.x, b.x) : Math.min(a.y, b.y) + const hi = horiz ? Math.max(a.x, b.x) : Math.max(a.y, b.y) + const want = Math.max(lo, Math.min(hi, horiz ? p.x : p.y)) + let v = want + if (!free) { + v = Math.round(want / GRID) * GRID + if (v < lo || v > hi) v = v < lo ? Math.ceil(lo / GRID) * GRID : Math.floor(hi / GRID) * GRID + if (v < lo || v > hi) return null + } + return horiz ? { x: v, y: a.y } : { x: a.x, y: v } +} + +/** where a click at `p` puts a new bend point: on the nearest segment of the + * route, snapped along it (Alt: free); null when that segment holds no grid + * line */ +export function bendAtClick(points: Pt[], p: Pt, free: boolean): Pt | null { + let best = -1 + let bestD = Infinity + for (let i = 1; i < points.length; i++) { + const d = distToSegment(p, points[i - 1], points[i]) + if (d < bestD) { + bestD = d + best = i + } + } + return best < 0 ? null : onSegment(points[best - 1], points[best], p, free) +} + +/** §ER16.2 — the bend point Enter adds: the middle of the longest editable + * segment of the route (between its two stub ends), snapped along it; equal + * lengths go to the one nearer the route's start; a segment whose point would + * be refused (`bendRejection`) gives way to the next. Null when none can take + * one. Deterministic: lengths and order only. */ +export function keyboardBend(nodes: LoopNode[], edges: LoopEdge[], edgeId: string, route: GuardedResult): Pt | null { + const pts = route.points + const sStart = lengthAt(pts, route.stubs[1]) + const sEnd = lengthAt(pts, route.stubs[2]) + const spans: { a: Pt; b: Pt; len: number; at: number }[] = [] + let acc = 0 + for (let i = 1; i < pts.length; i++) { + const a = pts[i - 1] + const b = pts[i] + const seg = Math.hypot(b.x - a.x, b.y - a.y) + const s0 = Math.max(acc, sStart) + const s1 = Math.min(acc + seg, sEnd) + if (s1 - s0 > COORD_TOL && seg > 0) { + const at = (k: number) => ({ x: a.x + ((b.x - a.x) * (k - acc)) / seg, y: a.y + ((b.y - a.y) * (k - acc)) / seg }) + spans.push({ a: at(s0), b: at(s1), len: s1 - s0, at: s0 }) + } + acc += seg + } + spans.sort((p, q) => q.len - p.len || p.at - q.at) + for (const s of spans) { + const mid = { x: (s.a.x + s.b.x) / 2, y: (s.a.y + s.b.y) / 2 } + const p = onSegment(s.a, s.b, mid, false) + if (p && bendRejection(nodes, edges, edgeId, p) === null) return p + } + return null +} +const COORD_TOL = 1e-6 + +/** the index a new bend point clicked at `p` takes among `waypoints`, for a + * route drawn through `points`: the span of the route the click lands on (the + * route passes through its bend points in order) */ +export function bendInsertIndex(points: Pt[], waypoints: Pt[], p: Pt): number { + const s = lengthAt(points, p) + return waypoints.filter((w) => lengthAt(points, w) < s).length +} + +/** test hook — how many FULL generations have been committed. */ export function __routeGenCount(): number { return genCount } +/** test hook — the synchronous full generation for this input, computed aside + * (the cache, the job and the diagnostics are untouched): what a committed + * sliced generation must equal */ +export function __syncFullGeneration(nodes: LoopNode[], edges: LoopEdge[]): Map { + return rebuildFull(nodes, edges).map +} +/** test hook — the synchronous Curved / Straight label slots for this input */ +export function __syncFreeSlots(nodes: LoopNode[], edges: LoopEdge[]): Map { + return rebuildFull(nodes, edges).free +} +/** test hook — replace the slice scheduler (null = synchronous) */ +export function __setRouteScheduler(s: Scheduler | null): void { + cancelJob() + scheduler = s +} export function __resetRouteCache(): void { - cacheKey = null - cacheMap = new Map() + cancelJob() + committed = null + base = null + generations.clear() + wantBusy = false + lastInput = null + idKey = null genCount = 0 + labelSizes.clear() + live = false + recordLabels = false + warned.clear() + lastProvisional = null + lastJob = { slices: 0, maxSliceMs: 0, maxStepMs: 0, slowestStep: '', cancelled: 0 } } diff --git a/src/store/shareApply.ts b/src/store/shareApply.ts index 2cde1ee2..28e1e4e2 100644 --- a/src/store/shareApply.ts +++ b/src/store/shareApply.ts @@ -40,6 +40,7 @@ export function applySharedGraph(parsed: ReturnType): void { { mode: 'document-boundary', canvasLocked: parsed.recommendedRunConfig?.canvasLocked === true, + layoutVersion: parsed.layoutVersion, // issue #344 — a legacy layout is re-placed once modelVersion: parsed.modelVersion, frames: parsed.frames, dataImports: parsed.dataImports, diff --git a/src/store/workspaceIO.ts b/src/store/workspaceIO.ts index 4951488b..94c00ef9 100644 --- a/src/store/workspaceIO.ts +++ b/src/store/workspaceIO.ts @@ -114,6 +114,14 @@ export async function importFile( * recovery proved (by digest) that a declared-v1 revision / proposal * file is v2 content. Absent ⇒ the declared version, as always. */ modelVersion?: ModelSemanticsVersion + /** issue #344 §DL2.8 — open the layout exactly as written, even when it + * predates the grid. Set by `revisionIO.routeImport` for a Project + * revision: its digest covers the positions, so a re-placement would make + * the file differ from its own revision the moment it opens. */ + keepLayout?: boolean + /** issue #344 step 3 — the file is a revision recorded before + * `loop-revision/10`: its Curved labels keep their recorded place */ + recordLabels?: boolean } = {}, ): Promise { const parsed = deserialize(text) // throws on a bad graph, as today @@ -133,6 +141,9 @@ export async function importFile( { mode: 'document-boundary', canvasLocked: parsed.recommendedRunConfig?.canvasLocked === true, + // issue #344 — a legacy layout is re-placed once (never a revision's) + layoutVersion: opts.keepLayout ? undefined : parsed.layoutVersion, + recordLabels: opts.recordLabels === true, modelVersion, frames: parsed.frames, dataImports: parsed.dataImports, diff --git a/test/fixtures/legacy-canonical-boxes.json b/test/fixtures/legacy-canonical-boxes.json new file mode 100644 index 00000000..c837e31c --- /dev/null +++ b/test/fixtures/legacy-canonical-boxes.json @@ -0,0 +1,796 @@ +{ + "equilibrium": { + "tpl-consume": [ + 181, + 58 + ], + "tpl-conv": [ + 139, + 58 + ], + "tpl-gate": [ + 201, + 64 + ], + "tpl-prod": [ + 172, + 74 + ], + "tpl-spill": [ + 181, + 58 + ], + "tpl-src": [ + 188, + 74 + ], + "tpl-vault": [ + 163, + 74 + ] + }, + "deadlock": { + "tpl-conv": [ + 139, + 58 + ], + "tpl-gate": [ + 201, + 64 + ], + "tpl-prod": [ + 172, + 74 + ], + "tpl-spill": [ + 181, + 58 + ], + "tpl-src": [ + 188, + 74 + ], + "tpl-vault": [ + 163, + 74 + ] + }, + "coffee-roastery": { + "cafe_retail": [ + 183, + 74 + ], + "cafe_retail_demand_kg": [ + 179, + 120 + ], + "daily_roast_kg": [ + 179, + 120 + ], + "dessert_prep": [ + 179, + 103 + ], + "dessert_prep_margin": [ + 197, + 118 + ], + "dessert_prep_src": [ + 188, + 58 + ], + "dessert_sales": [ + 181, + 58 + ], + "dessert_stock": [ + 162, + 59 + ], + "dessert_wrapup": [ + 183, + 74 + ], + "green_delivery": [ + 188, + 74 + ], + "green_stock": [ + 173, + 90 + ], + "green_wholesale": [ + 183, + 74 + ], + "green_wholesale_kg": [ + 179, + 120 + ], + "online_orders": [ + 179, + 120 + ], + "online_sales": [ + 183, + 90 + ], + "planned_cost": [ + 260, + 102 + ], + "projected_operating_margin": [ + 260, + 120 + ], + "projected_revenue": [ + 260, + 118 + ], + "roast_loss": [ + 183, + 74 + ], + "roasted_bleed": [ + 183, + 74 + ], + "roasted_stock": [ + 173, + 75 + ], + "roasted_supply_margin": [ + 260, + 118 + ], + "roasting": [ + 201, + 64 + ] + }, + "gacha-banner-zones": { + "all_zones_done": [ + 185, + 59 + ], + "ceiling_hits_pickup": [ + 140, + 59 + ], + "ceiling_hits_standard": [ + 140, + 59 + ], + "cmp1_hit_rate_free": [ + 260, + 118 + ], + "cmp2_hit_rate_standard": [ + 260, + 118 + ], + "cmp3_hit_rate_pickup": [ + 260, + 118 + ], + "cmp4_pickup_rate_pickup": [ + 260, + 120 + ], + "forced_ssr_standard": [ + 171, + 56 + ], + "fund_free": [ + 173, + 58 + ], + "fund_pickup": [ + 173, + 58 + ], + "fund_standard": [ + 173, + 58 + ], + "missed_pickup_pickup": [ + 147, + 59 + ], + "pickup_count_pickup": [ + 143, + 59 + ], + "pickup_hit_pickup": [ + 155, + 56 + ], + "pity_pickup": [ + 118, + 59 + ], + "pity_standard": [ + 118, + 59 + ], + "pulls_made_free": [ + 132, + 59 + ], + "pulls_made_pickup": [ + 132, + 59 + ], + "pulls_made_standard": [ + 132, + 59 + ], + "pulls_per_zone": [ + 179, + 120 + ], + "r_count_free": [ + 118, + 59 + ], + "r_count_pickup": [ + 118, + 59 + ], + "r_count_standard": [ + 118, + 59 + ], + "r_hit_free": [ + 152, + 56 + ], + "r_hit_pickup": [ + 152, + 56 + ], + "r_hit_standard": [ + 152, + 56 + ], + "roll_forced_open_pickup": [ + 201, + 64 + ], + "roll_forced_owed_pickup": [ + 201, + 64 + ], + "roll_gate_free": [ + 151, + 56 + ], + "roll_gate_standard": [ + 151, + 56 + ], + "roll_normal_open_pickup": [ + 201, + 64 + ], + "roll_normal_owed_pickup": [ + 201, + 64 + ], + "sr_count_free": [ + 118, + 59 + ], + "sr_count_pickup": [ + 118, + 59 + ], + "sr_count_standard": [ + 118, + 59 + ], + "sr_hit_free": [ + 152, + 56 + ], + "sr_hit_pickup": [ + 152, + 56 + ], + "sr_hit_standard": [ + 152, + 56 + ], + "ssr_count_free": [ + 122, + 59 + ], + "ssr_count_pickup": [ + 138, + 59 + ], + "ssr_count_standard": [ + 159, + 59 + ], + "ssr_hit_free": [ + 152, + 56 + ], + "ssr_hit_standard": [ + 152, + 56 + ], + "ssr_split_open_pickup": [ + 201, + 64 + ], + "standard_count_pickup": [ + 172, + 59 + ], + "standard_hit_pickup": [ + 176, + 56 + ], + "termination_fuel": [ + 173, + 90 + ], + "ticket_free": [ + 118, + 59 + ], + "ticket_pickup": [ + 118, + 59 + ], + "ticket_standard": [ + 118, + 59 + ], + "zone1_free_w_r": [ + 179, + 103 + ], + "zone1_free_w_sr": [ + 179, + 103 + ], + "zone1_free_w_ssr": [ + 179, + 103 + ], + "zone2_standard_hard_pity": [ + 179, + 120 + ], + "zone2_standard_w_r": [ + 179, + 103 + ], + "zone2_standard_w_sr": [ + 179, + 120 + ], + "zone2_standard_w_ssr": [ + 179, + 120 + ], + "zone3_pickup_hard_pity": [ + 179, + 120 + ], + "zone3_pickup_w_pickup": [ + 179, + 120 + ], + "zone3_pickup_w_r": [ + 179, + 103 + ], + "zone3_pickup_w_sr": [ + 179, + 103 + ], + "zone3_pickup_w_ssr": [ + 179, + 103 + ], + "zone3_pickup_w_standard": [ + 179, + 120 + ] + }, + "mmo-progression": { + "active_char": [ + 173, + 90 + ], + "bucket_consumable": [ + 173, + 90 + ], + "bucket_equip": [ + 145, + 74 + ], + "bucket_rare": [ + 139, + 74 + ], + "bucket_vendor": [ + 160, + 74 + ], + "char_creation": [ + 183, + 74 + ], + "clock": [ + 188, + 58 + ], + "combat_fails": [ + 145, + 59 + ], + "combat_wins": [ + 144, + 59 + ], + "completion": [ + 132, + 74 + ], + "completion_src": [ + 188, + 74 + ], + "consumable_conv": [ + 173, + 74 + ], + "death_conv": [ + 131, + 58 + ], + "death_pool": [ + 156, + 74 + ], + "deaths": [ + 164, + 59 + ], + "drop": [ + 118, + 74 + ], + "elapsed": [ + 162, + 59 + ], + "end15": [ + 185, + 59 + ], + "equip_conv": [ + 118, + 58 + ], + "fail_conv": [ + 152, + 58 + ], + "fail_pool": [ + 118, + 74 + ], + "food": [ + 144, + 59 + ], + "food_bought": [ + 173, + 75 + ], + "food_consumed": [ + 173, + 75 + ], + "food_upkeep": [ + 150, + 58 + ], + "gear_score": [ + 139, + 59 + ], + "gear_wear": [ + 130, + 74 + ], + "gold": [ + 118, + 59 + ], + "gold_earned": [ + 145, + 59 + ], + "hunt_payout": [ + 141, + 58 + ], + "hunt_xp": [ + 118, + 59 + ], + "items_consumed": [ + 173, + 75 + ], + "items_equipped": [ + 168, + 59 + ], + "items_looted": [ + 144, + 59 + ], + "items_sold": [ + 127, + 59 + ], + "level": [ + 118, + 59 + ], + "loot_category": [ + 190, + 56 + ], + "loot_dispatch": [ + 158, + 58 + ], + "loot_feed": [ + 144, + 74 + ], + "quest_payout": [ + 152, + 58 + ], + "quest_xp": [ + 118, + 59 + ], + "r_burned": [ + 260, + 118 + ], + "r_efflevel": [ + 179, + 102 + ], + "r_expense": [ + 260, + 102 + ], + "r_huntshare": [ + 260, + 102 + ], + "r_income": [ + 179, + 102 + ], + "r_items_acct": [ + 260, + 102 + ], + "r_netgold": [ + 260, + 102 + ], + "rare_conv": [ + 121, + 58 + ], + "repair_gold": [ + 146, + 58 + ], + "repair_spend": [ + 156, + 59 + ], + "repair_wear": [ + 159, + 58 + ], + "resupply": [ + 118, + 58 + ], + "resupply_spend": [ + 173, + 75 + ], + "reward": [ + 118, + 74 + ], + "reward_router": [ + 195, + 56 + ], + "training_spend": [ + 172, + 59 + ], + "vendor_conv": [ + 164, + 58 + ], + "vendor_revenue": [ + 173, + 75 + ], + "void": [ + 181, + 58 + ], + "water": [ + 150, + 59 + ], + "water_bought": [ + 173, + 75 + ], + "water_consumed": [ + 173, + 91 + ], + "water_upkeep": [ + 156, + 58 + ], + "wear_cleared": [ + 156, + 59 + ], + "xp": [ + 118, + 59 + ], + "xp_earned": [ + 130, + 59 + ], + "z1_combat": [ + 198, + 56 + ], + "z1_enc": [ + 173, + 90 + ], + "z1_enc_src": [ + 188, + 74 + ], + "z1_loot": [ + 172, + 56 + ], + "z1_lootroll": [ + 173, + 90 + ], + "z1_training": [ + 173, + 74 + ], + "z1_win": [ + 173, + 90 + ], + "z1_winamp": [ + 138, + 58 + ], + "z1_xp2lvl": [ + 173, + 74 + ], + "z1_xp_meter": [ + 201, + 64 + ], + "z2_combat": [ + 201, + 64 + ], + "z2_enc": [ + 173, + 90 + ], + "z2_enc_src": [ + 188, + 74 + ], + "z2_loot": [ + 184, + 56 + ], + "z2_lootroll": [ + 173, + 90 + ], + "z2_training": [ + 173, + 74 + ], + "z2_win": [ + 173, + 90 + ], + "z2_winamp": [ + 151, + 58 + ], + "z2_xp2lvl": [ + 173, + 74 + ], + "z2_xp_meter": [ + 201, + 64 + ], + "z3_combat": [ + 201, + 64 + ], + "z3_enc": [ + 173, + 90 + ], + "z3_enc_src": [ + 188, + 74 + ], + "z3_loot": [ + 192, + 56 + ], + "z3_lootroll": [ + 173, + 90 + ], + "z3_training": [ + 173, + 74 + ], + "z3_win": [ + 173, + 90 + ], + "z3_winamp": [ + 159, + 58 + ], + "z3_xp2lvl": [ + 173, + 74 + ], + "z3_xp_meter": [ + 201, + 64 + ] + } +} diff --git a/test/fixtures/templates-before-step4/coffee-roastery.json b/test/fixtures/templates-before-step4/coffee-roastery.json new file mode 100644 index 00000000..09dca8e1 --- /dev/null +++ b/test/fixtures/templates-before-step4/coffee-roastery.json @@ -0,0 +1,533 @@ +{ + "schema": "loop-studio/graph/2", + "version": 1, + "layoutVersion": 1, + "nodes": [ + { + "id": "cafe_retail_demand_kg", + "type": "parameter", + "position": { + "x": 40, + "y": 0 + }, + "data": { + "kind": "parameter", + "label": "Cafe & retail bean demand (kg/day)", + "value": 6 + } + }, + { + "id": "daily_roast_kg", + "type": "parameter", + "position": { + "x": 320, + "y": 0 + }, + "data": { + "kind": "parameter", + "label": "Daily roast amount (kg)", + "value": 26 + } + }, + { + "id": "online_orders", + "type": "parameter", + "position": { + "x": 600, + "y": 0 + }, + "data": { + "kind": "parameter", + "label": "Online bean orders (kg/day)", + "value": 10 + } + }, + { + "id": "green_wholesale_kg", + "type": "parameter", + "position": { + "x": 880, + "y": 0 + }, + "data": { + "kind": "parameter", + "label": "Green wholesale orders (kg)", + "value": 6 + } + }, + { + "id": "dessert_prep", + "type": "parameter", + "position": { + "x": 1160, + "y": 0 + }, + "data": { + "kind": "parameter", + "label": "Daily dessert prep", + "value": 22 + } + }, + { + "id": "green_delivery", + "type": "source", + "position": { + "x": 40, + "y": 200 + }, + "data": { + "kind": "source", + "label": "Green bean delivery", + "activation": "automatic", + "mode": "pushAny", + "accent": "#74906B" + } + }, + { + "id": "green_stock", + "type": "pool", + "position": { + "x": 300, + "y": 200 + }, + "data": { + "kind": "pool", + "label": "Green bean stock", + "activation": "passive", + "initial": 90, + "capacity": 130, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "green_wholesale", + "type": "drain", + "position": { + "x": 300, + "y": 360 + }, + "data": { + "kind": "drain", + "label": "Green wholesale", + "activation": "automatic", + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "roasting", + "type": "gate", + "position": { + "x": 540, + "y": 200 + }, + "data": { + "kind": "gate", + "label": "Roasting · 82% yield", + "activation": "automatic", + "distribution": "deterministic", + "accent": "#A78243" + } + }, + { + "id": "roasted_stock", + "type": "pool", + "position": { + "x": 780, + "y": 200 + }, + "data": { + "kind": "pool", + "label": "Roasted bean stock", + "activation": "passive", + "initial": 30, + "capacity": null, + "mode": "pullAny", + "accent": "#A78243" + } + }, + { + "id": "roast_loss", + "type": "drain", + "position": { + "x": 540, + "y": 360 + }, + "data": { + "kind": "drain", + "label": "Roasting weight loss", + "activation": "automatic", + "mode": "pullAny", + "accent": "#A78243" + } + }, + { + "id": "online_sales", + "type": "drain", + "position": { + "x": 1020, + "y": 160 + }, + "data": { + "kind": "drain", + "label": "Online bagged-bean sales", + "activation": "automatic", + "mode": "pullAny", + "accent": "#A78243" + } + }, + { + "id": "cafe_retail", + "type": "drain", + "position": { + "x": 780, + "y": 360 + }, + "data": { + "kind": "drain", + "label": "Cafe & retail bean use", + "activation": "automatic", + "mode": "pullAny", + "accent": "#A78243" + } + }, + { + "id": "roasted_bleed", + "type": "drain", + "position": { + "x": 1020, + "y": 300 + }, + "data": { + "kind": "drain", + "label": "Staff, cupping & sampling", + "activation": "automatic", + "mode": "pullAny", + "accent": "#A78243" + } + }, + { + "id": "dessert_prep_src", + "type": "source", + "position": { + "x": 40, + "y": 520 + }, + "data": { + "kind": "source", + "label": "Dessert prep", + "activation": "automatic", + "mode": "pushAny", + "accent": "#B47599" + } + }, + { + "id": "dessert_stock", + "type": "pool", + "position": { + "x": 300, + "y": 520 + }, + "data": { + "kind": "pool", + "label": "Dessert stock", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "dessert_sales", + "type": "drain", + "position": { + "x": 540, + "y": 520 + }, + "data": { + "kind": "drain", + "label": "Dessert sales", + "activation": "automatic", + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "dessert_wrapup", + "type": "drain", + "position": { + "x": 540, + "y": 660 + }, + "data": { + "kind": "drain", + "label": "End-of-day leftover", + "activation": "automatic", + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "projected_revenue", + "type": "register", + "position": { + "x": 1400, + "y": 0 + }, + "data": { + "kind": "register", + "label": "Projected daily revenue", + "expr": "@cafe_retail_demand_kg * 30 + @online_orders * 17 + @green_wholesale_kg * 9 + 60", + "unit": "kKRW/day" + } + }, + { + "id": "planned_cost", + "type": "register", + "position": { + "x": 1400, + "y": 120 + }, + "data": { + "kind": "register", + "label": "Planned daily cost", + "expr": "(@daily_roast_kg + @green_wholesale_kg) * 5.5 + @dessert_prep * 1.4 + 140", + "unit": "kKRW/day" + } + }, + { + "id": "projected_operating_margin", + "type": "register", + "position": { + "x": 1400, + "y": 240 + }, + "data": { + "kind": "register", + "label": "Projected daily operating margin", + "expr": "@projected_revenue - @planned_cost", + "unit": "kKRW/day" + } + }, + { + "id": "roasted_supply_margin", + "type": "register", + "position": { + "x": 1400, + "y": 360 + }, + "data": { + "kind": "register", + "label": "Roasted supply margin", + "expr": "@roasted_stock - (@cafe_retail_demand_kg + @online_orders) * 2.35" + } + }, + { + "id": "dessert_prep_margin", + "type": "register", + "position": { + "x": 1400, + "y": 480 + }, + "data": { + "kind": "register", + "label": "Dessert prep margin", + "expr": "@dessert_prep - 16" + } + } + ], + "edges": [ + { + "id": "e_green_in", + "source": "green_delivery", + "target": "green_stock", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "34", + "accent": "#74906B" + } + }, + { + "id": "e_green_wholesale", + "source": "green_stock", + "target": "green_wholesale", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@green_wholesale_kg", + "accent": "#74906B" + } + }, + { + "id": "e_green_roast", + "source": "green_stock", + "target": "roasting", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@daily_roast_kg" + } + }, + { + "id": "e_roast_yield", + "source": "roasting", + "target": "roasted_stock", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "82", + "accent": "#A78243" + } + }, + { + "id": "e_roast_loss", + "source": "roasting", + "target": "roast_loss", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "18", + "accent": "#A78243" + } + }, + { + "id": "e_online", + "source": "roasted_stock", + "target": "online_sales", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@online_orders", + "accent": "#A78243" + } + }, + { + "id": "e_cafe_retail", + "source": "roasted_stock", + "target": "cafe_retail", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@cafe_retail_demand_kg", + "accent": "#A78243" + } + }, + { + "id": "e_roasted_bleed", + "source": "roasted_stock", + "target": "roasted_bleed", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "14%", + "accent": "#A78243" + } + }, + { + "id": "e_dessert_in", + "source": "dessert_prep_src", + "target": "dessert_stock", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@dessert_prep", + "accent": "#B47599" + } + }, + { + "id": "e_dessert_sales", + "source": "dessert_stock", + "target": "dessert_sales", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "16", + "accent": "#B47599" + } + }, + { + "id": "e_dessert_wrap", + "source": "dessert_stock", + "target": "dessert_wrapup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "55%", + "accent": "#B47599" + } + } + ], + "recommendedRunConfig": { + "baseSeed": 1, + "runs": 200, + "steps": 30, + "tracked": [ + "green_stock", + "roasted_stock", + "dessert_stock" + ], + "timelineSeries": [ + "dessert_stock", + "green_stock", + "projected_operating_margin", + "roasted_stock" + ] + }, + "frames": [ + { + "id": "zone_supply", + "label": "Supply & inventory", + "rect": { + "x": 16, + "y": 176, + "w": 473, + "h": 432 + } + }, + { + "id": "zone_roasting", + "label": "Roasting & sales", + "rect": { + "x": 516, + "y": 136, + "w": 719, + "h": 612 + } + }, + { + "id": "zone_forecast", + "label": "Forecast metrics", + "rect": { + "x": 1376, + "y": -24, + "w": 308, + "h": 634 + } + } + ] +} diff --git a/test/fixtures/templates-before-step4/deadlock.json b/test/fixtures/templates-before-step4/deadlock.json new file mode 100644 index 00000000..315e3fe6 --- /dev/null +++ b/test/fixtures/templates-before-step4/deadlock.json @@ -0,0 +1,157 @@ +{ + "schema": "loop-studio/graph", + "version": 1, + "layoutVersion": 1, + "nodes": [ + { + "id": "tpl-src", + "type": "source", + "position": { + "x": 40, + "y": 190 + }, + "data": { + "kind": "source", + "label": "Material supply", + "activation": "automatic", + "mode": "pushAny" + } + }, + { + "id": "tpl-vault", + "type": "pool", + "position": { + "x": 268, + "y": 180 + }, + "data": { + "kind": "pool", + "label": "Raw inventory", + "activation": "passive", + "initial": 0, + "capacity": 10, + "mode": "pullAny" + } + }, + { + "id": "tpl-gate", + "type": "gate", + "position": { + "x": 479, + "y": 180 + }, + "data": { + "kind": "gate", + "label": "Production split", + "activation": "automatic", + "distribution": "deterministic" + } + }, + { + "id": "tpl-conv", + "type": "converter", + "position": { + "x": 740, + "y": 110 + }, + "data": { + "kind": "converter", + "label": "Processing", + "activation": "automatic", + "mode": "pullAny" + } + }, + { + "id": "tpl-prod", + "type": "pool", + "position": { + "x": 934, + "y": 110 + }, + "data": { + "kind": "pool", + "label": "Finished goods", + "activation": "passive", + "initial": 0, + "capacity": 3, + "mode": "pullAny" + } + }, + { + "id": "tpl-spill", + "type": "drain", + "position": { + "x": 740, + "y": 300 + }, + "data": { + "kind": "drain", + "label": "Scrap", + "activation": "automatic", + "mode": "pullAny" + } + } + ], + "edges": [ + { + "id": "tpl-e1", + "source": "tpl-src", + "target": "tpl-vault", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "3" + } + }, + { + "id": "tpl-e2", + "source": "tpl-vault", + "target": "tpl-gate", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "all" + } + }, + { + "id": "tpl-e3", + "source": "tpl-gate", + "target": "tpl-conv", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "2" + } + }, + { + "id": "tpl-e4", + "source": "tpl-gate", + "target": "tpl-spill", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "tpl-e5", + "source": "tpl-conv", + "target": "tpl-prod", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + } + ] +} diff --git a/test/fixtures/templates-before-step4/equilibrium.json b/test/fixtures/templates-before-step4/equilibrium.json new file mode 100644 index 00000000..02d1d92e --- /dev/null +++ b/test/fixtures/templates-before-step4/equilibrium.json @@ -0,0 +1,183 @@ +{ + "schema": "loop-studio/graph", + "version": 1, + "layoutVersion": 1, + "nodes": [ + { + "id": "tpl-src", + "type": "source", + "position": { + "x": 40, + "y": 190 + }, + "data": { + "kind": "source", + "label": "Material supply", + "activation": "automatic", + "mode": "pushAny" + } + }, + { + "id": "tpl-vault", + "type": "pool", + "position": { + "x": 268, + "y": 180 + }, + "data": { + "kind": "pool", + "label": "Raw inventory", + "activation": "passive", + "initial": 0, + "capacity": 10, + "mode": "pullAny" + } + }, + { + "id": "tpl-gate", + "type": "gate", + "position": { + "x": 479, + "y": 180 + }, + "data": { + "kind": "gate", + "label": "Production split", + "activation": "automatic", + "distribution": "deterministic" + } + }, + { + "id": "tpl-conv", + "type": "converter", + "position": { + "x": 740, + "y": 110 + }, + "data": { + "kind": "converter", + "label": "Processing", + "activation": "automatic", + "mode": "pullAny" + } + }, + { + "id": "tpl-prod", + "type": "pool", + "position": { + "x": 934, + "y": 110 + }, + "data": { + "kind": "pool", + "label": "Finished goods", + "activation": "passive", + "initial": 0, + "capacity": 3, + "mode": "pullAny" + } + }, + { + "id": "tpl-spill", + "type": "drain", + "position": { + "x": 740, + "y": 300 + }, + "data": { + "kind": "drain", + "label": "Scrap", + "activation": "automatic", + "mode": "pullAny" + } + }, + { + "id": "tpl-consume", + "type": "drain", + "position": { + "x": 1153, + "y": 110 + }, + "data": { + "kind": "drain", + "label": "Shipment", + "activation": "automatic", + "mode": "pullAny" + } + } + ], + "edges": [ + { + "id": "tpl-e1", + "source": "tpl-src", + "target": "tpl-vault", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "3" + } + }, + { + "id": "tpl-e2", + "source": "tpl-vault", + "target": "tpl-gate", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "all" + } + }, + { + "id": "tpl-e3", + "source": "tpl-gate", + "target": "tpl-conv", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "2" + } + }, + { + "id": "tpl-e4", + "source": "tpl-gate", + "target": "tpl-spill", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "tpl-e5", + "source": "tpl-conv", + "target": "tpl-prod", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "tpl-e6", + "source": "tpl-prod", + "target": "tpl-consume", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + } + ] +} diff --git a/test/fixtures/templates-before-step4/gacha-banner-zones.json b/test/fixtures/templates-before-step4/gacha-banner-zones.json new file mode 100644 index 00000000..e0a79c3d --- /dev/null +++ b/test/fixtures/templates-before-step4/gacha-banner-zones.json @@ -0,0 +1,2273 @@ +{ + "schema": "loop-studio/graph/2", + "version": 1, + "layoutVersion": 1, + "nodes": [ + { + "id": "pulls_per_zone", + "type": "parameter", + "position": { + "x": 40, + "y": 40 + }, + "data": { + "kind": "parameter", + "label": "Pulls per zone (whole number)", + "value": 200 + } + }, + { + "id": "cmp1_hit_rate_free", + "type": "register", + "position": { + "x": 380, + "y": 40 + }, + "data": { + "kind": "register", + "label": "Hit rate — General / Free", + "expr": "@ssr_count_free / @pulls_per_zone", + "format": "percent" + } + }, + { + "id": "cmp2_hit_rate_standard", + "type": "register", + "position": { + "x": 720, + "y": 40 + }, + "data": { + "kind": "register", + "label": "Hit rate — Premium Standard", + "expr": "@ssr_count_standard / @pulls_per_zone", + "format": "percent" + } + }, + { + "id": "cmp3_hit_rate_pickup", + "type": "register", + "position": { + "x": 1060, + "y": 40 + }, + "data": { + "kind": "register", + "label": "Hit rate — Premium Pickup", + "expr": "@ssr_count_pickup / @pulls_per_zone", + "format": "percent" + } + }, + { + "id": "cmp4_pickup_rate_pickup", + "type": "register", + "position": { + "x": 1400, + "y": 40 + }, + "data": { + "kind": "register", + "label": "Pickup rate — Premium Pickup", + "expr": "@pickup_count_pickup / @pulls_per_zone", + "format": "percent" + } + }, + { + "id": "fund_free", + "type": "source", + "position": { + "x": 40, + "y": 370 + }, + "data": { + "kind": "source", + "label": "Fund tickets", + "activation": "onStart", + "mode": "pushAny", + "accent": "#74906B" + } + }, + { + "id": "ticket_free", + "type": "pool", + "position": { + "x": 230, + "y": 370 + }, + "data": { + "kind": "pool", + "label": "Tickets", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "pulls_made_free", + "type": "pool", + "position": { + "x": 40, + "y": 730 + }, + "data": { + "kind": "pool", + "label": "Pulls made", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "zone1_free_w_ssr", + "type": "parameter", + "position": { + "x": 300, + "y": 730 + }, + "data": { + "kind": "parameter", + "label": "General / Free · SSR weight", + "value": 6 + } + }, + { + "id": "zone1_free_w_sr", + "type": "parameter", + "position": { + "x": 560, + "y": 730 + }, + "data": { + "kind": "parameter", + "label": "General / Free · SR weight", + "value": 51 + } + }, + { + "id": "zone1_free_w_r", + "type": "parameter", + "position": { + "x": 40, + "y": 870 + }, + "data": { + "kind": "parameter", + "label": "General / Free · R weight", + "value": 943 + } + }, + { + "id": "sr_count_free", + "type": "pool", + "position": { + "x": 800, + "y": 370 + }, + "data": { + "kind": "pool", + "label": "SR count", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "r_count_free", + "type": "pool", + "position": { + "x": 800, + "y": 470 + }, + "data": { + "kind": "pool", + "label": "R count", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "ssr_count_free", + "type": "pool", + "position": { + "x": 800, + "y": 570 + }, + "data": { + "kind": "pool", + "label": "Free SSR", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "roll_gate_free", + "type": "gate", + "position": { + "x": 420, + "y": 370 + }, + "data": { + "kind": "gate", + "label": "Roll", + "activation": "automatic", + "distribution": "probabilistic", + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "ssr_hit_free", + "type": "gate", + "position": { + "x": 610, + "y": 370 + }, + "data": { + "kind": "gate", + "label": "SSR", + "activation": "automatic", + "distribution": "deterministic", + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "sr_hit_free", + "type": "gate", + "position": { + "x": 610, + "y": 470 + }, + "data": { + "kind": "gate", + "label": "SR", + "activation": "automatic", + "distribution": "deterministic", + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "r_hit_free", + "type": "gate", + "position": { + "x": 610, + "y": 570 + }, + "data": { + "kind": "gate", + "label": "R", + "activation": "automatic", + "distribution": "deterministic", + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "fund_standard", + "type": "source", + "position": { + "x": 1260, + "y": 370 + }, + "data": { + "kind": "source", + "label": "Fund tickets", + "activation": "onStart", + "mode": "pushAny", + "accent": "#9182A8" + } + }, + { + "id": "ticket_standard", + "type": "pool", + "position": { + "x": 1450, + "y": 370 + }, + "data": { + "kind": "pool", + "label": "Tickets", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "pulls_made_standard", + "type": "pool", + "position": { + "x": 1520, + "y": 730 + }, + "data": { + "kind": "pool", + "label": "Pulls made", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "zone2_standard_w_ssr", + "type": "parameter", + "position": { + "x": 1780, + "y": 730 + }, + "data": { + "kind": "parameter", + "label": "Premium Standard · SSR weight", + "value": 10 + } + }, + { + "id": "zone2_standard_w_sr", + "type": "parameter", + "position": { + "x": 1260, + "y": 870 + }, + "data": { + "kind": "parameter", + "label": "Premium Standard · SR weight", + "value": 90 + } + }, + { + "id": "zone2_standard_w_r", + "type": "parameter", + "position": { + "x": 1520, + "y": 870 + }, + "data": { + "kind": "parameter", + "label": "Premium Standard · R weight", + "value": 900 + } + }, + { + "id": "sr_count_standard", + "type": "pool", + "position": { + "x": 2020, + "y": 370 + }, + "data": { + "kind": "pool", + "label": "SR count", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "r_count_standard", + "type": "pool", + "position": { + "x": 2020, + "y": 470 + }, + "data": { + "kind": "pool", + "label": "R count", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "ssr_count_standard", + "type": "pool", + "position": { + "x": 2020, + "y": 570 + }, + "data": { + "kind": "pool", + "label": "Standard SSR", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "roll_gate_standard", + "type": "gate", + "position": { + "x": 1640, + "y": 370 + }, + "data": { + "kind": "gate", + "label": "Roll", + "activation": "automatic", + "distribution": "probabilistic", + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "ssr_hit_standard", + "type": "gate", + "position": { + "x": 1830, + "y": 370 + }, + "data": { + "kind": "gate", + "label": "SSR", + "activation": "automatic", + "distribution": "deterministic", + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "sr_hit_standard", + "type": "gate", + "position": { + "x": 1830, + "y": 470 + }, + "data": { + "kind": "gate", + "label": "SR", + "activation": "automatic", + "distribution": "deterministic", + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "r_hit_standard", + "type": "gate", + "position": { + "x": 1830, + "y": 570 + }, + "data": { + "kind": "gate", + "label": "R", + "activation": "automatic", + "distribution": "deterministic", + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "pity_standard", + "type": "pool", + "position": { + "x": 1260, + "y": 730 + }, + "data": { + "kind": "pool", + "label": "Pity", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "zone2_standard_hard_pity", + "type": "parameter", + "position": { + "x": 1780, + "y": 870 + }, + "data": { + "kind": "parameter", + "label": "Premium Standard · Hard pity ceiling", + "value": 80 + } + }, + { + "id": "forced_ssr_standard", + "type": "gate", + "position": { + "x": 1640, + "y": 470 + }, + "data": { + "kind": "gate", + "label": "Forced SSR", + "activation": "automatic", + "distribution": "deterministic", + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "ceiling_hits_standard", + "type": "pool", + "position": { + "x": 1260, + "y": 1010 + }, + "data": { + "kind": "pool", + "label": "Ceiling hits", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "fund_pickup", + "type": "source", + "position": { + "x": 2480, + "y": 370 + }, + "data": { + "kind": "source", + "label": "Fund tickets", + "activation": "onStart", + "mode": "pushAny", + "accent": "#B47599" + } + }, + { + "id": "ticket_pickup", + "type": "pool", + "position": { + "x": 2800, + "y": 370 + }, + "data": { + "kind": "pool", + "label": "Tickets", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "pulls_made_pickup", + "type": "pool", + "position": { + "x": 3000, + "y": 830 + }, + "data": { + "kind": "pool", + "label": "Pulls made", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "zone3_pickup_w_ssr", + "type": "parameter", + "position": { + "x": 2480, + "y": 970 + }, + "data": { + "kind": "parameter", + "label": "Premium Pickup · SSR weight", + "value": 10 + } + }, + { + "id": "zone3_pickup_w_sr", + "type": "parameter", + "position": { + "x": 2740, + "y": 970 + }, + "data": { + "kind": "parameter", + "label": "Premium Pickup · SR weight", + "value": 90 + } + }, + { + "id": "zone3_pickup_w_r", + "type": "parameter", + "position": { + "x": 3000, + "y": 970 + }, + "data": { + "kind": "parameter", + "label": "Premium Pickup · R weight", + "value": 900 + } + }, + { + "id": "sr_count_pickup", + "type": "pool", + "position": { + "x": 3760, + "y": 370 + }, + "data": { + "kind": "pool", + "label": "SR count", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "r_count_pickup", + "type": "pool", + "position": { + "x": 3760, + "y": 470 + }, + "data": { + "kind": "pool", + "label": "R count", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "ssr_count_pickup", + "type": "pool", + "position": { + "x": 2480, + "y": 470 + }, + "data": { + "kind": "pool", + "label": "Pickup SSR", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "pity_pickup", + "type": "pool", + "position": { + "x": 2480, + "y": 830 + }, + "data": { + "kind": "pool", + "label": "Pity", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "zone3_pickup_hard_pity", + "type": "parameter", + "position": { + "x": 2480, + "y": 1110 + }, + "data": { + "kind": "parameter", + "label": "Premium Pickup · Hard pity ceiling", + "value": 80 + } + }, + { + "id": "ceiling_hits_pickup", + "type": "pool", + "position": { + "x": 2740, + "y": 1110 + }, + "data": { + "kind": "pool", + "label": "Ceiling hits", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "missed_pickup_pickup", + "type": "pool", + "position": { + "x": 2740, + "y": 830 + }, + "data": { + "kind": "pool", + "label": "Pickup owed", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "zone3_pickup_w_pickup", + "type": "parameter", + "position": { + "x": 3000, + "y": 1110 + }, + "data": { + "kind": "parameter", + "label": "Premium Pickup · Pickup weight", + "value": 50 + } + }, + { + "id": "zone3_pickup_w_standard", + "type": "parameter", + "position": { + "x": 2480, + "y": 1250 + }, + "data": { + "kind": "parameter", + "label": "Premium Pickup · Standard weight", + "value": 50 + } + }, + { + "id": "roll_normal_open_pickup", + "type": "gate", + "position": { + "x": 3120, + "y": 370 + }, + "data": { + "kind": "gate", + "label": "Roll — normal, not owed", + "activation": "automatic", + "distribution": "probabilistic", + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "roll_normal_owed_pickup", + "type": "gate", + "position": { + "x": 3120, + "y": 470 + }, + "data": { + "kind": "gate", + "label": "Roll — normal, owed", + "activation": "automatic", + "distribution": "probabilistic", + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "roll_forced_open_pickup", + "type": "gate", + "position": { + "x": 3120, + "y": 570 + }, + "data": { + "kind": "gate", + "label": "Roll — forced, not owed", + "activation": "automatic", + "distribution": "probabilistic", + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "roll_forced_owed_pickup", + "type": "gate", + "position": { + "x": 3120, + "y": 670 + }, + "data": { + "kind": "gate", + "label": "Roll — forced, owed", + "activation": "automatic", + "distribution": "deterministic", + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "ssr_split_open_pickup", + "type": "gate", + "position": { + "x": 3440, + "y": 370 + }, + "data": { + "kind": "gate", + "label": "SSR split — not owed", + "activation": "automatic", + "distribution": "probabilistic", + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "pickup_hit_pickup", + "type": "gate", + "position": { + "x": 3760, + "y": 570 + }, + "data": { + "kind": "gate", + "label": "Pickup hit", + "activation": "automatic", + "distribution": "deterministic", + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "standard_hit_pickup", + "type": "gate", + "position": { + "x": 3760, + "y": 670 + }, + "data": { + "kind": "gate", + "label": "Standard hit", + "activation": "automatic", + "distribution": "deterministic", + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "sr_hit_pickup", + "type": "gate", + "position": { + "x": 3440, + "y": 470 + }, + "data": { + "kind": "gate", + "label": "SR", + "activation": "automatic", + "distribution": "deterministic", + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "r_hit_pickup", + "type": "gate", + "position": { + "x": 3440, + "y": 570 + }, + "data": { + "kind": "gate", + "label": "R", + "activation": "automatic", + "distribution": "deterministic", + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "pickup_count_pickup", + "type": "pool", + "position": { + "x": 4080, + "y": 370 + }, + "data": { + "kind": "pool", + "label": "Pickup wins", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "standard_count_pickup", + "type": "pool", + "position": { + "x": 4080, + "y": 470 + }, + "data": { + "kind": "pool", + "label": "Standard count", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#B47599" + } + }, + { + "id": "termination_fuel", + "type": "pool", + "position": { + "x": 40, + "y": 170 + }, + "data": { + "kind": "pool", + "label": "Completion signal", + "activation": "passive", + "initial": 1, + "capacity": 1, + "mode": "pullAny" + } + }, + { + "id": "all_zones_done", + "type": "end", + "position": { + "x": 264, + "y": 170 + }, + "data": { + "kind": "end", + "label": "All zones complete", + "activation": "automatic" + } + } + ], + "edges": [ + { + "id": "e_free_1", + "source": "fund_free", + "target": "ticket_free", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@pulls_per_zone", + "route": "orthogonal", + "accent": "#74906B" + } + }, + { + "id": "e_free_2", + "source": "roll_gate_free", + "target": "ssr_hit_free", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@zone1_free_w_ssr", + "route": "orthogonal", + "accent": "#74906B" + } + }, + { + "id": "e_free_3", + "source": "roll_gate_free", + "target": "sr_hit_free", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@zone1_free_w_sr", + "route": "orthogonal", + "accent": "#74906B" + } + }, + { + "id": "e_free_4", + "source": "roll_gate_free", + "target": "r_hit_free", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@zone1_free_w_r", + "route": "orthogonal", + "accent": "#74906B" + } + }, + { + "id": "e_free_5", + "source": "sr_hit_free", + "target": "sr_count_free", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#74906B" + } + }, + { + "id": "e_free_6", + "source": "r_hit_free", + "target": "r_count_free", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#74906B" + } + }, + { + "id": "e_free_7", + "source": "ssr_hit_free", + "target": "pulls_made_free", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_free_8", + "source": "sr_hit_free", + "target": "pulls_made_free", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_free_9", + "source": "r_hit_free", + "target": "pulls_made_free", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_free_10", + "source": "ticket_free", + "target": "roll_gate_free", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#74906B" + } + }, + { + "id": "e_free_11", + "source": "ssr_hit_free", + "target": "ssr_count_free", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#74906B" + } + }, + { + "id": "e_standard_1", + "source": "fund_standard", + "target": "ticket_standard", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@pulls_per_zone", + "route": "orthogonal", + "accent": "#9182A8" + } + }, + { + "id": "e_standard_2", + "source": "roll_gate_standard", + "target": "ssr_hit_standard", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@zone2_standard_w_ssr", + "route": "orthogonal", + "accent": "#9182A8" + } + }, + { + "id": "e_standard_3", + "source": "roll_gate_standard", + "target": "sr_hit_standard", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@zone2_standard_w_sr", + "route": "orthogonal", + "accent": "#9182A8" + } + }, + { + "id": "e_standard_4", + "source": "roll_gate_standard", + "target": "r_hit_standard", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@zone2_standard_w_r", + "route": "orthogonal", + "waypoints": [ + { + "x": 1740, + "y": 370 + } + ], + "accent": "#9182A8" + } + }, + { + "id": "e_standard_5", + "source": "sr_hit_standard", + "target": "sr_count_standard", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#9182A8" + } + }, + { + "id": "e_standard_6", + "source": "r_hit_standard", + "target": "r_count_standard", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#9182A8" + } + }, + { + "id": "e_standard_7", + "source": "ssr_hit_standard", + "target": "pulls_made_standard", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_standard_8", + "source": "sr_hit_standard", + "target": "pulls_made_standard", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_standard_9", + "source": "r_hit_standard", + "target": "pulls_made_standard", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_standard_10", + "source": "ticket_standard", + "target": "roll_gate_standard", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#9182A8" + } + }, + { + "id": "e_standard_11", + "source": "ticket_standard", + "target": "forced_ssr_standard", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#9182A8" + } + }, + { + "id": "e_standard_12", + "source": "pity_standard", + "target": "roll_gate_standard", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": "< @zone2_standard_hard_pity - 1", + "route": "orthogonal", + "waypoints": [ + { + "x": 1200, + "y": 550 + } + ] + } + }, + { + "id": "e_standard_13", + "source": "pity_standard", + "target": "forced_ssr_standard", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= @zone2_standard_hard_pity - 1", + "route": "orthogonal", + "waypoints": [ + { + "x": 1320, + "y": 550 + } + ] + } + }, + { + "id": "e_standard_14", + "source": "forced_ssr_standard", + "target": "ceiling_hits_standard", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_standard_15", + "source": "ssr_hit_standard", + "target": "pity_standard", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "=0", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_standard_16", + "source": "forced_ssr_standard", + "target": "pity_standard", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "=0", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_standard_17", + "source": "sr_hit_standard", + "target": "pity_standard", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_standard_18", + "source": "r_hit_standard", + "target": "pity_standard", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_standard_19", + "source": "forced_ssr_standard", + "target": "pulls_made_standard", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_standard_20", + "source": "ssr_hit_standard", + "target": "ssr_count_standard", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#9182A8" + } + }, + { + "id": "e_standard_21", + "source": "forced_ssr_standard", + "target": "ssr_count_standard", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#9182A8" + } + }, + { + "id": "e_pickup_1", + "source": "fund_pickup", + "target": "ticket_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@pulls_per_zone", + "route": "orthogonal", + "accent": "#B47599" + } + }, + { + "id": "e_pickup_2", + "source": "ticket_pickup", + "target": "roll_normal_open_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#B47599" + } + }, + { + "id": "e_pickup_3", + "source": "ticket_pickup", + "target": "roll_normal_owed_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#B47599" + } + }, + { + "id": "e_pickup_4", + "source": "ticket_pickup", + "target": "roll_forced_open_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#B47599" + } + }, + { + "id": "e_pickup_5", + "source": "ticket_pickup", + "target": "roll_forced_owed_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#B47599" + } + }, + { + "id": "e_pickup_6", + "source": "pity_pickup", + "target": "roll_normal_open_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": "< @zone3_pickup_hard_pity - 1", + "route": "orthogonal" + } + }, + { + "id": "e_pickup_7", + "source": "missed_pickup_pickup", + "target": "roll_normal_open_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": "< 1", + "route": "orthogonal", + "waypoints": [ + { + "x": 2680, + "y": 600 + } + ] + } + }, + { + "id": "e_pickup_8", + "source": "pity_pickup", + "target": "roll_normal_owed_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": "< @zone3_pickup_hard_pity - 1", + "route": "orthogonal", + "waypoints": [ + { + "x": 2420, + "y": 600 + } + ] + } + }, + { + "id": "e_pickup_9", + "source": "missed_pickup_pickup", + "target": "roll_normal_owed_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 1", + "route": "orthogonal", + "waypoints": [ + { + "x": 2800, + "y": 600 + } + ] + } + }, + { + "id": "e_pickup_10", + "source": "pity_pickup", + "target": "roll_forced_open_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= @zone3_pickup_hard_pity - 1", + "route": "orthogonal", + "waypoints": [ + { + "x": 2540, + "y": 600 + } + ] + } + }, + { + "id": "e_pickup_11", + "source": "missed_pickup_pickup", + "target": "roll_forced_open_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": "< 1", + "route": "orthogonal" + } + }, + { + "id": "e_pickup_12", + "source": "pity_pickup", + "target": "roll_forced_owed_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= @zone3_pickup_hard_pity - 1", + "route": "orthogonal" + } + }, + { + "id": "e_pickup_13", + "source": "missed_pickup_pickup", + "target": "roll_forced_owed_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 1", + "route": "orthogonal" + } + }, + { + "id": "e_pickup_14", + "source": "roll_normal_open_pickup", + "target": "ssr_split_open_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@zone3_pickup_w_ssr", + "route": "orthogonal", + "accent": "#B47599" + } + }, + { + "id": "e_pickup_15", + "source": "roll_forced_open_pickup", + "target": "ssr_split_open_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#B47599" + } + }, + { + "id": "e_pickup_16", + "source": "ssr_split_open_pickup", + "target": "pickup_hit_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@zone3_pickup_w_pickup", + "route": "orthogonal", + "accent": "#B47599" + } + }, + { + "id": "e_pickup_17", + "source": "ssr_split_open_pickup", + "target": "standard_hit_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@zone3_pickup_w_standard", + "route": "orthogonal", + "waypoints": [ + { + "x": 3700, + "y": 549 + } + ], + "accent": "#B47599" + } + }, + { + "id": "e_pickup_18", + "source": "roll_normal_owed_pickup", + "target": "pickup_hit_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@zone3_pickup_w_ssr", + "route": "orthogonal", + "accent": "#B47599" + } + }, + { + "id": "e_pickup_19", + "source": "roll_normal_owed_pickup", + "target": "sr_hit_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@zone3_pickup_w_sr", + "route": "orthogonal", + "accent": "#B47599" + } + }, + { + "id": "e_pickup_20", + "source": "roll_normal_owed_pickup", + "target": "r_hit_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@zone3_pickup_w_r", + "route": "orthogonal", + "waypoints": [ + { + "x": 3360, + "y": 548 + } + ], + "accent": "#B47599" + } + }, + { + "id": "e_pickup_21", + "source": "roll_normal_open_pickup", + "target": "sr_hit_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@zone3_pickup_w_sr", + "route": "orthogonal", + "accent": "#B47599" + } + }, + { + "id": "e_pickup_22", + "source": "roll_normal_open_pickup", + "target": "r_hit_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "@zone3_pickup_w_r", + "route": "orthogonal", + "accent": "#B47599" + } + }, + { + "id": "e_pickup_23", + "source": "roll_forced_owed_pickup", + "target": "pickup_hit_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#B47599" + } + }, + { + "id": "e_pickup_24", + "source": "pickup_hit_pickup", + "target": "pickup_count_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#B47599" + } + }, + { + "id": "e_pickup_25", + "source": "standard_hit_pickup", + "target": "standard_count_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#B47599" + } + }, + { + "id": "e_pickup_26", + "source": "sr_hit_pickup", + "target": "sr_count_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#B47599" + } + }, + { + "id": "e_pickup_27", + "source": "r_hit_pickup", + "target": "r_count_pickup", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#B47599" + } + }, + { + "id": "e_pickup_28", + "source": "pickup_hit_pickup", + "target": "pity_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "=0", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal", + "waypoints": [ + { + "x": 3760, + "y": 780 + }, + { + "x": 2480, + "y": 780 + } + ] + } + }, + { + "id": "e_pickup_29", + "source": "standard_hit_pickup", + "target": "pity_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "=0", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_pickup_30", + "source": "sr_hit_pickup", + "target": "pity_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_pickup_31", + "source": "r_hit_pickup", + "target": "pity_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_pickup_32", + "source": "pickup_hit_pickup", + "target": "pulls_made_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_pickup_33", + "source": "standard_hit_pickup", + "target": "pulls_made_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_pickup_34", + "source": "sr_hit_pickup", + "target": "pulls_made_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_pickup_35", + "source": "r_hit_pickup", + "target": "pulls_made_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal", + "waypoints": [ + { + "x": 3200, + "y": 700 + } + ] + } + }, + { + "id": "e_pickup_36", + "source": "pickup_hit_pickup", + "target": "ssr_count_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal", + "waypoints": [ + { + "x": 3200, + "y": 650 + } + ] + } + }, + { + "id": "e_pickup_37", + "source": "standard_hit_pickup", + "target": "ssr_count_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal", + "waypoints": [ + { + "x": 3827, + "y": 340 + }, + { + "x": 2539, + "y": 340 + } + ] + } + }, + { + "id": "e_pickup_38", + "source": "roll_forced_open_pickup", + "target": "ceiling_hits_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_pickup_39", + "source": "roll_forced_owed_pickup", + "target": "ceiling_hits_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "+1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_pickup_40", + "source": "standard_hit_pickup", + "target": "missed_pickup_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "=1", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal", + "waypoints": [ + { + "x": 3200, + "y": 750 + } + ] + } + }, + { + "id": "e_pickup_41", + "source": "pickup_hit_pickup", + "target": "missed_pickup_pickup", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "label", + "expr": "=0", + "timing": "afterPull", + "when": "source-fired", + "route": "orthogonal" + } + }, + { + "id": "e_termination_fuel", + "source": "termination_fuel", + "target": "all_zones_done", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "all", + "route": "orthogonal" + } + }, + { + "id": "e_termination_gate_free", + "source": "pulls_made_free", + "target": "all_zones_done", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= @pulls_per_zone", + "route": "orthogonal" + } + }, + { + "id": "e_termination_gate_standard", + "source": "pulls_made_standard", + "target": "all_zones_done", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= @pulls_per_zone", + "route": "orthogonal" + } + }, + { + "id": "e_termination_gate_pickup", + "source": "pulls_made_pickup", + "target": "all_zones_done", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= @pulls_per_zone", + "route": "orthogonal" + } + } + ], + "recommendedRunConfig": { + "baseSeed": 1, + "runs": 2000, + "steps": 202, + "tracked": [ + "pulls_made_free", + "ssr_count_free", + "pulls_made_standard", + "ssr_count_standard", + "ceiling_hits_standard", + "pulls_made_pickup", + "ssr_count_pickup", + "ceiling_hits_pickup", + "pickup_count_pickup", + "standard_count_pickup" + ], + "timelineSeries": [ + "ssr_count_free", + "ssr_count_standard", + "ssr_count_pickup", + "pickup_count_pickup" + ], + "canvasLocked": true + }, + "frames": [ + { + "id": "zone_comparison", + "label": "Comparison", + "rect": { + "x": 0, + "y": 0, + "w": 1720, + "h": 300 + } + }, + { + "id": "zone_free", + "label": "General / Free", + "rect": { + "x": 0, + "y": 330, + "w": 950, + "h": 620 + }, + "color": "sage" + }, + { + "id": "zone_standard", + "label": "Premium Standard", + "rect": { + "x": 1220, + "y": 330, + "w": 950, + "h": 760 + }, + "color": "violet" + }, + { + "id": "zone_pickup", + "label": "Premium Pickup", + "rect": { + "x": 2440, + "y": 330, + "w": 1790, + "h": 1000 + }, + "color": "rose" + } + ] +} diff --git a/test/fixtures/templates-before-step4/mmo-progression.json b/test/fixtures/templates-before-step4/mmo-progression.json new file mode 100644 index 00000000..2899ddbd --- /dev/null +++ b/test/fixtures/templates-before-step4/mmo-progression.json @@ -0,0 +1,3377 @@ +{ + "schema": "loop-studio/graph", + "version": 1, + "layoutVersion": 1, + "nodes": [ + { + "id": "level", + "type": "pool", + "position": { + "x": 1520, + "y": 800 + }, + "data": { + "kind": "pool", + "label": "Level", + "activation": "passive", + "initial": 1, + "capacity": null, + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "xp", + "type": "pool", + "position": { + "x": 1120, + "y": 800 + }, + "data": { + "kind": "pool", + "label": "XP", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "xp_earned", + "type": "pool", + "position": { + "x": 1320, + "y": 800 + }, + "data": { + "kind": "pool", + "label": "XP earned", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "reward", + "type": "pool", + "position": { + "x": 1720, + "y": 800 + }, + "data": { + "kind": "pool", + "label": "Reward", + "activation": "passive", + "initial": 0, + "capacity": 12, + "mode": "pullAny", + "accent": "#A78243" + } + }, + { + "id": "fail_pool", + "type": "pool", + "position": { + "x": 560, + "y": 800 + }, + "data": { + "kind": "pool", + "label": "Setbacks", + "activation": "passive", + "initial": 0, + "capacity": 6, + "mode": "pullAny" + } + }, + { + "id": "death_pool", + "type": "pool", + "position": { + "x": 720, + "y": 800 + }, + "data": { + "kind": "pool", + "label": "Deaths queue", + "activation": "passive", + "initial": 0, + "capacity": 6, + "mode": "pullAny" + } + }, + { + "id": "reward_router", + "type": "gate", + "position": { + "x": 1590, + "y": 940 + }, + "data": { + "kind": "gate", + "label": "Reward router", + "activation": "automatic", + "distribution": "deterministic" + } + }, + { + "id": "hunt_payout", + "type": "converter", + "position": { + "x": 1820, + "y": 890 + }, + "data": { + "kind": "converter", + "label": "Hunt payout", + "activation": "automatic", + "mode": "pullAny", + "accent": "#A78243" + } + }, + { + "id": "quest_payout", + "type": "converter", + "position": { + "x": 1800, + "y": 1000 + }, + "data": { + "kind": "converter", + "label": "Quest payout", + "activation": "automatic", + "mode": "pullAny", + "accent": "#A78243" + } + }, + { + "id": "hunt_xp", + "type": "pool", + "position": { + "x": 2030, + "y": 890 + }, + "data": { + "kind": "pool", + "label": "Hunt XP", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "quest_xp", + "type": "pool", + "position": { + "x": 2030, + "y": 980 + }, + "data": { + "kind": "pool", + "label": "Quest XP", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "fail_conv", + "type": "converter", + "position": { + "x": 730, + "y": 940 + }, + "data": { + "kind": "converter", + "label": "Setback cost", + "activation": "automatic", + "mode": "pullAny" + } + }, + { + "id": "death_conv", + "type": "converter", + "position": { + "x": 900, + "y": 940 + }, + "data": { + "kind": "converter", + "label": "Death cost", + "activation": "automatic", + "mode": "pullAny" + } + }, + { + "id": "combat_wins", + "type": "pool", + "position": { + "x": 550, + "y": 890 + }, + "data": { + "kind": "pool", + "label": "Combat wins", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny" + } + }, + { + "id": "combat_fails", + "type": "pool", + "position": { + "x": 550, + "y": 980 + }, + "data": { + "kind": "pool", + "label": "Combat fails", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny" + } + }, + { + "id": "deaths", + "type": "pool", + "position": { + "x": 570, + "y": 1070 + }, + "data": { + "kind": "pool", + "label": "Deaths (count)", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny" + } + }, + { + "id": "gold", + "type": "pool", + "position": { + "x": 1500, + "y": 1130 + }, + "data": { + "kind": "pool", + "label": "Gold", + "activation": "passive", + "initial": 10, + "capacity": null, + "mode": "pullAny", + "resourceType": "currency", + "accent": "#A78243" + } + }, + { + "id": "gold_earned", + "type": "pool", + "position": { + "x": 1680, + "y": 1140 + }, + "data": { + "kind": "pool", + "label": "Gold earned", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#A78243" + } + }, + { + "id": "vendor_revenue", + "type": "pool", + "position": { + "x": 1860, + "y": 1140 + }, + "data": { + "kind": "pool", + "label": "Vendor revenue", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#A78243" + } + }, + { + "id": "repair_spend", + "type": "pool", + "position": { + "x": 1520, + "y": 1250 + }, + "data": { + "kind": "pool", + "label": "Repair spend", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#A78243" + } + }, + { + "id": "resupply_spend", + "type": "pool", + "position": { + "x": 1700, + "y": 1340 + }, + "data": { + "kind": "pool", + "label": "Resupply spend", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#A78243" + } + }, + { + "id": "training_spend", + "type": "pool", + "position": { + "x": 1860, + "y": 1250 + }, + "data": { + "kind": "pool", + "label": "Training spend", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#A78243" + } + }, + { + "id": "water", + "type": "pool", + "position": { + "x": 1540, + "y": 1430 + }, + "data": { + "kind": "pool", + "label": "Water (units)", + "activation": "passive", + "initial": 12, + "capacity": null, + "mode": "pullAny", + "resourceType": "supply" + } + }, + { + "id": "food", + "type": "pool", + "position": { + "x": 1520, + "y": 1530 + }, + "data": { + "kind": "pool", + "label": "Food (units)", + "activation": "passive", + "initial": 12, + "capacity": null, + "mode": "pullAny", + "resourceType": "supply" + } + }, + { + "id": "water_bought", + "type": "pool", + "position": { + "x": 1720, + "y": 1480 + }, + "data": { + "kind": "pool", + "label": "Water bought (units)", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny" + } + }, + { + "id": "food_bought", + "type": "pool", + "position": { + "x": 1940, + "y": 1480 + }, + "data": { + "kind": "pool", + "label": "Food bought (units)", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny" + } + }, + { + "id": "water_consumed", + "type": "pool", + "position": { + "x": 1200, + "y": 1220 + }, + "data": { + "kind": "pool", + "label": "Water consumed (units)", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny" + } + }, + { + "id": "food_consumed", + "type": "pool", + "position": { + "x": 1130, + "y": 1400 + }, + "data": { + "kind": "pool", + "label": "Food consumed (units)", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny" + } + }, + { + "id": "water_upkeep", + "type": "converter", + "position": { + "x": 1330, + "y": 1310 + }, + "data": { + "kind": "converter", + "label": "Water upkeep", + "activation": "automatic", + "mode": "pullAny" + } + }, + { + "id": "food_upkeep", + "type": "converter", + "position": { + "x": 1370, + "y": 1400 + }, + "data": { + "kind": "converter", + "label": "Food upkeep", + "activation": "automatic", + "mode": "pullAny" + } + }, + { + "id": "resupply", + "type": "converter", + "position": { + "x": 1700, + "y": 1250 + }, + "data": { + "kind": "converter", + "label": "Resupply", + "activation": "automatic", + "mode": "pullAny", + "accent": "#A78243" + } + }, + { + "id": "gear_score", + "type": "pool", + "position": { + "x": 2340, + "y": 1080 + }, + "data": { + "kind": "pool", + "label": "Gear score", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "resourceType": "power" + } + }, + { + "id": "gear_wear", + "type": "pool", + "position": { + "x": 2160, + "y": 1080 + }, + "data": { + "kind": "pool", + "label": "Gear wear", + "activation": "passive", + "initial": 0, + "capacity": 40, + "mode": "pullAny" + } + }, + { + "id": "wear_cleared", + "type": "pool", + "position": { + "x": 2160, + "y": 800 + }, + "data": { + "kind": "pool", + "label": "Wear cleared", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny" + } + }, + { + "id": "repair_wear", + "type": "converter", + "position": { + "x": 2190, + "y": 890 + }, + "data": { + "kind": "converter", + "label": "Repair (wear)", + "activation": "automatic", + "mode": "pullAny" + } + }, + { + "id": "repair_gold", + "type": "converter", + "position": { + "x": 2220, + "y": 990 + }, + "data": { + "kind": "converter", + "label": "Repair (bill)", + "activation": "automatic", + "mode": "pullAny", + "accent": "#A78243" + } + }, + { + "id": "drop", + "type": "pool", + "position": { + "x": 380, + "y": 800 + }, + "data": { + "kind": "pool", + "label": "Drops", + "activation": "passive", + "initial": 0, + "capacity": 8, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "loot_feed", + "type": "pool", + "position": { + "x": 370, + "y": 1070 + }, + "data": { + "kind": "pool", + "label": "Loot to sort", + "activation": "passive", + "initial": 0, + "capacity": 8, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "loot_dispatch", + "type": "converter", + "position": { + "x": 370, + "y": 940 + }, + "data": { + "kind": "converter", + "label": "Loot dispatch", + "activation": "automatic", + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "loot_category", + "type": "gate", + "position": { + "x": 380, + "y": 1180 + }, + "data": { + "kind": "gate", + "label": "Loot category", + "activation": "automatic", + "distribution": "probabilistic", + "accent": "#74906B" + } + }, + { + "id": "bucket_equip", + "type": "pool", + "position": { + "x": 270, + "y": 1300 + }, + "data": { + "kind": "pool", + "label": "Equip drops", + "activation": "passive", + "initial": 0, + "capacity": 8, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "bucket_vendor", + "type": "pool", + "position": { + "x": 440, + "y": 1300 + }, + "data": { + "kind": "pool", + "label": "Vendor drops", + "activation": "passive", + "initial": 0, + "capacity": 8, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "bucket_consumable", + "type": "pool", + "position": { + "x": 640, + "y": 1300 + }, + "data": { + "kind": "pool", + "label": "Consumable drops", + "activation": "passive", + "initial": 0, + "capacity": 8, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "bucket_rare", + "type": "pool", + "position": { + "x": 850, + "y": 1300 + }, + "data": { + "kind": "pool", + "label": "Rare drops", + "activation": "passive", + "initial": 0, + "capacity": 8, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "items_looted", + "type": "pool", + "position": { + "x": 200, + "y": 940 + }, + "data": { + "kind": "pool", + "label": "Items looted", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "items_equipped", + "type": "pool", + "position": { + "x": 80, + "y": 1290 + }, + "data": { + "kind": "pool", + "label": "Items equipped", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "items_sold", + "type": "pool", + "position": { + "x": 100, + "y": 1390 + }, + "data": { + "kind": "pool", + "label": "Items sold", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "items_consumed", + "type": "pool", + "position": { + "x": 90, + "y": 1480 + }, + "data": { + "kind": "pool", + "label": "Items consumed", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "equip_conv", + "type": "converter", + "position": { + "x": 290, + "y": 1420 + }, + "data": { + "kind": "converter", + "label": "Equip", + "activation": "automatic", + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "vendor_conv", + "type": "converter", + "position": { + "x": 450, + "y": 1420 + }, + "data": { + "kind": "converter", + "label": "Sell to vendor", + "activation": "automatic", + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "consumable_conv", + "type": "converter", + "position": { + "x": 630, + "y": 1420 + }, + "data": { + "kind": "converter", + "label": "Use consumable", + "activation": "automatic", + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "rare_conv", + "type": "converter", + "position": { + "x": 830, + "y": 1420 + }, + "data": { + "kind": "converter", + "label": "Sell rare", + "activation": "automatic", + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "elapsed", + "type": "pool", + "position": { + "x": 3020, + "y": 290 + }, + "data": { + "kind": "pool", + "label": "Elapsed steps", + "activation": "passive", + "initial": 0, + "capacity": null, + "mode": "pullAny" + } + }, + { + "id": "clock", + "type": "source", + "position": { + "x": 2820, + "y": 290 + }, + "data": { + "kind": "source", + "label": "Clock", + "activation": "automatic", + "mode": "pushAny" + } + }, + { + "id": "completion", + "type": "pool", + "position": { + "x": 3030, + "y": 170 + }, + "data": { + "kind": "pool", + "label": "Completion", + "activation": "passive", + "initial": 0, + "capacity": 3, + "mode": "pullAny" + } + }, + { + "id": "completion_src", + "type": "source", + "position": { + "x": 2810, + "y": 170 + }, + "data": { + "kind": "source", + "label": "Completion pulse", + "activation": "automatic", + "mode": "pushAny" + } + }, + { + "id": "end15", + "type": "end", + "position": { + "x": 3020, + "y": 40 + }, + "data": { + "kind": "end", + "label": "Reached level 15", + "activation": "automatic" + } + }, + { + "id": "z1_enc_src", + "type": "source", + "position": { + "x": 460, + "y": 160 + }, + "data": { + "kind": "source", + "label": "Starter encounters", + "activation": "automatic", + "mode": "pushAny" + } + }, + { + "id": "z1_enc", + "type": "pool", + "position": { + "x": 720, + "y": 40 + }, + "data": { + "kind": "pool", + "label": "Starter · Lv 1–5", + "activation": "passive", + "initial": 0, + "capacity": 3, + "mode": "pullAny" + } + }, + { + "id": "z1_combat", + "type": "gate", + "position": { + "x": 690, + "y": 300 + }, + "data": { + "kind": "gate", + "label": "Starter combat", + "activation": "automatic", + "distribution": "probabilistic" + } + }, + { + "id": "z1_win", + "type": "pool", + "position": { + "x": 460, + "y": 320 + }, + "data": { + "kind": "pool", + "label": "Starter victory", + "activation": "passive", + "initial": 0, + "capacity": 3, + "mode": "pullAny" + } + }, + { + "id": "z1_winamp", + "type": "converter", + "position": { + "x": 460, + "y": 460 + }, + "data": { + "kind": "converter", + "label": "Starter win", + "activation": "automatic", + "mode": "pullAny" + } + }, + { + "id": "z1_lootroll", + "type": "pool", + "position": { + "x": 460, + "y": 600 + }, + "data": { + "kind": "pool", + "label": "Starter loot roll", + "activation": "passive", + "initial": 0, + "capacity": 3, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "z1_loot", + "type": "gate", + "position": { + "x": 690, + "y": 620 + }, + "data": { + "kind": "gate", + "label": "Starter loot", + "activation": "automatic", + "distribution": "probabilistic", + "accent": "#74906B" + } + }, + { + "id": "z1_xp_meter", + "type": "gate", + "position": { + "x": 960, + "y": 290 + }, + "data": { + "kind": "gate", + "label": "Starter XP meter", + "activation": "automatic", + "distribution": "deterministic", + "mode": "pullAll", + "accent": "#9182A8" + } + }, + { + "id": "z1_xp2lvl", + "type": "converter", + "position": { + "x": 960, + "y": 440 + }, + "data": { + "kind": "converter", + "label": "Starter level up", + "activation": "automatic", + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "z1_training", + "type": "converter", + "position": { + "x": 690, + "y": 470 + }, + "data": { + "kind": "converter", + "label": "Starter training", + "activation": "automatic", + "mode": "pullAny" + } + }, + { + "id": "z2_enc_src", + "type": "source", + "position": { + "x": 1200, + "y": 160 + }, + "data": { + "kind": "source", + "label": "Foothills encounters", + "activation": "automatic", + "mode": "pushAny" + } + }, + { + "id": "z2_enc", + "type": "pool", + "position": { + "x": 1460, + "y": 40 + }, + "data": { + "kind": "pool", + "label": "Foothills · Lv 5–10", + "activation": "passive", + "initial": 0, + "capacity": 3, + "mode": "pullAny" + } + }, + { + "id": "z2_combat", + "type": "gate", + "position": { + "x": 1430, + "y": 300 + }, + "data": { + "kind": "gate", + "label": "Foothills combat", + "activation": "automatic", + "distribution": "probabilistic" + } + }, + { + "id": "z2_win", + "type": "pool", + "position": { + "x": 1200, + "y": 320 + }, + "data": { + "kind": "pool", + "label": "Foothills victory", + "activation": "passive", + "initial": 0, + "capacity": 3, + "mode": "pullAny" + } + }, + { + "id": "z2_winamp", + "type": "converter", + "position": { + "x": 1200, + "y": 460 + }, + "data": { + "kind": "converter", + "label": "Foothills win", + "activation": "automatic", + "mode": "pullAny" + } + }, + { + "id": "z2_lootroll", + "type": "pool", + "position": { + "x": 1200, + "y": 600 + }, + "data": { + "kind": "pool", + "label": "Foothills loot roll", + "activation": "passive", + "initial": 0, + "capacity": 3, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "z2_loot", + "type": "gate", + "position": { + "x": 1430, + "y": 620 + }, + "data": { + "kind": "gate", + "label": "Foothills loot", + "activation": "automatic", + "distribution": "probabilistic", + "accent": "#74906B" + } + }, + { + "id": "z2_xp_meter", + "type": "gate", + "position": { + "x": 1700, + "y": 290 + }, + "data": { + "kind": "gate", + "label": "Foothills XP meter", + "activation": "automatic", + "distribution": "deterministic", + "mode": "pullAll", + "accent": "#9182A8" + } + }, + { + "id": "z2_xp2lvl", + "type": "converter", + "position": { + "x": 1700, + "y": 440 + }, + "data": { + "kind": "converter", + "label": "Foothills level up", + "activation": "automatic", + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "z2_training", + "type": "converter", + "position": { + "x": 1430, + "y": 470 + }, + "data": { + "kind": "converter", + "label": "Foothills training", + "activation": "automatic", + "mode": "pullAny" + } + }, + { + "id": "z3_enc_src", + "type": "source", + "position": { + "x": 1940, + "y": 160 + }, + "data": { + "kind": "source", + "label": "Highlands encounters", + "activation": "automatic", + "mode": "pushAny" + } + }, + { + "id": "z3_enc", + "type": "pool", + "position": { + "x": 2200, + "y": 40 + }, + "data": { + "kind": "pool", + "label": "Highlands · Lv 10–15", + "activation": "passive", + "initial": 0, + "capacity": 3, + "mode": "pullAny" + } + }, + { + "id": "z3_combat", + "type": "gate", + "position": { + "x": 2170, + "y": 300 + }, + "data": { + "kind": "gate", + "label": "Highlands combat", + "activation": "automatic", + "distribution": "probabilistic" + } + }, + { + "id": "z3_win", + "type": "pool", + "position": { + "x": 1940, + "y": 320 + }, + "data": { + "kind": "pool", + "label": "Highlands victory", + "activation": "passive", + "initial": 0, + "capacity": 3, + "mode": "pullAny" + } + }, + { + "id": "z3_winamp", + "type": "converter", + "position": { + "x": 1940, + "y": 460 + }, + "data": { + "kind": "converter", + "label": "Highlands win", + "activation": "automatic", + "mode": "pullAny" + } + }, + { + "id": "z3_lootroll", + "type": "pool", + "position": { + "x": 1940, + "y": 600 + }, + "data": { + "kind": "pool", + "label": "Highlands loot roll", + "activation": "passive", + "initial": 0, + "capacity": 3, + "mode": "pullAny", + "accent": "#74906B" + } + }, + { + "id": "z3_loot", + "type": "gate", + "position": { + "x": 2170, + "y": 620 + }, + "data": { + "kind": "gate", + "label": "Highlands loot", + "activation": "automatic", + "distribution": "probabilistic", + "accent": "#74906B" + } + }, + { + "id": "z3_xp_meter", + "type": "gate", + "position": { + "x": 2440, + "y": 290 + }, + "data": { + "kind": "gate", + "label": "Highlands XP meter", + "activation": "automatic", + "distribution": "deterministic", + "mode": "pullAll", + "accent": "#9182A8" + } + }, + { + "id": "z3_xp2lvl", + "type": "converter", + "position": { + "x": 2440, + "y": 440 + }, + "data": { + "kind": "converter", + "label": "Highlands level up", + "activation": "automatic", + "mode": "pullAny", + "accent": "#9182A8" + } + }, + { + "id": "z3_training", + "type": "converter", + "position": { + "x": 2170, + "y": 470 + }, + "data": { + "kind": "converter", + "label": "Highlands training", + "activation": "automatic", + "mode": "pullAny" + } + }, + { + "id": "void", + "type": "drain", + "position": { + "x": 1920, + "y": 780 + }, + "data": { + "kind": "drain", + "label": "No drop", + "activation": "automatic", + "mode": "pullAny" + } + }, + { + "id": "char_creation", + "type": "source", + "position": { + "x": 40, + "y": 40 + }, + "data": { + "kind": "source", + "label": "Character creation", + "activation": "onStart", + "mode": "pushAny" + } + }, + { + "id": "active_char", + "type": "pool", + "position": { + "x": 320, + "y": 40 + }, + "data": { + "kind": "pool", + "label": "Active character", + "activation": "passive", + "initial": 0, + "capacity": 2, + "mode": "pullAny" + } + }, + { + "id": "r_income", + "type": "register", + "position": { + "x": 3280, + "y": 260 + }, + "data": { + "kind": "register", + "label": "Total income", + "expr": "@gold_earned", + "format": "int", + "unit": "gold" + } + }, + { + "id": "r_expense", + "type": "register", + "position": { + "x": 3280, + "y": 370 + }, + "data": { + "kind": "register", + "label": "Total expense", + "expr": "@repair_spend + @resupply_spend + @training_spend", + "format": "int", + "unit": "gold" + } + }, + { + "id": "r_netgold", + "type": "register", + "position": { + "x": 3280, + "y": 480 + }, + "data": { + "kind": "register", + "label": "Net gold check", + "expr": "@gold_earned - @repair_spend - @resupply_spend - @training_spend", + "format": "int", + "unit": "gold" + } + }, + { + "id": "r_huntshare", + "type": "register", + "position": { + "x": 3280, + "y": 150 + }, + "data": { + "kind": "register", + "label": "Hunt XP share", + "expr": "@hunt_xp / (@hunt_xp + @quest_xp + 0.001)", + "format": "percent" + } + }, + { + "id": "r_efflevel", + "type": "register", + "position": { + "x": 3280, + "y": 40 + }, + "data": { + "kind": "register", + "label": "XP pace index", + "expr": "@xp_earned / 10", + "format": "float" + } + }, + { + "id": "r_items_acct", + "type": "register", + "position": { + "x": 3280, + "y": 590 + }, + "data": { + "kind": "register", + "label": "Items accounted", + "expr": "@items_equipped + @items_sold + @items_consumed", + "format": "float", + "unit": "items" + } + }, + { + "id": "r_burned", + "type": "register", + "position": { + "x": 3280, + "y": 700 + }, + "data": { + "kind": "register", + "label": "Consumables burned", + "expr": "@water_consumed + @food_consumed", + "format": "int", + "unit": "units" + } + } + ], + "edges": [ + { + "id": "e_reward_in", + "source": "reward", + "target": "reward_router", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "all" + } + }, + { + "id": "e_router_hunt", + "source": "reward_router", + "target": "hunt_payout", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "3" + } + }, + { + "id": "e_router_quest", + "source": "reward_router", + "target": "quest_payout", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_hunt_xp", + "source": "hunt_payout", + "target": "xp", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "3" + } + }, + { + "id": "e_hunt_xpe", + "source": "hunt_payout", + "target": "xp_earned", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "3" + } + }, + { + "id": "e_hunt_qctr", + "source": "hunt_payout", + "target": "hunt_xp", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "3" + } + }, + { + "id": "e_hunt_gold", + "source": "hunt_payout", + "target": "gold", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "2", + "accent": "#A78243" + } + }, + { + "id": "e_hunt_golde", + "source": "hunt_payout", + "target": "gold_earned", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "2", + "accent": "#A78243" + } + }, + { + "id": "e_quest_xp", + "source": "quest_payout", + "target": "xp", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "9" + } + }, + { + "id": "e_quest_xpe", + "source": "quest_payout", + "target": "xp_earned", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "9" + } + }, + { + "id": "e_quest_qctr", + "source": "quest_payout", + "target": "quest_xp", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "9" + } + }, + { + "id": "e_quest_gold", + "source": "quest_payout", + "target": "gold", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "7", + "accent": "#A78243" + } + }, + { + "id": "e_quest_golde", + "source": "quest_payout", + "target": "gold_earned", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "7", + "accent": "#A78243" + } + }, + { + "id": "e_fail_in", + "source": "fail_pool", + "target": "fail_conv", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_fail_ct", + "source": "fail_conv", + "target": "combat_fails", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_fail_wear", + "source": "fail_conv", + "target": "gear_wear", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1.5" + } + }, + { + "id": "e_fail_time", + "source": "fail_conv", + "target": "elapsed", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_death_in", + "source": "death_pool", + "target": "death_conv", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_death_ct", + "source": "death_conv", + "target": "deaths", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_death_wear", + "source": "death_conv", + "target": "gear_wear", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "3" + } + }, + { + "id": "e_death_time", + "source": "death_conv", + "target": "elapsed", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "3" + } + }, + { + "id": "e_water_up", + "source": "water", + "target": "water_upkeep", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_water_up_ct", + "source": "water_upkeep", + "target": "water_consumed", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_food_up", + "source": "food", + "target": "food_upkeep", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_food_up_ct", + "source": "food_upkeep", + "target": "food_consumed", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_resupply_gold", + "source": "gold", + "target": "resupply", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "6", + "accent": "#A78243" + } + }, + { + "id": "e_resupply_water", + "source": "resupply", + "target": "water", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "8" + } + }, + { + "id": "e_resupply_waterb", + "source": "resupply", + "target": "water_bought", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "8" + } + }, + { + "id": "e_resupply_food", + "source": "resupply", + "target": "food", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "8" + } + }, + { + "id": "e_resupply_foodb", + "source": "resupply", + "target": "food_bought", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "8" + } + }, + { + "id": "e_resupply_spend", + "source": "resupply", + "target": "resupply_spend", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "6", + "accent": "#A78243" + } + }, + { + "id": "a_resupply", + "source": "water", + "target": "resupply", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": "< 5" + } + }, + { + "id": "e_repair_wear_in", + "source": "gear_wear", + "target": "repair_wear", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "4" + } + }, + { + "id": "e_repair_wear_ct", + "source": "repair_wear", + "target": "wear_cleared", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "4" + } + }, + { + "id": "e_repair_gold_in", + "source": "gold", + "target": "repair_gold", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "4", + "accent": "#A78243" + } + }, + { + "id": "e_repair_gold_ct", + "source": "repair_gold", + "target": "repair_spend", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "4", + "accent": "#A78243" + } + }, + { + "id": "a_repair_wear", + "source": "gear_wear", + "target": "repair_wear", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": "> 6" + } + }, + { + "id": "a_repair_wear_gold", + "source": "gold", + "target": "repair_wear", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 1" + } + }, + { + "id": "a_repair_gold", + "source": "gear_wear", + "target": "repair_gold", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": "> 6" + } + }, + { + "id": "a_repair_gold_gold", + "source": "gold", + "target": "repair_gold", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 1" + } + }, + { + "id": "e_drop_in", + "source": "drop", + "target": "loot_dispatch", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "accent": "#74906B" + } + }, + { + "id": "e_dispatch_looted", + "source": "loot_dispatch", + "target": "items_looted", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "accent": "#74906B" + } + }, + { + "id": "e_dispatch_feed", + "source": "loot_dispatch", + "target": "loot_feed", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "accent": "#74906B" + } + }, + { + "id": "e_feed_cat", + "source": "loot_feed", + "target": "loot_category", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "all", + "accent": "#74906B" + } + }, + { + "id": "e_cat_equip", + "source": "loot_category", + "target": "bucket_equip", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "34", + "accent": "#74906B" + } + }, + { + "id": "e_cat_vendor", + "source": "loot_category", + "target": "bucket_vendor", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "40", + "accent": "#74906B" + } + }, + { + "id": "e_cat_consumable", + "source": "loot_category", + "target": "bucket_consumable", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "20", + "accent": "#74906B" + } + }, + { + "id": "e_cat_rare", + "source": "loot_category", + "target": "bucket_rare", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "6", + "accent": "#74906B" + } + }, + { + "id": "e_equip_in", + "source": "bucket_equip", + "target": "equip_conv", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "accent": "#74906B" + } + }, + { + "id": "e_equip_ct", + "source": "equip_conv", + "target": "items_equipped", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "accent": "#74906B" + } + }, + { + "id": "e_equip_gear", + "source": "equip_conv", + "target": "gear_score", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "4" + } + }, + { + "id": "e_vendor_in", + "source": "bucket_vendor", + "target": "vendor_conv", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "accent": "#74906B" + } + }, + { + "id": "e_vendor_ct", + "source": "vendor_conv", + "target": "items_sold", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "accent": "#74906B" + } + }, + { + "id": "e_vendor_gold", + "source": "vendor_conv", + "target": "gold", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "3" + } + }, + { + "id": "e_vendor_golde", + "source": "vendor_conv", + "target": "gold_earned", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "3" + } + }, + { + "id": "e_vendor_rev", + "source": "vendor_conv", + "target": "vendor_revenue", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "3" + } + }, + { + "id": "e_cons_in", + "source": "bucket_consumable", + "target": "consumable_conv", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "accent": "#74906B" + } + }, + { + "id": "e_cons_ct", + "source": "consumable_conv", + "target": "items_consumed", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "accent": "#74906B" + } + }, + { + "id": "e_cons_water", + "source": "consumable_conv", + "target": "water", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "2" + } + }, + { + "id": "e_cons_waterb", + "source": "consumable_conv", + "target": "water_bought", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "2" + } + }, + { + "id": "e_cons_food", + "source": "consumable_conv", + "target": "food", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "2" + } + }, + { + "id": "e_cons_foodb", + "source": "consumable_conv", + "target": "food_bought", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "2" + } + }, + { + "id": "e_rare_in", + "source": "bucket_rare", + "target": "rare_conv", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "accent": "#74906B" + } + }, + { + "id": "e_rare_ct", + "source": "rare_conv", + "target": "items_sold", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "accent": "#74906B" + } + }, + { + "id": "e_rare_gold", + "source": "rare_conv", + "target": "gold", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "14" + } + }, + { + "id": "e_rare_golde", + "source": "rare_conv", + "target": "gold_earned", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "14" + } + }, + { + "id": "e_rare_rev", + "source": "rare_conv", + "target": "vendor_revenue", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "14" + } + }, + { + "id": "e_clock", + "source": "clock", + "target": "elapsed", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_completion_in", + "source": "completion_src", + "target": "completion", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_completion_end", + "source": "completion", + "target": "end15", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "all" + } + }, + { + "id": "a_end15", + "source": "level", + "target": "end15", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 15" + } + }, + { + "id": "e_z1_enc_in", + "source": "z1_enc_src", + "target": "z1_enc", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_z1_enc_gate", + "source": "z1_enc", + "target": "z1_combat", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "all" + } + }, + { + "id": "e_z1_win", + "source": "z1_combat", + "target": "z1_win", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "9" + } + }, + { + "id": "e_z1_fail", + "source": "z1_combat", + "target": "fail_pool", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1.8", + "route": "orthogonal" + } + }, + { + "id": "e_z1_death", + "source": "z1_combat", + "target": "death_pool", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "0.5", + "route": "orthogonal" + } + }, + { + "id": "e_z1_winamp_in", + "source": "z1_win", + "target": "z1_winamp", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_z1_winamp_reward", + "source": "z1_winamp", + "target": "reward", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1.15", + "route": "orthogonal" + } + }, + { + "id": "e_z1_winamp_ct", + "source": "z1_winamp", + "target": "combat_wins", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal" + } + }, + { + "id": "e_z1_winamp_loot", + "source": "z1_winamp", + "target": "z1_lootroll", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_z1_lootroll_gate", + "source": "z1_lootroll", + "target": "z1_loot", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "all", + "accent": "#74906B" + } + }, + { + "id": "e_z1_loot_drop", + "source": "z1_loot", + "target": "drop", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "38", + "route": "orthogonal", + "accent": "#74906B" + } + }, + { + "id": "e_z1_loot_miss", + "source": "z1_loot", + "target": "void", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "62", + "route": "orthogonal" + } + }, + { + "id": "e_z1_xpmeter_in", + "source": "xp", + "target": "z1_xp_meter", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "10", + "route": "orthogonal", + "accent": "#9182A8" + } + }, + { + "id": "e_z1_xpmeter_out", + "source": "z1_xp_meter", + "target": "z1_xp2lvl", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "10", + "accent": "#9182A8" + } + }, + { + "id": "e_z1_xp2lvl_out", + "source": "z1_xp2lvl", + "target": "level", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#9182A8" + } + }, + { + "id": "e_z1_training_in", + "source": "gold", + "target": "z1_training", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "0.25", + "route": "orthogonal" + } + }, + { + "id": "e_z1_training_ct", + "source": "z1_training", + "target": "training_spend", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "0.25", + "route": "orthogonal" + } + }, + { + "id": "a_z1_enc_src_hi", + "source": "level", + "target": "z1_enc_src", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": "< 5" + } + }, + { + "id": "a_z1_xp_meter_hi", + "source": "level", + "target": "z1_xp_meter", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": "< 5" + } + }, + { + "id": "a_z1_xp2lvl_hi", + "source": "level", + "target": "z1_xp2lvl", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": "< 5" + } + }, + { + "id": "a_z1_training_hi", + "source": "level", + "target": "z1_training", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": "< 5" + } + }, + { + "id": "e_z2_enc_in", + "source": "z2_enc_src", + "target": "z2_enc", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_z2_enc_gate", + "source": "z2_enc", + "target": "z2_combat", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "all" + } + }, + { + "id": "e_z2_win", + "source": "z2_combat", + "target": "z2_win", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "7" + } + }, + { + "id": "e_z2_fail", + "source": "z2_combat", + "target": "fail_pool", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "2.5", + "route": "orthogonal" + } + }, + { + "id": "e_z2_death", + "source": "z2_combat", + "target": "death_pool", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "0.9", + "route": "orthogonal" + } + }, + { + "id": "e_z2_winamp_in", + "source": "z2_win", + "target": "z2_winamp", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_z2_winamp_reward", + "source": "z2_winamp", + "target": "reward", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1.5", + "route": "orthogonal" + } + }, + { + "id": "e_z2_winamp_ct", + "source": "z2_winamp", + "target": "combat_wins", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal" + } + }, + { + "id": "e_z2_winamp_loot", + "source": "z2_winamp", + "target": "z2_lootroll", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_z2_lootroll_gate", + "source": "z2_lootroll", + "target": "z2_loot", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "all", + "accent": "#74906B" + } + }, + { + "id": "e_z2_loot_drop", + "source": "z2_loot", + "target": "drop", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "38", + "route": "orthogonal", + "accent": "#74906B" + } + }, + { + "id": "e_z2_loot_miss", + "source": "z2_loot", + "target": "void", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "62", + "route": "orthogonal" + } + }, + { + "id": "e_z2_xpmeter_in", + "source": "xp", + "target": "z2_xp_meter", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "19", + "route": "orthogonal", + "accent": "#9182A8" + } + }, + { + "id": "e_z2_xpmeter_out", + "source": "z2_xp_meter", + "target": "z2_xp2lvl", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "19", + "accent": "#9182A8" + } + }, + { + "id": "e_z2_xp2lvl_out", + "source": "z2_xp2lvl", + "target": "level", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#9182A8" + } + }, + { + "id": "e_z2_training_in", + "source": "gold", + "target": "z2_training", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "0.45", + "route": "orthogonal" + } + }, + { + "id": "e_z2_training_ct", + "source": "z2_training", + "target": "training_spend", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "0.45", + "route": "orthogonal" + } + }, + { + "id": "a_z2_enc_src_lo", + "source": "level", + "target": "z2_enc_src", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 5" + } + }, + { + "id": "a_z2_enc_src_hi", + "source": "level", + "target": "z2_enc_src", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": "< 10" + } + }, + { + "id": "a_z2_xp_meter_lo", + "source": "level", + "target": "z2_xp_meter", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 5" + } + }, + { + "id": "a_z2_xp_meter_hi", + "source": "level", + "target": "z2_xp_meter", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": "< 10" + } + }, + { + "id": "a_z2_xp2lvl_lo", + "source": "level", + "target": "z2_xp2lvl", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 5" + } + }, + { + "id": "a_z2_xp2lvl_hi", + "source": "level", + "target": "z2_xp2lvl", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": "< 10" + } + }, + { + "id": "a_z2_training_lo", + "source": "level", + "target": "z2_training", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 5" + } + }, + { + "id": "a_z2_training_hi", + "source": "level", + "target": "z2_training", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": "< 10" + } + }, + { + "id": "e_z3_enc_in", + "source": "z3_enc_src", + "target": "z3_enc", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_z3_enc_gate", + "source": "z3_enc", + "target": "z3_combat", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "all" + } + }, + { + "id": "e_z3_win", + "source": "z3_combat", + "target": "z3_win", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "6.5" + } + }, + { + "id": "e_z3_fail", + "source": "z3_combat", + "target": "fail_pool", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "2.6", + "route": "orthogonal" + } + }, + { + "id": "e_z3_death", + "source": "z3_combat", + "target": "death_pool", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1.2", + "route": "orthogonal" + } + }, + { + "id": "e_z3_winamp_in", + "source": "z3_win", + "target": "z3_winamp", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_z3_winamp_reward", + "source": "z3_winamp", + "target": "reward", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "2", + "route": "orthogonal" + } + }, + { + "id": "e_z3_winamp_ct", + "source": "z3_winamp", + "target": "combat_wins", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal" + } + }, + { + "id": "e_z3_winamp_loot", + "source": "z3_winamp", + "target": "z3_lootroll", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "e_z3_lootroll_gate", + "source": "z3_lootroll", + "target": "z3_loot", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "all", + "accent": "#74906B" + } + }, + { + "id": "e_z3_loot_drop", + "source": "z3_loot", + "target": "drop", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "44", + "route": "orthogonal", + "accent": "#74906B" + } + }, + { + "id": "e_z3_loot_miss", + "source": "z3_loot", + "target": "void", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "56", + "route": "orthogonal" + } + }, + { + "id": "e_z3_xpmeter_in", + "source": "xp", + "target": "z3_xp_meter", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "27", + "route": "orthogonal", + "accent": "#9182A8" + } + }, + { + "id": "e_z3_xpmeter_out", + "source": "z3_xp_meter", + "target": "z3_xp2lvl", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "27", + "accent": "#9182A8" + } + }, + { + "id": "e_z3_xp2lvl_out", + "source": "z3_xp2lvl", + "target": "level", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1", + "route": "orthogonal", + "accent": "#9182A8" + } + }, + { + "id": "e_z3_training_in", + "source": "gold", + "target": "z3_training", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "0.7", + "route": "orthogonal" + } + }, + { + "id": "e_z3_training_ct", + "source": "z3_training", + "target": "training_spend", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "0.7", + "route": "orthogonal" + } + }, + { + "id": "a_z3_enc_src_lo", + "source": "level", + "target": "z3_enc_src", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 10" + } + }, + { + "id": "a_z3_enc_src_gear", + "source": "gear_score", + "target": "z3_enc_src", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 4" + } + }, + { + "id": "a_z3_xp_meter_lo", + "source": "level", + "target": "z3_xp_meter", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 10" + } + }, + { + "id": "a_z3_xp_meter_gear", + "source": "gear_score", + "target": "z3_xp_meter", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 4" + } + }, + { + "id": "a_z3_xp2lvl_lo", + "source": "level", + "target": "z3_xp2lvl", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 10" + } + }, + { + "id": "a_z3_xp2lvl_gear", + "source": "gear_score", + "target": "z3_xp2lvl", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 4" + } + }, + { + "id": "a_z3_training_lo", + "source": "level", + "target": "z3_training", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 10" + } + }, + { + "id": "a_z3_training_gear", + "source": "gear_score", + "target": "z3_training", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 4" + } + }, + { + "id": "e_char_create", + "source": "char_creation", + "target": "active_char", + "sourceHandle": "out", + "targetHandle": "in", + "type": "loop", + "data": { + "kind": "resource", + "flow": "1" + } + }, + { + "id": "a_starter_open", + "source": "active_char", + "target": "z1_enc_src", + "sourceHandle": "state-source", + "targetHandle": "state-target", + "type": "loop", + "data": { + "kind": "state", + "mode": "activator", + "expr": ">= 1" + } + } + ], + "recommendedRunConfig": { + "baseSeed": 1, + "runs": 200, + "steps": 150, + "tracked": [ + "elapsed", + "level", + "deaths", + "combat_wins", + "combat_fails", + "water_consumed", + "food_consumed", + "items_looted", + "items_equipped", + "items_sold", + "items_consumed", + "gold_earned", + "repair_spend", + "resupply_spend", + "training_spend", + "gold", + "vendor_revenue", + "gear_score", + "quest_xp", + "hunt_xp" + ], + "timelineSeries": [ + "deaths", + "gear_score", + "gold", + "level", + "xp_earned" + ], + "canvasLocked": true + } +} diff --git a/test/routeCases.ts b/test/routeCases.ts index b4f6e101..c74d12e6 100644 --- a/test/routeCases.ts +++ b/test/routeCases.ts @@ -55,6 +55,15 @@ const MEASURED = readJson>>( /** the bundled examples that carry `route: "orthogonal"` edges */ export const ORTHO_EXAMPLES = ['gacha-banner-zones', 'mmo-progression', 'playback-choreography'] as const +/** issue #344 step 4 re-placed the Templates (widest geometry, every + * connection Auto orthogonal). This corpus pins the v1 router on the layouts + * it was frozen on, so the two Templates are read from their copies as they + * stood before that step (`fixtures/templates-before-step4/`, byte-equal + * to `examples/` at 2cd99f1); the playback fixture is not a Template and is + * read live. */ +const exampleFile = (graph: (typeof ORTHO_EXAMPLES)[number]): string => + graph === 'playback-choreography' ? `../examples/${graph}.json` : `./fixtures/templates-before-step4/${graph}.json` + // ── replication of src/store/routeMap.ts `rebuild` ────────────────────────── const handlePos = (h: string | null | undefined, fallback: Position): Position => h === 'in' @@ -133,7 +142,7 @@ function inputsFromDoc(prefix: string, doc: RawDoc, sizes: Record(`../examples/${graph}.json`) + const doc = readJson(exampleFile(graph)) return inputsFromDoc(graph, doc, MEASURED[graph] ?? {}) } @@ -142,15 +151,22 @@ export function exampleCases(graph: (typeof ORTHO_EXAMPLES)[number]): Case[] { * round 7 gave `e_pickup_36` one waypoint so its `+1` label no longer overlaps * `e_pickup_34`'s in ko on the shorter nodes. The router did not change, so * the golden keeps its e763eeb bytes: its inputs project exactly this waypoint - * out, and the routed path WITH it is pinned as its own case. */ + * out, and the routed path WITH it is pinned as its own case. Issue #344 + * step 1 (layout round 8): `e_pickup_17` and `e_pickup_20` gained one waypoint + * each, once the resource ports moved to the fixed 28 px row — projected out + * the same way. */ export const WAYPOINTS_ADDED_SINCE_GOLDEN: Partial>> = { - 'gacha-banner-zones': { e_pickup_36: [{ x: 3200, y: 650 }] }, + 'gacha-banner-zones': { + e_pickup_17: [{ x: 3700, y: 549 }], + e_pickup_20: [{ x: 3360, y: 548 }], + e_pickup_36: [{ x: 3200, y: 650 }], + }, } /** `exampleCases` with the waypoints added since the golden projected out — * each one only if it is exactly the listed value, else this throws */ export function goldenExampleCases(graph: (typeof ORTHO_EXAMPLES)[number]): Case[] { - const doc = readJson(`../examples/${graph}.json`) + const doc = readJson(exampleFile(graph)) for (const [edgeId, added] of Object.entries(WAYPOINTS_ADDED_SINCE_GOLDEN[graph] ?? {})) { const edge = doc.edges.find((e) => e.id === edgeId) if (JSON.stringify(edge?.data?.waypoints) !== JSON.stringify(added)) @@ -163,7 +179,7 @@ export function goldenExampleCases(graph: (typeof ORTHO_EXAMPLES)[number]): Case /** mmo-progression with EVERY edge forced orthogonal — 144 edges over 97 nodes, * the heaviest routing load the app can be put under (differential only). */ export function stressCases(): Case[] { - const doc = readJson('../examples/mmo-progression.json') + const doc = readJson(exampleFile('mmo-progression')) for (const e of doc.edges) e.data = { ...(e.data ?? {}), route: 'orthogonal' } return inputsFromDoc('STRESS-mmo-all-orthogonal', doc, MEASURED['mmo-progression'] ?? {}) }