atagen

monolith

public
Star0
Watch0
440b67836dd595993e4b0354ed0e838832bb836a
1
ssh://brewery@pls.lobotomise.me:33/atagen/monolith.git
design/planner.htmlHtml931 lines52.6 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
111
<h2>7. Four phases, the grid</h2>
112
<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>
113
<ol>
114
  <li>Every involved window shrinks vertically into its own horizontal strip, ordered by where it starts.</li>
115
  <li>It shrinks horizontally into its own column, ordered by where it ends. Every window is now a separate small cell.</li>
116
  <li>It moves vertically to its final height. Its column keeps it clear of everything.</li>
117
  <li>It expands horizontally to its final width.</li>
118
</ol>
119
<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>
120
<div class="demo" id="d9c"></div>
121
122
<h2>8. Joining and leaving stacks</h2>
123
<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>
124
<div class="demo" id="d9a"></div>
125
<div class="demo" id="d9b"></div>
126
127
<h2>9. Rigid blocks and movement commands</h2>
128
<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>
129
<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>
130
<div class="demo" id="d3b"></div>
131
132
<h2>10. Interrupts and velocity</h2>
133
<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>
134
<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>
135
<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>
136
<div class="demo" id="d6"></div>
137
138
<h2>11. Across outputs</h2>
139
<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>
140
<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>
141
<div class="demo" id="d7"></div>
142
143
<h2>12. Floating to tiled</h2>
144
<p>A floating window joining the tiled layout is phased, unlike a spawn. The tiled windows open a slot for it, concurrently and following the same rules as an insertion. Meanwhile the floating window travels above them, one axis per phase, until it sits over the slot. In the last phase it falls into place. Lifting a tiled window back out runs the same steps in reverse. The floating window is drawn above the tiled layer and is exempt from the overlap rule while it travels.</p>
145
<div class="demo" id="d10"></div>
146
147
<h2>13. Fallbacks</h2>
148
<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>
149
150
<h2>14. Algorithm</h2>
151
<pre>plan(from, to):
152
  if from > to in a canonical order: return reverse(plan(to, from))
153
  groups = motion groups (windows whose swept areas can touch, transitively)
154
  for each group:
155
    if every pair shares a relation between from and to
156
       and (style is direct or every window changes only one axis):
157
      return [from, to]                                    # 1 phase
158
    if 2-SAT over axis order finds K1, preferring no holes: # mixed rects
159
      return [from, K1, to]                                # 2 phases
160
    for lane_axis in [y, x]:                               # strips, then columns
161
      for set in [windows in reversals, all movers, all]:
162
        for k in 2..|set|:
163
          if lanes assignable with k lanes and all three phases validate:
164
            return [from, K1, K2, to]                      # 3 phases
165
    for set in [windows in reversals, all movers, all]:
166
      if the grid validates for set:                       # always true for all
167
        return [from, K1, K2, K3, to]                      # 4 phases
168
    return fallback(from, to)                              # unreachable in theory
169
170
  rigid blocks: run plan() on block rectangles first, then map windows into them
171
  floating:     slot opens via plan() with a seed, floater travels x, then y, then lands
172
  interrupts:   from = rectangles sampled at the current presentation time
173
  velocity:     first-phase curve per group, slope from the fastest mover,
174
                applied only if moving the same way, control point height ≤ 1
175
  validation:   exact pairwise check over the curve actually used</pre>
176
<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>
177
<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>
178
179
<h2>15. Compared with wry master</h2>
180
<table>
181
  <tr><th>wry master</th><th>this design</th></tr>
182
  <tr><td>SingleAction, MixedSinglePhase</td><td>1 phase</td></tr>
183
  <tr><td>SpaceThenOrthogonalGrowth, OrientationChange, HierarchyOrderedScales</td><td>2 phases, one 2-SAT problem instead of three strategies</td></tr>
184
  <tr><td>SwapLanes</td><td>3 phases, general lane assignment for any permutation</td></tr>
185
  <tr><td>no equivalent</td><td>4 phases, the grid, for simultaneous reversals on both axes</td></tr>
186
  <tr><td>ReversedForwardPlan</td><td>not needed, the construction is symmetric</td></tr>
187
  <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>
188
  <tr><td>Bridged retarget can make plans longer than the original</td><td>every replan obeys the same bound</td></tr>
189
  <tr><td>Spawns grow from the target centre</td><td>spawns grow from a seed on the old boundary, concurrently</td></tr>
190
  <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>
191
  <tr><td>Cross-output moves skip animation</td><td>planned in global coordinates, clipped per output</td></tr>
192
</table>
193
194
<h2>16. Interspersing tags (open, changes the layout model)</h2>
195
<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>
196
<table>
197
  <tr><th>option</th><th>meaning</th><th>verdict</th></tr>
198
  <tr><td>Forbid it</td><td>tags stay in their rows</td><td>limiting</td></tr>
199
  <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>
200
  <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>
201
  <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>
202
  <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>
203
</table>
204
<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>
205
<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>
206
<div class="demo" id="d8"></div>
207
208
<h2>17. Decisions</h2>
209
<h3>Settled</h3>
210
<ul>
211
  <li>Orthogonal is the default and the flagship look. Direct is a toggle.</li>
212
  <li>Window moves and block moves both exist as commands. Tag reordering moves rigid blocks.</li>
213
  <li>One curve per motion group, bounded by phase time rather than amount of motion.</li>
214
  <li>Floating to tiled is phased, travel one axis per phase, then land.</li>
215
  <li>The planner must never need a fallback. Pinch and lerp stay as safety nets to try.</li>
