Repository navigation
Expand file tree
/
Copy pathmdxComponents.tsx
More file actions
2155 lines (2038 loc) · 77 KB
/
Copy pathmdxComponents.tsx
File metadata and controls
2155 lines (2038 loc) · 77 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
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import {
Children,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import { animate, AnimatePresence, motion, useMotionTemplate, useMotionValue, useMotionValueEvent, useSpring, useTransform, type MotionValue } from 'motion/react'
import { F } from './Fragment'
import { Code, CodeMorph, Pre } from './Code'
import { ClaudeMark } from './ClaudeMark'
import { UNFOLD_DURATION, unfoldCircleAt, unfoldContact, type UnfoldCircle } from './unfoldMotion'
import { useSlide } from './slideContext'
import { balloonLaunch, balloonRest, CloudIn, FlightBackdrop, OrbLaunch, vesselHide } from './Traveller'
export function Cols({ children, n = 2, gap = 40 }: { children: ReactNode; n?: number; gap?: number }) {
return (
<div className="cols" style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))`, gap }}>
{children}
</div>
)
}
export function Box({
children,
tone = 'ink',
title,
}: {
children: ReactNode
tone?: 'ink' | 'blue' | 'pink' | 'warn'
title?: string
}) {
return (
<div className={`box box--${tone}`}>
{title && <div className="box__title">{title}</div>}
<div className="box__body">{children}</div>
</div>
)
}
export function Big({ children }: { children: ReactNode }) {
return <div className="big">{children}</div>
}
export function Stat({ value, label, size = 'md' }: { value: ReactNode; label: ReactNode; size?: 'md' | 'xl' }) {
return (
<div className={`stat stat--${size}`}>
<div className="stat__value">{value}</div>
<div className="stat__label">{label}</div>
</div>
)
}
export function Tag({ children, tone = 'blue' }: { children: ReactNode; tone?: 'blue' | 'pink' | 'ink' }) {
return <span className={`tag tag--${tone}`}>{children}</span>
}
/**
* The push -> pickup pipeline. Each <Step> reveals on its own click and the
* connector between steps animates in with it.
*/
export function Flow({ children }: { children: ReactNode }) {
return <div className="flow">{children}</div>
}
export function Step({
children,
ms,
at,
tone = 'ink',
}: {
children: ReactNode
/** Budget for this hop, rendered under the box. */
ms?: string
at?: number
tone?: 'ink' | 'blue' | 'pink' | 'warn'
}) {
const slide = useSlide()
const ordinal = at ?? undefined
return (
<F at={ordinal} mode="fade" className="flow__cell">
<motion.div className={`flow__step flow__step--${tone}`} layout>
{children}
</motion.div>
{ms && <div className="flow__ms">{ms}</div>}
</F>
)
}
/* ----------------------------------------------------------------- cards --- */
const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b))
const lcm = (a: number, b: number) => (a * b) / gcd(a, b)
/* Gradient "spot": one slab per grid that springs to whichever card is active.
It sits above the card backgrounds and below their content, so the text of
the card it lands on stays crisp on top of it. */
interface Rect {
x: number
y: number
width: number
height: number
}
type SetActive = (el: HTMLElement | null, pop?: number) => void
const SpotContext = createContext<SetActive | null>(null)
/** Same curve as the CSS transitions on the card content, so slab and text grow together. */
const POP_EASE = [0.22, 0.61, 0.36, 1] as const
function useSpotLayer() {
const [rect, setRect] = useState<Rect | null>(null)
const [visible, setVisible] = useState(false)
const [pop, setPop] = useState(1)
const setActive = useCallback<SetActive>((el, nextPop = 1) => {
if (!el) {
setVisible(false)
return
}
setRect({ x: el.offsetLeft, y: el.offsetTop, width: el.offsetWidth, height: el.offsetHeight })
setPop(nextPop)
setVisible(true)
}, [])
const spot = rect ? (
<motion.div
className="spot"
initial={{ ...rect, opacity: 0, scale: 1 }}
animate={{ ...rect, opacity: visible ? 1 : 0, scale: visible ? pop : 1 }}
transition={{
type: 'spring',
stiffness: 190,
damping: 27,
mass: 1,
opacity: { duration: 0.35 },
scale: { duration: 0.45, ease: POP_EASE },
}}
/>
) : null
return { setActive, spot }
}
/**
* Site-style card grid. `rows` is how many cards sit in each row, so
* rows={[3, 2]} lays five cards out as three on top and two below, each row
* filling the full width. Wrap cards in <F> to reveal them one click at a
* time, or give each <Card at={n}> to step the gradient spot across them.
*/
export function Cards({
children,
rows = [3, 2],
gap = 16,
height,
}: {
children: ReactNode
rows?: number[]
gap?: number
/** Fixed height in px; by default the grid takes all the height the slide has left. */
height?: number
}) {
const cols = rows.reduce((acc, n) => lcm(acc, n), 1)
const spans = rows.flatMap((n) => Array<number>(n).fill(cols / n))
const items = Children.toArray(children).filter((c) => !(typeof c === 'string' && c.trim() === ''))
const { setActive, spot } = useSpotLayer()
return (
<SpotContext.Provider value={setActive}>
<div
className="cards has-spot"
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, gap, height, flex: height != null ? 'none' : undefined }}
>
{items.map((child, i) => (
<div key={i} className="cards__cell" style={{ gridColumn: `span ${spans[i] ?? 1}` }}>
{child}
</div>
))}
{spot}
</div>
</SpotContext.Provider>
)
}
/* Bento accordion: cards never change order. With a single row (rows={[5]})
the lit card is a full-height square and the rest are narrow columns, so a
step only touches two neighbours. With several rows the lit card grows out
of its own spot into a full-height square and the other rows compress on
their side of it. With `stack` the square has a fixed side and the rest are
horizontal bars on the other; a step swaps two cards. Cards are
absolutely positioned and spring between rects, so the resize is the
animation. Before step 1 the rows share the height equally; static previews
show the last card grown and lit. */
interface BentoLayout {
rects: Map<number, Rect>
heroAt: number | null
/** false when the grid shows details elsewhere (Spotlight box), so cards stay title-only */
detailInCards?: boolean
/** every card is a horizontal bar (number + title, centred), whatever its height */
bar?: boolean
/** the lit card, when the grid decides it (Spotlight); undefined = lit on its own step */
activeAt?: number | null
/**
* Where a card starts from before it takes its slot: a circle it springs
* out of (Spotlight's opening view). While set, the card is drawn as that
* circle — outline only, no content.
*/
seed?: Map<number, Rect & { radius: number }>
/** the grid can seed cards, so their corner radius is animated too */
seedable?: boolean
}
const BentoContext = createContext<BentoLayout | null>(null)
export function Bento({
children,
rows = [5],
gap = 16,
stack,
}: {
children: ReactNode
/** Cards per row, in `at` order. Ignored when `stack` is set. */
rows?: number[]
gap?: number
/**
* Fixed layout: the lit card is a full-height square on one side and the
* others stack as horizontal bars on the `stack` side, in order. A step
* swaps the card entering the square with the one leaving it.
*/
stack?: 'left' | 'right'
}) {
const slide = useSlide()
const ref = useRef<HTMLDivElement>(null)
const [size, setSize] = useState<{ w: number; h: number } | null>(null)
useLayoutEffect(() => {
const el = ref.current
if (!el) return
const measure = () => setSize({ w: el.offsetWidth, h: el.offsetHeight })
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [])
const ats = Children.toArray(children)
.flatMap((c) => (isValidElement(c) && typeof (c.props as { at?: unknown }).at === 'number' ? [(c.props as { at: number }).at] : []))
.sort((a, b) => a - b)
const step = slide.step
const heroAt =
ats.length === 0
? null
: slide.static
? ats[ats.length - 1]
: ats.includes(step)
? step
: ([...ats].reverse().find((a) => a < step) ?? null)
const rects = new Map<number, Rect>()
if (size && stack) {
const { w: W, h: H } = size
/* before the first step the first card holds the square, dimmed */
const hero = heroAt ?? ats[0]
const side = Math.min(H, W)
const squareX = stack === 'right' ? 0 : W - side
const barX = stack === 'right' ? side + gap : 0
const barW = W - side - gap
const others = ats.filter((a) => a !== hero)
const barH = others.length > 0 ? (H - gap * (others.length - 1)) / others.length : H
if (hero != null) rects.set(hero, { x: squareX, y: 0, width: side, height: side })
others.forEach((a, i) => rects.set(a, { x: barX, y: i * (barH + gap), width: barW, height: barH }))
} else if (size) {
const { w: W, h: H } = size
let taken = 0
const rowCards = rows.map((n) => ats.slice(taken, (taken += n))).filter((r) => r.length > 0)
const rowH = (H - gap * (rowCards.length - 1)) / rowCards.length
/* resting grid: every row shares the full width */
const rest = new Map<number, Rect>()
rowCards.forEach((cards, ri) => {
const w = (W - gap * (cards.length - 1)) / cards.length
cards.forEach((a, ci) => rest.set(a, { x: ci * (w + gap), y: ri * (rowH + gap), width: w, height: rowH }))
})
const heroRow = heroAt == null ? -1 : rowCards.findIndex((r) => r.includes(heroAt))
if (heroAt == null || heroRow < 0) {
rest.forEach((r, a) => rects.set(a, r))
} else if (rowCards.length === 1) {
/* Single row = accordion. The lit card is a full-height square, every
other card is one fixed narrow column, all in order. A step only ever
changes the two adjacent cards; nothing else moves at all. */
const cards = rowCards[0]
const side = Math.min(H, W)
const narrow = (W - side - gap * (cards.length - 1)) / (cards.length - 1)
let x = 0
cards.forEach((a) => {
const w = a === heroAt ? side : narrow
rects.set(a, { x, y: 0, width: w, height: H })
x += w + gap
})
} else {
const side = Math.min(H, W)
/* The square grows out of the lit card's own resting spot: anchored to
the left edge, the right edge, or centred on it. */
const heroRest = rest.get(heroAt)!
const atLeft = heroRest.x <= 0.5
const atRight = heroRest.x + heroRest.width >= W - 0.5
const slot = atLeft && !atRight ? 'left' : atRight && !atLeft ? 'right' : 'center'
const sx =
slot === 'left'
? 0
: slot === 'right'
? W - side
: Math.max(0, Math.min(W - side, heroRest.x + heroRest.width / 2 - side / 2))
rects.set(heroAt, { x: sx, y: 0, width: side, height: side })
/* Everyone else keeps its row and order and simply compresses into the
space left on its own side of the square — a push, never a reshuffle. */
const place = (list: number[], ri: number, x0: number, x1: number) => {
if (list.length === 0) return
const w = (x1 - x0 - gap * (list.length - 1)) / list.length
list.forEach((a, i) => rects.set(a, { x: x0 + i * (w + gap), y: ri * (rowH + gap), width: w, height: rowH }))
}
const squareMid = sx + side / 2
rowCards.forEach((cards, ri) => {
const others = cards.filter((a) => a !== heroAt)
const left =
slot === 'right'
? others
: slot === 'left'
? []
: others.filter((a) => {
const r = rest.get(a)!
return r.x + r.width / 2 < squareMid
})
const right = others.filter((a) => !left.includes(a))
place(left, ri, 0, sx - gap)
place(right, ri, sx + side + gap, W)
})
}
}
return (
<BentoContext.Provider value={{ rects, heroAt: stack ? (heroAt ?? ats[0] ?? null) : heroAt }}>
<div ref={ref} className="bento">
{children}
</div>
</BentoContext.Provider>
)
}
/* Spotlight: a fixed gradient box on the left and the cards as fixed bars on
the right. Nothing ever moves. Before the first step the box carries the
slide title; on step 1 the title hands off to the top of the slide and the
box starts showing the lit card's number, title and detail, crossfading as
the highlight walks down the bars. */
/* ------------------------------------------------------------------- orb --- */
/* All the pink is one blob: it rests in the bottom-left corner (where the
brand gradient has its pink) and chases the pointer on a lazy spring while
the pointer is inside the host; a small white sheen follows faster, like a
reflection, and the blue base leans a few degrees toward the pointer.
Pointer out: everything drifts home and the corner is pink again. */
interface OrbValues {
skin: ReturnType<typeof useMotionTemplate>
glowX: ReturnType<typeof useSpring>
glowY: ReturnType<typeof useSpring>
sheenX: ReturnType<typeof useSpring>
sheenY: ReturnType<typeof useSpring>
}
interface OrbOptions {
/** false in static views: blob parked, no listeners */
active: boolean
/** element to read the pointer from; defaults to the host */
listen?: (el: HTMLElement) => HTMLElement | null
/** where the blob goes instead of the pointer (host px); the pointer only drifts it a little */
target?: { x: number; y: number } | null
}
/**
* `w` × `h` is the host's size in stage px (0 until measured). Without a
* target the blob rests in the bottom-left corner and follows the pointer
* while it is over `listen`; with one it flies there on each change.
*/
function useOrb(host: React.RefObject<HTMLElement | null>, w: number, h: number, { active, listen, target }: OrbOptions): OrbValues {
const px = useMotionValue(0)
const py = useMotionValue(0)
const ax = useMotionValue(225)
const glowX = useSpring(px, { stiffness: 46, damping: 15, mass: 1.3 })
const glowY = useSpring(py, { stiffness: 46, damping: 15, mass: 1.3 })
const sheenX = useSpring(px, { stiffness: 150, damping: 22 })
const sheenY = useSpring(py, { stiffness: 150, damping: 22 })
const angle = useSpring(ax, { stiffness: 60, damping: 18 })
/* the base is blue only — every bit of pink comes from the blob */
const skin = useMotionTemplate`linear-gradient(${angle}deg, var(--blue) 40%, #4a5cff 100%)`
const tx = target?.x
const ty = target?.y
const goalRef = useRef({ x: 0, y: h })
goalRef.current = tx != null && ty != null ? { x: tx, y: ty } : { x: 0, y: h }
const aim = useCallback(
(x: number, y: number) => {
px.set(x)
py.set(y)
ax.set(225 - (x / Math.max(w, 1) - 0.5) * 34)
},
[px, py, ax, w],
)
/* first placement is instant — no sliding in from the top-left corner */
useEffect(() => {
if (!w || !h) return
const g = goalRef.current
aim(g.x, g.y)
for (const v of [glowX, sheenX]) v.jump(g.x)
for (const v of [glowY, sheenY]) v.jump(g.y)
angle.jump(225 - (g.x / w - 0.5) * 34)
}, [w, h, aim, glowX, glowY, sheenX, sheenY, angle])
/* a new target: fly there */
useEffect(() => {
if (!w || !h) return
const g = goalRef.current
aim(g.x, g.y)
}, [tx, ty, w, h, aim])
useEffect(() => {
const el = host.current
if (!w || !h || !el || !active) return
const el2 = listen ? listen(el) : el
if (!el2) return
const move = (e: PointerEvent) => {
const r = el.getBoundingClientRect()
if (r.width === 0) return
const k = w / r.width
const x = (e.clientX - r.left) * k
const y = (e.clientY - r.top) * k
const g = goalRef.current
if (tx != null) aim(g.x + (x - g.x) * 0.1, g.y + (y - g.y) * 0.1)
else aim(x, y)
}
const back = () => aim(goalRef.current.x, goalRef.current.y)
el2.addEventListener('pointermove', move)
el2.addEventListener('pointerleave', back)
return () => {
el2.removeEventListener('pointermove', move)
el2.removeEventListener('pointerleave', back)
}
}, [host, w, h, active, listen, tx, aim])
return { skin, glowX, glowY, sheenX, sheenY }
}
/* `together`: the sheen rides the glow's own spring instead of its faster one —
on a small glow the two springs read as two orbs */
function OrbLayers({ orb, left = 0, together = false }: { orb: OrbValues; left?: number; together?: boolean }) {
return (
<>
<motion.div className="orb__glow" style={{ x: orb.glowX, y: orb.glowY, left }} />
<motion.div className="orb__sheen" style={{ x: together ? orb.glowX : orb.sheenX, y: together ? orb.glowY : orb.sheenY, left }} />
</>
)
}
const slideOf = (el: HTMLElement) => el.closest<HTMLElement>('.slide')
/**
* Full-bleed background with the pointer-chasing blob: fills the nearest
* positioned ancestor. `listen="slide"` (default) reads the pointer over the
* whole slide, so content stacked on top doesn't block it; `self` only
* inside the layer's own box.
*/
export function Orb({
listen = 'slide',
target,
glow = true,
children,
}: {
listen?: 'slide' | 'self'
target?: { x: number; y: number } | null
/** false: the base only — render the blob yourself with <Lamp>, e.g. above a fog */
glow?: boolean
/** rendered after the layer with the orb's motion values — for things that follow the blob */
children?: (orb: OrbValues) => ReactNode
}) {
const slide = useSlide()
const ref = useRef<HTMLDivElement>(null)
const [size, setSize] = useState<{ w: number; h: number } | null>(null)
useLayoutEffect(() => {
const el = ref.current
if (!el) return
const measure = () => setSize({ w: el.offsetWidth, h: el.offsetHeight })
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [])
const orb = useOrb(ref, size?.w ?? 0, size?.h ?? 0, { active: !slide.static, listen: listen === 'slide' ? slideOf : undefined, target })
return (
<>
<motion.div ref={ref} className="orb" style={{ background: orb.skin }}>
{glow && <OrbLayers orb={orb} />}
</motion.div>
{children?.(orb)}
</>
)
}
/** The blob and its sheen on their own layer — a transform-only mover, cheap to stack above a fog. */
export function Lamp({ orb, together = false }: { orb: OrbValues; together?: boolean }) {
return (
<div className="lamp">
<OrbLayers orb={orb} together={together} />
</div>
)
}
/**
* Fog of war: a dark layer with soft holes. The mask is static — each hole's
* colour is a registered custom property (`--fog-hN`, see deck.css) that goes
* from black (fog) to transparent (clear), so opening one is a CSS fade in
* place, not a growing disc and not a per-frame mask rebuild. Up to 8.
*/
export function Fog({
on = true,
holes,
open,
clear = 210,
}: {
on?: boolean
/** every place that can be revealed */
holes: { x: number; y: number }[]
/** which of them are open right now */
open: boolean[]
clear?: number
}) {
const layers = holes
.map((h, k) => `radial-gradient(circle at ${h.x}px ${h.y}px, var(--fog-h${k}) ${clear}px, #000 ${Math.round(clear * 1.9)}px)`)
.join(', ')
const vars: Record<string, string> = {}
holes.forEach((_, k) => {
vars[`--fog-h${k}`] = open[k] ? 'transparent' : '#000'
})
return (
<motion.div
className="fog"
style={{ WebkitMaskImage: layers, maskImage: layers, WebkitMaskComposite: 'source-in', maskComposite: 'intersect', ...vars }}
initial={false}
animate={{ opacity: on ? 1 : 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
/>
)
}
/* ----------------------------------------------------------------- route --- */
/**
* Poster opening, then the orb flies from stop to stop, one per click. Each
* <Stop title> is a ring on a line. Rings light by how close the orb is and
* stay lit once it has passed; the line draws in under the orb as it
* travels. No fog: the base stays bright throughout; the blob rides on its
* own layer (<Lamp>) above the line and below the rings.
*
* `poster` is an opening title shown before `title` on the same poster, one
* extra click: the words of one swap for the words of the other in place —
* old words lift out, new words rise in, staggered. Stepping back runs the
* swap downward. The same word motion carries the title off when the first
* stop lights and brings it back when the deck steps back onto the poster.
*
* An <Act title> after the stops is a further act on the same route. On the
* click after the last stop the whole frame — title, line and rings — lifts
* out and the act's own title rises in its place as a poster, the same look
* the route opened with; the next click carries that title into the corner
* and the same rings come back empty for the orb to visit again. Stepping
* back brings the old act down with its rings still lit.
*/
const ROUTE_EASE = [0.22, 0.61, 0.36, 1] as const
const coverMotion = {
in: {},
show: { transition: { staggerChildren: 0.07, delayChildren: 0.1 } },
out: { transition: { staggerChildren: 0.045 } },
}
const wordMotion = {
in: (dir: number) => ({ opacity: 0, y: 56 * dir }),
show: { opacity: 1, y: 0, transition: { duration: 0.55, ease: ROUTE_EASE } },
out: (dir: number) => ({ opacity: 0, y: -56 * dir, transition: { duration: 0.38, ease: ROUTE_EASE } }),
}
/* each word on its own motion span, so a title can swap word by word.
`custom` is set on every span: the mount-time `in` variant is resolved from
the span's own props (the presence custom only reaches exit), so without it
the words would fade in without rising. */
function Words({ text, dir }: { text: ReactNode; dir: number }) {
const parts = typeof text === 'string' ? text.split(' ') : [text]
return (
<>
{parts.flatMap((w, i) => [
i > 0 ? ' ' : null,
<motion.span key={i} className="route__word" variants={wordMotion} custom={dir}>
{w}
</motion.span>,
])}
</>
)
}
type StopProps = { n?: ReactNode; title: ReactNode; children?: ReactNode }
/** one act of the route: the titles it opens with, then its rings */
type ActData = { covers: ReactNode[]; title: ReactNode; stops: StopProps[]; xs: number[] }
const ROUTE_W = 1280
const ROUTE_Y = 400
/* how far an act travels when the deck steps past it */
const ACT_LIFT = 180
const spread = (n: number) => Array.from({ length: n }, (_, i) => (n === 1 ? ROUTE_W / 2 : 240 + (i * (ROUTE_W - 480)) / (n - 1)))
/* the stops written straight into <Route> are the first act; every <Act> adds
another, and each act opens on its own title poster */
function readActs(title: ReactNode, poster: ReactNode, children: ReactNode): ActData[] {
const kids = Children.toArray(children).filter(isValidElement)
const stopsOf = (nodes: ReactNode) =>
Children.toArray(nodes)
.filter(isValidElement)
.map((c) => c.props as StopProps)
const acts: Omit<ActData, 'xs'>[] = [
{ covers: poster != null ? [poster, title] : [title], title, stops: kids.filter((k) => k.type !== Act).map((k) => k.props as StopProps) },
]
for (const k of kids) {
if (k.type !== Act) continue
const a = k.props as { title: ReactNode; children?: ReactNode }
acts.push({ covers: [a.title], title: a.title, stops: stopsOf(a.children) })
}
return acts.map((a) => ({ ...a, xs: spread(a.stops.length) }))
}
export function Route({ poster, title, children }: { poster?: ReactNode; title: ReactNode; children: ReactNode }) {
const slide = useSlide()
const acts = readActs(title, poster, children)
/* every beat of the route: each opening title is one, each stop is one. The
deck sits on the first beat at step 0, so there is one click fewer. */
const beats = acts.flatMap((a, i) => [
...a.covers.map((_, j) => ({ act: i, cover: j, active: 0 })),
...a.stops.map((_, m) => ({ act: i, cover: -1, active: m + 1 })),
])
useState(() => {
for (let i = 1; i < beats.length; i++) slide.register(i)
return null
})
const step = slide.static ? beats.length - 1 : Math.min(beats.length - 1, Math.max(0, slide.step))
const beat = beats[step]
/* which way the last click went — the title swap runs with it */
const prevStep = useRef(step)
const dir = step >= prevStep.current ? 1 : -1
useEffect(() => {
prevStep.current = step
}, [step])
const target = beat.active === 0 ? null : { x: acts[beat.act].xs[beat.active - 1], y: ROUTE_Y }
return (
<div className="route">
<Orb target={target} glow={false}>
{(orb) => (
<>
{acts.map((a, i) => {
const at = i === beat.act ? beat.active : i < beat.act ? a.stops.length : 0
return (
<ActLayer
key={i}
orb={orb}
title={a.title}
stops={a.stops}
xs={a.xs}
active={at}
/* the corner title only exists once the act's own poster is gone */
titleShown={i < beat.act || at > 0}
state={i === beat.act ? 0 : i < beat.act ? -1 : 1}
isStatic={slide.static}
/>
)
})}
<Lamp orb={orb} />
</>
)}
</Orb>
<AnimatePresence initial={false} custom={dir}>
{beat.cover >= 0 && (
<motion.div key={`cover-${step}`} className="route__cover" variants={coverMotion} custom={dir} initial="in" animate="show" exit="out">
<Words text={acts[beat.act].covers[beat.cover]} dir={dir} />
</motion.div>
)}
</AnimatePresence>
</div>
)
}
/**
* One act: its title, the line and the rings. Two layers with the blob's own
* layer between them, so the light keeps running above the line and below the
* rings. `state` is -1 for an act the deck has stepped past (it lifts out of
* frame), 0 for the one on screen, 1 for one still waiting below it.
*/
function ActLayer({
orb,
title,
stops,
xs,
active,
isStatic,
state,
titleShown,
}: {
orb: OrbValues
title: ReactNode
stops: StopProps[]
xs: number[]
active: number
isStatic: boolean
state: -1 | 0 | 1
titleShown: boolean
}) {
/* a ring is held fully lit once the orb has reached it, for as long as it
is at or before the current stop. Without this a step back would flip
the ring the orb returns to from "passed" to distance-lit while the orb
is still far away — a blink. Rings beyond the current stop go back to
distance lighting, which fades as the orb leaves them. */
const [reached, setReached] = useState<boolean[]>(() => xs.map((_, i) => i < active - 1 || (isStatic && i < active)))
const reachedRef = useRef(reached)
reachedRef.current = reached
useMotionValueEvent(orb.glowX, 'change', (gx) => {
if (state !== 0) return
const i = active - 1
if (i < 0 || reachedRef.current[i]) return
if (Math.hypot(gx - xs[i], orb.glowY.get() - ROUTE_Y) < 60) setReached((a) => a.map((v, k) => (k === i ? true : v)))
})
useEffect(() => {
setReached((a) => {
const next = a.map((v, i) => i < active - 1 || (i < active && v))
return next.some((v, i) => v !== a[i]) ? next : a
})
}, [active])
const move = {
initial: false as const,
animate: { y: state === 0 ? 0 : state < 0 ? -ACT_LIFT : ACT_LIFT, opacity: state === 0 ? 1 : 0 },
transition: { duration: 0.6, ease: ROUTE_EASE },
}
return (
<>
<motion.div className="route__act route__act--lines" {...move}>
{/* held back until the orb is at the first ring: on the poster it roams with
the pointer, and an act that opens from the right would otherwise show
its lines already drawn while the orb flies back to the start */}
<motion.svg
className="route__path"
viewBox="0 0 1280 720"
initial={false}
animate={{ opacity: active > 0 && reached[0] ? 1 : 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
>
{xs.slice(1).map((x, i) => (
<Segment key={i} orb={orb} a={xs[i]} b={x} y={ROUTE_Y} inset={114} />
))}
</motion.svg>
</motion.div>
<motion.div className="route__act route__act--stops" {...move}>
<AnimatePresence initial={false}>
{titleShown && (
<motion.h1
key="title"
className="slide__title route__title"
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.4, ease: ROUTE_EASE, delay: 0.1 }}
>
{title}
</motion.h1>
)}
</AnimatePresence>
{stops.map((s, i) => (
<StopView
key={i}
orb={orb}
x={xs[i]}
y={ROUTE_Y}
n={s.n ?? (i + 1).toString(2).padStart(2, '0')}
title={s.title}
body={s.children}
shown={active > 0}
held={i < active - 1 || (i === active - 1 && reached[i])}
delay={0.08 * i}
/>
))}
</motion.div>
</>
)
}
/* the line between two stops, drawn as far as the orb has travelled between their centres */
function Segment({ orb, a, b, y, inset }: { orb: OrbValues; a: number; b: number; y: number; inset: number }) {
const progress = useTransform(orb.glowX, (gx: number) => Math.max(0, Math.min(1, (gx - a) / (b - a))))
const opacity = useTransform(progress, (p) => (p > 0.02 ? 1 : 0))
return <motion.line x1={a + inset} y1={y} x2={b - inset} y2={y} style={{ pathLength: progress, opacity }} />
}
/* how lit the ring a component sits in is — <Munch> waits for it to arrive */
const StopLit = createContext<MotionValue<number> | null>(null)
/* a ring lit by the orb's distance; `held` pins it fully lit (reached, and at or before the current stop) */
function StopView({
orb,
x,
y,
n,
title,
body,
shown,
held,
delay,
}: {
orb: OrbValues
x: number
y: number
n: ReactNode
title: ReactNode
body?: ReactNode
shown: boolean
held: boolean
delay: number
}) {
const lit = useTransform([orb.glowX, orb.glowY], ([gx, gy]: number[]) => {
if (held) return 1
const d = Math.hypot(gx - x, gy - y)
return Math.max(0, Math.min(1, 1 - (d - 60) / 260))
})
const border = useTransform(lit, (v) => `rgba(255, 255, 255, ${v})`)
const halo = useTransform(lit, (v) => `0 0 0 12px rgba(255, 255, 255, ${0.14 * v})`)
const scale = useTransform(lit, (v) => 1 + 0.06 * v)
const text = useTransform(lit, (v) => v)
return (
<motion.div
className="stop"
style={{ left: x, top: y }}
initial={false}
animate={{ opacity: shown ? 1 : 0, y: shown ? 0 : 24 }}
transition={{ duration: 0.45, ease: [0.22, 0.61, 0.36, 1], delay: shown ? delay : 0 }}
>
<StopLit.Provider value={lit}>
<motion.div className="stop__ring" style={{ borderColor: border, boxShadow: halo, scale }}>
<motion.span className="stop__n" style={{ opacity: text }}>
{n}
</motion.span>
</motion.div>
<motion.div className="stop__title" style={{ opacity: text }}>
{title}
</motion.div>
</StopLit.Provider>
{body && (
<motion.div className="stop__body" style={{ opacity: text }}>
{body}
</motion.div>
)}
</motion.div>
)
}
/* ---------------------------------------------------------------- unfold --- */
/**
* One page, no layout change: a full-slide gradient with a hero drawing on
* it and the blob resting on the hero. On the first click the hero shrinks
* up out of the way and its circles (`seeds`, in slide px, one per <Stop>)
* unfold into a row of rings, number inside, title and body under —
* the blob stays on the hero, riding its shrink. Only the next click moves
* it: it lights the rings one per click, the line drawing in behind it,
* exactly like <Route>. Stepping back folds it up again.
*/
const UNFOLD_SPRING = { type: 'spring' as const, stiffness: 150, damping: 24, mass: 0.9 }
type UnfoldSeed = UnfoldCircle & { content?: ReactNode }
export function Unfold({
hero,
seeds,
orbAt,
heroAt = { x: 640, y: 360 },
heroTo = { x: 640, y: 200 },
shrink = 0.5,
y = 470,
ring = 120,
launch = false,
billiards = false,
children,
}: {
hero: ReactNode
/** the hero's circles the stops start out as, in slide px, in stop order */
seeds: UnfoldSeed[]
/** where the blob rests on the hero */
orbAt: { x: number; y: number }
/** the hero's centre — what it shrinks around */
heroAt?: { x: number; y: number }
/** where that centre goes once it has shrunk */
heroTo?: { x: number; y: number }
shrink?: number
/** the rings' centre line */
y?: number
/** ring diameter */
ring?: number
/** one more step after the last ring: it becomes a balloon and carries the light off the top, turning the page on its way */
launch?: boolean
/** The central avatar drops into the two buttons and knocks them outwards. */
billiards?: boolean
children: ReactNode
}) {
const slide = useSlide()
const stops = Children.toArray(children)
.filter(isValidElement)
.map((c) => c.props as StopProps)
const n = stops.length
const last = n + 1 + (launch ? 1 : 0)
/* one click to unfold, then one per ring, then the launch */
useState(() => {
for (let i = 1; i <= last; i++) slide.register(i)
return null
})
const step = slide.static ? n + 1 : Math.min(last, Math.max(0, slide.step))
const open = step > 0
const unfold = useMotionValue(open ? 1 : 0)
useLayoutEffect(() => {
const to = open ? 1 : 0
const animation = animate(unfold, to, { duration: UNFOLD_DURATION * Math.abs(to - unfold.get()), ease: 'linear' })
return () => animation.stop()
}, [open, unfold])
const contact = billiards && seeds.length === 3 ? unfoldContact(seeds[1], seeds[0], ring / 2) : undefined
/* how many rings the orb has been sent to */
const active = Math.min(n, Math.max(0, step - 1))
/* the last step: the ring the orb sits on lifts off as a balloon and takes the light with it */
const away = launch && step === last
const xs = spread(n)
/* where the blob's resting spot on the hero ends up once the hero has shrunk */
const orbAtShrunk = { x: heroTo.x + (orbAt.x - heroAt.x) * shrink, y: heroTo.y + (orbAt.y - heroAt.y) * shrink }
const target = !open ? orbAt : active === 0 ? orbAtShrunk : { x: xs[active - 1], y }
/* same rule as the route: a ring is held lit once the orb has reached it,
for as long as it is at or before the current stop — no blink on the
way back */
const [reached, setReached] = useState<boolean[]>(() => xs.map((_, i) => i < active - 1 || (slide.static && i < active)))
const reachedRef = useRef(reached)
reachedRef.current = reached
useEffect(() => {
setReached((a) => {
const next = a.map((v, i) => i < active - 1 || (i < active && v))
return next.some((v, i) => v !== a[i]) ? next : a
})
}, [active])
/* The launch step: the ring becomes a balloon and the camera follows it
into the vendor's area. Both scenes remain mounted in <FlightScene>.
Re-entered from the next slide it only shows its rest state — balloon on
the ring, no replay and above all no auto-advance. */
const alive = useRef(true)
useEffect(() => {
alive.current = true
return () => { alive.current = false }
}, [])
const seen = useRef<number | null>(null)
const latest = useRef({ advance: slide.advance, index: slide.index })
latest.current = { advance: slide.advance, index: slide.index }
useEffect(() => {
if (slide.static || slide.active === false || !launch) return
const was = seen.current
seen.current = step
const at = { x: xs[n - 1], y }
const from = { x: (xs[n - 2] ?? xs[n - 1] - 400) + ring / 2 + 14, y }
if (step === last) {
if (was === last - 1) void balloonLaunch(at, from, latest.current.advance, () => alive.current)
else balloonRest(at, from, latest.current.index)
} else if (was === last) void vesselHide(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step, slide.static, slide.active, launch])
return (
<div className={`unfold${open ? ' unfold--open' : ''}${away ? ' unfold--away' : ''}`}>
<Orb target={target} glow={false}>
{(orb) => (
<>
{launch && <FlightBackdrop skin={orb.skin} />}
<Reach orb={orb} x={active > 0 ? xs[active - 1] : null} y={y} onReach={() => setReached((a) => a.map((v, k) => (k === active - 1 ? true : v)))} skip={!!reachedRef.current[active - 1]} />
<motion.div
className="unfold__hero"
style={{ transformOrigin: `${heroAt.x}px ${heroAt.y}px` }}
initial={false}
animate={open ? { scale: shrink, x: heroTo.x - heroAt.x, y: heroTo.y - heroAt.y } : { scale: 1, x: 0, y: 0 }}
transition={UNFOLD_SPRING}
>
{hero}
</motion.div>
{/* held back until the orb is at the first ring: it arrives from the hero, from above and off-line */}
<motion.svg
className="unfold__path"
viewBox="0 0 1280 720"
initial={false}
animate={{ opacity: open && reached[0] ? 1 : 0 }}