atagen

monolith

public
Star0
Watch0
e44abf08e08def8e65b2fbf3d27a6969ef1a77f4
1
ssh://brewery@pls.lobotomise.me:33/atagen/monolith.git
design/planner.htmlHtml1058 lines59.5 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 3</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
<p>Nothing. Lanes span the involved band, and the hidden-slot rules stand.</p>
244
245
</main>
246
247
<script id="planner">
248
const G = 10;
249
const EPS = 0.5;
250
let PHASE_MS = 450;
251
let STYLE = 'orthogonal';
252
let LANE_REGION = null;
253
254
const R = (x, y, w, h) => ({ x, y, w, h });
255
const clone = L => { const o = {}; for (const k in L) o[k] = { ...L[k] }; return o; };
256
const eq = (a, b) => Math.abs(a - b) < 0.05;
257
const lerp = (a, b, s) => a + (b - a) * s;
258
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));
259
260
function splitX(rect, ids, ratios) {
261
  ratios = ratios || ids.map(() => 1);
262
  const tot = ratios.reduce((a, b) => a + b, 0);
263
  const avail = rect.w - G * (ids.length - 1);
264
  let x = rect.x;
265
  const out = {};
266
  ids.forEach((id, i) => { const w = avail * ratios[i] / tot; out[id] = R(x, rect.y, w, rect.h); x += w + G; });
267
  return out;
268
}
269
function splitY(rect, ids, ratios) {
270
  ratios = ratios || ids.map(() => 1);
271
  const tot = ratios.reduce((a, b) => a + b, 0);
272
  const avail = rect.h - G * (ids.length - 1);
273
  let y = rect.y;
274
  const out = {};
275
  ids.forEach((id, i) => { const h = avail * ratios[i] / tot; out[id] = R(rect.x, y, rect.w, h); y += h + G; });
276
  return out;
277
}
278
279
function relBits(a, b) {
280
  let s = 0;
281
  if (a.x + a.w <= b.x + EPS) s |= 1;
282
  if (b.x + b.w <= a.x + EPS) s |= 2;
283
  if (a.y + a.h <= b.y + EPS) s |= 4;
284
  if (b.y + b.h <= a.y + EPS) s |= 8;
285
  return s;
286
}
287
function phaseOk(K1, K2, list) {
288
  for (let i = 0; i < list.length; i++)
289
    for (let j = i + 1; j < list.length; j++) {
290
      const a = list[i], b = list[j];
291
      if ((relBits(K1[a], K1[b]) & relBits(K2[a], K2[b])) === 0) return false;
292
    }
293
  return true;
294
}
295
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)) }; }
296
function pureOk(keys) {
297
  if (STYLE !== 'orthogonal') return true;
298
  for (let k = 0; k + 1 < keys.length; k++)
299
    for (const id in keys[k + 1]) { const c = chg(keys[k][id], keys[k + 1][id]); if (c.x && c.y) return false; }
300
  return true;
301
}
302
303
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(';'); }
304
305
function plan(from, to, opts = {}) {
306
  if (opts.naive) return { kind: 'naive lerp', keys: [from, to], durs: [PHASE_MS] };
307
  if (signature(from) <= signature(to)) return planOneWay(from, to);
308
  const p = planOneWay(to, from);
309
  return { kind: p.kind, keys: [...p.keys].reverse(), durs: [...p.durs].reverse() };
310
}
311
312
function planOneWay(from, to) {
313
  const list = Object.keys(to);
314
  const T = PHASE_MS;
315
  const movers = list.filter(id => { const c = chg(from[id], to[id]); return c.x || c.y; });
316
  if (!movers.length) return { kind: 'no-op', keys: [from, to], durs: [0] };
317
  const pure = movers.every(id => { const c = chg(from[id], to[id]); return !(c.x && c.y); });
318
  if (phaseOk(from, to, list) && (STYLE === 'direct' || pure))
319
    return { kind: '1 phase', keys: [from, to], durs: [T] };
320
  const two = axisOrder(from, to, list, movers);
321
  if (two) return two;
322
  for (const ax of ['y', 'x']) { const l = lanes(from, to, list, movers, ax); if (l) return l; }
323
  const g = grid(from, to, list, movers);
324
  if (g) return g;
325
  return pinch(from, to, movers);
326
}
327
328
function conflictSet(from, to, list) {
329
  const out = new Set();
330
  for (let i = 0; i < list.length; i++)
331
    for (let j = i + 1; j < list.length; j++) {
332
      const a = list[i], b = list[j];
333
      if ((relBits(from[a], from[b]) & relBits(to[a], to[b])) === 0) { out.add(a); out.add(b); }
334
    }
335
  return [...out];
336
}
337
338
function grid(from, to, list, movers) {
339
  const cx = r => r.x + r.w / 2, cy = r => r.y + r.h / 2;
340
  for (const set of [conflictSet(from, to, list), movers, list]) {
341
    const n = set.length;
342
    if (n < 2) continue;
343
    let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
344
    for (const id of set) for (const r of [from[id], to[id]]) {
345
      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);
346
    }
347
    const sh = (y1 - y0 - (n - 1) * G) / n, sw = (x1 - x0 - (n - 1) * G) / n;
348
    const rowOf = {}, colOf = {};
349
    [...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));
350
    [...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));
351
    const inSet = new Set(set);
352
    for (const phase of [3, 2, 4, 1]) {
353
      const K = [{}, {}, {}];
354
      for (const id of list) {
355
        const f = from[id], t = to[id];
356
        if (inSet.has(id)) {
357
          K[0][id] = R(f.x, rowOf[id], f.w, sh);
358
          K[1][id] = R(colOf[id], rowOf[id], sw, sh);
359
          K[2][id] = R(colOf[id], t.y, sw, t.h);
360
        } else for (let k = 0; k < 3; k++) K[k][id] = k + 1 >= phase ? t : f;
361
      }
362
      const keys = [from, ...K, to];
363
      let ok = true;
364
      for (let k = 0; k < 4 && ok; k++) ok = phaseOk(keys[k], keys[k + 1], list);
365
      if (ok && pureOk(keys)) return { kind: `4 phases (${n}×${n} grid)`, keys, durs: [PHASE_MS, PHASE_MS, PHASE_MS, PHASE_MS] };
366
    }