216
</ul>
217
<h3>Open</h3>
218
<div class="q"><strong>Seed shape.</strong> Line on the old boundary, point on the old boundary, or target centre (section 4). Line is always one phase. Point looks more like your "grow from centre" but costs a phase in orthogonal style.</div>
219
<div class="q"><strong>Landing in orthogonal style.</strong> The final "falls into place" step resizes both width and height at once. Is that an accepted exception, or must it also split into two phases?</div>
220
<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>
221
<div class="q"><strong>The layout model (section 16).</strong> One tree per output with tags as a filter, or something else. You raised the case of windows being closed and moved while others are hidden. In the one-tree model, hidden windows keep their slot, closing removes only that leaf, and moves are made relative to visible neighbours, so hidden siblings stay attached to whichever visible neighbour they were next to. That rule is what needs discussing.</div>
222
</main>
223
224
<script id="planner">
225
const G = 10;
226
const EPS = 0.5;
227
let PHASE_MS = 450;
228
let STYLE = 'orthogonal';
229
230
const R = (x, y, w, h) => ({ x, y, w, h });
231
const clone = L => { const o = {}; for (const k in L) o[k] = { ...L[k] }; return o; };
232
const eq = (a, b) => Math.abs(a - b) < 0.05;
233
const lerp = (a, b, s) => a + (b - a) * s;
234
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));
235
236
function splitX(rect, ids, ratios) {
237
  ratios = ratios || ids.map(() => 1);
238
  const tot = ratios.reduce((a, b) => a + b, 0);
239
  const avail = rect.w - G * (ids.length - 1);
240
  let x = rect.x;
241
  const out = {};
242
  ids.forEach((id, i) => { const w = avail * ratios[i] / tot; out[id] = R(x, rect.y, w, rect.h); x += w + G; });
243
  return out;
244
}
245
function splitY(rect, ids, ratios) {
246
  ratios = ratios || ids.map(() => 1);
247
  const tot = ratios.reduce((a, b) => a + b, 0);
248
  const avail = rect.h - G * (ids.length - 1);
249
  let y = rect.y;
250
  const out = {};
251
  ids.forEach((id, i) => { const h = avail * ratios[i] / tot; out[id] = R(rect.x, y, rect.w, h); y += h + G; });
252
  return out;
253
}
254
255
function relBits(a, b) {
256
  let s = 0;
257
  if (a.x + a.w <= b.x + EPS) s |= 1;
258
  if (b.x + b.w <= a.x + EPS) s |= 2;
259
  if (a.y + a.h <= b.y + EPS) s |= 4;
260
  if (b.y + b.h <= a.y + EPS) s |= 8;
261
  return s;
262
}
263
function phaseOk(K1, K2, list) {
264
  for (let i = 0; i < list.length; i++)
265
    for (let j = i + 1; j < list.length; j++) {
266
      const a = list[i], b = list[j];
267
      if ((relBits(K1[a], K1[b]) & relBits(K2[a], K2[b])) === 0) return false;
268
    }
269
  return true;
270
}
271
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)) }; }
272
function pureOk(keys) {
273
  if (STYLE !== 'orthogonal') return true;
274
  for (let k = 0; k + 1 < keys.length; k++)
275
    for (const id in keys[k + 1]) { const c = chg(keys[k][id], keys[k + 1][id]); if (c.x && c.y) return false; }
276
  return true;
277
}
278
279
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(';'); }
280
281
function plan(from, to, opts = {}) {
282
  if (opts.naive) return { kind: 'naive lerp', keys: [from, to], durs: [PHASE_MS] };
283
  if (signature(from) <= signature(to)) return planOneWay(from, to);
284
  const p = planOneWay(to, from);
285
  return { kind: p.kind, keys: [...p.keys].reverse(), durs: [...p.durs].reverse() };
286
}
287
288
function planOneWay(from, to) {
289
  const list = Object.keys(to);
290
  const T = PHASE_MS;
291
  const movers = list.filter(id => { const c = chg(from[id], to[id]); return c.x || c.y; });
292
  if (!movers.length) return { kind: 'no-op', keys: [from, to], durs: [0] };
293
  const pure = movers.every(id => { const c = chg(from[id], to[id]); return !(c.x && c.y); });
294
  if (phaseOk(from, to, list) && (STYLE === 'direct' || pure))
295
    return { kind: '1 phase', keys: [from, to], durs: [T] };
296
  const two = axisOrder(from, to, list, movers);
297
  if (two) return two;
298
  for (const ax of ['y', 'x']) { const l = lanes(from, to, list, movers, ax); if (l) return l; }
299
  const g = grid(from, to, list, movers);
300
  if (g) return g;
301
  return pinch(from, to, movers);
302
}
303
304
function conflictSet(from, to, list) {
305
  const out = new Set();
306
  for (let i = 0; i < list.length; i++)
307
    for (let j = i + 1; j < list.length; j++) {
308
      const a = list[i], b = list[j];
309
      if ((relBits(from[a], from[b]) & relBits(to[a], to[b])) === 0) { out.add(a); out.add(b); }
310
    }
311
  return [...out];
312
}
313
314
function grid(from, to, list, movers) {
315
  const cx = r => r.x + r.w / 2, cy = r => r.y + r.h / 2;
316
  for (const set of [conflictSet(from, to, list), movers, list]) {
317
    const n = set.length;
318
    if (n < 2) continue;
319
    let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
320
    for (const id of set) for (const r of [from[id], to[id]]) {
321
      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);
322
    }
323
    const sh = (y1 - y0 - (n - 1) * G) / n, sw = (x1 - x0 - (n - 1) * G) / n;
324
    const rowOf = {}, colOf = {};
325
    [...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));
326
    [...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));
327
    const inSet = new Set(set);
