atagen

monolith

public
Star0
Watch0
944fc4782ac3cc4eee18a8f5a9b71665eecf7b8f
1
ssh://brewery@pls.lobotomise.me:33/atagen/monolith.git
design/planner.htmlHtml1059 lines59.7 KB
1
<!doctype html>
2
<html lang="en">
3
<head>
4
<meta charset="utf-8">
5
<title>monolith planner design, draft 1</title>
6
<style>
7
  :root { --bg:#111317; --panel:#181b21; --fg:#d8dbe2; --dim:#8a909c; --accent:#e0774a; --bad:#ff4d5e; }
8
  * { box-sizing:border-box; }
9
  body { margin:0; background:var(--bg); color:var(--fg); font:15px/1.6 system-ui, sans-serif; }
10
  main { max-width:920px; margin:0 auto; padding:32px 20px 120px; }
11
  h1 { font-size:28px; margin:0 0 4px; }
12
  h2 { font-size:20px; margin:48px 0 8px; border-bottom:1px solid #2a2e37; padding-bottom:4px; }
13
  h3 { font-size:16px; margin:24px 0 6px; }
14
  p, li { color:var(--fg); }
15
  .sub { color:var(--dim); margin:0 0 24px; }
16
  code { background:#22262e; padding:1px 5px; border-radius:4px; font-size:13px; }
17
  pre { background:#1a1d23; padding:14px; border-radius:8px; overflow-x:auto; font-size:13px; line-height:1.45; }
18
  table { border-collapse:collapse; width:100%; font-size:14px; margin:12px 0; }
19
  th, td { border:1px solid #2a2e37; padding:6px 8px; text-align:left; vertical-align:top; }
20
  th { background:#1d2027; }
21
  .demo { background:var(--panel); border:1px solid #2a2e37; border-radius:10px; padding:12px; margin:16px 0; }
22
  .demo canvas { display:block; width:100%; height:auto; border-radius:6px; background:#0d0f12; }
23
  .controls { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-top:10px; }
24
  button { background:#2a2f39; color:var(--fg); border:1px solid #3a404c; border-radius:6px; padding:5px 12px; cursor:pointer; font:inherit; font-size:14px; }
25
  button:hover { background:#343a46; }
26
  label { color:var(--dim); font-size:14px; display:flex; gap:6px; align-items:center; }
27
  .status { font:13px/1.4 ui-monospace, monospace; color:var(--dim); margin-top:8px; min-height:1.4em; }
28
  .status .bad { color:var(--bad); }
29
  .global { position:sticky; top:0; z-index:5; background:rgba(17,19,23,0.94); backdrop-filter:blur(4px); border-bottom:1px solid #2a2e37; padding:8px 20px; display:flex; gap:20px; justify-content:center; flex-wrap:wrap; }
30
  .q { background:#1d2027; border-left:3px solid var(--accent); padding:8px 12px; margin:10px 0; border-radius:0 6px 6px 0; }
31
  .note { color:var(--dim); font-size:14px; }
32
</style>
33
</head>
34
<body>
35
<div class="global">
36
  <label>phase duration <input id="phaseMs" type="range" min="150" max="1500" step="50" value="450"> <span id="phaseMsOut">450 ms</span></label>
37
  <label>style <select id="style"><option value="orthogonal">orthogonal (default, one axis per window per phase)</option><option value="direct">direct (diagonal when safe)</option></select></label>
38
</div>
39
<main>
40
<h1>monolith planner, draft 2</h1>
41
<p class="sub">Design for the multiphase layout animation planner (milestone M1). Every demo runs a small JavaScript port of the proposed algorithm, so what you see is what the design does. Red means two tiled windows overlap. Orthogonal is the default style, and the selector at the top switches every demo to direct.</p>
42
43
<h2>1. What the planner does</h2>
44
<p>The layout engine produces a target rectangle for every window. The planner turns "where windows are on screen now" and "where they should be" into a short sequence of keyframe layouts. Each step between two keyframes is a <em>phase</em>. Every window in a phase moves on the same curve and the phase takes exactly <code>t</code>, the configured duration, so a plan lasts <code>phases × t</code>.</p>
45
<p>Rules carried over from wry <code>master</code>.</p>
46
<ul>
47
  <li>Tiled windows never overlap while they move.</li>
48
  <li>Phases are synchronised. Everything in a phase starts and stops together.</li>
49
  <li>In orthogonal style, the default, a window moves or resizes along one axis per phase.</li>
50
  <li>An interrupt throws the plan away and replans from what is on screen. Plans never stack.</li>
51
  <li>Neighbours whose edges touch at both ends of a transition keep touching throughout. No holes open mid-plan.</li>
52
  <li>Planning B to A is the exact reverse of planning A to B, so undoing a change looks like rewinding it.</li>
53
</ul>
54
<p>New requirements wry never met.</p>
55
<ul>
56
  <li>Moves between outputs, and large groups such as viewing or hiding a tag.</li>
57
  <li>Best-effort velocity continuity when an interrupt lands mid-phase.</li>
58
  <li>A hard bound on plan length, and a construction that never needs a fallback.</li>
59
</ul>
60
61
<h2>2. The problem</h2>
62
<p>Swapping two windows by interpolating straight from start to end drives them through each other. Toggle "naive" to compare.</p>
63
<div class="demo" id="d1"></div>
64
65
<h2>3. The core rule, shared separators</h2>
66
<p>For any two windows there are four ways they can be apart. A is left of B, right of B, above B, or below B. Each is a linear inequality on window edges, for example <code>A.right ≤ B.left</code>. If an inequality holds at both ends of a phase and both windows move on the same curve, it holds for the whole phase, because every edge is the same weighted average of its start and end value.</p>
67
<p>So the planner's entire job reduces to this. <strong>Pick keyframes so that for every pair of windows, every two consecutive keyframes share at least one separating relation.</strong> wry checked overlap after building a plan. This rule makes plans correct by construction.</p>
68
<p>Looking at a single pair gives a lower bound on phases.</p>
69
<table>
70
  <tr><th>pair goes from</th><th>to</th><th>minimum phases</th><th>why</th></tr>
71
  <tr><td>left of</td><td>left of (any sizes)</td><td>1</td><td>relation shared</td></tr>
72
  <tr><td>left of</td><td>above</td><td>2</td><td>needs a middle keyframe where A is both left of and above B</td></tr>
73
  <tr><td>left of</td><td>right of</td><td>3</td><td>left and right can't both hold, so the pair must pass through above or below on the way</td></tr>
74
</table>
75
<p>Pairs interact, so a whole layout can need one more phase than its worst pair (section 7). <strong>Every plan is 1 to 4 phases, and the 4-phase construction works for any input</strong>, so no fallback is ever required. The rule is sufficient, not necessary. Some overlap-free phases break it, and the Rust validator keeps an exact check for those, but the planner only builds plans that satisfy it.</p>
76
<p class="note">Stress test of the JavaScript port. 6,000 random pairs of layouts with 2 to 7 windows, each target a full random reshuffle, in both styles. Zero overlapping frames, zero windows moving on two axes in one phase in orthogonal style, zero fallbacks. Real edits are far smaller than random reshuffles.</p>
77
78
<h2>4. One phase, and seeds</h2>
79
<p>Anything that keeps the arrangement of windows intact is a single phase. That covers ratio changes, inserting and removing windows, splitting a column, and viewing or hiding a tag.</p>
80
<p><strong>Seeds.</strong> A window that appears starts as a zero-size rectangle and grows while its neighbours make room, concurrently, in the same phase. The only question is where it starts growing from.</p>
81
<ul>
82
  <li><strong>Line on the old boundary</strong> (default). A zero-width line where the two neighbours currently meet. It is always free space, so the whole insertion is one phase in either style.</li>
83
  <li><strong>Point at the middle of the old boundary.</strong> Looks like growing from a centre, and never overlaps. Growing from a point changes both axes, so orthogonal style splits it into two phases (grow tall, then wide).</li>
84
  <li><strong>Point at the centre of the new window's target.</strong> At the start of the phase that point is usually still covered by a neighbour that hasn't moved out yet. The planner has to push the neighbours away first and grow second. With naive on you see the overlap instead.</li>
85
</ul>
86
<p>Cycle the seed mode below and insert D to compare.</p>
87
<div class="demo" id="d2"></div>
88
<p>Viewing and hiding a tag is the same thing at a larger scale. The new tag's windows are seeds on the edge where their row will open.</p>
89
<div class="demo" id="d3"></div>
90
91
<h2>5. Two phases, axis order</h2>
92
<p>When a pair turns a corner (left of becomes above), the middle keyframe is built from a <em>mixed</em> rectangle. Each window takes its horizontal extent from one end and its vertical extent from the other. "X first" means the window takes its target horizontal extent in phase 1 and its target vertical extent in phase 2. "Y first" is the reverse. Every window then moves along exactly one axis per phase.</p>
93
<p>Each window's choice is one boolean. Every pair rules out some of the four combinations, and each forbidden combination is a clause over two variables. That makes it a <strong>2-SAT problem, solvable in linear time</strong> with no search. Windows that only change one axis use the same boolean to choose whether they move in phase 1 or phase 2, and the planner prefers phase 1 so nothing waits needlessly.</p>
94
<p><strong>Coupled edges.</strong> Avoiding overlap is not enough. A window can vanish in phase 1 while the neighbour that absorbs its space only moves in phase 2, leaving a hole in between. Any two neighbours whose edges touch at both ends must also touch in the middle keyframe. Among valid orderings the solver picks one that keeps every such edge together, and only accepts a hole if none exists. In the 6,000-case stress test no two-phase plan needed one.</p>
95
<p><strong>Rewind symmetry.</strong> Every pair of layouts is planned in one canonical direction, and the other direction plays that plan backwards. Insert D and split B with E together, and D arrives first, then E. Undo it, and E leaves first, then D.</p>
96
<div class="demo" id="d4a"></div>
97
<p>Growing a window in both directions inside a grid. Orthogonal makes space along one axis and then grows along the other. Direct does it in one diagonal phase.</p>
98
<div class="demo" id="d4b"></div>
99
100
<h2>6. Three phases, lanes</h2>
101
<p>A <strong>lane</strong> is a temporary strip, like a motorway lane used for overtaking. Before two windows can pass each other along one axis, each shrinks along the other axis into its own strip of the space they share. For a horizontal swap the strips are horizontal, so each window gets a slice of the height. In phase 2 they slide past each other inside their strips, and in phase 3 they expand back to full size.</p>
102
<ul>
103
  <li>Two windows may share a lane only if they never pass each other.</li>
104
  <li>Windows stacked along the strip axis must keep their stacking order in the lanes, at both ends.</li>
105
  <li>Only windows that pass something enter lanes. Everything else moves in whichever phase validates, preferring the slide phase.</li>
106
  <li>The strips span only the band the involved windows occupy, not the whole output.</li>
107
</ul>
108
<p>Picking lanes is a small constrained colouring problem. For a pure reordering of a row, the lane count equals the length of the longest decreasing subsequence of the new order (Dilworth's theorem). Swapping two neighbours or rotating needs 2 lanes, and fully reversing four windows needs 4.</p>
109
<div class="demo" id="d5"></div>
110
<p><strong>Lane extent.</strong> Below, T fills the top 70% and A, B, C share a short bottom row. Reversing the row needs 3 lanes. Spanning only the involved band, each lane is a third of the short row, so the windows get thin but T never moves. Spanning the whole output, the lanes are thicker, but T is in the way and has to squeeze into a lane too.</p>
111
<div class="demo" id="d12"></div>
112
113
<h2>7. Four phases, the grid</h2>
114
<p>Some transitions reverse windows along both axes at once. In <code>(A over D) | (C over B)</code> to <code>(A over B over C) | D</code>, D has to pass B and C horizontally while B and C swap vertical order. Lanes along either axis contradict the other reversal. The grid handles it.</p>
115
<ol>
116
  <li>Every involved window shrinks vertically into its own horizontal strip, ordered by where it starts.</li>
117
  <li>It shrinks horizontally into its own column, ordered by where it ends. Every window is now a separate small cell.</li>
118
  <li>It moves vertically to its final height. Its column keeps it clear of everything.</li>
119
  <li>It expands horizontally to its final width.</li>
120
</ol>
121
<p>Each step provably keeps every pair separated, whatever the input, so this is the guarantee behind "no fallback needed". It is also the ugliest plan, so the planner only reaches it after everything else fails, and only for the windows involved.</p>
122
<div class="demo" id="d9c"></div>
123
124
<h2>8. Joining and leaving stacks</h2>
125
<p>These have historically been the hard cases. Notation <code>A | B</code> is side by side and <code>A / B</code> is A stacked over B. Click any layout to animate to it from whatever is on screen, including mid-animation.</p>
126
<div class="demo" id="d9a"></div>
127
<div class="demo" id="d9b"></div>
128
129
<h2>9. Rigid blocks and movement commands</h2>
130
<p>Swapping the order of two viewed tags reverses every window of one tag against every window of the other. Planned window by window, that needs one lane per window. Planned with each tag as a <strong>rigid block</strong>, it needs 2 lanes and the windows ride inside their block. The block planner is the same algorithm one level up.</p>
131
<p>Both movement units exist as commands, for example <code>meta+shift+hjkl</code> moves a window and <code>meta+ctrl+hjkl</code> moves its block. The unit being moved decides the plan. A block move plans with rigid blocks, a window move plans per window.</p>
132
<div class="demo" id="d3b"></div>
133
134
<h2>10. Interrupts and velocity</h2>
135
<p>An interrupt samples the current on-screen rectangles and plans from there to the new target. The new plan obeys the same bound however many times you interrupt. Try the storm button, which fires a new target every few hundred milliseconds.</p>
136
<p><strong>Velocity.</strong> The base curve starts and ends at rest, so a window moving when interrupted stops dead. Velocity matching measures how fast things were moving and starts the first phase of the new plan at that speed, by setting the bezier's first control point so the initial slope matches. It only applies when the first phase continues in the direction things were already moving. Otherwise the window stops and turns. The control point's height is clamped to 1, which rules out overshoot, since a cubic bezier never leaves the range of its control points.</p>
137
<p>One curve per <em>motion group</em>, never per window. Windows that share an edge must move it on the same curve, or the edge tears open or overlaps. Since phases are bounded by time rather than distance, the group's curve follows its fastest mover. The graph shows window A's speed. Turn matching off to see the drop to zero at every interrupt.</p>
138
<div class="demo" id="d6"></div>
139
140
<h2>11. Across outputs</h2>
141
<p>Outputs live in one global coordinate space, so a move between outputs is planned like any other move and drawn clipped to each output. The window crosses the bezel and appears on the other side. Moving B over is one phase. Moving A over means A has to pass B, a reversal, so it takes lanes, and the lanes span both outputs.</p>
142
<p class="note">Not shown. Outputs at different scales draw the same window at different sizes, since the planner works in logical coordinates and each output scales its part. If two outputs aren't adjacent in the global space, the window travels through empty space.</p>
143
<div class="demo" id="d7"></div>
144
145
<h2>12. Floating, fullscreen and expand</h2>
146
<p>A floating window joining the tiled layout travels above the tiled layer and is exempt from the overlap rule while it does. The mode button cycles three choreographies. Lifting out plays each one in reverse.</p>
147
<ul>
148
  <li><strong>Direct glide</strong> (default). F lerps straight into the slot while the gap opens, in one phase.</li>
149
  <li><strong>Slot opens last.</strong> F travels one axis per phase until it hovers over its slot, then the gap opens and F lands as it widens.</li>
150
  <li><strong>Slot opens first.</strong> The gap opens while F is still travelling, then F lands.</li>
151
</ul>
152
<div class="demo" id="d10"></div>
153
<h3>Fullscreen</h3>
154
<p>Fullscreen uses the same direct glide. The window lifts above the tiled layer and lerps to cover the output in one phase. Its tiled slot stays where it is underneath, so leaving fullscreen glides back into it and nothing else moves either way.</p>
155
<div class="demo" id="d13"></div>
156
<h3>Expand</h3>
157
<p>Expand is a tiled operation, not a glide. The expanded window or subtree grows to fill its slot while its siblings are squeezed to zero-size lines against the slot's edges, so it follows the normal planner rules, orthogonal included. Expanding a window inside its column is one vertical phase. Expanding it to the whole output changes both axes, so orthogonal style takes two.</p>
158
<div class="demo" id="d14"></div>
159
160
<h2>13. Fallbacks</h2>
161
<p>The grid construction means no plan should ever need a fallback, and the stress test never reached one. Two remain wired in as safety nets for malformed input. <strong>Pinch</strong> shrinks a window to a point, jumps invisibly and regrows it. <strong>Lerp</strong> interpolates straight to the target and accepts overlap. Which one is the net is a matter of trying both.</p>
162
163
<h2>14. Algorithm</h2>
164
<pre>plan(from, to):
165
  if from > to in a canonical order: return reverse(plan(to, from))
166
  groups = motion groups (windows whose swept areas can touch, transitively)
167
  for each group:
168
    if every pair shares a relation between from and to
169
       and (style is direct or every window changes only one axis):
170
      return [from, to]                                    # 1 phase
171
    if 2-SAT over axis order finds K1, preferring no holes: # mixed rects
172
      return [from, K1, to]                                # 2 phases
173
    for lane_axis in [y, x]:                               # strips, then columns
174
      for set in [windows in reversals, all movers, all]:
175
        for k in 2..|set|:
176
          if lanes assignable with k lanes and all three phases validate:
177
            return [from, K1, K2, to]                      # 3 phases
178
    for set in [windows in reversals, all movers, all]:
179
      if the grid validates for set:                       # always true for all
180
        return [from, K1, K2, K3, to]                      # 4 phases
181
    return fallback(from, to)                              # unreachable in theory
182
183
  rigid blocks: run plan() on block rectangles first, then map windows into them
184
  floating:     slot opens via plan() with a seed, floater travels x, then y, then lands
185
  interrupts:   from = rectangles sampled at the current presentation time
186
  velocity:     first-phase curve per group, slope from the fastest mover,
187
                applied only if moving the same way, control point height ≤ 1
188
  validation:   exact pairwise check over the curve actually used</pre>
189
<p>Cost. Pair checks are O(n²) per group. 2-SAT is linear in clauses, so also O(n²). Lane assignment is backtracking over small sets with a greedy cut-off for large ones. Fifty windows is about 1,200 pairs, which is microseconds.</p>
190
<p>Everything here is pure geometry with no smithay dependency, so the <code>planner</code> crate gets property tests. Generate random layouts and random interrupt sequences, then check every frame of every plan for overlap, every plan for at most 4 phases, every orthogonal phase for single-axis motion, and every interrupt for continuity of position.</p>
191
192
<h2>15. Compared with wry master</h2>
193
<table>
194
  <tr><th>wry master</th><th>this design</th></tr>
195
  <tr><td>SingleAction, MixedSinglePhase</td><td>1 phase</td></tr>
196
  <tr><td>SpaceThenOrthogonalGrowth, OrientationChange, HierarchyOrderedScales</td><td>2 phases, one 2-SAT problem instead of three strategies</td></tr>
197
  <tr><td>SwapLanes</td><td>3 phases, general lane assignment for any permutation</td></tr>
198
  <tr><td>no equivalent</td><td>4 phases, the grid, for simultaneous reversals on both axes</td></tr>
199
  <tr><td>ReversedForwardPlan</td><td>not needed, the construction is symmetric</td></tr>
200
  <tr><td>Unplannable groups fall back to overlapping plain interpolation</td><td>every input has a plan, fallbacks are only a safety net</td></tr>
201
  <tr><td>Bridged retarget can make plans longer than the original</td><td>every replan obeys the same bound</td></tr>
202
  <tr><td>Spawns grow from the target centre</td><td>spawns grow from a seed on the old boundary, concurrently</td></tr>
203
  <tr><td>Exact rational overlap check validates every plan</td><td>plans are correct by construction, the exact check remains as a safety net and for velocity-shaped curves</td></tr>
204
  <tr><td>Cross-output moves skip animation</td><td>planned in global coordinates, clipped per output</td></tr>
205
</table>
206
207
<h2>16. Interspersing tags (open, changes the layout model)</h2>
208
<p>Row stacking answers "what does a combined view look like" but not "what happens when I drag A from tag 1's row in between D and E of tag 2". There are five answers.</p>
209
<table>
210
  <tr><th>option</th><th>meaning</th><th>verdict</th></tr>
211
  <tr><td>Forbid it</td><td>tags stay in their rows</td><td>limiting</td></tr>
212
  <tr><td>Dragging retags</td><td>moving A into tag 2's row makes A a tag 2 window</td><td>honest and simple, but A then vanishes from tag 1 viewed alone, which is usually not what the drag meant</td></tr>
213
  <tr><td>2D positional merging (L/C/R × T/M/B)</td><td>windows merge into slots by position</td><td>kill it. Users must predict an implicit rule, and every edit in a combined view is ambiguous about which tag's tree it changes</td></tr>
214
  <tr><td>Layout per view combination</td><td>each set of viewed tags gets its own tree, seeded by row stacking</td><td>no surprises between views, but N combinations of trees to keep in sync, and every new window has to be inserted into all of them</td></tr>
215
  <tr><td><strong>One tree per output, tags filter it</strong></td><td>every window of every tag lives in a single tree per output. Viewing tags shows a projection with hidden windows collapsed. Each tag starts as its own row, so row stacking becomes the default arrangement instead of a merge rule</td><td><strong>recommended</strong></td></tr>
216
</table>
217
<p>The last option is dwm's model (one client list, tags as a filter) with a tree in place of the list. The tree is the record of every relative position, permanently, because hidden windows never leave it. Nothing has to be restored. And for the planner, <strong>viewing or hiding any tag is always a single phase</strong>, because hidden windows are zero-weight seeds sitting at their own spot. Switching views never rearranges anything.</p>
218
<p>Its cost is action at a distance. Intersperse A into tag 2's row below, then view tag 1 alone. A now sits under B and C, because in the tree it lives in the lower row. You changed tag 1's single view without ever viewing it alone.</p>
219
<div class="demo" id="d8"></div>
220
<p><strong>Hidden windows keep their slot.</strong> They are not coupled to visible neighbours. My earlier phrasing was wrong, and your version is the rule.</p>
221
<ul>
222
  <li>The tree never moves a hidden leaf. Moves in a filtered view swap the slots of visible windows only. Swap A and B below with tag 2 hidden, then show it again, and D is still between them.</li>
223
  <li>Containers die by the normal rule, when one child is left, counting hidden children. A hidden only child is demoted into the grandparent at the container's position. Move C next to B below with tag 2 hidden. Row 2 is left holding only E, so it collapses and E becomes a full-width row of its own.</li>
224
  <li>A container holding only hidden children survives as long as it has two or more, invisible until they return.</li>
225
  <li>New windows insert next to the focused visible window.</li>
226
  <li>Hidden windows keep their share of a container's size when visible siblings are resized.</li>
227
</ul>
228
<p>Tick "show hidden slots" to see where hidden windows will reappear.</p>
229
<div class="demo" id="d11"></div>
230
231
<h2>17. Decisions</h2>
232
<h3>Settled</h3>
233
<ul>
234
  <li>Orthogonal is the default and the flagship look. Direct is a toggle.</li>
235
  <li>Window moves and block moves both exist as commands. Tag reordering moves rigid blocks.</li>
236
  <li>One curve per motion group, bounded by phase time rather than amount of motion.</li>
237
  <li>Seeds start as a line on the old boundary.</li>
238
  <li>Floating to tiled and fullscreen use a direct glide. Expand is tiled and follows the planner.</li>
239
  <li>One tree per output, tags filter it. Hidden windows keep their slots.</li>
240
  <li>The planner must never need a fallback. Pinch and lerp stay as safety nets to try.</li>
241
</ul>
242
<h3>Open</h3>
243
244
<div class="q"><strong>Lane extent.</strong> Strips span only the band of the involved windows. Spanning the whole output gives thicker lanes for small windows, but disturbs more of the screen.</div>
245
<div class="q"><strong>Hidden-slot rules (section 16).</strong> Check the rules list against your intent, especially demotion and insertion next to the focused window.</div>
246
</main>
247
248
<script id="planner">
249
const G = 10;
250
const EPS = 0.5;
251
let PHASE_MS = 450;
252
let STYLE = 'orthogonal';
253
let LANE_REGION = null;
254
255
const R = (x, y, w, h) => ({ x, y, w, h });
256
const clone = L => { const o = {}; for (const k in L) o[k] = { ...L[k] }; return o; };
257
const eq = (a, b) => Math.abs(a - b) < 0.05;
258
const lerp = (a, b, s) => a + (b - a) * s;
259
const lerpRect = (a, b, s) => R(lerp(a.x, b.x, s), lerp(a.y, b.y, s), lerp(a.w, b.w, s), lerp(a.h, b.h, s));
260
261
function splitX(rect, ids, ratios) {
262
  ratios = ratios || ids.map(() => 1);
263
  const tot = ratios.reduce((a, b) => a + b, 0);
264
  const avail = rect.w - G * (ids.length - 1);
265
  let x = rect.x;
266
  const out = {};
267
  ids.forEach((id, i) => { const w = avail * ratios[i] / tot; out[id] = R(x, rect.y, w, rect.h); x += w + G; });
268
  return out;
269
}
270
function splitY(rect, ids, ratios) {
271
  ratios = ratios || ids.map(() => 1);
272
  const tot = ratios.reduce((a, b) => a + b, 0);
273
  const avail = rect.h - G * (ids.length - 1);
274
  let y = rect.y;
275
  const out = {};
276
  ids.forEach((id, i) => { const h = avail * ratios[i] / tot; out[id] = R(rect.x, y, rect.w, h); y += h + G; });
277
  return out;
278
}
279
280
function relBits(a, b) {
281
  let s = 0;
282
  if (a.x + a.w <= b.x + EPS) s |= 1;
283
  if (b.x + b.w <= a.x + EPS) s |= 2;
284
  if (a.y + a.h <= b.y + EPS) s |= 4;
285
  if (b.y + b.h <= a.y + EPS) s |= 8;
286
  return s;
287
}
288
function phaseOk(K1, K2, list) {
289
  for (let i = 0; i < list.length; i++)
290
    for (let j = i + 1; j < list.length; j++) {
291
      const a = list[i], b = list[j];
292
      if ((relBits(K1[a], K1[b]) & relBits(K2[a], K2[b])) === 0) return false;
293
    }
294
  return true;
295
}
296
function chg(a, b) { return { x: !(eq(a.x, b.x) && eq(a.w, b.w)), y: !(eq(a.y, b.y) && eq(a.h, b.h)) }; }
297
function pureOk(keys) {
298
  if (STYLE !== 'orthogonal') return true;
299
  for (let k = 0; k + 1 < keys.length; k++)
300
    for (const id in keys[k + 1]) { const c = chg(keys[k][id], keys[k + 1][id]); if (c.x && c.y) return false; }
301
  return true;
302
}
303
304
function signature(L) { return Object.keys(L).sort().map(id => { const r = L[id]; return [r.x, r.y, r.w, r.h].map(v => v.toFixed(1)).join(','); }).join(';'); }
305
306
function plan(from, to, opts = {}) {
307
  if (opts.naive) return { kind: 'naive lerp', keys: [from, to], durs: [PHASE_MS] };
308
  if (signature(from) <= signature(to)) return planOneWay(from, to);
309
  const p = planOneWay(to, from);
310
  return { kind: p.kind, keys: [...p.keys].reverse(), durs: [...p.durs].reverse() };
311
}
312
313
function planOneWay(from, to) {
314
  const list = Object.keys(to);
315
  const T = PHASE_MS;
316
  const movers = list.filter(id => { const c = chg(from[id], to[id]); return c.x || c.y; });
317
  if (!movers.length) return { kind: 'no-op', keys: [from, to], durs: [0] };
318
  const pure = movers.every(id => { const c = chg(from[id], to[id]); return !(c.x && c.y); });
319
  if (phaseOk(from, to, list) && (STYLE === 'direct' || pure))
320
    return { kind: '1 phase', keys: [from, to], durs: [T] };
321
  const two = axisOrder(from, to, list, movers);
322
  if (two) return two;
323
  for (const ax of ['y', 'x']) { const l = lanes(from, to, list, movers, ax); if (l) return l; }
324
  const g = grid(from, to, list, movers);
325
  if (g) return g;
326
  return pinch(from, to, movers);
327
}
328
329
function conflictSet(from, to, list) {
330
  const out = new Set();
331
  for (let i = 0; i < list.length; i++)
332
    for (let j = i + 1; j < list.length; j++) {
333
      const a = list[i], b = list[j];
334
      if ((relBits(from[a], from[b]) & relBits(to[a], to[b])) === 0) { out.add(a); out.add(b); }
335
    }
336
  return [...out];
337
}
338
339
function grid(from, to, list, movers) {
340
  const cx = r => r.x + r.w / 2, cy = r => r.y + r.h / 2;
341
  for (const set of [conflictSet(from, to, list), movers, list]) {
342
    const n = set.length;
343
    if (n < 2) continue;
344
    let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
345
    for (const id of set) for (const r of [from[id], to[id]]) {
346
      x0 = Math.min(x0, r.x); x1 = Math.max(x1, r.x + r.w); y0 = Math.min(y0, r.y); y1 = Math.max(y1, r.y + r.h);
347
    }
348
    const sh = (y1 - y0 - (n - 1) * G) / n, sw = (x1 - x0 - (n - 1) * G) / n;
349
    const rowOf = {}, colOf = {};
350
    [...set].sort((a, b) => cy(from[a]) - cy(from[b]) || cx(from[a]) - cx(from[b])).forEach((id, i) => rowOf[id] = y0 + i * (sh + G));
351
    [...set].sort((a, b) => cx(to[a]) - cx(to[b]) || cy(to[a]) - cy(to[b])).forEach((id, i) => colOf[id] = x0 + i * (sw + G));
352
    const inSet = new Set(set);
353
    for (const phase of [3, 2, 4, 1]) {
354
      const K = [{}, {}, {}];
355
      for (const id of list) {
356
        const f = from[id], t = to[id];
357
        if (inSet.has(id)) {
358
          K[0][id] = R(f.x, rowOf[id], f.w, sh);
359
          K[1][id] = R(colOf[id], rowOf[id], sw, sh);
360
          K[2][id] = R(colOf[id], t.y, sw, t.h);
361
        } else for (let k = 0; k < 3; k++) K[k][id] = k + 1 >= phase ? t : f;
362
      }
363
      const keys = [from, ...K, to];
364
      let ok = true;
365
      for (let k = 0; k < 4 && ok; k++) ok = phaseOk(keys[k], keys[k + 1], list);
366
      if (ok && pureOk(keys)) return { kind: `4 phases (${n}×${n} grid)`, keys, durs: [PHASE_MS, PHASE_MS, PHASE_MS, PHASE_MS] };
367
    }
368
  }
369
  return null;
370
}
371
372
function mixed(f, t, xFirst) { return xFirst ? R(t.x, f.y, t.w, f.h) : R(f.x, t.y, f.w, t.h); }
373
374
function adjBits(a, b) {
375
  const ovX = a.x < b.x + b.w + EPS && b.x < a.x + a.w + EPS;
376
  const ovY = a.y < b.y + b.h + EPS && b.y < a.y + a.h + EPS;
377
  let s = 0;
378
  if (ovY && Math.abs(b.x - (a.x + a.w)) <= G + EPS) s |= 1;
379
  if (ovY && Math.abs(a.x - (b.x + b.w)) <= G + EPS) s |= 2;
380
  if (ovX && Math.abs(b.y - (a.y + a.h)) <= G + EPS) s |= 4;
381
  if (ovX && Math.abs(a.y - (b.y + b.h)) <= G + EPS) s |= 8;
382
  return s;
383
}
384
function coupledEdges(from, to, list) {
385
  const out = [];
386
  for (let i = 0; i < list.length; i++)
387
    for (let j = i + 1; j < list.length; j++) {
388
      const a = list[i], b = list[j], s = adjBits(from[a], from[b]) & adjBits(to[a], to[b]);
389
      if (s) out.push([a, b, s]);
390
    }
391
  return out;
392
}
393
function holes(K, coupled) {
394
  let n = 0;
395
  for (const [a, b, s] of coupled) if ((adjBits(K[a], K[b]) & s) !== s) n++;
396
  return n;
397
}
398
399
function axisOrder(from, to, list, movers) {
400
  const n = movers.length;
401
  if (n > 14) return null;
402
  const coupled = coupledEdges(from, to, list);
403
  let best = null, bestScore = Infinity;
404
  for (let mask = 0; mask < (1 << n); mask++) {
405
    const K1 = { ...from };
406
    let late = 0;
407
    movers.forEach((id, i) => {
408
      const xf = !!(mask & (1 << i));
409
      const c = chg(from[id], to[id]);
410
      K1[id] = mixed(from[id], to[id], xf);
411
      if (c.x && !c.y && !xf) late++;
412
      if (c.y && !c.x && xf) late++;
413
    });
414
    if (late >= bestScore) continue;
415
    if (!phaseOk(from, K1, list) || !phaseOk(K1, to, list)) continue;
416
    const score = holes(K1, coupled) * 1000 + late;
417
    if (score < bestScore) { best = K1; bestScore = score; if (score === 0) break; }
418
  }
419
  return best ? { kind: '2 phases (axis order)' + (bestScore >= 1000 ? ', opens a hole' : ''), keys: [from, best, to], durs: [PHASE_MS, PHASE_MS] } : null;
420
}
421
422
function lanes(from, to, list, movers, ax) {
423
  const size = ax === 'y' ? 'h' : 'w';
424
  const bitsT = ax === 'y' ? 3 : 12;
425
  const before = ax === 'y' ? 4 : 1, after = ax === 'y' ? 8 : 2;
426
  const conflict = new Set();
427
  for (let i = 0; i < list.length; i++)
428
    for (let j = i + 1; j < list.length; j++) {
429
      const a = list[i], b = list[j];
430
      if ((relBits(from[a], from[b]) & relBits(to[a], to[b])) === 0) { conflict.add(a); conflict.add(b); }
431
    }
432
  const sets = [[...conflict], movers, list];
433
  for (const set of sets) {
434
    const n = set.length;
435
    if (n < 2) continue;
436
    let lo = Infinity, hi = -Infinity;
437
    for (const id of set) for (const r of [from[id], to[id]]) { lo = Math.min(lo, r[ax]); hi = Math.max(hi, r[ax] + r[size]); }
438
    if (LANE_REGION) { lo = LANE_REGION[ax]; hi = LANE_REGION[ax] + LANE_REGION[size]; }
439
    const diff = [], lt = [];
440
    for (let i = 0; i < n; i++) { diff.push(new Array(n).fill(false)); lt.push(new Array(n).fill(false)); }
441
    for (let i = 0; i < n; i++)
442
      for (let j = 0; j < n; j++) {
443
        if (i === j) continue;
444
        const f = relBits(from[set[i]], from[set[j]]), t = relBits(to[set[i]], to[set[j]]);
445
        if (((f & bitsT) & (t & bitsT)) === 0) diff[i][j] = true;
446
        for (const r of [f, t]) if ((r & bitsT) === 0 && (r & before)) lt[i][j] = true;
447
      }
448
    for (let k = 2; k <= n; k++) {
449
      const s = (hi - lo - (k - 1) * G) / k;
450
      if (s < 6) break;
451
      const lane = new Array(n).fill(-1);
452
      const assign = i => {
453
        if (i === n) return true;
454
        for (let l = 0; l < k; l++) {
455
          let ok = true;
456
          for (let j = 0; j < i && ok; j++) {
457
            if (diff[i][j] && lane[j] === l) ok = false;
458
            if (lt[i][j] && !(l < lane[j])) ok = false;
459
            if (lt[j][i] && !(lane[j] < l)) ok = false;
460
          }
461
          if (ok) { lane[i] = l; if (assign(i + 1)) return true; lane[i] = -1; }
462
        }
463
        return false;
464
      };
465
      if (!assign(0)) continue;
466
      const strip = l => lo + l * (s + G);
467
      const inSet = new Set(set);
468
      for (const phase of [2, 1, 3]) {
469
        const K1 = {}, K2 = {};
470
        for (const id of list) {
471
          const f = from[id], t = to[id];
472
          if (inSet.has(id)) {
473
            const l = lane[set.indexOf(id)];
474
            K1[id] = ax === 'y' ? R(f.x, strip(l), f.w, s) : R(strip(l), f.y, s, f.h);
475
            K2[id] = ax === 'y' ? R(t.x, strip(l), t.w, s) : R(strip(l), t.y, s, t.h);
476
          } else if (phase === 1) { K1[id] = t; K2[id] = t; }
477
          else if (phase === 2) { K1[id] = f; K2[id] = t; }
478
          else { K1[id] = f; K2[id] = f; }
479
        }
480
        if (phaseOk(from, K1, list) && phaseOk(K1, K2, list) && phaseOk(K2, to, list) && pureOk([from, K1, K2, to]))
481
          return { kind: `3 phases (${k} lanes)`, keys: [from, K1, K2, to], durs: [PHASE_MS, PHASE_MS, PHASE_MS] };
482
      }
483
    }
484
  }
485
  return null;
486
}
487
488
function pinch(from, to, movers) {
489
  const K1 = { ...from }, K2 = { ...to };
490
  for (const id of movers) {
491
    const f = from[id], t = to[id];
492
    K1[id] = R(f.x + f.w / 2, f.y + f.h / 2, 0, 0);
493
    K2[id] = R(t.x + t.w / 2, t.y + t.h / 2, 0, 0);
494
  }
495
  return { kind: 'pinch fallback', keys: [from, K1, K2, to], durs: [PHASE_MS, 0, PHASE_MS] };
496
}
497
498
function bbox(rects) {
499
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
500
  for (const r of rects) { x0 = Math.min(x0, r.x); y0 = Math.min(y0, r.y); x1 = Math.max(x1, r.x + r.w); y1 = Math.max(y1, r.y + r.h); }
501
  return R(x0, y0, x1 - x0, y1 - y0);
502
}
503
function planBlocks(from, to, groups) {
504
  const bf = {}, bt = {};
505
  for (const b in groups) { bf[b] = bbox(groups[b].map(id => from[id])); bt[b] = bbox(groups[b].map(id => to[id])); }
506
  const bp = plan(bf, bt);
507
  const last = bp.keys.length - 1;
508
  const keys = bp.keys.map((K, ki) => {
509
    if (ki === 0) return from;
510
    if (ki === last) return to;
511
    const L = { ...from };
512
    for (const b in groups) for (const id of groups[b]) {
513
      const r = from[id], box = bf[b], k = K[b];
514
      const nx = box.w ? (r.x - box.x) / box.w : 0, ny = box.h ? (r.y - box.y) / box.h : 0;
515
      const nw = box.w ? r.w / box.w : 0, nh = box.h ? r.h / box.h : 0;
516
      L[id] = R(k.x + nx * k.w, k.y + ny * k.h, nw * k.w, nh * k.h);
517
    }
518
    return L;
519
  });
520
  return { kind: bp.kind + ' on blocks', keys, durs: bp.durs };
521
}
522
523
function bezier(x1, y1, x2, y2) {
524
  const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx;
525
  const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
526
  const X = u => ((ax * u + bx) * u + cx) * u, Y = u => ((ay * u + by) * u + cy) * u;
527
  const dX = u => (3 * ax * u + 2 * bx) * u + cx;
528
  return t => {
529
    if (t <= 0) return 0;
530
    if (t >= 1) return 1;
531
    let u = t;
532
    for (let i = 0; i < 8; i++) {
533
      const x = X(u) - t;
534
      if (Math.abs(x) < 1e-6) return Y(u);
535
      const d = dX(u);
536
      if (Math.abs(d) < 1e-6) break;
537
      u -= x / d;
538
    }
539
    let lo = 0, hi = 1; u = t;
540
    for (let i = 0; i < 40; i++) { if (X(u) < t) lo = u; else hi = u; u = (lo + hi) / 2; }
541
    return Y(u);
542
  };
543
}
544
const BASE = bezier(0.45, 0, 0.2, 1);
545
const edges = r => [r.x, r.x + r.w, r.y, r.y + r.h];
546
</script>
547
548
<script>
549
const PAL = { T: '#9aa3b5', A: '#e0774a', B: '#4aa3e0', C: '#7bc96f', D: '#c77be0', E: '#e0c24a', F: '#4ae0c2' };
550
const demos = [];
551
552
class Demo {
553
  constructor(id, W, H, opts = {}) {
554
    this.W = W; this.H = H;
555
    this.outputs = opts.outputs || [R(0, 0, W, H)];
556
    this.floating = new Set(opts.floating || []);
557
    const el = document.getElementById(id);
558
    this.canvas = document.createElement('canvas');
559
    const dpr = window.devicePixelRatio || 1;
560
    this.canvas.width = W * dpr; this.canvas.height = H * dpr;
561
    this.canvas.style.maxWidth = W + 'px';
562
    this.ctx = this.canvas.getContext('2d');
563
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
564
    el.appendChild(this.canvas);
565
    if (opts.graph) {
566
      this.graph = document.createElement('canvas');
567
      this.graph.width = W * dpr; this.graph.height = 90 * dpr;
568
      this.graph.style.maxWidth = W + 'px'; this.graph.style.marginTop = '8px';
569
      this.gctx = this.graph.getContext('2d');
570
      this.gctx.setTransform(dpr, 0, 0, dpr, 0, 0);
571
      el.appendChild(this.graph);
572
      this.hist = [];
573
    }
574
    this.controls = document.createElement('div'); this.controls.className = 'controls'; el.appendChild(this.controls);
575
    this.status = document.createElement('div'); this.status.className = 'status'; el.appendChild(this.status);
576
    this.anim = null; this.prev = null; this.vel = {}; this.naive = false; this.vmatch = true; this.lastKind = 'idle';
577
    demos.push(this);
578
  }
579
  button(label, fn) { const b = document.createElement('button'); b.textContent = label; b.onclick = fn; this.controls.appendChild(b); return b; }
580
  check(label, init, fn) {
581
    const l = document.createElement('label'); const c = document.createElement('input'); c.type = 'checkbox'; c.checked = init;
582
    c.onchange = () => fn(c.checked); l.appendChild(c); l.appendChild(document.createTextNode(label)); this.controls.appendChild(l); return c;
583
  }
584
  set(L) { this.layout = clone(L); this.anim = null; }
585
  go(target, opts = {}) {
586
    const now = performance.now();
587
    const cur = this.sampleAt(now);
588
    const from = {};
589
    for (const id in target) from[id] = cur[id] || target[id];
590
    const p = opts.planFn ? opts.planFn(from) : opts.blocks ? planBlocks(from, target, opts.blocks) : plan(from, target, { naive: this.naive });
591
    const curves = p.durs.map(() => BASE);
592
    const interrupted = !!this.anim;
593
    let matched = false;
594
    if (this.vmatch && interrupted && p.durs[0] > 0 && p.keys.length > 1) {
595
      const K1 = p.keys[1];
596
      let best = null, bs = 0;
597
      for (const id in K1) {
598
        const v = this.vel[id]; if (!v) continue;
599
        const D = edges(K1[id]).map((e, i) => e - edges(from[id])[i]);
600
        const dd = D.reduce((a, b) => a + b * b, 0); if (dd < 1) continue;
601
        const sp = Math.hypot(...v);
602
        if (sp > bs) { bs = sp; best = { v, D, dd }; }
603
      }
604
      if (best) {
605
        const s = best.v.reduce((a, b, i) => a + b * best.D[i], 0) / best.dd;
606
        const slope = s * p.durs[0];
607
        if (slope > 0.05) { const x1 = 0.3; curves[0] = bezier(x1, Math.min(1, x1 * slope), 0.2, 1); matched = true; }
608
      }
609
    }
610
    this.anim = { keys: p.keys, durs: p.durs, curves, start: now };
611
    this.lastKind = p.kind + (interrupted ? ', replanned' : '') + (matched ? ', velocity matched' : '');
612
    this.overlapSeen = false;
613
  }
614
  sampleAt(t) {
615
    if (!this.anim) return clone(this.layout);
616
    let e = t - this.anim.start;
617
    const { keys, durs, curves } = this.anim;
618
    for (let i = 0; i < durs.length; i++) {
619
      const d = durs[i];
620
      if (e < d) {
621
        this.phase = i;
622
        const s = curves[i](e / d);
623
        const L = {};
624
        for (const id in keys[i + 1]) L[id] = lerpRect(keys[i][id], keys[i + 1][id], s);
625
        return L;
626
      }
627
      e -= d;
628
    }
629
    this.layout = clone(keys[keys.length - 1]);
630
    this.anim = null;
631
    return clone(this.layout);
632
  }
633
  frame(now) {
634
    const L = this.sampleAt(now);
635
    if (this.prev) {
636
      const dt = now - this.prev.t;
637
      if (dt > 0) for (const id in L) if (this.prev.L[id]) {
638
        const a = edges(this.prev.L[id]), b = edges(L[id]);
639
        this.vel[id] = b.map((v, i) => (v - a[i]) / dt);
640
      }
641
    }
642
    this.prev = { t: now, L };
643
    this.draw(L);
644
    const overlaps = this.findOverlaps(L);
645
    if (overlaps.length) this.overlapSeen = true;
646
    const phaseTxt = this.anim ? ` · phase ${this.phase + 1}/${this.anim.durs.length}` : '';
647
    this.status.innerHTML = `${this.lastKind}${phaseTxt}` + (this.overlapSeen ? ' · <span class="bad">overlap</span>' : '');
648
    if (this.graph) this.drawGraph(now);
649
  }
650
  findOverlaps(L) {
651
    const ids = Object.keys(L).filter(id => L[id].w > 0.5 && L[id].h > 0.5 && !this.floating.has(id));
652
    const out = [];
653
    for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) {
654
      const a = L[ids[i]], b = L[ids[j]];
655
      const x0 = Math.max(a.x, b.x), y0 = Math.max(a.y, b.y);
656
      const x1 = Math.min(a.x + a.w, b.x + b.w), y1 = Math.min(a.y + a.h, b.y + b.h);
657
      if (x1 - x0 > 1 && y1 - y0 > 1) out.push(R(x0, y0, x1 - x0, y1 - y0));
658
    }
659
    return out;
660
  }
661
  draw(L) {
662
    const c = this.ctx;
663
    c.clearRect(0, 0, this.W, this.H);
664
    const overlaps = this.findOverlaps(L);
665
    for (const o of this.outputs) {
666
      c.save();
667
      c.fillStyle = '#1b1e25'; c.fillRect(o.x, o.y, o.w, o.h);
668
      c.beginPath(); c.rect(o.x, o.y, o.w, o.h); c.clip();
669
      for (const id of Object.keys(L).sort((a, b) => this.floating.has(a) - this.floating.has(b))) {
670
        const r = L[id];
671
        if (r.w < 0.5 || r.h < 0.5) continue;
672
        const rad = Math.min(8, r.w / 2, r.h / 2);
673
        c.beginPath(); c.roundRect(r.x, r.y, r.w, r.h, rad);
674
        c.fillStyle = (PAL[id] || '#888') + 'cc'; c.fill();
675
        c.strokeStyle = PAL[id] || '#888'; c.lineWidth = 1.5; c.stroke();
676
        if (r.w > 22 && r.h > 22) {
677
          c.fillStyle = '#0d0f12'; c.font = '600 16px system-ui'; c.textAlign = 'center'; c.textBaseline = 'middle';
678
          c.fillText(id, r.x + r.w / 2, r.y + r.h / 2);
679
        }
680
      }
681
      for (const o2 of overlaps) { c.fillStyle = 'rgba(255,77,94,0.55)'; c.fillRect(o2.x, o2.y, o2.w, o2.h); c.strokeStyle = '#ff4d5e'; c.lineWidth = 2; c.strokeRect(o2.x, o2.y, o2.w, o2.h); }
682
      if (this.overlay) for (const r of this.overlay()) { c.setLineDash([6, 4]); c.strokeStyle = '#8a909c'; c.lineWidth = 1.5; c.strokeRect(r.x, r.y, r.w, r.h); c.setLineDash([]); c.fillStyle = '#8a909c'; c.font = '13px system-ui'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(r.id + ' (hidden)', r.x + r.w / 2, r.y + r.h / 2); }
683
      c.restore();
684
    }
685
  }
686
  drawGraph(now) {
687
    const v = this.vel.A;
688
    const sp = v ? Math.hypot(v[0] + v[1], v[2] + v[3]) * 1000 / 2 : 0;
689
    this.hist.push([now, sp]);
690
    while (this.hist.length && now - this.hist[0][0] > 4000) this.hist.shift();
691
    const c = this.gctx, W = this.W, H = 90;
692
    c.clearRect(0, 0, W, H);
693
    c.fillStyle = '#0d0f12'; c.fillRect(0, 0, W, H);
694
    const max = Math.max(400, ...this.hist.map(h => h[1]));
695
    c.beginPath();
696
    this.hist.forEach(([t, s], i) => { const x = W - (now - t) / 4000 * W, y = H - 6 - s / max * (H - 16); i ? c.lineTo(x, y) : c.moveTo(x, y); });
697
    c.strokeStyle = PAL.A; c.lineWidth = 2; c.stroke();
698
    c.fillStyle = '#8a909c'; c.font = '12px system-ui'; c.textAlign = 'left'; c.fillText('speed of window A, last 4 s', 8, 14);
699
  }
700
}
701
702
const inner = (W, H, ox = 0) => R(ox + G, G, W - 2 * G, H - 2 * G);
703
704
{ // d1 swap
705
  const W = 640, H = 260, d = new Demo('d1', W, H), I = inner(W, H);
706
  let swapped = false;
707
  const L = () => splitX(I, swapped ? ['B', 'A'] : ['A', 'B']);
708
  d.set(L());
709
  d.button('Swap A and B', () => { swapped = !swapped; d.go(L()); });
710
  d.check('naive', false, v => d.naive = v);
711
}
712
713
{ // d2 one phase
714
  const W = 640, H = 300, d = new Demo('d2', W, H), I = inner(W, H);
715
  const s = { wide: false, d: false, e: false };
716
  const modes = ['line on old boundary', 'point on old boundary', 'target centre'];
717
  let mode = 0;
718
  const ratios = ids => ids.map(id => (id === 'A' && s.wide) ? 2 : 1);
719
  const L = () => {
720
    const ids = s.d ? ['A', 'B', 'D', 'C'] : ['A', 'B', 'C'];
721
    const out = splitX(I, ids, ratios(ids));
722
    if (!s.d) {
723
      const bx = (out.B.x + out.B.w + out.C.x) / 2;
724
      const T = splitX(I, ['A', 'B', 'D', 'C'], ratios(['A', 'B', 'D', 'C'])).D;
725
      out.D = [R(bx, I.y, 0, I.h), R(bx, I.y + I.h / 2, 0, 0), R(T.x + T.w / 2, T.y + T.h / 2, 0, 0)][mode];
726
    }
727
    const b = out.B;
728
    if (s.e) Object.assign(out, splitY(b, ['B', 'E'])); else out.E = R(b.x, b.y + b.h, b.w, 0);
729
    return out;
730
  };
731
  d.set(L());
732
  d.button('Widen A', () => { s.wide = !s.wide; d.go(L()); });
733
  d.button('Insert / remove D', () => { s.d = !s.d; d.go(L()); });
734
  d.button('Split B with E', () => { s.e = !s.e; d.go(L()); });
735
  d.button('All at once', () => { s.wide = !s.wide; s.d = !s.d; s.e = !s.e; d.go(L()); });
736
  const mb = d.button('seed: ' + modes[0], () => {
737
    mode = (mode + 1) % modes.length; mb.textContent = 'seed: ' + modes[mode];
738
    if (!s.d && !d.anim) d.set(L());
739
  });
740
  d.check('naive', false, v => d.naive = v);
741
}
742
function tagLayout(I, view2, order) {
743
  const L = {};
744
  if (view2) {
745
    const rows = splitY(I, order);
746
    Object.assign(L, splitX(rows.t1, ['A', 'B', 'C']), splitX(rows.t2, ['D', 'E']));
747
  } else {
748
    Object.assign(L, splitX(I, ['A', 'B', 'C']));
749
    const y = order[0] === 't2' ? I.y : I.y + I.h;
750
    Object.assign(L, splitX(R(I.x, y, I.w, 0), ['D', 'E']));
751
  }
752
  return L;
753
}
754
755
{ // d3 tag toggle
756
  const W = 640, H = 320, d = new Demo('d3', W, H), I = inner(W, H);
757
  let view2 = false;
758
  d.set(tagLayout(I, view2, ['t1', 't2']));
759
  d.button('View / hide tag 2 (D, E)', () => { view2 = !view2; d.go(tagLayout(I, view2, ['t1', 't2'])); });
760
}
761
762
{ // d3b tag reorder
763
  const W = 640, H = 320, d = new Demo('d3b', W, H), I = inner(W, H);
764
  let order = ['t1', 't2'], blocks = true;
765
  d.set(tagLayout(I, true, order));
766
  d.button('Swap tag order', () => { order = [order[1], order[0]]; d.go(tagLayout(I, true, order), blocks ? { blocks: { t1: ['A', 'B', 'C'], t2: ['D', 'E'] } } : {}); });
767
  d.check('rigid blocks', true, v => blocks = v);
768
}
769
770
{ // d4a orientation flip
771
  const W = 640, H = 300, d = new Demo('d4a', W, H), I = inner(W, H);
772
  let rows = false;
773
  const L = () => rows ? splitY(I, ['A', 'B', 'C']) : splitX(I, ['A', 'B', 'C']);
774
  d.set(L());
775
  d.button('Flip orientation', () => { rows = !rows; d.go(L()); });
776
  d.check('naive', false, v => d.naive = v);
777
}
778
779
{ // d4b grow both axes
780
  const W = 640, H = 300, d = new Demo('d4b', W, H), I = inner(W, H);
781
  let wide = false;
782
  const L = () => {
783
    const r = splitY(I, ['top', 'bot'], wide ? [7, 3] : [1, 1]);
784
    return { ...splitX(r.top, ['A', 'B'], wide ? [7, 3] : [1, 1]), ...splitX(r.bot, ['C', 'D']) };
785
  };
786
  d.set(L());
787
  d.button('Grow A in both directions', () => { wide = !wide; d.go(L()); });
788
}
789
790
{ // d5 lanes
791
  const W = 640, H = 300, d = new Demo('d5', W, H), I = inner(W, H);
792
  let order = ['A', 'B', 'C', 'D'];
793
  const L = () => splitX(I, order);
794
  d.set(L());
795
  d.button('Swap first two', () => { order = [order[1], order[0], ...order.slice(2)]; d.go(L()); });
796
  d.button('Rotate', () => { order = [...order.slice(1), order[0]]; d.go(L()); });
797
  d.button('Reverse', () => { order = [...order].reverse(); d.go(L()); });
798
  d.button('Reset', () => { order = ['A', 'B', 'C', 'D']; d.go(L()); });
799
}
800
801
{ // d6 interrupts
802
  const W = 640, H = 260, d = new Demo('d6', W, H, { graph: true }), I = inner(W, H);
803
  const names = ["A B C", "C A B", "A wide", "B C A", "close B"];
804
  const targets = [
805
    () => splitX(I, ['A', 'B', 'C']),
806
    () => splitX(I, ['C', 'A', 'B']),
807
    () => splitX(I, ['A', 'B', 'C'], [3, 1, 1]),
808
    () => splitX(I, ['B', 'C', 'A']),
809
    () => ({ ...splitX(I, ['A', 'C'], [2, 1]), B: R(I.x + I.w * 0.66, I.y + I.h, I.w * 0.34, 0) }),
810
  ];
811
  const withB = L => { if (!L.B) L.B = R(I.x, I.y, 0, 0); return L; };
812
  let idx = 0;
813
  d.set(withB(targets[0]()));
814
  const next = () => { idx = (idx + 1) % targets.length; d.go(withB(targets[idx]())); d.lastKind += ` · target "${names[idx]}"`; };
815
  d.button('Next target', next);
816
  d.button('Storm (8 rapid interrupts)', () => {
817
    let n = 0;
818
    const fire = () => { next(); if (++n < 8) setTimeout(fire, 180 + Math.random() * 300); };
819
    fire();
820
  });
821
  d.check('match velocity', true, v => d.vmatch = v);
822
}
823
824
{ // d7 across outputs
825
  const W = 680, H = 260;
826
  const o1 = R(0, 0, 320, H), o2 = R(360, 0, 320, H);
827
  const d = new Demo('d7', W, H, { outputs: [o1, o2] });
828
  const I1 = inner(320, H, 0), I2 = inner(320, H, 360);
829
  let s1 = ['A', 'B'], s2 = ['C'];
830
  const L = () => ({ ...splitX(I1, s1), ...splitX(I2, s2) });
831
  d.set(L());
832
  d.button('Move B across', () => {
833
    if (s1.includes('B')) { s1 = s1.filter(x => x !== 'B'); s2 = ['B', ...s2]; } else { s2 = s2.filter(x => x !== 'B'); s1 = [...s1, 'B']; }
834
    d.go(L());
835
  });
836
  d.button('Move A across', () => {
837
    if (s1.includes('A')) { s1 = s1.filter(x => x !== 'A'); s2 = ['A', ...s2]; } else { s2 = s2.filter(x => x !== 'A'); s1 = ['A', ...s1]; }
838
    d.go(L());
839
  });
840
  d.button('Reset', () => { s1 = ['A', 'B']; s2 = ['C']; d.go(L()); });
841
}
842
843
function layoutTree(node, rect, visible, out) {
844
  if (typeof node === 'string') { out[node] = visible(node) ? rect : R(rect.x, rect.y, 0, 0); return; }
845
  const horiz = node.dir === 'row';
846
  const shown = node.kids.map(k => anyVisible(k, visible));
847
  const m = shown.filter(Boolean).length;
848
  const len = horiz ? rect.w : rect.h;
849
  const avail = len - G * Math.max(0, m - 1);
850
  let pos = horiz ? rect.x : rect.y, seen = 0;
851
  node.kids.forEach((k, i) => {
852
    if (!shown[i]) {
853
      const p = seen ? pos - G / 2 : pos;
854
      const r = horiz ? R(p, rect.y, 0, rect.h) : R(rect.x, p, rect.w, 0);
855
      layoutTree(k, r, () => false, out);
856
      return;
857
    }
858
    const size = avail / m;
859
    const r = horiz ? R(pos, rect.y, size, rect.h) : R(rect.x, pos, rect.w, size);
860
    layoutTree(k, r, visible, out);
861
    pos += size + G; seen++;
862
  });
863
}
864
function anyVisible(node, visible) { return typeof node === 'string' ? visible(node) : node.kids.some(k => anyVisible(k, visible)); }
865
866
{ // d8 one tree, tags filter
867
  const W = 640, H = 320, d = new Demo('d8', W, H), I = inner(W, H);
868
  const tagOf = { A: 1, B: 1, C: 1, D: 2, E: 2 };
869
  const view = { 1: true, 2: true };
870
  let mixed = false;
871
  const tree = () => mixed
872
    ? { dir: 'col', kids: [{ dir: 'row', kids: ['B', 'C'] }, { dir: 'row', kids: ['D', 'A', 'E'] }] }
873
    : { dir: 'col', kids: [{ dir: 'row', kids: ['A', 'B', 'C'] }, { dir: 'row', kids: ['D', 'E'] }] };
874
  const L = () => { const out = {}; layoutTree(tree(), I, id => view[tagOf[id]], out); return out; };
875
  d.set(L());
876
  d.button('Toggle tag 1 (A, B, C)', () => { view[1] = !view[1]; d.go(L()); });
877
  d.button('Toggle tag 2 (D, E)', () => { view[2] = !view[2]; d.go(L()); });
878
  d.button('Intersperse A between D and E', () => { mixed = !mixed; d.go(L()); });
879
}
880
881
function stackLayouts(I) {
882
  const col = (L, id, ids) => { const r = L[id]; delete L[id]; Object.assign(L, splitY(r, ids)); return L; };
883
  const row = (L, id, ids) => { const r = L[id]; delete L[id]; Object.assign(L, splitX(r, ids)); return L; };
884
  return {
885
    'A | B | C': () => splitX(I, ['A', 'B', 'C']),
886
    'A | (B / C)': () => col(splitX(I, ['A', 'R']), 'R', ['B', 'C']),
887
    '(A / C) | B': () => col(splitX(I, ['L', 'B']), 'L', ['A', 'C']),
888
    '(A / B) | C': () => col(splitX(I, ['L', 'C']), 'L', ['A', 'B']),
889
    'B | C | A': () => splitX(I, ['B', 'C', 'A']),
890
    'A / B / C': () => splitY(I, ['A', 'B', 'C']),
891
    'A / (B | C)': () => row(splitY(I, ['A', 'R']), 'R', ['B', 'C']),
892
    '(A / B) | (C / D)': () => col(col(splitX(I, ['L', 'R']), 'L', ['A', 'B']), 'R', ['C', 'D']),
893
    'A | (C / B / D)': () => col(splitX(I, ['A', 'R']), 'R', ['C', 'B', 'D']),
894
    '(A / D) | (C / B)': () => col(col(splitX(I, ['L', 'R']), 'L', ['A', 'D']), 'R', ['C', 'B']),
895
    '(A / B / C) | D': () => col(splitX(I, ['L', 'D']), 'L', ['A', 'B', 'C']),
896
    '(A / C) | (B / D)': () => col(col(splitX(I, ['L', 'R']), 'L', ['A', 'C']), 'R', ['B', 'D']),
897
  };
898
}
899
900
for (const [id, names] of [
901
  ['d9a', ['A | B | C', 'A | (B / C)', '(A / C) | B', '(A / B) | C', 'B | C | A', 'A / B / C', 'A / (B | C)']],
902
  ['d9b', ['(A / B) | (C / D)', 'A | (C / B / D)', '(A / D) | (C / B)', '(A / B / C) | D', '(A / C) | (B / D)']],
903
]) {
904
  const W = 640, H = 300, d = new Demo(id, W, H), I = inner(W, H), S = stackLayouts(I);
905
  d.set(S[names[0]]());
906
  for (const n of names) d.button(n, () => d.go(S[n]()));
907
}
908
909
{ // d9c grid
910
  const W = 640, H = 300, d = new Demo('d9c', W, H), I = inner(W, H), S = stackLayouts(I);
911
  let at = 0;
912
  const names = ['(A / D) | (C / B)', '(A / B / C) | D'];
913
  d.set(S[names[0]]());
914
  d.button('(A / D) | (C / B)  ⇄  (A / B / C) | D', () => { at = 1 - at; d.go(S[names[at]]()); });
915
}
916
917
{ // d10 floating to tiled
918
  const W = 640, H = 300, d = new Demo('d10', W, H, { floating: ['F'] }), I = inner(W, H);
919
  const FL = R(220, 40, 220, 130);
920
  const modes = ['direct glide', 'slot opens last', 'slot opens first'];
921
  let tiled = false, mode = 0;
922
  const tiledL = () => splitX(I, ['A', 'B', 'F', 'C']);
923
  const floatL = () => ({ ...splitX(I, ['A', 'B', 'C']), F: FL });
924
  const ortho = () => STYLE === 'orthogonal';
925
  const assemble = (n, tiledAt, fAt, kind) => {
926
    const keys = [];
927
    for (let i = 0; i <= n; i++) { const t = tiledAt(i); keys.push({ A: t.A, B: t.B, C: t.C, F: fAt(i) }); }
928
    return { kind, keys, durs: keys.slice(1).map(() => PHASE_MS) };
929
  };
930
  const slotPlan = (from, to, opening) => {
931
    const fromBx = (from.B.x + from.B.w + from.C.x) / 2, toBx = (to.B.x + to.B.w + to.C.x) / 2;
932
    return opening
933
      ? plan({ A: from.A, B: from.B, C: from.C, P: R(fromBx, I.y, 0, I.h) }, { A: to.A, B: to.B, C: to.C, P: to.F })
934
      : plan({ A: from.A, B: from.B, C: from.C, P: from.F }, { A: to.A, B: to.B, C: to.C, P: R(toBx, I.y, 0, I.h) });
935
  };
936
  const at = (tp, i) => tp.keys[Math.max(0, Math.min(i, tp.durs.length))];
937
  const floatIn = from => {
938
    const to = tiledL(), slot = to.F, tp = slotPlan(from, to, true), k = tp.durs.length, f = from.F;
939
    if (modes[mode] === 'direct glide') {
940
      const n = Math.max(1, k);
941
      return assemble(n, i => at(tp, i), i => i === 0 ? f : slot, `glide in, slot ${tp.kind}`);
942
    }
943
    const cx = slot.x + slot.w / 2 - f.w / 2, cy = slot.y + slot.h / 2 - f.h / 2;
944
    const path = ortho() ? [f, R(cx, f.y, f.w, f.h), R(cx, cy, f.w, f.h)] : [f, R(cx, cy, f.w, f.h)];
945
    const travel = path.length - 1;
946
    if (modes[mode] === 'slot opens last') {
947
      const n = travel + Math.max(1, k);
948
      return assemble(n, i => at(tp, i - (n - k)), i => i === n ? slot : path[Math.min(i, travel)], `float in, slot ${tp.kind}`);
949
    }
950
    const n = Math.max(travel, k) + 1;
951
    return assemble(n, i => at(tp, i), i => i === n ? slot : path[Math.min(i, travel)], `float in, slot ${tp.kind}`);
952
  };
953
  const floatOut = from => {
954
    const to = floatL(), slot = from.F, tp = slotPlan(from, to, false), k = tp.durs.length;
955
    if (modes[mode] === 'direct glide') {
956
      const n = Math.max(1, k);
957
      return assemble(n, i => at(tp, i), i => i === 0 ? slot : FL, `glide out, slot ${tp.kind}`);
958
    }
959
    const lift = R(slot.x + slot.w / 2 - FL.w / 2, slot.y + slot.h / 2 - FL.h / 2, FL.w, FL.h);
960
    const path = ortho() ? [slot, lift, R(lift.x, FL.y, FL.w, FL.h), FL] : [slot, lift, FL];
961
    const travel = path.length - 1;
962
    if (modes[mode] === 'slot opens last') {
963
      const n = Math.max(travel, k);
964
      return assemble(n, i => at(tp, i), i => path[Math.min(i, travel)], `lift out, slot ${tp.kind}`);
965
    }
966
    const n = Math.max(travel, k + 1);
967
    return assemble(n, i => at(tp, i - 1), i => path[Math.min(i, travel)], `lift out, slot ${tp.kind}`);
968
  };
969
  d.set(floatL());
970
  d.button('Tile F / float F', () => { tiled = !tiled; d.go(tiled ? tiledL() : floatL(), { planFn: tiled ? floatIn : floatOut }); });
971
  const mb = d.button('mode: ' + modes[0], () => { mode = (mode + 1) % modes.length; mb.textContent = 'mode: ' + modes[mode]; });
972
}
973
974
{ // d11 one tree, hidden slots
975
  const W = 640, H = 320, d = new Demo('d11', W, H), I = inner(W, H);
976
  const tagOf = { A: 1, B: 1, C: 1, D: 2, E: 2 };
977
  const view = { 1: true, 2: true };
978
  let tree, ghosts = false;
979
  const reset = () => { tree = { dir: 'col', kids: [{ dir: 'row', kids: ['A', 'D', 'B'] }, { dir: 'row', kids: ['C', 'E'] }] }; view[2] = true; };
980
  reset();
981
  const L = () => { const o = {}; layoutTree(tree, I, id => view[tagOf[id]], o); return o; };
982
  const parentOf = (node, id, par = null) => {
983
    if (typeof node === 'string') return node === id ? par : null;
984
    for (const k of node.kids) { const r = parentOf(k, id, node); if (r) return r; }
985
    return null;
986
  };
987
  const collapse = node => {
988
    if (typeof node === 'string') return node;
989
    node.kids = node.kids.map(collapse).filter(k => typeof k === 'string' || k.kids.length);
990
    return node.kids.length === 1 ? node.kids[0] : node;
991
  };
992
  const swap = (a, b) => { const pa = parentOf(tree, a), pb = parentOf(tree, b); const ia = pa.kids.indexOf(a), ib = pb.kids.indexOf(b); pa.kids[ia] = b; pb.kids[ib] = a; };
993
  const moveAfter = (id, target) => {
994
    const p = parentOf(tree, id); p.kids.splice(p.kids.indexOf(id), 1);
995
    tree = collapse(tree);
996
    const pt = parentOf(tree, target); pt.kids.splice(pt.kids.indexOf(target) + 1, 0, id);
997
  };
998
  d.overlay = () => {
999
    if (!ghosts) return [];
1000
    const full = {}; layoutTree(tree, I, () => true, full);
1001
    return Object.keys(full).filter(id => !view[tagOf[id]]).map(id => ({ ...full[id], id }));
1002
  };
1003
  d.set(L());
1004
  d.button('Hide / show tag 2 (D, E)', () => { view[2] = !view[2]; d.go(L()); });
1005
  d.button('Swap A and B', () => { swap('A', 'B'); d.go(L()); });
1006
  d.button('Move C next to B', () => { if (parentOf(tree, 'C') !== parentOf(tree, 'B')) { moveAfter('C', 'B'); d.go(L()); } });
1007
  d.button('Reset', () => { reset(); d.go(L()); });
1008
  d.check('show hidden slots', false, v => ghosts = v);
1009
}
1010
1011
{ // d12 lane extent
1012
  const W = 640, H = 320, d = new Demo('d12', W, H), I = inner(W, H);
1013
  let order = ['A', 'B', 'C'], whole = false;
1014
  const L = () => { const r = splitY(I, ['top', 'bot'], [7, 3]); return { T: r.top, ...splitX(r.bot, order) }; };
1015
  d.set(L());
1016
  d.button('Reverse bottom row', () => {
1017
    order = [...order].reverse();
1018
    const tgt = L();
1019
    d.go(tgt, { planFn: from => { LANE_REGION = whole ? I : null; try { return plan(from, tgt); } finally { LANE_REGION = null; } } });
1020
  });
1021
  const mb = d.button('lanes span: involved band', () => { whole = !whole; mb.textContent = 'lanes span: ' + (whole ? 'whole output' : 'involved band'); });
1022
}
1023
1024
{ // d13 fullscreen
1025
  const W = 640, H = 300, d = new Demo('d13', W, H, { floating: ['B'] }), I = inner(W, H);
1026
  let full = false;
1027
  const L = () => { const o = splitX(I, ['A', 'B', 'C']); if (full) o.B = R(0, 0, W, H); return o; };
1028
  d.set(L());
1029
  d.button('Fullscreen B', () => {
1030
    full = !full;
1031
    const tgt = L();
1032
    d.go(tgt, { planFn: from => ({ kind: full ? 'glide to fullscreen' : 'glide back to slot', keys: [from, tgt], durs: [PHASE_MS] }) });
1033
  });
1034
}
1035
1036
{ // d14 expand
1037
  const W = 640, H = 300, d = new Demo('d14', W, H), I = inner(W, H);
1038
  let col = false, root = false;
1039
  const L = () => {
1040
    const cols = splitX(I, ['A', 'R']);
1041
    const area = root ? I : cols.R;
1042
    const o = root ? { A: R(I.x, I.y, 0, I.h) } : { A: cols.A };
1043
    if (col) Object.assign(o, { B: R(area.x, area.y, area.w, 0), C: area, D: R(area.x, area.y + area.h, area.w, 0) });
1044
    else Object.assign(o, splitY(area, ['B', 'C', 'D']));
1045
    return o;
1046
  };
1047
  d.set(L());
1048
  d.button('Expand C in its column', () => { col = !col; d.go(L()); });
1049
  d.button('Expand the column to the output', () => { root = !root; d.go(L()); });
1050
  d.button('Expand C all the way', () => { const on = !(col && root); col = on; root = on; d.go(L()); });
1051
}
1052
1053
document.getElementById('phaseMs').oninput = e => { PHASE_MS = +e.target.value; document.getElementById('phaseMsOut').textContent = PHASE_MS + ' ms'; };
1054
document.getElementById('style').onchange = e => { STYLE = e.target.value; };
1055
const loop = now => { for (const d of demos) d.frame(now); requestAnimationFrame(loop); };
1056
requestAnimationFrame(loop);
1057
</script>
1058
</body>
1059
</html>