-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscript.js
More file actions
741 lines (673 loc) · 30.5 KB
/
Copy pathscript.js
File metadata and controls
741 lines (673 loc) · 30.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
/* ============================================================
AERØ — scroll-driven frame sequence + data-driven composition
Two things worth reading before changing anything.
1. The sequence is cross-faded, not snapped. Rounding the
playhead to an integer frame means the picture can only change
once every `px per frame` of scroll, which is what reads as
lag even when nothing is actually slow. Drawing the frame
below the playhead and compositing the one above it at the
fractional alpha makes every pixel of scroll produce visible
change.
2. The giant word is placed from measured data, not guesses.
assets/frames-meta.json carries, for every frame, the
aircraft's bounding box and how much of the frame it covers,
extracted offline. The word picks the largest free band around
that box and steps back when coverage is high. That is what
keeps the product the hero instead of hoping the text lands
somewhere harmless.
============================================================ */
(function () {
"use strict";
/* ---------- CONFIGURATION -------------------------------------
The only place asset paths live. The brief assumed
/frames/frame-001.webp; the supplied set is 0-indexed webp in
drone_frames/, and index 0 is a white studio shot that hard
cuts to the dark sequence, so the journey starts at 1. */
var CONFIG = {
dir: "drone_frames/",
prefix: "frame_",
ext: ".webp",
pad: 6,
first: 1, /* skip index 0: different lighting, hard cut */
last: 239,
meta: "assets/frames-meta.json",
gate: 12, /* frames the loader waits for */
frameW: 1280,
frameH: 720
};
function framePath(i) {
return CONFIG.dir + CONFIG.prefix + String(i).padStart(CONFIG.pad, "0") + CONFIG.ext;
}
var COUNT = CONFIG.last - CONFIG.first + 1;
var SMOOTH_MS = 68; /* playhead time constant; short on purpose */
var BLEND_STEP = 0.05; /* redraw resolution, in frames */
document.documentElement.classList.add("js");
var reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
/* ---------- CHAPTERS ------------------------------------------
Ranges are fractions of the journey runway. They are placed on
what the footage actually shows, checked frame by frame: a wide
hover to ~f32, the push in to ~f88, arms and motors to ~f112,
the body and power module to ~f172, sensors to ~f196, systems
to ~f222, then the pull back out. */
var CHAPTERS = [
{ from: 0.00, to: 0.10, word: "FLIGHT", idx: "01",
title: "Engineered for flight.",
body: "A professional aerial platform, resolved down to every part that makes it fly.",
card: "SYSTEM", specs: ["Aerial platform", "Stabilised optics", "Autonomous sensing"],
tags: [] },
{ from: 0.10, to: 0.20, word: "FORM", idx: "02",
title: "Closer than a spec sheet.",
body: "The camera moves in. Nothing is hidden behind a rendering.",
card: "AIRFRAME", specs: ["Four axis", "Folding arms", "Integrated gimbal"],
tags: [["PROPELLER", 18, 26, "left"], ["GIMBAL", 62, 70, "right"]] },
{ from: 0.20, to: 0.31, word: "FRAME", idx: "03",
title: "One load path.",
body: "Shell, arms and mounts resolve into a single structure carrying one load.",
card: "STRUCTURE", specs: ["Load bearing shell", "Keyed joints", "Sealed seams"],
tags: [["SHELL", 24, 34, "left"], ["JOINT", 66, 58, "right"]] },
{ from: 0.31, to: 0.42, word: "BALANCE", idx: "04",
title: "Held in the air.",
body: "Thrust is trimmed continuously across four axes so the frame holds its line.",
card: "GEOMETRY", specs: ["Symmetric thrust", "Locked geometry", "Damped mounts"],
tags: [["ARM", 20, 62, "left"], ["MOUNT", 70, 40, "right"]] },
{ from: 0.42, to: 0.52, word: "DRIVE", idx: "05",
title: "Every movement is controlled.",
body: "A brushless drive unit at each corner, corrected thousands of times a second.",
card: "DRIVE", specs: ["Brushless motor", "Direct drive", "Sealed bearing"],
tags: [["MOTOR", 26, 44, "left"], ["BLADE", 64, 26, "right"]] },
{ from: 0.52, to: 0.62, word: "CORE", idx: "06",
title: "Inside the airframe.",
body: "The shell opens onto the architecture it protects. Everything has a seat.",
card: "INTERNAL", specs: ["Stacked boards", "Shielded runs", "Thermal path"],
tags: [["BOARD", 22, 36, "left"], ["VENT", 68, 62, "right"]] },
{ from: 0.62, to: 0.72, word: "POWER", idx: "07",
title: "Power without compromise.",
body: "The cell carries its own contacts and slides into the structure it powers.",
card: "POWER", specs: ["Integrated cell", "Keyed contact", "Tool free service"],
tags: [["CELL", 24, 30, "left"], ["CONTACT", 62, 52, "right"]] },
{ from: 0.72, to: 0.83, word: "VISION", idx: "08",
title: "It sees the space around it.",
body: "Multi-directional sensing built into the airframe, not bolted onto it.",
card: "VISION", specs: ["Forward sensing", "Downward sensing", "Continuous"],
tags: [["SENSOR", 20, 40, "left"], ["LENS", 66, 34, "right"]] },
/* The climax. Frames ~197-222 are a genuine full exploded view:
lens stack, motors, board, bearings and shell all separated in
space. The labels below point at parts that are actually on
screen in that stretch, which is why they are placed where they
are rather than spread evenly. */
{ from: 0.83, to: 0.93, word: "EVERY PART", idx: "09",
/* the component index below the journey already carries
"Every part has a purpose", so this beat gets its own line */
title: "Taken apart in mid air.",
body: "Lens stack, drive units, flight board, shell. Nothing in it is decorative.",
card: "EXPLODED", specs: ["Lens stack", "Drive units", "Flight board"],
tags: [["OPTICS", 17, 40, "left"], ["BOARD", 40, 62, "left"],
["DRIVE", 63, 44, "right"], ["BEARING", 55, 26, "right"]] },
{ from: 0.93, to: 1.00, word: "TAKE FLIGHT", idx: "10",
title: "Built as one.",
body: "Every part back in its place. A single instrument, ready to leave the ground.",
card: "AERØ", specs: ["Professional system", "Field serviceable", "Flight ready"],
tags: [] }
];
/* ---------- helpers ------------------------------------------- */
function clamp(n, a, b) { return n < a ? a : n > b ? b : n; }
function vhNow() { return window.innerHeight; }
function ramp(n, a, b) { return b === a ? (n >= b ? 1 : 0) : clamp((n - a) / (b - a), 0, 1); }
function ease(n) { return n * n * (3 - 2 * n); }
/* ---------- elements ------------------------------------------ */
var boot = document.getElementById("boot");
var bootFill = document.getElementById("bootFill");
var bootPct = document.getElementById("bootPct");
var bootState = document.getElementById("bootState");
var bootSkip = document.getElementById("bootSkip");
var stage = document.getElementById("stage");
var canvas = document.getElementById("seq");
/* opaque: this canvas never composites into transparency, and an
opaque context is measurably cheaper to clear and blit */
var ctx = canvas.getContext("2d", { alpha: false });
var journey = document.getElementById("journey");
var giant = document.getElementById("giant");
var giantWord = document.getElementById("giantWord");
var chapterEl = document.getElementById("chapter");
var chIdx = document.getElementById("chIdx");
var chTitle = document.getElementById("chTitle");
var chBody = document.getElementById("chBody");
var card = document.getElementById("card");
var cardLabel = document.getElementById("cardLabel");
var cardList = document.getElementById("cardList");
var tagsEl = document.getElementById("tags");
var progNow = document.getElementById("progNow");
var progFill = document.getElementById("progFill");
var huds = [].slice.call(document.querySelectorAll("[data-hud]"));
/* ---------- state --------------------------------------------- */
var images = new Array(CONFIG.last + 1);
var meta = null;
var ready = false;
var vw = 0, vh = 0, dpr = 1;
var runway = 1;
var progress = 0;
var target = CONFIG.first;
var shown = CONFIG.first;
var ticking = false, lastAt = 0, dirty = true, drawnKey = "";
var chapterNow = -1;
/* ============================================================
LOADING
The gate is the first handful of frames only. Everything else
streams in afterwards, and draw() falls back to the nearest
decoded frame in the meantime, so scrolling early degrades to
a coarser sequence rather than to a blank canvas.
============================================================ */
function loadRange(from, to, onEach, done) {
var left = to - from + 1;
if (left <= 0) { if (done) done(); return; }
for (var i = from; i <= to; i++) {
var img = new Image();
img.decoding = "async";
images[i] = img;
/* onerror too, or one missing file leaves the loader stuck */
img.onload = img.onerror = tick;
img.src = framePath(i);
}
function tick() {
left--;
if (onEach) onEach();
if (left === 0 && done) done();
}
}
function frameAt(i) {
var img = images[i];
return img && img.complete && img.naturalWidth ? img : null;
}
/* nearest decoded frame, so an un-streamed index still paints */
function nearestLoaded(i) {
var hit = frameAt(i);
if (hit) return hit;
for (var d = 1; d < 24; d++) {
hit = frameAt(i - d); if (hit) return hit;
hit = frameAt(i + d); if (hit) return hit;
}
return null;
}
/* ============================================================
SIZING
============================================================ */
/* Every layout-dependent number the scroll handler needs, measured
once here instead of on every tick. Reading offsetTop/offsetWidth
inside a scroll handler forces a synchronous layout, and there
were nine such reads per tick between the composition, the stage
exit and the nav; that showed up as a 28ms spike in the paint
timings. Nothing below the fold changes size while scrolling, so
caching costs nothing in accuracy. */
var geom = { top: 0, cardW: 172, cardH: 149, sections: [] };
function measure() {
geom.top = journey.offsetTop;
geom.cardW = card.offsetWidth || 172;
geom.cardH = card.offsetHeight || 149;
geom.sections = (sections || []).map(function (s) {
return s ? s.offsetTop : Infinity;
});
runway = Math.max(1, journey.offsetHeight - window.innerHeight);
}
function resize() {
var w = canvas.clientWidth, h = canvas.clientHeight;
if (!w || !h) return; /* hidden or collapsed: wait */
dpr = Math.min(window.devicePixelRatio || 1, 2);
vw = w; vh = h;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
measure();
fitCache = {}; /* fitted sizes are viewport bound */
dirty = true;
drawnKey = "";
stills.forEach(drawStill);
}
/* ============================================================
DRAW
Cover-fit with a cross-fade between the two frames the
fractional playhead sits between.
============================================================ */
function paint(index) {
var pos = clamp(index, CONFIG.first, CONFIG.last);
var base = Math.floor(pos);
var mix = pos - base;
var blend = mix > 0.001;
var under = nearestLoaded(base);
var over = blend ? frameAt(base + 1) : null;
if (!under && !over) return false;
var s = Math.max(vw / CONFIG.frameW, vh / CONFIG.frameH);
var dw = CONFIG.frameW * s, dh = CONFIG.frameH * s;
var x = (vw - dw) / 2, y = (vh - dh) / 2;
if (under) ctx.drawImage(under, x, y, dw, dh);
if (over) {
ctx.globalAlpha = under ? mix : 1;
ctx.drawImage(over, x, y, dw, dh);
ctx.globalAlpha = 1;
}
/* a partial paint must not be cached, or nothing repaints when
the missing frame finishes streaming */
return !!under && (!blend || !!over);
}
function render() {
if (!vw || !vh) return;
var key = Math.round(shown / BLEND_STEP);
if (!dirty && key === drawnKey) return;
var complete = paint(shown);
drawnKey = complete ? key : "";
dirty = false;
}
/* ============================================================
COMPOSITION FROM MEASURED OCCUPANCY
For every frame, assets/frames-meta.json carries a 16x9 grid of
how occupied each cell is and how much of the frame the aircraft
covers overall, both extracted offline. That is what the giant
word and the floating card are positioned from, so the layout
responds to where the machine actually is instead of hoping a
hardcoded position stays clear of it.
============================================================ */
function metaFor(i) {
if (!meta) return null;
var v = meta.frames[String(clamp(Math.round(i), CONFIG.first, CONFIG.last))];
return v || null;
}
/* The largest genuinely free rectangle, found in the occupancy
grid rather than around the bounding box.
The box is far too coarse for this footage: a hovering aircraft
spans about 90% of the frame corner to corner, so box-based
banding reports a 34px sliver and the word collapses to nothing.
The grid tells the truth. On the same frame it shows two
completely empty rows above the aircraft and two below, because
what lies between the propeller tips is starfield.
Maximal rectangle over a 16x9 grid of occupancy values, the
standard largest-rectangle-under-a-histogram sweep. Cheap enough
to run per frame and it handles top, bottom and side space with
one piece of logic instead of four special cases. */
var FREE = 1.4; /* cell occupancy counted as empty, of 0..9 */
/* `taken` optionally blanks out cells already claimed, so the card
can be given the best region that is left after the word has
chosen. Without it the two fight over the same rectangle and the
card simply never finds room in a wide shot, where the largest
free region is a long flat band the word wants. */
function freeRect(v, taken) {
var GW = meta.grid[0], GH = meta.grid[1], g = v.grid;
var heights = new Array(GW).fill(0);
var best = { x: 0, y: 0, w: 0, h: 0, area: 0 };
for (var y = 0; y < GH; y++) {
for (var x = 0; x < GW; x++) {
var open = g[y * GW + x] <= FREE;
if (open && taken && x >= taken.x && x < taken.x + taken.w
&& y >= taken.y && y < taken.y + taken.h) open = false;
heights[x] = open ? heights[x] + 1 : 0;
}
/* widest rectangle ending on this row, per candidate height */
for (var i = 0; i < GW; i++) {
if (!heights[i]) continue;
var hMin = heights[i];
for (var j = i; j < GW; j++) {
if (!heights[j]) break;
if (heights[j] < hMin) hMin = heights[j];
var area = hMin * (j - i + 1);
if (area > best.area) {
best = { x: i, y: y - hMin + 1, w: j - i + 1, h: hMin, area: area };
}
}
}
}
return best;
}
/* grid cells -> viewport px, through the same cover-fit transform
the frame itself is drawn with */
function rectToViewport(rect) {
var GW = meta.grid[0], GH = meta.grid[1];
var s = Math.max(vw / CONFIG.frameW, vh / CONFIG.frameH);
var dw = CONFIG.frameW * s, dh = CONFIG.frameH * s;
var ox = (vw - dw) / 2, oy = (vh - dh) / 2;
var x = ox + (rect.x / GW) * dw;
var y = oy + (rect.y / GH) * dh;
var w = (rect.w / GW) * dw;
var h = (rect.h / GH) * dh;
/* Clip to what is on screen, then reserve the bands the fixed
furniture owns. The bottom reserve is much larger on a phone
because the chapter caption moves down there, and without it
the word happily picks the bottom band and lands on the copy. */
var narrow = vw < 780;
var topKeep = narrow ? 62 : 70;
var botKeep = narrow ? 176 : 88;
var x0 = Math.max(x, 8), x1 = Math.min(x + w, vw - 8);
var y0 = Math.max(y, topKeep), y1 = Math.min(y + h, vh - botKeep);
return { x: x0, y: y0, w: Math.max(0, x1 - x0), h: Math.max(0, y1 - y0),
cells: rect.w * rect.h };
}
/* Fit the word to the band rather than clamping a font-size. One
measurement at a reference size gives the size that fills the
target, because metrics scale linearly. A clamp cannot do this:
its ceiling is a guess and the word either overflows a narrow
band or floats tiny inside a wide one. */
var fitCanvas = document.createElement("canvas");
var fitCtx = fitCanvas.getContext("2d");
var REF = 200;
var fitCache = {};
function fitWord(text, maxW, maxH) {
var k = text + "|" + Math.round(maxW) + "|" + Math.round(maxH);
if (fitCache[k]) return fitCache[k];
fitCtx.font = "800 " + REF + "px Inter, Arial, sans-serif";
var m = fitCtx.measureText(text);
var asc = m.actualBoundingBoxAscent || REF * 0.72;
var desc = m.actualBoundingBoxDescent || REF * 0.02;
var byW = REF * (maxW / Math.max(1, m.width));
var byH = REF * (maxH / Math.max(1, asc + desc));
var size = Math.max(28, Math.min(byW, byH));
fitCache[k] = size;
return size;
}
function compose(p, frame) {
var v = metaFor(frame);
if (!v) return;
var word = CHAPTERS[chapterNow] ? CHAPTERS[chapterNow].word : "FLIGHT";
var wordCells = freeRect(v);
var free = rectToViewport(wordCells);
/* Fit into the free rectangle, with a little breathing room, and
never let a single word run wider than the viewport. */
var size = fitWord(word, Math.min(free.w * 0.96, vw * 0.94), free.h * 0.88);
/* The product always wins. Two independent brakes:
`cover` is how much of the frame the aircraft occupies, and
`cells` is how much genuinely empty grid there is to put a
word in. Either one running out pulls the word back rather
than letting it sit on the machine. */
var crowd = ramp(v.cover, 0.46, 0.68);
var starved = 1 - ramp(free.cells, 6, 20);
var back = Math.max(ease(crowd), starved);
/* A giant word that cannot be giant should not pretend to be one:
at 40-odd pixels it stops reading as an architectural element
and starts looking like a stray caption. */
var small = 1 - ramp(size, 62, 108);
var op = (0.94 - 0.82 * Math.max(back, small)) * (1 - ramp(p, 0.972, 1));
var scale = 1 - 0.2 * back;
var cx = free.x + free.w / 2, cy = free.y + free.h / 2;
/* No honest negative space left. Rather than dropping the word
for a whole chapter, which breaks the through-line, it becomes
the graphic element the brief asks for: enormous, hard cropped
by the viewport edge, and dim enough that it reads as texture
in the dark rather than as ink on the machine. Screen blending
does the rest, since the lit airframe simply overpowers it. */
if (size < 62 && p < 0.97) {
size = vh * 0.46;
var toLeft = free.x + free.w / 2 < vw / 2;
cx = toLeft ? -vw * 0.06 : vw * 1.06; /* most of it off screen */
cy = vh * 0.5;
scale = 1;
op = 0.17 * (1 - 0.35 * ease(crowd));
giantWord.dataset.mode = "edge";
} else {
giantWord.dataset.mode = "fit";
if (free.w < 90 || free.h < 40) op = 0;
}
giantWord.textContent = word;
giantWord.style.fontSize = size.toFixed(1) + "px";
giantWord.style.setProperty("--gx", (cx / vw * 100).toFixed(2) + "%");
giantWord.style.setProperty("--gy", (cy / vh * 100).toFixed(2) + "%");
giantWord.style.setProperty("--gsc", scale.toFixed(3));
giantWord.style.setProperty("--go", op.toFixed(3));
/* The card gets the best region left after the word has taken
its own, so the two never stack and neither goes over the
aircraft. In this footage that puts it in the side gaps and
corners of the wider shots and hides it during the close-ups,
which is the behaviour the composition wants anyway. */
var cw = geom.cardW, chh = geom.cardH;
var spare = rectToViewport(freeRect(v, wordCells));
var cardOn = spare.w > cw + 16 && spare.h > chh + 12 && back < 0.6 ? 1 : 0;
if (cardOn) {
var px = clamp(spare.x + spare.w / 2 - cw / 2, 14, vw - cw - 14);
/* the chapter caption owns the lower left corner, so the card is
kept clear of it rather than stacking on top of the copy */
var floor = px < vw * 0.52 ? vh - chh - 250 : vh - chh - 92;
var py = clamp(spare.y + spare.h / 2 - chh / 2, 74, Math.max(74, floor));
card.style.setProperty("--cx", px.toFixed(0) + "px");
card.style.setProperty("--cy", py.toFixed(0) + "px");
}
card.style.setProperty("--co", cardOn ? "1" : "0");
}
/* ---------- chapter swap -------------------------------------- */
function chapterAt(p) {
for (var i = CHAPTERS.length - 1; i >= 0; i--) if (p >= CHAPTERS[i].from) return i;
return 0;
}
function buildTags(ch) {
tagsEl.innerHTML = "";
ch.tags.forEach(function (t, i) {
var b = document.createElement("b");
b.textContent = t[0];
b.style.setProperty("--tx", t[1] + "%");
b.style.setProperty("--ty", t[2] + "%");
b.style.setProperty("--tw", (26 + i * 12) + "px");
b.setAttribute("data-side", t[3]);
tagsEl.appendChild(b);
requestAnimationFrame(function () {
setTimeout(function () { b.classList.add("is-on"); }, 90 + i * 130);
});
});
}
function setChapter(i) {
if (i === chapterNow) return;
chapterNow = i;
var ch = CHAPTERS[i];
chapterEl.classList.add("is-swapping");
setTimeout(function () {
chIdx.textContent = ch.idx + " / 10";
chTitle.textContent = ch.title;
chBody.textContent = ch.body;
cardLabel.textContent = ch.card;
cardList.innerHTML = "";
ch.specs.forEach(function (s) {
var li = document.createElement("li");
li.textContent = s;
cardList.appendChild(li);
});
chapterEl.classList.remove("is-swapping");
}, 190);
buildTags(ch);
progNow.textContent = ch.idx;
}
/* ============================================================
SCROLL
============================================================ */
function onScroll() {
progress = clamp((window.scrollY - geom.top) / runway, 0, 1);
target = CONFIG.first + progress * (COUNT - 1);
setChapter(chapterAt(progress));
compose(progress, target);
progFill.style.width = (progress * 100).toFixed(2) + "%";
/* the opening HUD steps aside once the journey is underway */
var fade = 1 - ramp(progress, 0.02, 0.08);
huds.forEach(function (el, i) { el.style.opacity = i === 3 ? "1" : fade.toFixed(3); });
/* release the fixed stage as the journey ends, so the sections
below are not competing with a live canvas behind them */
var exit = ease(ramp(window.scrollY,
geom.top + runway + vhNow() * 0.12,
geom.top + runway + vhNow() * 0.9));
stage.style.opacity = (1 - exit).toFixed(3);
stage.style.visibility = exit >= 0.999 ? "hidden" : "visible";
if (reduced) { shown = target; dirty = true; render(); }
else if (!ticking) { ticking = true; requestAnimationFrame(loop); }
}
function loop(now) {
var dt = lastAt ? Math.min(now - lastAt, 64) : 16;
lastAt = now;
/* time based, not a flat fraction per tick: a flat step eases
twice as fast on a 120Hz display as on a 60Hz one */
shown += (target - shown) * (1 - Math.exp(-dt / SMOOTH_MS));
if (Math.abs(target - shown) < BLEND_STEP * 0.5) {
shown = target; ticking = false; lastAt = 0;
} else {
requestAnimationFrame(loop);
}
render();
}
/* ============================================================
STILLS
The feature sections and the closing hero want one frame each.
They reuse the same decoded images, drawn cover-fit into their
own canvases, so nothing is fetched twice.
============================================================ */
var stills = [].slice.call(document.querySelectorAll("[data-still]"));
function drawStill(el) {
var n = +el.getAttribute("data-still");
var img = nearestLoaded(n);
var w = el.clientWidth, h = el.clientHeight;
if (!img || !w || !h) return;
var d = Math.min(window.devicePixelRatio || 1, 2);
if (el.width !== Math.round(w * d)) {
el.width = Math.round(w * d);
el.height = Math.round(h * d);
}
var c = el.getContext("2d");
c.setTransform(d, 0, 0, d, 0, 0);
var s = Math.max(w / CONFIG.frameW, h / CONFIG.frameH);
var dw = CONFIG.frameW * s, dh = CONFIG.frameH * s;
c.fillStyle = "#1B222B";
c.fillRect(0, 0, w, h);
c.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh);
}
/* The oversized background words and the component rows fill their
glyphs with a frame. A relative url() inside a custom property
resolves against the stylesheet that consumes it, not the
document, so "drone_frames/x" would be looked for next to
style.css. Absolute, always. */
function wireFills() {
[].slice.call(document.querySelectorAll("[data-fill], [data-still]"))
.forEach(function (el) {
var n = el.getAttribute("data-fill") || el.getAttribute("data-still");
var abs = new URL(framePath(+n), document.baseURI).href;
el.style.setProperty("--fill", 'url("' + abs + '")');
});
/* the component rows read --fill from their <li> */
[].slice.call(document.querySelectorAll(".index__list li")).forEach(function (li) {
var h3 = li.querySelector("h3");
if (h3) h3.style.setProperty("--fill", li.style.getPropertyValue("--fill"));
});
}
/* ============================================================
BOOT
============================================================ */
function enter() {
if (ready) return;
ready = true;
document.body.classList.remove("is-loading");
boot.classList.add("is-done");
resize();
onScroll();
render();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", function () { resize(); onScroll(); });
new ResizeObserver(function () { if (ready) { resize(); onScroll(); } }).observe(canvas);
/* everything after the gate, in the background */
loadRange(CONFIG.first + CONFIG.gate, CONFIG.last, function () {
dirty = true; render();
}, function () { stills.forEach(drawStill); });
}
bootSkip.addEventListener("click", enter);
setTimeout(function () { bootSkip.classList.add("is-on"); }, 2600);
wireFills();
/* meta and the gate frames load together; neither blocks the other */
fetch(CONFIG.meta)
.then(function (r) { return r.json(); })
.then(function (j) { meta = j; })
.catch(function () { meta = null; }); /* composition falls back to centred */
var gateDone = 0;
var gateTotal = CONFIG.gate + 1;
loadRange(CONFIG.first, CONFIG.first + CONFIG.gate, function () {
gateDone++;
var pct = Math.round(gateDone / gateTotal * 100);
bootFill.style.width = pct + "%";
bootPct.textContent = pct;
if (pct > 55) bootState.textContent = "CALIBRATING SEQUENCE";
}, function () {
bootState.textContent = "READY";
setTimeout(enter, 380);
});
/* hard ceiling: never hold the page hostage to the network */
setTimeout(enter, 9000);
/* ============================================================
LOWER PAGE
============================================================ */
var io = new IntersectionObserver(function (es) {
es.forEach(function (e) {
if (!e.isIntersecting) return;
e.target.classList.add("is-in");
if (e.target.hasAttribute("data-still")) drawStill(e.target);
io.unobserve(e.target);
});
}, { rootMargin: "80px 0px -10% 0px", threshold: 0.12 });
[].slice.call(document.querySelectorAll(".line, .reveal, .feat__shot, [data-still]"))
.forEach(function (el) { io.observe(el); });
/* component rows: hover carries it on a pointer, tap on touch */
[].slice.call(document.querySelectorAll(".index__list li")).forEach(function (li) {
li.addEventListener("click", function () {
var open = li.classList.contains("is-open");
[].slice.call(document.querySelectorAll(".index__list li"))
.forEach(function (o) { o.classList.remove("is-open"); });
if (!open) li.classList.add("is-open");
});
});
/* nav */
var nav = document.getElementById("nav");
var navMenu = document.getElementById("navMenu");
var navLinks = [].slice.call(document.querySelectorAll("#navLinks a"));
var sections = navLinks.map(function (a) { return document.querySelector(a.getAttribute("href")); });
var activeLink = null;
navMenu.addEventListener("click", function () {
var on = navMenu.getAttribute("aria-expanded") === "true";
navMenu.setAttribute("aria-expanded", String(!on));
});
function navState() {
nav.classList.toggle("is-stuck", window.scrollY > 30);
var line = window.scrollY + window.innerHeight * 0.36;
var found = null;
for (var i = 0; i < sections.length; i++) {
if (geom.sections[i] <= line) found = navLinks[i];
}
if (found === activeLink) return;
activeLink = found;
navLinks.forEach(function (a) { a.classList.toggle("is-active", a === found); });
}
navState();
window.addEventListener("scroll", navState, { passive: true });
/* redraw stills on resize, but not on every scroll tick */
var stillTimer;
window.addEventListener("resize", function () {
clearTimeout(stillTimer);
stillTimer = setTimeout(function () { stills.forEach(drawStill); }, 140);
});
/* ---------- test surface -------------------------------------- */
window.__aero = {
get progress() { return progress; },
get frame() { return shown; },
get target() { return target; },
get chapter() { return chapterNow; },
get meta() { return meta; },
config: CONFIG,
chapters: CHAPTERS,
canvas: canvas,
runway: function () { return runway; },
loaded: function () {
var n = 0;
for (var i = CONFIG.first; i <= CONFIG.last; i++) if (frameAt(i)) n++;
return n;
},
/* rAF is stopped in a hidden pane, so automated checks need a
way to advance the playhead and paint synchronously */
settle: function () {
shown = target; ticking = false; lastAt = 0; dirty = true;
render();
return shown;
},
free: function () {
var v = metaFor(target);
return v ? rectToViewport(freeRect(v)) : null;
},
cells: function () {
var v = metaFor(target);
return v ? freeRect(v) : null;
}
};
})();