328
    for (const phase of [3, 2, 4, 1]) {
329
      const K = [{}, {}, {}];
330
      for (const id of list) {
331
        const f = from[id], t = to[id];
332
        if (inSet.has(id)) {
333
          K[0][id] = R(f.x, rowOf[id], f.w, sh);
334
          K[1][id] = R(colOf[id], rowOf[id], sw, sh);
335
          K[2][id] = R(colOf[id], t.y, sw, t.h);
336
        } else for (let k = 0; k < 3; k++) K[k][id] = k + 1 >= phase ? t : f;
337
      }
338
      const keys = [from, ...K, to];
339
      let ok = true;
340
      for (let k = 0; k < 4 && ok; k++) ok = phaseOk(keys[k], keys[k + 1], list);
341
      if (ok && pureOk(keys)) return { kind: `4 phases (${n}×${n} grid)`, keys, durs: [PHASE_MS, PHASE_MS, PHASE_MS, PHASE_MS] };
342
    }
343
  }
344
  return null;
345
}
346
347
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); }
348
349
function adjBits(a, b) {
350
  const ovX = a.x < b.x + b.w + EPS && b.x < a.x + a.w + EPS;
351
  const ovY = a.y < b.y + b.h + EPS && b.y < a.y + a.h + EPS;
352
  let s = 0;
353
  if (ovY && Math.abs(b.x - (a.x + a.w)) <= G + EPS) s |= 1;
354
  if (ovY && Math.abs(a.x - (b.x + b.w)) <= G + EPS) s |= 2;
355
  if (ovX && Math.abs(b.y - (a.y + a.h)) <= G + EPS) s |= 4;
356
  if (ovX && Math.abs(a.y - (b.y + b.h)) <= G + EPS) s |= 8;
357
  return s;
358
}
359
function coupledEdges(from, to, list) {
360
  const out = [];
361
  for (let i = 0; i < list.length; i++)
362
    for (let j = i + 1; j < list.length; j++) {
363
      const a = list[i], b = list[j], s = adjBits(from[a], from[b]) & adjBits(to[a], to[b]);
364
      if (s) out.push([a, b, s]);
365
    }
366
  return out;
367
}
368
function holes(K, coupled) {
369
  let n = 0;
370
  for (const [a, b, s] of coupled) if ((adjBits(K[a], K[b]) & s) !== s) n++;
371
  return n;
372
}
373
374
function axisOrder(from, to, list, movers) {
375
  const n = movers.length;
376
  if (n > 14) return null;
377
  const coupled = coupledEdges(from, to, list);
378
  let best = null, bestScore = Infinity;
379
  for (let mask = 0; mask < (1 << n); mask++) {
380
    const K1 = { ...from };
381
    let late = 0;
382
    movers.forEach((id, i) => {
383
      const xf = !!(mask & (1 << i));
384
      const c = chg(from[id], to[id]);
385
      K1[id] = mixed(from[id], to[id], xf);
386
      if (c.x && !c.y && !xf) late++;
387
      if (c.y && !c.x && xf) late++;
388
    });
389
    if (late >= bestScore) continue;
390
    if (!phaseOk(from, K1, list) || !phaseOk(K1, to, list)) continue;
391
    const score = holes(K1, coupled) * 1000 + late;
392
    if (score < bestScore) { best = K1; bestScore = score; if (score === 0) break; }
393
  }
394
  return best ? { kind: '2 phases (axis order)' + (bestScore >= 1000 ? ', opens a hole' : ''), keys: [from, best, to], durs: [PHASE_MS, PHASE_MS] } : null;
395
}
396
397
function lanes(from, to, list, movers, ax) {
398
  const size = ax === 'y' ? 'h' : 'w';
399
  const bitsT = ax === 'y' ? 3 : 12;
400
  const before = ax === 'y' ? 4 : 1, after = ax === 'y' ? 8 : 2;
401
  const conflict = new Set();
402
  for (let i = 0; i < list.length; i++)
403
    for (let j = i + 1; j < list.length; j++) {
404
      const a = list[i], b = list[j];
405
      if ((relBits(from[a], from[b]) & relBits(to[a], to[b])) === 0) { conflict.add(a); conflict.add(b); }
406
    }
407
  const sets = [[...conflict], movers, list];
408
  for (const set of sets) {
409
    const n = set.length;
410
    if (n < 2) continue;
411
    let lo = Infinity, hi = -Infinity;
412
    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]); }
413
    const diff = [], lt = [];
414
    for (let i = 0; i < n; i++) { diff.push(new Array(n).fill(false)); lt.push(new Array(n).fill(false)); }
415
    for (let i = 0; i < n; i++)
416
      for (let j = 0; j < n; j++) {
417
        if (i === j) continue;
418
        const f = relBits(from[set[i]], from[set[j]]), t = relBits(to[set[i]], to[set[j]]);
419
        if (((f & bitsT) & (t & bitsT)) === 0) diff[i][j] = true;
420
        for (const r of [f, t]) if ((r & bitsT) === 0 && (r & before)) lt[i][j] = true;
421
      }