367
  }
368
  return null;
369
}
370
371
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); }
372
373
function adjBits(a, b) {
374
  const ovX = a.x < b.x + b.w + EPS && b.x < a.x + a.w + EPS;
375
  const ovY = a.y < b.y + b.h + EPS && b.y < a.y + a.h + EPS;
376
  let s = 0;
377
  if (ovY && Math.abs(b.x - (a.x + a.w)) <= G + EPS) s |= 1;
378
  if (ovY && Math.abs(a.x - (b.x + b.w)) <= G + EPS) s |= 2;
379
  if (ovX && Math.abs(b.y - (a.y + a.h)) <= G + EPS) s |= 4;
380
  if (ovX && Math.abs(a.y - (b.y + b.h)) <= G + EPS) s |= 8;
381
  return s;
382
}
383
function coupledEdges(from, to, list) {
384
  const out = [];
385
  for (let i = 0; i < list.length; i++)
386
    for (let j = i + 1; j < list.length; j++) {
387
      const a = list[i], b = list[j], s = adjBits(from[a], from[b]) & adjBits(to[a], to[b]);
388
      if (s) out.push([a, b, s]);
389
    }
390
  return out;
391
}
392
function holes(K, coupled) {
393
  let n = 0;
394
  for (const [a, b, s] of coupled) if ((adjBits(K[a], K[b]) & s) !== s) n++;
395
  return n;
396
}
397
398
function axisOrder(from, to, list, movers) {
399
  const n = movers.length;
400
  if (n > 14) return null;
401
  const coupled = coupledEdges(from, to, list);
402
  let best = null, bestScore = Infinity;
403
  for (let mask = 0; mask < (1 << n); mask++) {
404
    const K1 = { ...from };
405
    let late = 0;
406
    movers.forEach((id, i) => {
407
      const xf = !!(mask & (1 << i));
408
      const c = chg(from[id], to[id]);
409
      K1[id] = mixed(from[id], to[id], xf);
410
      if (c.x && !c.y && !xf) late++;
411
      if (c.y && !c.x && xf) late++;
412
    });
413
    if (late >= bestScore) continue;
414
    if (!phaseOk(from, K1, list) || !phaseOk(K1, to, list)) continue;
415
    const score = holes(K1, coupled) * 1000 + late;
416
    if (score < bestScore) { best = K1; bestScore = score; if (score === 0) break; }
417
  }
418
  return best ? { kind: '2 phases (axis order)' + (bestScore >= 1000 ? ', opens a hole' : ''), keys: [from, best, to], durs: [PHASE_MS, PHASE_MS] } : null;
419
}
420
421
function lanes(from, to, list, movers, ax) {
422
  const size = ax === 'y' ? 'h' : 'w';
423
  const bitsT = ax === 'y' ? 3 : 12;
424
  const before = ax === 'y' ? 4 : 1, after = ax === 'y' ? 8 : 2;
425
  const conflict = new Set();
426
  for (let i = 0; i < list.length; i++)
427
    for (let j = i + 1; j < list.length; j++) {
428
      const a = list[i], b = list[j];
429
      if ((relBits(from[a], from[b]) & relBits(to[a], to[b])) === 0) { conflict.add(a); conflict.add(b); }
430
    }
431
  const sets = [[...conflict], movers, list];
432
  for (const set of sets) {
433
    const n = set.length;
434
    if (n < 2) continue;
435
    let lo = Infinity, hi = -Infinity;
436
    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]); }
437
    if (LANE_REGION) { lo = LANE_REGION[ax]; hi = LANE_REGION[ax] + LANE_REGION[size]; }
438
    const diff = [], lt = [];
439
    for (let i = 0; i < n; i++) { diff.push(new Array(n).fill(false)); lt.push(new Array(n).fill(false)); }
440
    for (let i = 0; i < n; i++)
441
      for (let j = 0; j < n; j++) {
442
        if (i === j) continue;
443
        const f = relBits(from[set[i]], from[set[j]]), t = relBits(to[set[i]], to[set[j]]);
444
        if (((f & bitsT) & (t & bitsT)) === 0) diff[i][j] = true;
445
        for (const r of [f, t]) if ((r & bitsT) === 0 && (r & before)) lt[i][j] = true;
446
      }
447
    for (let k = 2; k <= n; k++) {
448
      const s = (hi - lo - (k - 1) * G) / k;
449
      if (s < 6) break;
450
      const lane = new Array(n).fill(-1);
451
      const assign = i => {
452
        if (i === n) return true;
453
        for (let l = 0; l < k; l++) {
454
          let ok = true;
455
          for (let j = 0; j < i && ok; j++) {
456
            if (diff[i][j] && lane[j] === l) ok = false;
457
            if (lt[i][j] && !(l < lane[j])) ok = false;
458
            if (lt[j][i] && !(lane[j] < l)) ok = false;
459
          }
460
          if (ok) { lane[i] = l; if (assign(i + 1)) return true; lane[i] = -1; }
461
        }
462
        return false;
463
      };
464
      if (!assign(0)) continue;
465
      const strip = l => lo + l * (s + G);
466
      const inSet = new Set(set);
467
      for (const phase of [2, 1, 3]) {
468
        const K1 = {}, K2 = {};
469
        for (const id of list) {
470
          const f = from[id], t = to[id];
471
          if (inSet.has(id)) {
472
            const l = lane[set.indexOf(id)];
473
            K1[id] = ax === 'y' ? R(f.x, strip(l), f.w, s) : R(strip(l), f.y, s, f.h);
474
            K2[id] = ax === 'y' ? R(t.x, strip(l), t.w, s) : R(strip(l), t.y, s, t.h);
475
          } else if (phase === 1) { K1[id] = t; K2[id] = t; }
476
          else if (phase === 2) { K1[id] = f; K2[id] = t; }
477
          else { K1[id] = f; K2[id] = f; }
478
        }
479
        if (phaseOk(from, K1, list) && phaseOk(K1, K2, list) && phaseOk(K2, to, list) && pureOk([from, K1, K2, to]))
480
          return { kind: `3 phases (${k} lanes)`, keys: [from, K1, K2, to], durs: [PHASE_MS, PHASE_MS, PHASE_MS] };
481
      }
482
    }
