atagen

monolith

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