422
    for (let k = 2; k <= n; k++) {
423
      const s = (hi - lo - (k - 1) * G) / k;
424
      if (s < 6) break;
425
      const lane = new Array(n).fill(-1);
426
      const assign = i => {
427
        if (i === n) return true;
428
        for (let l = 0; l < k; l++) {
429
          let ok = true;
430
          for (let j = 0; j < i && ok; j++) {
431
            if (diff[i][j] && lane[j] === l) ok = false;
432
            if (lt[i][j] && !(l < lane[j])) ok = false;
433
            if (lt[j][i] && !(lane[j] < l)) ok = false;
434
          }
435
          if (ok) { lane[i] = l; if (assign(i + 1)) return true; lane[i] = -1; }
436
        }
437
        return false;
438
      };
439
      if (!assign(0)) continue;
440
      const strip = l => lo + l * (s + G);
441
      const inSet = new Set(set);
442
      for (const phase of [2, 1, 3]) {
443
        const K1 = {}, K2 = {};
444
        for (const id of list) {
445
          const f = from[id], t = to[id];
446
          if (inSet.has(id)) {
447
            const l = lane[set.indexOf(id)];
448
            K1[id] = ax === 'y' ? R(f.x, strip(l), f.w, s) : R(strip(l), f.y, s, f.h);
449
            K2[id] = ax === 'y' ? R(t.x, strip(l), t.w, s) : R(strip(l), t.y, s, t.h);
450
          } else if (phase === 1) { K1[id] = t; K2[id] = t; }
451
          else if (phase === 2) { K1[id] = f; K2[id] = t; }
452
          else { K1[id] = f; K2[id] = f; }
453
        }
454
        if (phaseOk(from, K1, list) && phaseOk(K1, K2, list) && phaseOk(K2, to, list) && pureOk([from, K1, K2, to]))
455
          return { kind: `3 phases (${k} lanes)`, keys: [from, K1, K2, to], durs: [PHASE_MS, PHASE_MS, PHASE_MS] };
456
      }
457
    }
458
  }
459
  return null;
460
}
461
462
function pinch(from, to, movers) {
463
  const K1 = { ...from }, K2 = { ...to };
464
  for (const id of movers) {
465
    const f = from[id], t = to[id];
466
    K1[id] = R(f.x + f.w / 2, f.y + f.h / 2, 0, 0);
467
    K2[id] = R(t.x + t.w / 2, t.y + t.h / 2, 0, 0);
468
  }
469
  return { kind: 'pinch fallback', keys: [from, K1, K2, to], durs: [PHASE_MS, 0, PHASE_MS] };
470
}
471
472
function bbox(rects) {
473
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
474
  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); }
475
  return R(x0, y0, x1 - x0, y1 - y0);