483
  }
484
  return null;
485
}
486
487
function pinch(from, to, movers) {
488
  const K1 = { ...from }, K2 = { ...to };
489
  for (const id of movers) {
490
    const f = from[id], t = to[id];
491
    K1[id] = R(f.x + f.w / 2, f.y + f.h / 2, 0, 0);
492
    K2[id] = R(t.x + t.w / 2, t.y + t.h / 2, 0, 0);
493
  }
494
  return { kind: 'pinch fallback', keys: [from, K1, K2, to], durs: [PHASE_MS, 0, PHASE_MS] };
495
}
496
497
function bbox(rects) {
498
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
499
  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); }
500
  return R(x0, y0, x1 - x0, y1 - y0);
501
}
502
function planBlocks(from, to, groups) {
503
  const bf = {}, bt = {};
504
  for (const b in groups) { bf[b] = bbox(groups[b].map(id => from[id])); bt[b] = bbox(groups[b].map(id => to[id])); }
505
  const bp = plan(bf, bt);
506
  const last = bp.keys.length - 1;
507
  const keys = bp.keys.map((K, ki) => {
508
    if (ki === 0) return from;
509
    if (ki === last) return to;
510
    const L = { ...from };
511
    for (const b in groups) for (const id of groups[b]) {
512
      const r = from[id], box = bf[b], k = K[b];
513
      const nx = box.w ? (r.x - box.x) / box.w : 0, ny = box.h ? (r.y - box.y) / box.h : 0;
514
      const nw = box.w ? r.w / box.w : 0, nh = box.h ? r.h / box.h : 0;
515
      L[id] = R(k.x + nx * k.w, k.y + ny * k.h, nw * k.w, nh * k.h);
516
    }
517
    return L;
518
  });
519
  return { kind: bp.kind + ' on blocks', keys, durs: bp.durs };
520
}
521
522
function bezier(x1, y1, x2, y2) {
523
  const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx;
524
  const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
525
  const X = u => ((ax * u + bx) * u + cx) * u, Y = u => ((ay * u + by) * u + cy) * u;
526
  const dX = u => (3 * ax * u + 2 * bx) * u + cx;
527
  return t => {
528
    if (t <= 0) return 0;
529
    if (t >= 1) return 1;
530
    let u = t;
531
    for (let i = 0; i < 8; i++) {
532
      const x = X(u) - t;
533
      if (Math.abs(x) < 1e-6) return Y(u);
534
      const d = dX(u);
535
      if (Math.abs(d) < 1e-6) break;
536
      u -= x / d;
537
    }
538
    let lo = 0, hi = 1; u = t;
539
    for (let i = 0; i < 40; i++) { if (X(u) < t) lo = u; else hi = u; u = (lo + hi) / 2; }
540
    return Y(u);
541
  };
542
}
543
const BASE = bezier(0.45, 0, 0.2, 1);
544
const edges = r => [r.x, r.x + r.w, r.y, r.y + r.h];
545
</script>
546
547
<script>
548
const PAL = { T: '#9aa3b5', A: '#e0774a', B: '#4aa3e0', C: '#7bc96f', D: '#c77be0', E: '#e0c24a', F: '#4ae0c2' };
549
const demos = [];
550
551
class Demo {
552
  constructor(id, W, H, opts = {}) {
553
    this.W = W; this.H = H;
554
    this.outputs = opts.outputs || [R(0, 0, W, H)];
555
    this.floating = new Set(opts.floating || []);
556
    const el = document.getElementById(id);
557
    this.canvas = document.createElement('canvas');
558
    const dpr = window.devicePixelRatio || 1;
559
    this.canvas.width = W * dpr; this.canvas.height = H * dpr;
560
    this.canvas.style.maxWidth = W + 'px';
561
    this.ctx = this.canvas.getContext('2d');
562
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
563
    el.appendChild(this.canvas);
564
    if (opts.graph) {
565
      this.graph = document.createElement('canvas');
566
      this.graph.width = W * dpr; this.graph.height = 90 * dpr;
567
      this.graph.style.maxWidth = W + 'px'; this.graph.style.marginTop = '8px';
568
      this.gctx = this.graph.getContext('2d');
569
      this.gctx.setTransform(dpr, 0, 0, dpr, 0, 0);
570
      el.appendChild(this.graph);
571
      this.hist = [];
572
    }
573
    this.controls = document.createElement('div'); this.controls.className = 'controls'; el.appendChild(this.controls);
574
    this.status = document.createElement('div'); this.status.className = 'status'; el.appendChild(this.status);
575
    this.anim = null; this.prev = null; this.vel = {}; this.naive = false; this.vmatch = true; this.lastKind = 'idle';
576
    demos.push(this);
577
  }
578
  button(label, fn) { const b = document.createElement('button'); b.textContent = label; b.onclick = fn; this.controls.appendChild(b); return b; }
579
  check(label, init, fn) {
580
    const l = document.createElement('label'); const c = document.createElement('input'); c.type = 'checkbox'; c.checked = init;
581
    c.onchange = () => fn(c.checked); l.appendChild(c); l.appendChild(document.createTextNode(label)); this.controls.appendChild(l); return c;
582
  }
583
  set(L) { this.layout = clone(L); this.anim = null; }
584
  go(target, opts = {}) {
585
    const now = performance.now();
586
    const cur = this.sampleAt(now);
587
    const from = {};
588
    for (const id in target) from[id] = cur[id] || target[id];
589
    const p = opts.planFn ? opts.planFn(from) : opts.blocks ? planBlocks(from, target, opts.blocks) : plan(from, target, { naive: this.naive });
590
    const curves = p.durs.map(() => BASE);
591
    const interrupted = !!this.anim;
592
    let matched = false;
593
    if (this.vmatch && interrupted && p.durs[0] > 0 && p.keys.length > 1) {
594
      const K1 = p.keys[1];
595
      let best = null, bs = 0;
596
      for (const id in K1) {
597
        const v = this.vel[id]; if (!v) continue;
598
        const D = edges(K1[id]).map((e, i) => e - edges(from[id])[i]);
599
        const dd = D.reduce((a, b) => a + b * b, 0); if (dd < 1) continue;
600
        const sp = Math.hypot(...v);
601
        if (sp > bs) { bs = sp; best = { v, D, dd }; }
602
      }
603
      if (best) {
604
        const s = best.v.reduce((a, b, i) => a + b * best.D[i], 0) / best.dd;
605
        const slope = s * p.durs[0];
606
        if (slope > 0.05) { const x1 = 0.3; curves[0] = bezier(x1, Math.min(1, x1 * slope), 0.2, 1); matched = true; }
607
      }
608
    }
609
    this.anim = { keys: p.keys, durs: p.durs, curves, start: now };
610
    this.lastKind = p.kind + (interrupted ? ', replanned' : '') + (matched ? ', velocity matched' : '');
611
    this.overlapSeen = false;
612
  }
613
  sampleAt(t) {
614
    if (!this.anim) return clone(this.layout);
615
    let e = t - this.anim.start;
616
    const { keys, durs, curves } = this.anim;
617
    for (let i = 0; i < durs.length; i++) {
618
      const d = durs[i];
619
      if (e < d) {
620
        this.phase = i;
621
        const s = curves[i](e / d);
622
        const L = {};
623
        for (const id in keys[i + 1]) L[id] = lerpRect(keys[i][id], keys[i + 1][id], s);
624
        return L;
625
      }
626
      e -= d;
627
    }
628
    this.layout = clone(keys[keys.length - 1]);
629
    this.anim = null;
630
    return clone(this.layout);
631
  }
632
  frame(now) {
633
    const L = this.sampleAt(now);
634
    if (this.prev) {
635
      const dt = now - this.prev.t;
636
      if (dt > 0) for (const id in L) if (this.prev.L[id]) {
637
        const a = edges(this.prev.L[id]), b = edges(L[id]);
638
        this.vel[id] = b.map((v, i) => (v - a[i]) / dt);
639
      }
640
    }
641
    this.prev = { t: now, L };
642
    this.draw(L);
643
    const overlaps = this.findOverlaps(L);
644
    if (overlaps.length) this.overlapSeen = true;
645
    const phaseTxt = this.anim ? ` · phase ${this.phase + 1}/${this.anim.durs.length}` : '';
646
    this.status.innerHTML = `${this.lastKind}${phaseTxt}` + (this.overlapSeen ? ' · <span class="bad">overlap</span>' : '');
647
    if (this.graph) this.drawGraph(now);
648
  }
649
  findOverlaps(L) {
650
    const ids = Object.keys(L).filter(id => L[id].w > 0.5 && L[id].h > 0.5 && !this.floating.has(id));
651
    const out = [];
652
    for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) {
653
      const a = L[ids[i]], b = L[ids[j]];
654
      const x0 = Math.max(a.x, b.x), y0 = Math.max(a.y, b.y);
655
      const x1 = Math.min(a.x + a.w, b.x + b.w), y1 = Math.min(a.y + a.h, b.y + b.h);
656
      if (x1 - x0 > 1 && y1 - y0 > 1) out.push(R(x0, y0, x1 - x0, y1 - y0));
657
    }
658
    return out;
659
  }
