atagen

monolith

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