476
}
477
function planBlocks(from, to, groups) {
478
  const bf = {}, bt = {};
479
  for (const b in groups) { bf[b] = bbox(groups[b].map(id => from[id])); bt[b] = bbox(groups[b].map(id => to[id])); }
480
  const bp = plan(bf, bt);
481
  const last = bp.keys.length - 1;
482
  const keys = bp.keys.map((K, ki) => {
483
    if (ki === 0) return from;
484
    if (ki === last) return to;
485
    const L = { ...from };
486
    for (const b in groups) for (const id of groups[b]) {
487
      const r = from[id], box = bf[b], k = K[b];
488
      const nx = box.w ? (r.x - box.x) / box.w : 0, ny = box.h ? (r.y - box.y) / box.h : 0;
489
      const nw = box.w ? r.w / box.w : 0, nh = box.h ? r.h / box.h : 0;
490
      L[id] = R(k.x + nx * k.w, k.y + ny * k.h, nw * k.w, nh * k.h);
491
    }
492
    return L;
493
  });
494
  return { kind: bp.kind + ' on blocks', keys, durs: bp.durs };
495
}
496
497
function bezier(x1, y1, x2, y2) {
498
  const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx;
499
  const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
500
  const X = u => ((ax * u + bx) * u + cx) * u, Y = u => ((ay * u + by) * u + cy) * u;
501
  const dX = u => (3 * ax * u + 2 * bx) * u + cx;
502
  return t => {
503
    if (t <= 0) return 0;
504
    if (t >= 1) return 1;
505
    let u = t;
506
    for (let i = 0; i < 8; i++) {
507
      const x = X(u) - t;
508
      if (Math.abs(x) < 1e-6) return Y(u);
509
      const d = dX(u);
510
      if (Math.abs(d) < 1e-6) break;
511
      u -= x / d;
512
    }
513
    let lo = 0, hi = 1; u = t;
514
    for (let i = 0; i < 40; i++) { if (X(u) < t) lo = u; else hi = u; u = (lo + hi) / 2; }
515
    return Y(u);
516
  };
517
}
518
const BASE = bezier(0.45, 0, 0.2, 1);
519
const edges = r => [r.x, r.x + r.w, r.y, r.y + r.h];
520
</script>
521
522
<script>
523
const PAL = { A: '#e0774a', B: '#4aa3e0', C: '#7bc96f', D: '#c77be0', E: '#e0c24a', F: '#4ae0c2' };
524
const demos = [];
525
526
class Demo {
527
  constructor(id, W, H, opts = {}) {
528
    this.W = W; this.H = H;
529
    this.outputs = opts.outputs || [R(0, 0, W, H)];
530
    this.floating = new Set(opts.floating || []);
531
    const el = document.getElementById(id);
532
    this.canvas = document.createElement('canvas');
533
    const dpr = window.devicePixelRatio || 1;
534
    this.canvas.width = W * dpr; this.canvas.height = H * dpr;
535
    this.canvas.style.maxWidth = W + 'px';
536
    this.ctx = this.canvas.getContext('2d');
537
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
538
    el.appendChild(this.canvas);
539
    if (opts.graph) {
540
      this.graph = document.createElement('canvas');
541
      this.graph.width = W * dpr; this.graph.height = 90 * dpr;
542
      this.graph.style.maxWidth = W + 'px'; this.graph.style.marginTop = '8px';
543
      this.gctx = this.graph.getContext('2d');
544
      this.gctx.setTransform(dpr, 0, 0, dpr, 0, 0);
545
      el.appendChild(this.graph);
546
      this.hist = [];
547
    }
548
    this.controls = document.createElement('div'); this.controls.className = 'controls'; el.appendChild(this.controls);
549
    this.status = document.createElement('div'); this.status.className = 'status'; el.appendChild(this.status);
550
    this.anim = null; this.prev = null; this.vel = {}; this.naive = false; this.vmatch = true; this.lastKind = 'idle';
551
    demos.push(this);
552
  }
553
  button(label, fn) { const b = document.createElement('button'); b.textContent = label; b.onclick = fn; this.controls.appendChild(b); return b; }
554
  check(label, init, fn) {
555
    const l = document.createElement('label'); const c = document.createElement('input'); c.type = 'checkbox'; c.checked = init;
556
    c.onchange = () => fn(c.checked); l.appendChild(c); l.appendChild(document.createTextNode(label)); this.controls.appendChild(l); return c;
557
  }
558
  set(L) { this.layout = clone(L); this.anim = null; }
559
  go(target, opts = {}) {
560
    const now = performance.now();
561
    const cur = this.sampleAt(now);
562
    const from = {};
563
    for (const id in target) from[id] = cur[id] || target[id];
564
    const p = opts.planFn ? opts.planFn(from) : opts.blocks ? planBlocks(from, target, opts.blocks) : plan(from, target, { naive: this.naive });
565
    const curves = p.durs.map(() => BASE);
566
    const interrupted = !!this.anim;
567
    let matched = false;
568
    if (this.vmatch && interrupted && p.durs[0] > 0 && p.keys.length > 1) {
569
      const K1 = p.keys[1];
570
      let best = null, bs = 0;
571
      for (const id in K1) {
572
        const v = this.vel[id]; if (!v) continue;
573
        const D = edges(K1[id]).map((e, i) => e - edges(from[id])[i]);
574
        const dd = D.reduce((a, b) => a + b * b, 0); if (dd < 1) continue;
575
        const sp = Math.hypot(...v);
576
        if (sp > bs) { bs = sp; best = { v, D, dd }; }
577
      }
578
      if (best) {
579
        const s = best.v.reduce((a, b, i) => a + b * best.D[i], 0) / best.dd;
580
        const slope = s * p.durs[0];
581
        if (slope > 0.05) { const x1 = 0.3; curves[0] = bezier(x1, Math.min(1, x1 * slope), 0.2, 1); matched = true; }
582
      }
583
    }
584
    this.anim = { keys: p.keys, durs: p.durs, curves, start: now };
585
    this.lastKind = p.kind + (interrupted ? ', replanned' : '') + (matched ? ', velocity matched' : '');
586
    this.overlapSeen = false;
587
  }
588
  sampleAt(t) {
589
    if (!this.anim) return clone(this.layout);
590
    let e = t - this.anim.start;
591
    const { keys, durs, curves } = this.anim;
592
    for (let i = 0; i < durs.length; i++) {
593
      const d = durs[i];
594
      if (e < d) {
595
        this.phase = i;
596
        const s = curves[i](e / d);
597
        const L = {};
598
        for (const id in keys[i + 1]) L[id] = lerpRect(keys[i][id], keys[i + 1][id], s);
599
        return L;
600
      }
601
      e -= d;
602
    }
603
    this.layout = clone(keys[keys.length - 1]);
604
    this.anim = null;
605
    return clone(this.layout);
606
  }
607
  frame(now) {
608
    const L = this.sampleAt(now);
609
    if (this.prev) {
610
      const dt = now - this.prev.t;
611
      if (dt > 0) for (const id in L) if (this.prev.L[id]) {
612
        const a = edges(this.prev.L[id]), b = edges(L[id]);
613
        this.vel[id] = b.map((v, i) => (v - a[i]) / dt);
614
      }
615
    }
616
    this.prev = { t: now, L };
617
    this.draw(L);
618
    const overlaps = this.findOverlaps(L);
619
    if (overlaps.length) this.overlapSeen = true;
620
    const phaseTxt = this.anim ? ` · phase ${this.phase + 1}/${this.anim.durs.length}` : '';
621
    this.status.innerHTML = `${this.lastKind}${phaseTxt}` + (this.overlapSeen ? ' · <span class="bad">overlap</span>' : '');
622
    if (this.graph) this.drawGraph(now);
623
  }
624
  findOverlaps(L) {
625
    const ids = Object.keys(L).filter(id => L[id].w > 0.5 && L[id].h > 0.5 && !this.floating.has(id));
626
    const out = [];
627
    for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) {
628
      const a = L[ids[i]], b = L[ids[j]];
629
      const x0 = Math.max(a.x, b.x), y0 = Math.max(a.y, b.y);
630
      const x1 = Math.min(a.x + a.w, b.x + b.w), y1 = Math.min(a.y + a.h, b.y + b.h);
631
      if (x1 - x0 > 1 && y1 - y0 > 1) out.push(R(x0, y0, x1 - x0, y1 - y0));
632
    }
633
    return out;
634
  }
635
  draw(L) {
636
    const c = this.ctx;
637
    c.clearRect(0, 0, this.W, this.H);
638
    const overlaps = this.findOverlaps(L);
639
    for (const o of this.outputs) {
640
      c.save();
641
      c.fillStyle = '#1b1e25'; c.fillRect(o.x, o.y, o.w, o.h);
642
      c.beginPath(); c.rect(o.x, o.y, o.w, o.h); c.clip();
643
      for (const id of Object.keys(L).sort((a, b) => this.floating.has(a) - this.floating.has(b))) {
644
        const r = L[id];
645
        if (r.w < 0.5 || r.h < 0.5) continue;
646
        const rad = Math.min(8, r.w / 2, r.h / 2);
647
        c.beginPath(); c.roundRect(r.x, r.y, r.w, r.h, rad);
648
        c.fillStyle = (PAL[id] || '#888') + 'cc'; c.fill();
649
        c.strokeStyle = PAL[id] || '#888'; c.lineWidth = 1.5; c.stroke();
650
        if (r.w > 22 && r.h > 22) {
651
          c.fillStyle = '#0d0f12'; c.font = '600 16px system-ui'; c.textAlign = 'center'; c.textBaseline = 'middle';
652
          c.fillText(id, r.x + r.w / 2, r.y + r.h / 2);
653
        }
654
      }
655
      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); }