660
  draw(L) {
661
    const c = this.ctx;
662
    c.clearRect(0, 0, this.W, this.H);
663
    const overlaps = this.findOverlaps(L);
664
    for (const o of this.outputs) {
665
      c.save();
666
      c.fillStyle = '#1b1e25'; c.fillRect(o.x, o.y, o.w, o.h);
667
      c.beginPath(); c.rect(o.x, o.y, o.w, o.h); c.clip();
668
      for (const id of Object.keys(L).sort((a, b) => this.floating.has(a) - this.floating.has(b))) {
669
        const r = L[id];
670
        if (r.w < 0.5 || r.h < 0.5) continue;
671
        const rad = Math.min(8, r.w / 2, r.h / 2);
672
        c.beginPath(); c.roundRect(r.x, r.y, r.w, r.h, rad);
673
        c.fillStyle = (PAL[id] || '#888') + 'cc'; c.fill();
674
        c.strokeStyle = PAL[id] || '#888'; c.lineWidth = 1.5; c.stroke();
675
        if (r.w > 22 && r.h > 22) {
676
          c.fillStyle = '#0d0f12'; c.font = '600 16px system-ui'; c.textAlign = 'center'; c.textBaseline = 'middle';
677
          c.fillText(id, r.x + r.w / 2, r.y + r.h / 2);
678
        }
679
      }
680
      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); }
681
      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); }
682
      c.restore();
683
    }
684
  }
