1/* ============================================================================ 2 Beat 2 - the AI tutor, mobile. Builder B. 3 4 Exactly one global: 5 6 BeatTutor.init(root) root is the #beatTutor element. 7 BeatTutor.setProgress(p) p is 0..1 across the beat's 150svh. 8 9 No listeners on window or document. The host's single rAF loop calls 10 setProgress with its own lerped value. The one rAF this file does schedule is 11 the bounded 45-frame catch-up in kickSettle, which exists only because the 12 pane applies its own classes a frame or two after setTarget; it cancels itself 13 and never drives progress. 14 15 ONE SCREEN. The headline is up from the first frame and stays to the last; 16 the Euler window sits below it in a bordered card, the whole pane at native 17 type size. Nothing lifts out and nothing takes the screen. What this file 18 changes across the beat is the session inside the window, plus a 12px settle 19 on the card over the first tenth of the beat. 20 21 ROUND M3b. The lede, the pane's floating Back and menu buttons and 40px of 22 the dock are cut (see beat-tutor.css), and everything they freed is in the 23 card: 350 x 520 becomes 350 x 630 and the board window 288px becomes 490px at 24 390x844. Two numbers here move with them, CARD_AR and the column's own top 25 inset, and the second is now read off the pane rather than hardcoded. 26 27 ROUND M5 answers the client's "why is there space between the tutor diagram 28 and the closed caption?". The card's height was set by the ratio and the room 29 under the copy with no reference to how much column the pane has to show, and 30 the shortfall - 38.6px at 390x844, 116.7px at 375x667 - landed as white above 31 the caption bubble. Three changes, all here: fit() also clamps the card to 32 what the lit column can fill, moveCol may move the column down by TOP_DROP so 33 a column shorter than the band can still meet the dock, and DOCK_GAP comes to 34 16 to match the clearance the column's own padding already gives the last 35 block. The figure now sits 16.6px above the caption bubble at every viewport 36 in the target set, against 16.6px for the KEY IDEA card one beat later. 37 The ask bar's own defect is entirely in beat-tutor.css, item 3. 38 39 ROUND M6 answers "for the ai tutor, start from the first part instead of 40 directly coming to the bottom of the tutor board and have the user scroll a 41 litle bit to show some content being scrolled." Three changes, and they are a 42 set - any one of them alone makes the beat worse: 43 44 1. MAP's floor moves from the pane's beat 2 to its beat 1, so the beat 45 opens on the session's first content instead of on a board that already 46 holds every block. The note on MAP says why the floor is not 0.00. 47 2. fit() clamps the card against the OPENING block as well as the tallest 48 one. Without it the new floor is round M2's half-empty card returning: 49 212px of lit column in a 484px board window. With it the card is 440 50 rather than 624 at 390x844, and the figure inside it is unchanged, 51 because the figure's box comes from the card's width. 52 3. moveCol places the column as a function of p rather than snapping and 53 letting a 0.5s CSS transition catch up. The board now travels 348px 54 across the beat, in two moves tied to the scroll, and a cold load at any 55 p lands on the frame that scrolling there would have produced. 56 57 Everything below is a fraction of the beat, never a pixel count or an svh, so 58 the TUTOR=short variant (100svh instead of 150svh, a knob in Builder A's 59 beats.json) needs nothing from this file: the figure lands and completes at 60 the same fractions and only the thumb cost changes. 61 62 The one measurement it does make is the fit: the card's height from its width 63 and the room left under the copy, and the real height of the pane's floating 64 dock at that width. That runs at init and once more on document.fonts.ready, 65 because Bebas Neue's fallback metrics are much wider and would leave the copy 66 two lines taller. It writes two custom properties; it never runs on scroll. 67 ========================================================================= */ 68(function () { 69 'use strict'; 70 71 /* Beat progress -> pane session progress, as control points. 72 Gotcha 14: the whole session streams before the figure arrives, so a 73 straight 0..1 pass lands the drawing far too late in a 150svh beat. The 74 pane is started mid-session instead. 75 76 The mobile pane's own timeline (its Readme, section 3), in pane progress q: 77 78 q 0.00-0.09 beat 0 avatar and turn title 79 q 0.09-0.20 beat 1 the violet callout 80 q 0.20-0.30 beat 2 the figure frame lands, step 0, axes and grid only 81 q 0.30-0.44 beat 3 step 1 q 0.44-0.58 beat 4 step 2 82 q 0.58-0.72 beat 5 step 3 q 0.72-0.86 beat 6 step 5 83 q 0.86-1.00 beat 7 the KEY IDEA card, step 6 84 85 THE FLOOR IS 0.10, AND IT IS 0.10 RATHER THAN 0.00 FOR ONE REASON. 86 Client, round M6: "for the ai tutor, start from the first part instead of 87 directly coming to the bottom of the tutor board and have the user scroll a 88 litle bit to show some content being scrolled." 89 90 Rounds M2 to M5 ran a 0.20 floor, which opens on the pane's beat 2 with the 91 figure frame already on the board and nothing drawn in it. That was chosen 92 to stop the card reading half empty, and it is what he has now rejected: a 93 board that arrives with every block already on it has nothing left to 94 scroll, so the beat reads as a screenshot with a drawing animating inside 95 it. The floor is now inside the pane's beat 1, so the beat opens on the 96 session's first content and the board fills as the visitor scrolls. 97 98 IT IS NOT 0.00, AND THAT COSTS NOTHING HE ASKED FOR. Beat 0 is the greeting,
99 "Welcome. Today we'll explore how air resistance changes the optimal launch 100 angle.". Two things are wrong with playing it. Its caption measures 88px at 101 a 350 card, three lines, against Sriram's round 6b rule that the tutor's 102 talk is two lines a beat and against the pane's own 66px caption reserve, 103 so the bubble would stand proud of its wrap. And it puts nothing on the 104 board that this fragment keeps: its blocks are 0, the "S" user avatar, cut 105 here by the data-ep="0" rule in beat-tutor.css, and 1, the turn title, 106 which beat 1 carries anyway. So beat 1 IS the session's first content: the 107 turn title and the violet callout, under a 65px two-line caption. 108 109 WHAT THE OPENING FRAME COSTS, MEASURED. At the opening the lit column is 110 the title and the callout, 212px at a 350 card, against a figure block of 111 252px. A card sized for the figure cannot be filled by the opening, so the 112 card is now also clamped against the OPENING block (OPEN_SLACK in fit(), 113 and the ladder that set it is in the note there). Tallest blank band inside 114 the card at 390x844, measured the same way as round M5's 7.1%: 115 116 p 0.00 the opening, title and callout 101px 23.0% 117 p 0.10 the figure lands, below the fold 44px 10.1% 118 p 0.30 drawing 28px 6.4% 119 p 0.55 drawing 28px 6.4% 120 p 0.85 KEY IDEA 16px 3.6% 121 122 The 23.0% is the price of the client's own instruction and it is not 123 reducible below it: a session that has just started has two blocks in it. 124 What it is NOT is round M2's 52.5%, and it is at the BOTTOM of the card, 125 under the last thing the tutor said, where a chat that has not filled its 126 screen yet always has it. 127 128 Everything is a fraction of the beat and never an svh or a pixel count, so 129 TUTOR=short (100svh instead of 150svh) plays the identical sequence. 130 131 opening, title + callout p 0.00 -> 0.10 (10% of the beat) 132 figure lands p 0.10 | in place by p 0.20 133 first curve p 0.203 134 curves drawing p 0.203 -> 0.78 (58% of the beat) 135 figure complete p 0.78 | KEY IDEA lands p 0.78, 136 in place by 0.88, held to 1.00 137 138 against the brief's "figure on screen by 0.30, complete by 0.90". The 139 drawing owns 58% of the beat against 66% before; 10% of that went to the 140 opening the client asked for and 8% to landing the KEY IDEA early enough 141 to be read, which at the old 0.84 would have been still sliding into place 142 at p 0.85. */ 143 var MAP = [[0.00, 0.10], [0.10, 0.20], [0.78, 0.86], [0.88, 1.00], [1.00, 1.00]]; 144 145 /* the card's arrival. Opacity is NOT animated: the window is fully present at 146 p = 0 so a cold load at the top of the beat is a complete frame, and only a 147 12px settle reads as arrival. 148 CARD_RISE is 12 and not more because --bt-bot is 14: a bigger start offset 149 pushes the card's bottom corner past the stage's edge and the very first 150 frame of the beat is shot with 2px of it clipped. */ 151 var CARD_IN_A = 0.00, CARD_IN_B = 0.10, CARD_RISE = 12; 152 153 /* the card's shape. Held as a ratio so a wider phone gets a wider card and a 154 taller board window instead of a bigger gap, clamped to the room left under 155 the copy, AND clamped to what the session itself can fill (see fit()). 156 Nothing here is a viewport unit or a beat fraction; it is the box the pane 157 is given, once, at init. 158 159 1.80 and not more: with the lede gone, 350 x 1.80 = 630 at 390x844 and the 160 stack's own centring splits whatever slack is left above and below the 161 group, so the card has air rather than sitting hard on the safe-area floor. 162 The room clamp is what protects a narrower phone, where the headline takes 163 a third line: at 360x780 the room is 563px and the card takes all of it. 164 MAX_H is 720 because the card is capped at the pane's native 390px wide and 165 390 x 1.80 = 702. MIN_H is 400, which is the smallest card whose board 166 window (400 - 12 - 128 = 260) still holds the 350x250 figure whole. 167 168 THE FILL CLAMP IS ROUND M5's FIX AND IT IS THE ONE THAT MATTERS. See fit(). 169 Since the host nav came down from 82px to 56px the room clamp almost never 170 binds, so without the fill clamp the freed 26px would land as white inside 171 the card rather than as a bigger window. */ 172 var CARD_AR = 1.80, MIN_H = 400, MAX_H = 720; 173 174 /* How much white the OPENING frame is allowed to carry under its last block. 175 Round M6's one taste number, and the ladder that chose it is in fit(). */ 176 var OPEN_SLACK = 100; 177 178 function clamp(v) { return v < 0 ? 0 : v > 1 ? 1 : v; } 179 function ramp(v, a, b) { return b === a ? (v >= b ? 1 : 0) : clamp((v - a) / (b - a)); } 180 function easeOut(t) { return 1 - Math.pow(1 - t, 3); } 181 182 function piecewise(pts, p) { 183 for (var i = 1; i < pts.length; i++) { 184 if (p <= pts[i][0]) { 185 var a = pts[i - 1], b = pts[i]; 186 var t = b[0] === a[0] ? 1 : (p - a[0]) / (b[0] - a[0]); 187 return a[1] + (b[1] - a[1]) * t; 188 } 189 } 190 return pts[pts.length - 1][1]; 191 } 192 193 /* MAP read backwards: the beat progress at which the pane reaches a given q. 194 It is what turns a pane beat boundary into a page progress, which is how 195 the column's travel is tied to the thumb rather than to a timer - see
196 moveCol. MAP is monotonic in q, so this is a straight walk; a q below the 197 floor answers 0, which is correct, that q is never played. */ 198 function invMap(q) { 199 var pts = MAP; 200 if (q <= pts[0][1]) return pts[0][0]; 201 for (var i = 1; i < pts.length; i++) { 202 if (q <= pts[i][1]) { 203 var a = pts[i - 1], b = pts[i]; 204 var t = b[1] === a[1] ? 1 : (q - a[1]) / (b[1] - a[1]); 205 return a[0] + (b[0] - a[0]) * t; 206 } 207 } 208 return pts[pts.length - 1][0]; 209 } 210 211 var root = null, stack = null, copy = null, card = null; 212 var api = null, last = -1, lastQ = -1; 213 214 /* -------------------------------------------------------------------------- 215 The fit. Writes the card's height and the pane's real dock height. This is 216 the one place in the fragment that reflows the pane, and it runs twice, both 217 times at init - never on scroll. 218 ------------------------------------------------------------------------ */ 219 function fit() { 220 if (!root || !stack || !card || !copy) return; 221 var cs = getComputedStyle(stack); 222 var padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0); 223 var gap = parseFloat(getComputedStyle(card).marginTop) || 0; 224 var avail = stack.clientHeight - padY - copy.getBoundingClientRect().height - gap; 225 var w = card.getBoundingClientRect().width; /* CSS owns the width: one gutter each side */ 226 var h = w * CARD_AR; 227 if (h > avail) h = avail; 228 if (h > MAX_H) h = MAX_H; 229 /* MIN_H IS AN ADVISORY FLOOR, NOT A HARD ONE, AND THAT DISTINCTION IS THE 230 WHOLE POINT OF IT. 100svh is not the device height: an iPhone 13 in 231 Safari with its chrome showing resolves 100svh to about 659px, not 844, 232 and the stage this beat lives in is 100svh. At 659 the room under the 233 copy is 495px and at a 600px stage it is 436px, both of which the card 234 can take. Below a 564px stage the room falls under MIN_H, and a hard 235 floor there would make the card taller than the stack that holds it - 236 which, with justify-content:center inside an overflow:hidden stage, saws 237 the ask bar off the bottom and the headline off the top at the same time. 238 A card smaller than the figure is a worse-looking beat; a card with its 239 input bar cut in half is a broken one. The room wins. 240 241 Nothing else in the beat carries a fixed height that the stage can 242 outrun: the pane's own 480px min-height is overridden to 0 in 243 beat-tutor.css, its dock is absolutely positioned at the card's bottom 244 and its board is flex:1 with min-height:0, so every pixel of the squeeze 245 lands on the board window and none of it on the caption bubble or the 246 ask bar. */ 247 if (h < MIN_H) h = Math.min(MIN_H, avail); 248 if (h < 120) h = 120; 249 /* provisional, so the dock below lays out in a box the right order of size. 250 Nothing measured after this depends on it: the column's blocks, the figure 251 and the dock are all width-driven. */ 252 root.style.setProperty('--bt-cardh', h.toFixed(1) + 'px'); 253 254 /* The dock is a real component, not a constant: at 350 wide its caption can 255 take a line more than it does at the pane's native 390. Measure what it 256 actually is and hand that to --epm-dock, which is what the board column 257 pads its bottom by and what moveCol reads. 258 259 --epm-dock is how much of the bottom the dock COVERS, which is not its box 260 height: its top 48px of padding is the gradient fade the board is meant to 261 run into, and counting it would reserve a band of empty paper above the 262 caption bubble. Measured box minus that padding is the opaque part. After 263 the M3b cuts the box is 176 at 350 wide and the padding is still 48, so 264 128 - against 168 before them and the pane's own 148 at 390. 265 266 It is measured after the height is written so the dock has settled into 267 the box it will live in. Nothing in the dock's own height depends on 268 --epm-dock, so there is no loop. */ 269 var dockH = 128; 270 if (dockEl) { 271 var ds = getComputedStyle(dockEl); 272 var dh = Math.round(dockEl.getBoundingClientRect().height - (parseFloat(ds.paddingTop) || 0)); 273 if (dh > 0) { dockH = dh; root.style.setProperty('--bt-dock', dh + 'px'); } 274 } 275 /* the column's own top inset, off the live style rather than restated here. 276 beat-tutor.css sets it to 12px now that the pane's floating chrome is 277 cut; reading it means a change there needs no change in this file. */ 278 if (colEl) { 279 var ct = parseFloat(getComputedStyle(colEl).paddingTop); 280 if (ct >= 0) COL_TOP = ct; 281 } 282 283 /* THE FILL CLAMP. The defect it fixes, in the client's words: "why is there 284 space between the tutor diagram and the closed caption?" 285 286 The card's height used to be set by the ratio and the room under the copy 287 alone, with no reference to how much column the pane actually has to 288 show. At 390x844 that gave a 630px card, a 502px board above the dock, 289 and only 464px of lit column to put in it - the session title, the violet 290 callout and the figure. moveCol bottom-aligns the newest block to the 291 dock, but its scroll clamps at 0 (a column cannot scroll backwards), so 292 the 38px shortfall landed as white between the figure's ruled bottom edge 293 and the caption bubble for the first 84% of the beat. Measured 38.6px at 294 390x844, 51.7 at 402x874, 82.1 at 430x932, and 116.7 at 375x667, where 295 the block was top-aligned instead and the whole shortfall sat at the 296 bottom in one piece. 297 298 So the card is now also clamped to the height at which the lit column 299 reaches the dock: 300 301 card <= (bottom of the tallest block the beat dwells on) 302 + DOCK_GAP + the dock's opaque height + TOP_DROP 303 304 The last block is left out of that maximum on purpose. KEY IDEA is lit 305 only from p 0.84 and the column's own padding-bottom (--epm-dock + 16) 306 already governs where it lands; counting it would make the clamp read the 307 full 772px column, which never binds, and the beat would be back to 308 leading on 38px of white. 309 310 TOP_DROP is the one thing the clamp does not have to buy back, because
311 moveCol is now allowed to move the column DOWN by that much (see there). 312 It comes back as a top inset on the session title, where 12px was tight 313 anyway. */ 314 if (colEl && blockEls.length > 1) { 315 measureCol(); 316 var fill = 0; 317 for (var i = 0; i < blockEls.length - 1; i++) { 318 if (tops[i] + hs[i] > fill) fill = tops[i] + hs[i]; 319 } 320 var capH = fill + DOCK_GAP + dockH + TOP_DROP; 321 322 /* THE OPENING CLAMP, ROUND M6. The fill clamp above sizes the card to the 323 block the beat DWELLS on, the figure. Since the floor moved into the 324 pane's beat 1 the beat also has to open on a column that is only the 325 title and the callout - 212px at a 350 card against the figure's 252 - 326 and a card sized for the figure cannot be filled by that. Left alone it 327 is round M2's fault coming back: at the 624px card round M5 landed on, 328 the opening frame is 272px of white below the callout, 44% of the card. 329 330 So the card is clamped a second time, against the OPENING block, with 331 OPEN_SLACK as the white the opening frame is allowed to carry under its 332 last block. It is a real trade and it is the trade this round is: every 333 pixel of card above (opening bottom + OPEN_SLACK) is a pixel the 334 opening frame cannot fill, and every pixel below it is a pixel of board 335 window. The ladder was built and shot at 390x844 before it was picked - 336 opening bottom 212, dock 128, figure block 252: 337 338 OPEN_SLACK card board window opening blank board travel 339 (none) 624 484 285px 45.7% 164px 340 200 540 400 201px 37.2% 248px 341 140 480 340 141px 29.4% 308px 342 100 440 300 101px 23.0% 348px 343 60 400 260 61px 15.2% 388px 344
345 100 is what ships, and the reason it is not lower is the board window: 346 at 60 it is 260px against a 252px figure, so the figure fills the band 347 and the taller-than-the-band branch in moveCol starts firing on a short 348 stage. At 100 the window is 300px, 48px of room around the figure. 349 350 THE COST IS NOT WHAT IT LOOKS LIKE. The card goes 624 -> 440, but the 351 FIGURE does not change by a pixel: it is 252px at a 350 card whatever 352 the card's height, because its box comes from the card's WIDTH through 353 the iframe's own 420/300 aspect. What comes off is paper the beat could 354 not have filled, and what it buys is the 348px of travel the client 355 asked to see. The type is untouched: 19px title, 15.5px body, 13.5px 356 callout, 15px caption, all the product's own. */ 357 var open = 0, oi = openNewest(blockEls.length - 1); 358 if (oi >= 0 && oi < blockEls.length) open = tops[oi] + hs[oi]; 359 if (open > 0) { 360 var capO = open + TOP_DROP + dockH + OPEN_SLACK; 361 if (capO < capH) capH = capO; 362 } 363 364 if (fill > 0 && capH < h) { 365 /* the same advisory floor as above, and for the same reason: on a short 366 stage the room has already taken the card under MIN_H and the clamp 367 must not put it back over the room. */ 368 var floor = Math.min(MIN_H, avail); 369 h = capH < floor ? floor : capH; 370 if (h < 120) h = 120; 371 root.style.setProperty('--bt-cardh', h.toFixed(1) + 'px'); 372 } 373 } 374 375 curN = -2; yApplied = null; /* the band changed, so re-place the column */ 376 moveCol(); 377 } 378 379 /* -------------------------------------------------------------------------- 380 The transcript column, moved here instead of by the pane. 381 382 The pane centres the newest block in the band between its own top inset 383 (64px, re-pointed at 12px here now that the chrome it cleared is cut) and 384 the top of its floating dock. On the late beats, once the column is 385 finally taller than the board, that leaves white between the newest card 386 and the caption bubble. This bottom-aligns the newest block to the dock 387 instead, at every beat, so the bubble always reads as narrating the thing 388 directly above it. Round M4 clamped that alignment at 0 and round M5 does 389 not: the floor is -TOP_DROP, because a column shorter than the band has 390 nothing above to scroll away and can only meet the dock by moving down. 391 Everything the card's own fill clamp could not take out comes out there, 392 and it comes out at the top of the board rather than in the middle of the 393 frame. 394 395 The class is only set once the column, the board and the blocks are all 396 found, so a pane built differently keeps its own behaviour. The easing is a 397 CSS transition, so this file still owns no progress loop. 398 ------------------------------------------------------------------------ */ 399 var colEl = null, boardEl = null, blockEls = [], paneEl = null, dockEl = null, onCls = 'epm-on', mobile = true; 400 var tops = [], hs = []; 401 /* Clearance between the newest block and the top of the dock's opaque part, 402 which is where the caption bubble's own top edge sits (the bubble measures 403 65px inside a 66px wrap, bottom-anchored). 404 405 16, down from 24 in round M4. 16 is not a taste number: the pane's column 406 already carries padding-bottom: calc(var(--epm-dock) + 16px), so the LAST 407 block in the session is landed 16px above the dock by the scroll clamp 408 whatever this constant says. At 24 the figure sat 24px clear and the KEY 409 IDEA card 16px clear, in the same card, one beat apart. Now both are 16 and 410 the bubble reads as floating just under whatever it is narrating. */ 411 var DOCK_GAP = 16; 412 /* How much of the beat one move of the column takes. 413 414 ROUND M6, AND IT IS THE HALF OF THE CLIENT'S NOTE THAT MATTERS: "have the 415 user scroll a litle bit to show some content being scrolled." Until now the 416 column jumped to its new place on a 0.5s CSS transition, which is a timer, 417 not the thumb; and with the 0.20 floor there was only ever one jump, at 418 p 0.84. Now the newest block's landing place is reached over MOVE_SPAN of 419 the BEAT, measured from the page progress at which the pane's own beat 420 introduced that block. Two consequences worth having: 421 422 - the board scrolls while the visitor scrolls, at roughly the page's own 423 rate. 0.10 of a 150svh beat is 127px of thumb, against the 184px the 424 column travels when the figure arrives and the 164px it travels when 425 KEY IDEA does. 348px of board travel across the beat, in two moves, 426 where round M5 had one move of 148px at p 0.84 and nothing before it. 427 - a cold load anywhere in the beat lands on the frame that scrolling 428 there would have produced, because the column's offset is an absolute 429 function of p and not an ease from wherever it happened to be. The 0.5s 430 transition could not do that: on a cold load it started from the 431 opening position and ran on wall-clock time. 432 433 The CSS transition is turned off (.bt-colp) whenever this path is live. It 434 needs the pane's own beat table, which is read through the documented 435 EulerPaneM.beatAt API at init; a pane that does not expose it keeps the 436 transition and the old step behaviour. */ 437 var MOVE_SPAN = 0.10; 438 /* How far moveCol may push the column DOWN, past the top of its own content. 439 A column that is shorter than the band cannot be bottom-aligned by 440 scrolling - there is nothing above to scroll away - so the shortfall used 441 to land as white above the caption bubble. Letting the column drop moves 442 that white to the top of the board, where it reads as the card's own inset 443 above the session title rather than as a hole in the middle of the frame. 444 16px, and fit()'s fill clamp is what guarantees the drop is never asked to 445 be more than this. */ 446 var TOP_DROP = 16; 447 /* The column's own top inset. It was the pane's 64px clearance under the 448 floating chrome; with the chrome cut, beat-tutor.css re-points it at 12px, 449 which is breathing room off the card's top edge and nothing more. It is 450 READ rather than restated, so the two files cannot drift: fit() writes it 451 from the live computed style and this is only the value before the first 452 measurement. */ 453 var COL_TOP = 12; 454 455 function offsetIn(el, stop) { 456 var t = 0; 457 while (el && el !== stop) { t += el.offsetTop; el = el.offsetParent; } 458 return t; 459 } 460 function measureCol() { 461 tops = []; hs = []; 462 for (var i = 0; i < blockEls.length; i++) { 463 tops.push(offsetIn(blockEls[i], colEl)); 464 hs.push(blockEls[i].offsetHeight); 465 } 466 } 467 468 /* The pane applies its own reveal classes from its own easing loop, one or 469 more frames after setTarget. The host may stop calling setProgress before 470 that lands, so a short bounded catch-up runs after every change of target:
471 45 frames, self-cancelling, no listeners. */ 472 var settle = 0, sraf = 0; 473 function settleLoop() { 474 sraf = 0; 475 moveCol(); 476 if (--settle > 0) sraf = requestAnimationFrame(settleLoop); 477 } 478 function kickSettle() { 479 settle = 45; 480 if (!sraf) sraf = requestAnimationFrame(settleLoop); 481 } 482 483 /* WHERE THE NEWEST BLOCK WANTS TO LAND. Split out of moveCol in round M6 so 484 that the same rule can be asked about a block that is not the current one: 485 the ramp in moveCol needs the block it is travelling FROM as well as the 486 one it is travelling to, and asking for both by index is what makes the 487 column's offset an absolute function of p. */ 488 function targetFor(n) { 489 var bh = boardEl.clientHeight, y; 490 if (mobile) { 491 var dock = parseFloat(getComputedStyle(paneEl).getPropertyValue('--epm-dock')) || 128; 492 /* the band the visitor can actually see: under the column's own top 493 inset, above the floating dock. 494 495 ONE RULE NOW, NOT TWO. The newest block is bottom-aligned to the dock, 496 so the caption bubble always reads as narrating the thing directly 497 above it. The only exception is a block TALLER than the whole band, 498 which is top-aligned so its head is not sliced off against the card's 499 top edge with its tail under the dock. 500 501 Round M4 had a 0.62-of-the-band threshold instead, and it produced a 502 gap at both ends of it. Above the threshold (375x667: the figure at 503 0.66) the block top-aligned and the whole shortfall sat under it, 504 116.7px of it. Below the threshold (390x844: the figure at 0.51) the 505 block bottom-aligned but the scroll clamped at 0 and 38.6px of it sat 506 under it anyway, because the lit column was shorter than the band. The 507 threshold was picking which end of the card the hole appeared at, not 508 whether there was one. fit()'s fill clamp is what removes the hole; the 509 threshold then has nothing left to decide. 510 511 Measured at 350 wide after round M6's opening clamp: card 440, band 512 300px, figure block 252px, KEY IDEA 148px. The figure is 0.84 of the 513 band, so it bottom-aligns with 48px of the callout still showing above 514 it. The taller-than-the-band branch is the guard on that 48px: at a 515 600svh stage the room clamp takes the card to 436 and the band to 296, 516 and a copy block that ever took another line would push the figure 517 across it. */ 518 var band = bh - COL_TOP - dock; 519 if (hs[n] > band) y = tops[n] - COL_TOP - 8; 520 else y = tops[n] + hs[n] + DOCK_GAP + dock - bh; /* newest block sits just above the dock */ 521 } else if (hs[n] > bh - 32) { 522 y = tops[n] - 12; /* taller than the board: top-align it */ 523 } else { 524 y = tops[n] + hs[n] - bh + 20; 525 } 526 /* The floor is -TOP_DROP, not 0. A negative y translates the column DOWN, 527 which is the only way a column shorter than the band can have its last 528 lit block meet the dock. Anything the fill clamp could not take out of 529 the card comes out here, and it comes out at the top of the board rather 530 than as a hole above the caption bubble. */ 531 var maxY = Math.max(0, colEl.scrollHeight - bh); 532 return Math.max(mobile ? -TOP_DROP : 0, Math.min(y, maxY)); 533 } 534 535 /* The pane's own beat table, read at init through the documented 536 EulerPaneM.beatAt(q) API by walking it beat by beat. What is wanted from it 537 is one number per block: the pane progress at which that block first became 538 the newest one. invMap turns each into a page progress, and that is the 539 start of the block's move. Nothing here hardcodes the table - a pane with a 540 different session, or the desktop capture, either answers or does not, and 541 if it does not the fragment falls back to the old step behaviour. */ 542 var qNewest = null; 543 function readBeats() { 544 qNewest = null; 545 if (!api || typeof api.beatAt !== 'function') return; 546 try { 547 var out = {}, acc = -1, q = 0, guard = 0; 548 while (q < 1 && guard++ < 64) { 549 var b = api.beatAt(q); 550 if (!b || typeof b.b !== 'number' || b.b <= q) break; 551 var bl = b.blocks || [], m = -1; 552 for (var i = 0; i < bl.length; i++) if (bl[i] > m) m = bl[i]; 553 if (m > acc) { acc = m; out[m] = b.a; } 554 q = b.b + 1e-6; 555 } 556 if (acc >= 0) qNewest = out; 557 } catch (e) { qNewest = null; } 558 } 559 /* the page progress at which block n started moving into place, and the block 560 it displaced. Both are read off qNewest, so both are the same at every p. */ 561 function moveStart(n) { 562 return (qNewest && qNewest[n] !== undefined) ? invMap(qNewest[n]) : -1; 563 } 564 function prevNewest(n) { 565 if (!qNewest) return -1; 566 var best = -1; 567 for (var k in qNewest) { k = +k; if (k < n && k > best) best = k; } 568 return best; 569 }
570 /* which block is the newest one in the beat's OPENING frame, i.e. at the 571 MAP's own floor. fit() clamps the card against it. Without a beat table the 572 answer is the block before the one the beat dwells on, which is the same 573 answer for this session and a reasonable one for any other. */ 574 function openNewest(fallback) { 575 var q0 = piecewise(MAP, 0), best = -1; 576 if (qNewest) { 577 for (var k in qNewest) { k = +k; if (qNewest[k] <= q0 + 1e-9 && k > best) best = k; } 578 if (best >= 0) return best; 579 } 580 return fallback === undefined ? -1 : fallback - 1; 581 } 582 583 /* THE PLACEMENT. y is a pure function of p: which block is newest decides the 584 two ends of the move, and p decides how far along it is. Nothing reads the 585 currently applied offset, which is what makes a cold load at any p identical 586 to scrolling to that p. */ 587 var curN = -2, yTo = 0, yFrom = 0, pMove = 0, yApplied = null; 588 589 function moveCol() { 590 if (!colEl || !blockEls.length) return; 591 var n = -1; 592 for (var i = 0; i < blockEls.length; i++) if (blockEls[i].classList.contains(onCls)) n = i; 593 if (n !== curN) { 594 curN = n; 595 if (n < 0) { yTo = yFrom = 0; pMove = 0; } 596 else { 597 measureCol(); /* the figure's height only settles once its srcdoc reports an aspect, so re-read on each change of the newest block - eight reads across the whole beat */ 598 yTo = targetFor(n); 599 var pn = prevNewest(n); 600 yFrom = pn >= 0 ? targetFor(pn) : yTo; 601 pMove = moveStart(n); 602 if (pMove < 0) { yFrom = yTo; pMove = 0; } /* no beat table: snap, and let the CSS transition do the easing as it did before round M6 */ 603 } 604 } 605 var y = qNewest ? yFrom + (yTo - yFrom) * easeOut(ramp(last < 0 ? 0 : last, pMove, pMove + MOVE_SPAN)) 606 : yTo; 607 if (yApplied === null || Math.abs(y - yApplied) > 0.05) { 608 yApplied = y; 609 colEl.style.setProperty('--bt-coly', (-y).toFixed(1) + 'px'); 610 } 611 } 612 613 function findPane() { 614 if (!root) return null; 615 return root.querySelector('#euler-pane-m') || root.querySelector('#euler-pane'); 616 } 617 618 function init(el) { 619 inited = true; 620 root = el || document.getElementById('beatTutor'); 621 if (!root) return; 622 stack = root.querySelector('.bt-stack'); 623 copy = root.querySelector('.bt-copy'); 624 card = root.querySelector('.bt-card'); 625 626 var paneRoot = findPane(); 627 if (!paneRoot) { 628 /* the pane token in beat-tutor.html was never substituted. Hide the card 629 rather than print the raw token, and leave the copy working. */ 630 root.classList.add('bt-nopane'); 631 } else { 632 api = window.EulerPaneM || window.EulerPane || null; 633 try { if (api && typeof api.init === 'function') api.init(paneRoot); } catch (e) {} 634 /* the shared v2 pane freezes itself fully drawn below 1001px. If the 635 mobile pane inherits that guard, undo it; this beat drives the session. */ 636 paneRoot.classList.remove('ep-static'); 637 638 paneEl = paneRoot; 639 dockEl = paneRoot.querySelector('.epm-dock'); 640 mobile = !!paneRoot.querySelector('.epm-col'); 641 onCls = mobile ? 'epm-on' : 'ep-on'; 642 colEl = paneRoot.querySelector(mobile ? '.epm-col' : '.ep-col'); 643 boardEl = paneRoot.querySelector(mobile ? '.epm-board' : '.ep-board'); 644 blockEls = [].slice.call(paneRoot.querySelectorAll('[data-ep]')); 645 if (colEl && boardEl && blockEls.length) { 646 measureCol(); 647 root.classList.add('bt-colown'); 648 /* the pane's beat table, for the scroll-linked column travel. Only when 649 it answers does the column stop using the CSS transition; a pane that 650 does not expose beatAt keeps round M5's step behaviour exactly. */ 651 readBeats(); 652 if (qNewest) root.classList.add('bt-colp'); 653 curN = -2; yApplied = null; 654 } else { colEl = null; } 655 } 656 657 fit(); 658 /* Bebas Neue's fallback reserves far more width, so the headline measures 659 one line taller before the webfont lands and the card would be sized for a 660 copy block it no longer has. The pane's own Inter lands on the same 661 promise, which is also when its dock settles at its final height. 662 document.fonts.ready is a promise, not a listener on document, and this is 663 the same fix the hero cycler uses. */ 664 try { 665 if (document.fonts && document.fonts.ready && document.fonts.ready.then) { 666 document.fonts.ready.then(fit); 667 } 668 } catch (e) {} 669 670 last = -1; lastQ = -1; 671 setProgress(0); 672 } 673 674 function drive(q) { 675 if (!api) return; 676 if (lastQ >= 0 && Math.abs(q - lastQ) < 0.0008) return; 677 lastQ = q; 678 try { 679 if (typeof api.setTarget === 'function') api.setTarget(q); 680 else if (typeof api.setProgress === 'function') api.setProgress(q); 681 } catch (e) {} 682 kickSettle(); 683 } 684 685 var inited = false;
686 function setProgress(p) { 687 /* tolerate a host that drives before it inits */ 688 if (!root && !inited) { inited = true; init(null); } 689 if (!root) return; 690 p = clamp(+p || 0); 691 if (last >= 0 && Math.abs(p - last) < 0.0004) return; 692 last = p; 693 694 /* the only thing progress moves outside the pane. A transform, never a 695 reflow, and it is over by a tenth of the beat. */ 696 if (card) { 697 var t = easeOut(ramp(p, CARD_IN_A, CARD_IN_B)); 698 card.style.transform = 'translate3d(0,' + (CARD_RISE * (1 - t)).toFixed(2) + 'px,0)'; 699 } 700 701 drive(piecewise(MAP, p)); 702 moveCol(); 703 } 704 705 window.BeatTutor = { 706 init: init, 707 setProgress: setProgress, 708 /* read-only, for the harness and for anyone checking the timing */ 709 paneProgress: function (p) { return piecewise(MAP, clamp(p)); } 710 }; 711})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.