656
      c.restore();
657
    }
658
  }
659
  drawGraph(now) {
660
    const v = this.vel.A;
661
    const sp = v ? Math.hypot(v[0] + v[1], v[2] + v[3]) * 1000 / 2 : 0;
662
    this.hist.push([now, sp]);
663
    while (this.hist.length && now - this.hist[0][0] > 4000) this.hist.shift();
664
    const c = this.gctx, W = this.W, H = 90;
665
    c.clearRect(0, 0, W, H);
666
    c.fillStyle = '#0d0f12'; c.fillRect(0, 0, W, H);
667
    const max = Math.max(400, ...this.hist.map(h => h[1]));
668
    c.beginPath();
669
    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); });
670
    c.strokeStyle = PAL.A; c.lineWidth = 2; c.stroke();
671
    c.fillStyle = '#8a909c'; c.font = '12px system-ui'; c.textAlign = 'left'; c.fillText('speed of window A, last 4 s', 8, 14);
672
  }
673
}
674
675
const inner = (W, H, ox = 0) => R(ox + G, G, W - 2 * G, H - 2 * G);
676
677
{ // d1 swap
678
  const W = 640, H = 260, d = new Demo('d1', W, H), I = inner(W, H);
679
  let swapped = false;
680
  const L = () => splitX(I, swapped ? ['B', 'A'] : ['A', 'B']);
681
  d.set(L());
682
  d.button('Swap A and B', () => { swapped = !swapped; d.go(L()); });
683
  d.check('naive', false, v => d.naive = v);
684
}
685
686
{ // d2 one phase
687
  const W = 640, H = 300, d = new Demo('d2', W, H), I = inner(W, H);
688
  const s = { wide: false, d: false, e: false };
689
  const modes = ['line on old boundary', 'point on old boundary', 'target centre'];
690
  let mode = 0;
691
  const ratios = ids => ids.map(id => (id === 'A' && s.wide) ? 2 : 1);
692
  const L = () => {
693
    const ids = s.d ? ['A', 'B', 'D', 'C'] : ['A', 'B', 'C'];
694
    const out = splitX(I, ids, ratios(ids));
695
    if (!s.d) {
696
      const bx = (out.B.x + out.B.w + out.C.x) / 2;
697
      const T = splitX(I, ['A', 'B', 'D', 'C'], ratios(['A', 'B', 'D', 'C'])).D;
698
      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];
699
    }
700
    const b = out.B;
701
    if (s.e) Object.assign(out, splitY(b, ['B', 'E'])); else out.E = R(b.x, b.y + b.h, b.w, 0);
702
    return out;
703
  };
704
  d.set(L());
705
  d.button('Widen A', () => { s.wide = !s.wide; d.go(L()); });
706
  d.button('Insert / remove D', () => { s.d = !s.d; d.go(L()); });
707
  d.button('Split B with E', () => { s.e = !s.e; d.go(L()); });
708
  d.button('All at once', () => { s.wide = !s.wide; s.d = !s.d; s.e = !s.e; d.go(L()); });
709
  const mb = d.button('seed: ' + modes[0], () => {
710
    mode = (mode + 1) % modes.length; mb.textContent = 'seed: ' + modes[mode];
711
    if (!s.d && !d.anim) d.set(L());
712
  });
713
  d.check('naive', false, v => d.naive = v);