685
  drawGraph(now) {
686
    const v = this.vel.A;
687
    const sp = v ? Math.hypot(v[0] + v[1], v[2] + v[3]) * 1000 / 2 : 0;
688
    this.hist.push([now, sp]);
689
    while (this.hist.length && now - this.hist[0][0] > 4000) this.hist.shift();
690
    const c = this.gctx, W = this.W, H = 90;
691
    c.clearRect(0, 0, W, H);
692
    c.fillStyle = '#0d0f12'; c.fillRect(0, 0, W, H);
693
    const max = Math.max(400, ...this.hist.map(h => h[1]));
694
    c.beginPath();
695
    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); });
696
    c.strokeStyle = PAL.A; c.lineWidth = 2; c.stroke();
697
    c.fillStyle = '#8a909c'; c.font = '12px system-ui'; c.textAlign = 'left'; c.fillText('speed of window A, last 4 s', 8, 14);
698
  }
699
}
700
701
const inner = (W, H, ox = 0) => R(ox + G, G, W - 2 * G, H - 2 * G);
702
703
{ // d1 swap
704
  const W = 640, H = 260, d = new Demo('d1', W, H), I = inner(W, H);
705
  let swapped = false;
706
  const L = () => splitX(I, swapped ? ['B', 'A'] : ['A', 'B']);
707
  d.set(L());
708
  d.button('Swap A and B', () => { swapped = !swapped; d.go(L()); });
709
  d.check('naive', false, v => d.naive = v);
710
}
711
712
{ // d2 one phase
713
  const W = 640, H = 300, d = new Demo('d2', W, H), I = inner(W, H);
714
  const s = { wide: false, d: false, e: false };
715
  const modes = ['line on old boundary', 'point on old boundary', 'target centre'];
716
  let mode = 0;
717
  const ratios = ids => ids.map(id => (id === 'A' && s.wide) ? 2 : 1);
718
  const L = () => {
719
    const ids = s.d ? ['A', 'B', 'D', 'C'] : ['A', 'B', 'C'];
720
    const out = splitX(I, ids, ratios(ids));
721
    if (!s.d) {
722
      const bx = (out.B.x + out.B.w + out.C.x) / 2;
723
      const T = splitX(I, ['A', 'B', 'D', 'C'], ratios(['A', 'B', 'D', 'C'])).D;
724
      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];
725
    }
726
    const b = out.B;
727
    if (s.e) Object.assign(out, splitY(b, ['B', 'E'])); else out.E = R(b.x, b.y + b.h, b.w, 0);
728
    return out;
729
  };
730
  d.set(L());
731
  d.button('Widen A', () => { s.wide = !s.wide; d.go(L()); });
732
  d.button('Insert / remove D', () => { s.d = !s.d; d.go(L()); });
733
  d.button('Split B with E', () => { s.e = !s.e; d.go(L()); });
734
  d.button('All at once', () => { s.wide = !s.wide; s.d = !s.d; s.e = !s.e; d.go(L()); });
735
  const mb = d.button('seed: ' + modes[0], () => {
736
    mode = (mode + 1) % modes.length; mb.textContent = 'seed: ' + modes[mode];
737
    if (!s.d && !d.anim) d.set(L());
738
  });
739
  d.check('naive', false, v => d.naive = v);