714
}
715
function tagLayout(I, view2, order) {
716
  const L = {};
717
  if (view2) {
718
    const rows = splitY(I, order);
719
    Object.assign(L, splitX(rows.t1, ['A', 'B', 'C']), splitX(rows.t2, ['D', 'E']));
720
  } else {
721
    Object.assign(L, splitX(I, ['A', 'B', 'C']));
722
    const y = order[0] === 't2' ? I.y : I.y + I.h;
723
    Object.assign(L, splitX(R(I.x, y, I.w, 0), ['D', 'E']));
724
  }
725
  return L;
726
}
727
728
{ // d3 tag toggle
729
  const W = 640, H = 320, d = new Demo('d3', W, H), I = inner(W, H);
730
  let view2 = false;
731
  d.set(tagLayout(I, view2, ['t1', 't2']));
732
  d.button('View / hide tag 2 (D, E)', () => { view2 = !view2; d.go(tagLayout(I, view2, ['t1', 't2'])); });
733
}
734
735
{ // d3b tag reorder
736
  const W = 640, H = 320, d = new Demo('d3b', W, H), I = inner(W, H);
737
  let order = ['t1', 't2'], blocks = true;
738
  d.set(tagLayout(I, true, order));
739
  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'] } } : {}); });
740
  d.check('rigid blocks', true, v => blocks = v);
741
}
742
743
{ // d4a orientation flip
744
  const W = 640, H = 300, d = new Demo('d4a', W, H), I = inner(W, H);
745
  let rows = false;
746
  const L = () => rows ? splitY(I, ['A', 'B', 'C']) : splitX(I, ['A', 'B', 'C']);
747
  d.set(L());
748
  d.button('Flip orientation', () => { rows = !rows; d.go(L()); });
749
  d.check('naive', false, v => d.naive = v);
750
}
751
752
{ // d4b grow both axes
753
  const W = 640, H = 300, d = new Demo('d4b', W, H), I = inner(W, H);
754
  let wide = false;
755
  const L = () => {
756
    const r = splitY(I, ['top', 'bot'], wide ? [7, 3] : [1, 1]);
757
    return { ...splitX(r.top, ['A', 'B'], wide ? [7, 3] : [1, 1]), ...splitX(r.bot, ['C', 'D']) };
758
  };
759
  d.set(L());
760
  d.button('Grow A in both directions', () => { wide = !wide; d.go(L()); });
761
}
762
763
{ // d5 lanes
764
  const W = 640, H = 300, d = new Demo('d5', W, H), I = inner(W, H);
765
  let order = ['A', 'B', 'C', 'D'];
766
  const L = () => splitX(I, order);
767
  d.set(L());
768
  d.button('Swap first two', () => { order = [order[1], order[0], ...order.slice(2)]; d.go(L()); });
769
  d.button('Rotate', () => { order = [...order.slice(1), order[0]]; d.go(L()); });
770
  d.button('Reverse', () => { order = [...order].reverse(); d.go(L()); });
771
  d.button('Reset', () => { order = ['A', 'B', 'C', 'D']; d.go(L()); });
772
}
773
774
{ // d6 interrupts
775
  const W = 640, H = 260, d = new Demo('d6', W, H, { graph: true }), I = inner(W, H);
776
  const targets = [
777
    () => splitX(I, ['A', 'B', 'C']),
778
    () => splitX(I, ['C', 'A', 'B']),
779
    () => splitX(I, ['A', 'B', 'C'], [3, 1, 1]),
780
    () => splitX(I, ['B', 'C', 'A']),
781
    () => ({ ...splitX(I, ['A', 'C'], [2, 1]), B: R(I.x + I.w * 0.66, I.y + I.h, I.w * 0.34, 0) }),
782
  ];
783
  const withB = L => { if (!L.B) L.B = R(I.x, I.y, 0, 0); return L; };
784
  let idx = 0;
785
  d.set(withB(targets[0]()));
786
  const next = () => { idx = (idx + 1) % targets.length; d.go(withB(targets[idx]())); };
787
  d.button('Next target', next);
788
  d.button('Storm (8 rapid interrupts)', () => {
789
    let n = 0;
790
    const fire = () => { next(); if (++n < 8) setTimeout(fire, 180 + Math.random() * 300); };
791
    fire();
792
  });
793
  d.check('match velocity', true, v => d.vmatch = v);
794
}
795
796
{ // d7 across outputs
797
  const W = 680, H = 260;
798
  const o1 = R(0, 0, 320, H), o2 = R(360, 0, 320, H);
799
  const d = new Demo('d7', W, H, { outputs: [o1, o2] });
800
  const I1 = inner(320, H, 0), I2 = inner(320, H, 360);
801
  let s1 = ['A', 'B'], s2 = ['C'];
802
  const L = () => ({ ...splitX(I1, s1), ...splitX(I2, s2) });
803
  d.set(L());
804
  d.button('Move B across', () => {
805
    if (s1.includes('B')) { s1 = s1.filter(x => x !== 'B'); s2 = ['B', ...s2]; } else { s2 = s2.filter(x => x !== 'B'); s1 = [...s1, 'B']; }
806
    d.go(L());
807
  });
808
  d.button('Move A across', () => {
809
    if (s1.includes('A')) { s1 = s1.filter(x => x !== 'A'); s2 = ['A', ...s2]; } else { s2 = s2.filter(x => x !== 'A'); s1 = ['A', ...s1]; }
810
    d.go(L());
811
  });
812
  d.button('Reset', () => { s1 = ['A', 'B']; s2 = ['C']; d.go(L()); });
813
}
814
815
function layoutTree(node, rect, visible, out) {
816
  if (typeof node === 'string') { out[node] = visible(node) ? rect : R(rect.x, rect.y, 0, 0); return; }
817
  const horiz = node.dir === 'row';
818
  const shown = node.kids.map(k => anyVisible(k, visible));
819
  const m = shown.filter(Boolean).length;
820
  const len = horiz ? rect.w : rect.h;
821
  const avail = len - G * Math.max(0, m - 1);
822
  let pos = horiz ? rect.x : rect.y, seen = 0;
823
  node.kids.forEach((k, i) => {
824
    if (!shown[i]) {
825
      const p = seen ? pos - G / 2 : pos;
826
      const r = horiz ? R(p, rect.y, 0, rect.h) : R(rect.x, p, rect.w, 0);
827
      layoutTree(k, r, () => false, out);
828
      return;
829
    }
830
    const size = avail / m;
831
    const r = horiz ? R(pos, rect.y, size, rect.h) : R(rect.x, pos, rect.w, size);
832
    layoutTree(k, r, visible, out);
833
    pos += size + G; seen++;
834
  });
835
}
836
function anyVisible(node, visible) { return typeof node === 'string' ? visible(node) : node.kids.some(k => anyVisible(k, visible)); }
837
838
{ // d8 one tree, tags filter
839
  const W = 640, H = 320, d = new Demo('d8', W, H), I = inner(W, H);
840
  const tagOf = { A: 1, B: 1, C: 1, D: 2, E: 2 };
841
  const view = { 1: true, 2: true };
842
  let mixed = false;
843
  const tree = () => mixed
844
    ? { dir: 'col', kids: [{ dir: 'row', kids: ['B', 'C'] }, { dir: 'row', kids: ['D', 'A', 'E'] }] }
845
    : { dir: 'col', kids: [{ dir: 'row', kids: ['A', 'B', 'C'] }, { dir: 'row', kids: ['D', 'E'] }] };
846
  const L = () => { const out = {}; layoutTree(tree(), I, id => view[tagOf[id]], out); return out; };
847
  d.set(L());
848
  d.button('Toggle tag 1 (A, B, C)', () => { view[1] = !view[1]; d.go(L()); });
849
  d.button('Toggle tag 2 (D, E)', () => { view[2] = !view[2]; d.go(L()); });
850
  d.button('Intersperse A between D and E', () => { mixed = !mixed; d.go(L()); });
851
}
852
853
function stackLayouts(I) {
854
  const col = (L, id, ids) => { const r = L[id]; delete L[id]; Object.assign(L, splitY(r, ids)); return L; };
855
  const row = (L, id, ids) => { const r = L[id]; delete L[id]; Object.assign(L, splitX(r, ids)); return L; };
856
  return {
857
    'A | B | C': () => splitX(I, ['A', 'B', 'C']),
858
    'A | (B / C)': () => col(splitX(I, ['A', 'R']), 'R', ['B', 'C']),
859
    '(A / C) | B': () => col(splitX(I, ['L', 'B']), 'L', ['A', 'C']),
860
    '(A / B) | C': () => col(splitX(I, ['L', 'C']), 'L', ['A', 'B']),
861
    'B | C | A': () => splitX(I, ['B', 'C', 'A']),
862
    'A / B / C': () => splitY(I, ['A', 'B', 'C']),
863
    'A / (B | C)': () => row(splitY(I, ['A', 'R']), 'R', ['B', 'C']),
864
    '(A / B) | (C / D)': () => col(col(splitX(I, ['L', 'R']), 'L', ['A', 'B']), 'R', ['C', 'D']),
865
    'A | (C / B / D)': () => col(splitX(I, ['A', 'R']), 'R', ['C', 'B', 'D']),
866
    '(A / D) | (C / B)': () => col(col(splitX(I, ['L', 'R']), 'L', ['A', 'D']), 'R', ['C', 'B']),
867
    '(A / B / C) | D': () => col(splitX(I, ['L', 'D']), 'L', ['A', 'B', 'C']),
868
    '(A / C) | (B / D)': () => col(col(splitX(I, ['L', 'R']), 'L', ['A', 'C']), 'R', ['B', 'D']),
869
  };
870
}
871
872
for (const [id, names] of [
873
  ['d9a', ['A | B | C', 'A | (B / C)', '(A / C) | B', '(A / B) | C', 'B | C | A', 'A / B / C', 'A / (B | C)']],
874
  ['d9b', ['(A / B) | (C / D)', 'A | (C / B / D)', '(A / D) | (C / B)', '(A / B / C) | D', '(A / C) | (B / D)']],
875
]) {
876
  const W = 640, H = 300, d = new Demo(id, W, H), I = inner(W, H), S = stackLayouts(I);
877
  d.set(S[names[0]]());
878
  for (const n of names) d.button(n, () => d.go(S[n]()));
879
}
880
881
{ // d9c grid
882
  const W = 640, H = 300, d = new Demo('d9c', W, H), I = inner(W, H), S = stackLayouts(I);
883
  let at = 0;
884
  const names = ['(A / D) | (C / B)', '(A / B / C) | D'];
885
  d.set(S[names[0]]());
886
  d.button('(A / D) | (C / B)  ⇄  (A / B / C) | D', () => { at = 1 - at; d.go(S[names[at]]()); });
887
}
888
889
{ // d10 floating to tiled
890
  const W = 640, H = 300, d = new Demo('d10', W, H, { floating: ['F'] }), I = inner(W, H);
891
  const FL = R(220, 40, 220, 130);
892
  let tiled = false;
893
  const tiledL = () => splitX(I, ['A', 'B', 'F', 'C']);
894
  const floatL = () => ({ ...splitX(I, ['A', 'B', 'C']), F: FL });
895
  const ortho = () => STYLE === 'orthogonal';
896
  const assemble = (n, tiledAt, fAt, kind) => {
897
    const keys = [];
898
    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) }); }