740
}
741
function tagLayout(I, view2, order) {
742
  const L = {};
743
  if (view2) {
744
    const rows = splitY(I, order);
745
    Object.assign(L, splitX(rows.t1, ['A', 'B', 'C']), splitX(rows.t2, ['D', 'E']));
746
  } else {
747
    Object.assign(L, splitX(I, ['A', 'B', 'C']));
748
    const y = order[0] === 't2' ? I.y : I.y + I.h;
749
    Object.assign(L, splitX(R(I.x, y, I.w, 0), ['D', 'E']));
750
  }
751
  return L;
752
}
753
754
{ // d3 tag toggle
755
  const W = 640, H = 320, d = new Demo('d3', W, H), I = inner(W, H);
756
  let view2 = false;
757
  d.set(tagLayout(I, view2, ['t1', 't2']));
758
  d.button('View / hide tag 2 (D, E)', () => { view2 = !view2; d.go(tagLayout(I, view2, ['t1', 't2'])); });
759
}
760
761
{ // d3b tag reorder
762
  const W = 640, H = 320, d = new Demo('d3b', W, H), I = inner(W, H);
763
  let order = ['t1', 't2'], blocks = true;
764
  d.set(tagLayout(I, true, order));
765
  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'] } } : {}); });
766
  d.check('rigid blocks', true, v => blocks = v);
767
}
768
769
{ // d4a orientation flip
770
  const W = 640, H = 300, d = new Demo('d4a', W, H), I = inner(W, H);
771
  let rows = false;
772
  const L = () => rows ? splitY(I, ['A', 'B', 'C']) : splitX(I, ['A', 'B', 'C']);
773
  d.set(L());
774
  d.button('Flip orientation', () => { rows = !rows; d.go(L()); });
775
  d.check('naive', false, v => d.naive = v);
776
}
777
778
{ // d4b grow both axes
779
  const W = 640, H = 300, d = new Demo('d4b', W, H), I = inner(W, H);
780
  let wide = false;
781
  const L = () => {
782
    const r = splitY(I, ['top', 'bot'], wide ? [7, 3] : [1, 1]);
783
    return { ...splitX(r.top, ['A', 'B'], wide ? [7, 3] : [1, 1]), ...splitX(r.bot, ['C', 'D']) };
784
  };
785
  d.set(L());
786
  d.button('Grow A in both directions', () => { wide = !wide; d.go(L()); });
787
}
788
789
{ // d5 lanes
790
  const W = 640, H = 300, d = new Demo('d5', W, H), I = inner(W, H);
791
  let order = ['A', 'B', 'C', 'D'];
792
  const L = () => splitX(I, order);
793
  d.set(L());
794
  d.button('Swap first two', () => { order = [order[1], order[0], ...order.slice(2)]; d.go(L()); });
795
  d.button('Rotate', () => { order = [...order.slice(1), order[0]]; d.go(L()); });
796
  d.button('Reverse', () => { order = [...order].reverse(); d.go(L()); });
797
  d.button('Reset', () => { order = ['A', 'B', 'C', 'D']; d.go(L()); });
798
}
799
800
{ // d6 interrupts
801
  const W = 640, H = 260, d = new Demo('d6', W, H, { graph: true }), I = inner(W, H);
802
  const names = ["A B C", "C A B", "A wide", "B C A", "close B"];
803
  const targets = [
804
    () => splitX(I, ['A', 'B', 'C']),
805
    () => splitX(I, ['C', 'A', 'B']),
806
    () => splitX(I, ['A', 'B', 'C'], [3, 1, 1]),
807
    () => splitX(I, ['B', 'C', 'A']),
808
    () => ({ ...splitX(I, ['A', 'C'], [2, 1]), B: R(I.x + I.w * 0.66, I.y + I.h, I.w * 0.34, 0) }),
809
  ];
810
  const withB = L => { if (!L.B) L.B = R(I.x, I.y, 0, 0); return L; };
811
  let idx = 0;
812
  d.set(withB(targets[0]()));
813
  const next = () => { idx = (idx + 1) % targets.length; d.go(withB(targets[idx]())); d.lastKind += ` · target "${names[idx]}"`; };
814
  d.button('Next target', next);
815
  d.button('Storm (8 rapid interrupts)', () => {
816
    let n = 0;
817
    const fire = () => { next(); if (++n < 8) setTimeout(fire, 180 + Math.random() * 300); };
818
    fire();
819
  });
820
  d.check('match velocity', true, v => d.vmatch = v);
821
}
822
823
{ // d7 across outputs
824
  const W = 680, H = 260;
825
  const o1 = R(0, 0, 320, H), o2 = R(360, 0, 320, H);
826
  const d = new Demo('d7', W, H, { outputs: [o1, o2] });
827
  const I1 = inner(320, H, 0), I2 = inner(320, H, 360);
828
  let s1 = ['A', 'B'], s2 = ['C'];
829
  const L = () => ({ ...splitX(I1, s1), ...splitX(I2, s2) });
830
  d.set(L());
831
  d.button('Move B across', () => {
832
    if (s1.includes('B')) { s1 = s1.filter(x => x !== 'B'); s2 = ['B', ...s2]; } else { s2 = s2.filter(x => x !== 'B'); s1 = [...s1, 'B']; }
833
    d.go(L());
834
  });
835
  d.button('Move A across', () => {
836
    if (s1.includes('A')) { s1 = s1.filter(x => x !== 'A'); s2 = ['A', ...s2]; } else { s2 = s2.filter(x => x !== 'A'); s1 = ['A', ...s1]; }
837
    d.go(L());
838
  });
839
  d.button('Reset', () => { s1 = ['A', 'B']; s2 = ['C']; d.go(L()); });
840
}
841
842
function layoutTree(node, rect, visible, out) {
843
  if (typeof node === 'string') { out[node] = visible(node) ? rect : R(rect.x, rect.y, 0, 0); return; }
844
  const horiz = node.dir === 'row';
845
  const shown = node.kids.map(k => anyVisible(k, visible));
846
  const m = shown.filter(Boolean).length;
847
  const len = horiz ? rect.w : rect.h;
848
  const avail = len - G * Math.max(0, m - 1);
849
  let pos = horiz ? rect.x : rect.y, seen = 0;
850
  node.kids.forEach((k, i) => {
851
    if (!shown[i]) {
852
      const p = seen ? pos - G / 2 : pos;
853
      const r = horiz ? R(p, rect.y, 0, rect.h) : R(rect.x, p, rect.w, 0);
854
      layoutTree(k, r, () => false, out);
855
      return;
856
    }
857
    const size = avail / m;
858
    const r = horiz ? R(pos, rect.y, size, rect.h) : R(rect.x, pos, rect.w, size);
859
    layoutTree(k, r, visible, out);
860
    pos += size + G; seen++;
861
  });
862
}
863
function anyVisible(node, visible) { return typeof node === 'string' ? visible(node) : node.kids.some(k => anyVisible(k, visible)); }
864
865
{ // d8 one tree, tags filter
866
  const W = 640, H = 320, d = new Demo('d8', W, H), I = inner(W, H);
867
  const tagOf = { A: 1, B: 1, C: 1, D: 2, E: 2 };
868
  const view = { 1: true, 2: true };
869
  let mixed = false;
870
  const tree = () => mixed
871
    ? { dir: 'col', kids: [{ dir: 'row', kids: ['B', 'C'] }, { dir: 'row', kids: ['D', 'A', 'E'] }] }
872
    : { dir: 'col', kids: [{ dir: 'row', kids: ['A', 'B', 'C'] }, { dir: 'row', kids: ['D', 'E'] }] };
873
  const L = () => { const out = {}; layoutTree(tree(), I, id => view[tagOf[id]], out); return out; };
874
  d.set(L());
875
  d.button('Toggle tag 1 (A, B, C)', () => { view[1] = !view[1]; d.go(L()); });
876
  d.button('Toggle tag 2 (D, E)', () => { view[2] = !view[2]; d.go(L()); });
877
  d.button('Intersperse A between D and E', () => { mixed = !mixed; d.go(L()); });
878
}
879
880
function stackLayouts(I) {
881
  const col = (L, id, ids) => { const r = L[id]; delete L[id]; Object.assign(L, splitY(r, ids)); return L; };
882
  const row = (L, id, ids) => { const r = L[id]; delete L[id]; Object.assign(L, splitX(r, ids)); return L; };
883
  return {
884
    'A | B | C': () => splitX(I, ['A', 'B', 'C']),
885
    'A | (B / C)': () => col(splitX(I, ['A', 'R']), 'R', ['B', 'C']),
886
    '(A / C) | B': () => col(splitX(I, ['L', 'B']), 'L', ['A', 'C']),
887
    '(A / B) | C': () => col(splitX(I, ['L', 'C']), 'L', ['A', 'B']),
888
    'B | C | A': () => splitX(I, ['B', 'C', 'A']),
889
    'A / B / C': () => splitY(I, ['A', 'B', 'C']),
890
    'A / (B | C)': () => row(splitY(I, ['A', 'R']), 'R', ['B', 'C']),
891
    '(A / B) | (C / D)': () => col(col(splitX(I, ['L', 'R']), 'L', ['A', 'B']), 'R', ['C', 'D']),
892
    'A | (C / B / D)': () => col(splitX(I, ['A', 'R']), 'R', ['C', 'B', 'D']),
893
    '(A / D) | (C / B)': () => col(col(splitX(I, ['L', 'R']), 'L', ['A', 'D']), 'R', ['C', 'B']),
894
    '(A / B / C) | D': () => col(splitX(I, ['L', 'D']), 'L', ['A', 'B', 'C']),
895
    '(A / C) | (B / D)': () => col(col(splitX(I, ['L', 'R']), 'L', ['A', 'C']), 'R', ['B', 'D']),
896
  };
897
}
898
899
for (const [id, names] of [
900
  ['d9a', ['A | B | C', 'A | (B / C)', '(A / C) | B', '(A / B) | C', 'B | C | A', 'A / B / C', 'A / (B | C)']],
901
  ['d9b', ['(A / B) | (C / D)', 'A | (C / B / D)', '(A / D) | (C / B)', '(A / B / C) | D', '(A / C) | (B / D)']],
902
]) {
903
  const W = 640, H = 300, d = new Demo(id, W, H), I = inner(W, H), S = stackLayouts(I);
904
  d.set(S[names[0]]());
905
  for (const n of names) d.button(n, () => d.go(S[n]()));
906
}
907
908
{ // d9c grid
909
  const W = 640, H = 300, d = new Demo('d9c', W, H), I = inner(W, H), S = stackLayouts(I);
910
  let at = 0;
911
  const names = ['(A / D) | (C / B)', '(A / B / C) | D'];
912
  d.set(S[names[0]]());
913
  d.button('(A / D) | (C / B)  ⇄  (A / B / C) | D', () => { at = 1 - at; d.go(S[names[at]]()); });
914
}
915
916
{ // d10 floating to tiled
917
  const W = 640, H = 300, d = new Demo('d10', W, H, { floating: ['F'] }), I = inner(W, H);
918
  const FL = R(220, 40, 220, 130);
919
  const modes = ['direct glide', 'slot opens last', 'slot opens first'];
920
  let tiled = false, mode = 0;
921
  const tiledL = () => splitX(I, ['A', 'B', 'F', 'C']);
922
  const floatL = () => ({ ...splitX(I, ['A', 'B', 'C']), F: FL });
923
  const ortho = () => STYLE === 'orthogonal';
924
  const assemble = (n, tiledAt, fAt, kind) => {
925
    const keys = [];
926
    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) }); }