899
    return { kind, keys, durs: keys.slice(1).map(() => PHASE_MS) };
900
  };
901
  const floatIn = from => {
902
    const slot = tiledL().F, to = tiledL();
903
    const bx = (from.B.x + from.B.w + from.C.x) / 2;
904
    const tp = plan({ A: from.A, B: from.B, C: from.C, P: R(bx, I.y, 0, I.h) }, { A: to.A, B: to.B, C: to.C, P: slot });
905
    const k = tp.durs.length, f = from.F;
906
    const cx = slot.x + slot.w / 2 - f.w / 2, cy = slot.y + slot.h / 2 - f.h / 2;
907
    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)];
908
    const n = Math.max(path.length - 1, k) + 1;
909
    return assemble(n, i => tp.keys[Math.min(i, k)], i => i === n ? slot : path[Math.min(i, path.length - 1)], `float in, slot ${tp.kind}`);
910
  };
911
  const floatOut = from => {
912
    const slot = from.F, to = floatL();
913
    const bx = (to.B.x + to.B.w + to.C.x) / 2;
914
    const tp = plan({ A: from.A, B: from.B, C: from.C, P: slot }, { A: to.A, B: to.B, C: to.C, P: R(bx, I.y, 0, I.h) });
915
    const k = tp.durs.length;
916
    const lift = R(slot.x + slot.w / 2 - FL.w / 2, slot.y + slot.h / 2 - FL.h / 2, FL.w, FL.h);
917
    const path = ortho() ? [slot, lift, R(lift.x, FL.y, FL.w, FL.h), FL] : [slot, lift, FL];
918
    const n = Math.max(path.length - 1, k + 1);
919
    return assemble(n, i => tp.keys[Math.max(0, Math.min(i - 1, k))], i => path[Math.min(i, path.length - 1)], `lift out, slot ${tp.kind}`);
920
  };
921
  d.set(floatL());
922
  d.button('Tile F / float F', () => { tiled = !tiled; d.go(tiled ? tiledL() : floatL(), { planFn: tiled ? floatIn : floatOut }); });
923
}
924
925
document.getElementById('phaseMs').oninput = e => { PHASE_MS = +e.target.value; document.getElementById('phaseMsOut').textContent = PHASE_MS + ' ms'; };
926
document.getElementById('style').onchange = e => { STYLE = e.target.value; };
927
const loop = now => { for (const d of demos) d.frame(now); requestAnimationFrame(loop); };
928
requestAnimationFrame(loop);
929
</script>
930
</body>
931
</html>