927
    return { kind, keys, durs: keys.slice(1).map(() => PHASE_MS) };
928
  };
929
  const slotPlan = (from, to, opening) => {
930
    const fromBx = (from.B.x + from.B.w + from.C.x) / 2, toBx = (to.B.x + to.B.w + to.C.x) / 2;
931
    return opening
932
      ? 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 })
933
      : 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) });
934
  };
935
  const at = (tp, i) => tp.keys[Math.max(0, Math.min(i, tp.durs.length))];
936
  const floatIn = from => {
937
    const to = tiledL(), slot = to.F, tp = slotPlan(from, to, true), k = tp.durs.length, f = from.F;
938
    if (modes[mode] === 'direct glide') {
939
      const n = Math.max(1, k);
940
      return assemble(n, i => at(tp, i), i => i === 0 ? f : slot, `glide in, slot ${tp.kind}`);
941
    }
942
    const cx = slot.x + slot.w / 2 - f.w / 2, cy = slot.y + slot.h / 2 - f.h / 2;
943
    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)];
944
    const travel = path.length - 1;
945
    if (modes[mode] === 'slot opens last') {
946
      const n = travel + Math.max(1, k);
947
      return assemble(n, i => at(tp, i - (n - k)), i => i === n ? slot : path[Math.min(i, travel)], `float in, slot ${tp.kind}`);
948
    }
949
    const n = Math.max(travel, k) + 1;
950
    return assemble(n, i => at(tp, i), i => i === n ? slot : path[Math.min(i, travel)], `float in, slot ${tp.kind}`);
951
  };
952
  const floatOut = from => {
953
    const to = floatL(), slot = from.F, tp = slotPlan(from, to, false), k = tp.durs.length;
954
    if (modes[mode] === 'direct glide') {
955
      const n = Math.max(1, k);
956
      return assemble(n, i => at(tp, i), i => i === 0 ? slot : FL, `glide out, slot ${tp.kind}`);
957
    }
958
    const lift = R(slot.x + slot.w / 2 - FL.w / 2, slot.y + slot.h / 2 - FL.h / 2, FL.w, FL.h);
959
    const path = ortho() ? [slot, lift, R(lift.x, FL.y, FL.w, FL.h), FL] : [slot, lift, FL];
960
    const travel = path.length - 1;
961
    if (modes[mode] === 'slot opens last') {
962
      const n = Math.max(travel, k);
963
      return assemble(n, i => at(tp, i), i => path[Math.min(i, travel)], `lift out, slot ${tp.kind}`);
964
    }
965
    const n = Math.max(travel, k + 1);
966
    return assemble(n, i => at(tp, i - 1), i => path[Math.min(i, travel)], `lift out, slot ${tp.kind}`);
967
  };
968
  d.set(floatL());
969
  d.button('Tile F / float F', () => { tiled = !tiled; d.go(tiled ? tiledL() : floatL(), { planFn: tiled ? floatIn : floatOut }); });
970
  const mb = d.button('mode: ' + modes[0], () => { mode = (mode + 1) % modes.length; mb.textContent = 'mode: ' + modes[mode]; });
971
}
972
973
{ // d11 one tree, hidden slots
974
  const W = 640, H = 320, d = new Demo('d11', W, H), I = inner(W, H);
975
  const tagOf = { A: 1, B: 1, C: 1, D: 2, E: 2 };
976
  const view = { 1: true, 2: true };
977
  let tree, ghosts = false;
978
  const reset = () => { tree = { dir: 'col', kids: [{ dir: 'row', kids: ['A', 'D', 'B'] }, { dir: 'row', kids: ['C', 'E'] }] }; view[2] = true; };
979
  reset();
980
  const L = () => { const o = {}; layoutTree(tree, I, id => view[tagOf[id]], o); return o; };
981
  const parentOf = (node, id, par = null) => {
982
    if (typeof node === 'string') return node === id ? par : null;
983
    for (const k of node.kids) { const r = parentOf(k, id, node); if (r) return r; }
984
    return null;
985
  };
986
  const collapse = node => {
987
    if (typeof node === 'string') return node;
988
    node.kids = node.kids.map(collapse).filter(k => typeof k === 'string' || k.kids.length);
989
    return node.kids.length === 1 ? node.kids[0] : node;
990
  };
991
  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; };
992
  const moveAfter = (id, target) => {
993
    const p = parentOf(tree, id); p.kids.splice(p.kids.indexOf(id), 1);
994
    tree = collapse(tree);
995
    const pt = parentOf(tree, target); pt.kids.splice(pt.kids.indexOf(target) + 1, 0, id);
996
  };
997
  d.overlay = () => {
998
    if (!ghosts) return [];
999
    const full = {}; layoutTree(tree, I, () => true, full);
1000
    return Object.keys(full).filter(id => !view[tagOf[id]]).map(id => ({ ...full[id], id }));
1001
  };
1002
  d.set(L());
1003
  d.button('Hide / show tag 2 (D, E)', () => { view[2] = !view[2]; d.go(L()); });
1004
  d.button('Swap A and B', () => { swap('A', 'B'); d.go(L()); });
1005
  d.button('Move C next to B', () => { if (parentOf(tree, 'C') !== parentOf(tree, 'B')) { moveAfter('C', 'B'); d.go(L()); } });
1006
  d.button('Reset', () => { reset(); d.go(L()); });
1007
  d.check('show hidden slots', false, v => ghosts = v);
1008
}
1009
1010
{ // d12 lane extent
1011
  const W = 640, H = 320, d = new Demo('d12', W, H), I = inner(W, H);
1012
  let order = ['A', 'B', 'C'], whole = false;
1013
  const L = () => { const r = splitY(I, ['top', 'bot'], [7, 3]); return { T: r.top, ...splitX(r.bot, order) }; };
1014
  d.set(L());
1015
  d.button('Reverse bottom row', () => {
1016
    order = [...order].reverse();
1017
    const tgt = L();
1018
    d.go(tgt, { planFn: from => { LANE_REGION = whole ? I : null; try { return plan(from, tgt); } finally { LANE_REGION = null; } } });
1019
  });
1020
  const mb = d.button('lanes span: involved band', () => { whole = !whole; mb.textContent = 'lanes span: ' + (whole ? 'whole output' : 'involved band'); });
1021
}
1022
1023
{ // d13 fullscreen
1024
  const W = 640, H = 300, d = new Demo('d13', W, H, { floating: ['B'] }), I = inner(W, H);
1025
  let full = false;
1026
  const L = () => { const o = splitX(I, ['A', 'B', 'C']); if (full) o.B = R(0, 0, W, H); return o; };
1027
  d.set(L());
1028
  d.button('Fullscreen B', () => {
1029
    full = !full;
1030
    const tgt = L();
1031
    d.go(tgt, { planFn: from => ({ kind: full ? 'glide to fullscreen' : 'glide back to slot', keys: [from, tgt], durs: [PHASE_MS] }) });
1032
  });
1033
}
1034
1035
{ // d14 expand
1036
  const W = 640, H = 300, d = new Demo('d14', W, H), I = inner(W, H);
1037
  let col = false, root = false;
1038
  const L = () => {
1039
    const cols = splitX(I, ['A', 'R']);
1040
    const area = root ? I : cols.R;
1041
    const o = root ? { A: R(I.x, I.y, 0, I.h) } : { A: cols.A };
1042
    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) });
1043
    else Object.assign(o, splitY(area, ['B', 'C', 'D']));
1044
    return o;
1045
  };
1046
  d.set(L());
1047
  d.button('Expand C in its column', () => { col = !col; d.go(L()); });
1048
  d.button('Expand the column to the output', () => { root = !root; d.go(L()); });
1049
  d.button('Expand C all the way', () => { const on = !(col && root); col = on; root = on; d.go(L()); });
1050
}
1051
1052
document.getElementById('phaseMs').oninput = e => { PHASE_MS = +e.target.value; document.getElementById('phaseMsOut').textContent = PHASE_MS + ' ms'; };
1053
document.getElementById('style').onchange = e => { STYLE = e.target.value; };
1054
const loop = now => { for (const d of demos) d.frame(now); requestAnimationFrame(loop); };
1055
requestAnimationFrame(loop);
1056
</script>
1057
</body>
1058
</html>