maverick-wave 5.33.0 → 5.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/maverick-wave.min.css +16 -2
- package/maverick-wave.min.js +1 -1
- package/package.json +2 -2
- package/src/js/main.js +769 -3
- package/src/js/occasions.js +107 -0
- package/src/scss/abstracts/_variables.scss +2 -0
- package/src/scss/components/_deck-blocks.scss +963 -0
- package/src/scss/components/_deck-frame.scss +102 -0
- package/src/scss/components/_deck.scss +700 -0
- package/src/scss/components/_index.scss +3 -0
- package/src/scss/components/_occasions.scss +1574 -0
- package/src/scss/layout/_section.scss +4 -4
package/src/js/main.js
CHANGED
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
initTagRemovals();
|
|
51
51
|
initCopyButtons();
|
|
52
52
|
initLocalhostIndicator();
|
|
53
|
+
initOccasions();
|
|
53
54
|
warnMissingViewportFit();
|
|
54
55
|
}
|
|
55
56
|
|
|
@@ -62,6 +63,7 @@
|
|
|
62
63
|
initPortraitGalleries(scope);
|
|
63
64
|
initMosaics(scope);
|
|
64
65
|
initStoryReels(scope);
|
|
66
|
+
initDecks(scope);
|
|
65
67
|
initAccordions(scope);
|
|
66
68
|
initProgressBars(scope);
|
|
67
69
|
initReveals(scope);
|
|
@@ -75,10 +77,16 @@
|
|
|
75
77
|
initLangSwitches(scope);
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
// A failure in the page-wide part must not take the components with it,
|
|
81
|
+
// and a script that arrives after the parse (async, injected) still starts
|
|
82
|
+
function start() {
|
|
83
|
+
try {
|
|
84
|
+
initPage();
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error('MaverickWave: page behaviour not initialised', error);
|
|
87
|
+
}
|
|
80
88
|
initComponents(document);
|
|
81
|
-
}
|
|
89
|
+
}
|
|
82
90
|
|
|
83
91
|
// The way back in for anything that renders after DOMContentLoaded: an htmx
|
|
84
92
|
// swap, a modal filled from a fetch, a view transition that replaces the
|
|
@@ -809,6 +817,250 @@
|
|
|
809
817
|
});
|
|
810
818
|
}
|
|
811
819
|
|
|
820
|
+
// ===== Decks =====
|
|
821
|
+
// The swipe is the browser's own scroll snap. This adds what snapping cannot:
|
|
822
|
+
// buttons, keys and the wheel, counter, progress, fullscreen and the entrance.
|
|
823
|
+
// A deck that fails leaves the others, and the rest of the page, working.
|
|
824
|
+
function initDecks(root) {
|
|
825
|
+
root.querySelectorAll('.mw-deck').forEach((deck) => {
|
|
826
|
+
if (!fresh(deck)) return;
|
|
827
|
+
try {
|
|
828
|
+
initDeck(deck);
|
|
829
|
+
} catch (error) {
|
|
830
|
+
console.error('MaverickWave: deck not initialised', error);
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function initDeck(deck) {
|
|
836
|
+
const scroller = deck.querySelector('.mw-deck-slides');
|
|
837
|
+
if (!scroller) return;
|
|
838
|
+
const slides = Array.from(scroller.children).filter((child) =>
|
|
839
|
+
child.classList.contains('mw-slide')
|
|
840
|
+
);
|
|
841
|
+
if (!slides.length) return;
|
|
842
|
+
|
|
843
|
+
const across = !deck.classList.contains('mw-deck-portrait');
|
|
844
|
+
// A deck that is the page takes the keys, the wheel and the address bar;
|
|
845
|
+
// one sitting inside a page leaves them to it
|
|
846
|
+
const page = deck === document.body;
|
|
847
|
+
const counter = deck.querySelector('.mw-deck-counter');
|
|
848
|
+
const segments = deck.querySelector('.mw-deck-segments');
|
|
849
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
850
|
+
const start = Number.parseInt(location.hash.slice(1), 10) - 1;
|
|
851
|
+
const first = page && start > 0 && start < slides.length ? start : 0;
|
|
852
|
+
// Where the controls count from - set at once, while the slide itself is
|
|
853
|
+
// only lit a frame later, after its entrance could be painted
|
|
854
|
+
let current = first;
|
|
855
|
+
let lit = -1;
|
|
856
|
+
let started = false;
|
|
857
|
+
|
|
858
|
+
if (segments) {
|
|
859
|
+
segments.replaceChildren(
|
|
860
|
+
...slides.map(() => document.createElement('span'))
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function activate(index) {
|
|
865
|
+
if (index < 0 || index >= slides.length) return;
|
|
866
|
+
current = index;
|
|
867
|
+
if (index === lit) return;
|
|
868
|
+
lit = index;
|
|
869
|
+
slides.forEach((slide, i) => setActive(slide, i === index));
|
|
870
|
+
if (counter) {
|
|
871
|
+
const now = document.createElement('span');
|
|
872
|
+
now.className = 'mw-deck-counter-current';
|
|
873
|
+
now.textContent = pad(index + 1);
|
|
874
|
+
counter.replaceChildren(now, ' / ' + pad(slides.length));
|
|
875
|
+
}
|
|
876
|
+
deck.style.setProperty(
|
|
877
|
+
'--mw-deck-progress',
|
|
878
|
+
String((index + 1) / slides.length)
|
|
879
|
+
);
|
|
880
|
+
if (segments) {
|
|
881
|
+
Array.from(segments.children).forEach((segment, i) =>
|
|
882
|
+
segment.classList.toggle('mw-done', i <= index)
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
// A sandboxed frame or a page from the file system may refuse it
|
|
886
|
+
if (page) {
|
|
887
|
+
try {
|
|
888
|
+
history.replaceState(history.state, '', '#' + (index + 1));
|
|
889
|
+
} catch {
|
|
890
|
+
// the slide number in the address is a convenience, not a feature
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const offset = (slide) => (across ? slide.offsetLeft : slide.offsetTop);
|
|
896
|
+
|
|
897
|
+
function show(index, smooth = true) {
|
|
898
|
+
const slide = slides[Math.max(0, Math.min(slides.length - 1, index))];
|
|
899
|
+
const reduced =
|
|
900
|
+
window.matchMedia &&
|
|
901
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
902
|
+
scroller.scrollTo({
|
|
903
|
+
left: across ? slide.offsetLeft : 0,
|
|
904
|
+
top: across ? 0 : slide.offsetTop,
|
|
905
|
+
behavior: smooth && !reduced ? 'smooth' : 'auto',
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// The slide nearest the scroll position is the one on screen. Read from
|
|
910
|
+
// the position rather than an observer's threshold, so it holds for any
|
|
911
|
+
// slide size and in a browser without IntersectionObserver.
|
|
912
|
+
function nearest() {
|
|
913
|
+
const position = across ? scroller.scrollLeft : scroller.scrollTop;
|
|
914
|
+
let best = 0;
|
|
915
|
+
slides.forEach((slide, i) => {
|
|
916
|
+
if (
|
|
917
|
+
Math.abs(offset(slide) - position) <
|
|
918
|
+
Math.abs(offset(slides[best]) - position)
|
|
919
|
+
) {
|
|
920
|
+
best = i;
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
return best;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
let queued = false;
|
|
927
|
+
scroller.addEventListener(
|
|
928
|
+
'scroll',
|
|
929
|
+
() => {
|
|
930
|
+
if (!started || queued) return;
|
|
931
|
+
queued = true;
|
|
932
|
+
requestAnimationFrame(() => {
|
|
933
|
+
queued = false;
|
|
934
|
+
activate(nearest());
|
|
935
|
+
});
|
|
936
|
+
},
|
|
937
|
+
{ passive: true }
|
|
938
|
+
);
|
|
939
|
+
|
|
940
|
+
const fullscreenElement = () =>
|
|
941
|
+
document.fullscreenElement || document.webkitFullscreenElement;
|
|
942
|
+
const fullscreenEnabled =
|
|
943
|
+
document.fullscreenEnabled || document.webkitFullscreenEnabled;
|
|
944
|
+
const fullscreenTarget = page ? document.documentElement : deck;
|
|
945
|
+
|
|
946
|
+
function toggleFullscreen() {
|
|
947
|
+
if (!fullscreenEnabled) return;
|
|
948
|
+
try {
|
|
949
|
+
const result = fullscreenElement()
|
|
950
|
+
? (document.exitFullscreen || document.webkitExitFullscreen).call(
|
|
951
|
+
document
|
|
952
|
+
)
|
|
953
|
+
: (
|
|
954
|
+
fullscreenTarget.requestFullscreen ||
|
|
955
|
+
fullscreenTarget.webkitRequestFullscreen
|
|
956
|
+
).call(fullscreenTarget);
|
|
957
|
+
// Refused by the browser or by the frame around the deck
|
|
958
|
+
if (result && result.catch) result.catch(() => {});
|
|
959
|
+
} catch {
|
|
960
|
+
// same refusal in a browser that throws instead of rejecting
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// No fullscreen for anything but video on an iPhone, or in a frame
|
|
965
|
+
// without allow="fullscreen" - the button goes
|
|
966
|
+
if (!fullscreenEnabled) {
|
|
967
|
+
deck.querySelectorAll('[data-deck-fullscreen]').forEach((button) => {
|
|
968
|
+
button.hidden = true;
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Fullscreen or a turn changes the width, and the slide would stop half-way
|
|
973
|
+
const settle = () => show(current, false);
|
|
974
|
+
['fullscreenchange', 'webkitfullscreenchange'].forEach((type) => {
|
|
975
|
+
document.addEventListener(type, () => {
|
|
976
|
+
deck.classList.toggle(
|
|
977
|
+
'mw-deck-fullscreen',
|
|
978
|
+
Boolean(fullscreenElement())
|
|
979
|
+
);
|
|
980
|
+
settle();
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
window.addEventListener('resize', settle);
|
|
984
|
+
|
|
985
|
+
deck.addEventListener('click', (e) => {
|
|
986
|
+
if (!(e.target instanceof Element)) return;
|
|
987
|
+
const control = e.target.closest(
|
|
988
|
+
'[data-deck-goto], [data-deck-next], [data-deck-prev], [data-deck-fullscreen]'
|
|
989
|
+
);
|
|
990
|
+
if (!control) return;
|
|
991
|
+
if (control.hasAttribute('data-deck-goto')) {
|
|
992
|
+
const index = slides.findIndex(
|
|
993
|
+
(slide) => slide.id === control.dataset.deckGoto
|
|
994
|
+
);
|
|
995
|
+
if (index >= 0) show(index);
|
|
996
|
+
} else if (control.hasAttribute('data-deck-next')) show(current + 1);
|
|
997
|
+
else if (control.hasAttribute('data-deck-prev')) show(current - 1);
|
|
998
|
+
else toggleFullscreen();
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
(page ? document : deck).addEventListener('keydown', (e) => {
|
|
1002
|
+
if (e.defaultPrevented || e.isComposing) return;
|
|
1003
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
1004
|
+
const focus = e.target instanceof Element ? e.target : document.body;
|
|
1005
|
+
if (focus.closest('input, textarea, select, [contenteditable]')) return;
|
|
1006
|
+
// Space presses a button reached by keyboard. One focused by a click -
|
|
1007
|
+
// fullscreen, a jump tile - must not be pressed again by the next page.
|
|
1008
|
+
if (
|
|
1009
|
+
e.key === ' ' &&
|
|
1010
|
+
focus.closest('button, a, summary') &&
|
|
1011
|
+
focus.matches(':focus-visible')
|
|
1012
|
+
) {
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
const steps = {
|
|
1016
|
+
ArrowRight: 1,
|
|
1017
|
+
ArrowDown: 1,
|
|
1018
|
+
PageDown: 1,
|
|
1019
|
+
' ': e.shiftKey ? -1 : 1,
|
|
1020
|
+
ArrowLeft: -1,
|
|
1021
|
+
ArrowUp: -1,
|
|
1022
|
+
PageUp: -1,
|
|
1023
|
+
};
|
|
1024
|
+
if (e.key in steps) show(current + steps[e.key]);
|
|
1025
|
+
else if (e.key === 'Home') show(0);
|
|
1026
|
+
else if (e.key === 'End') show(slides.length - 1);
|
|
1027
|
+
else if (e.key === 'f' || e.key === 'F') toggleFullscreen();
|
|
1028
|
+
else return;
|
|
1029
|
+
e.preventDefault();
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
// A wheel does not move a landscape deck at all and a portrait one only
|
|
1033
|
+
// over the phone, so it pages itself. Embedded in another page the wheel
|
|
1034
|
+
// belongs to that page, and a pinch on a trackpad (ctrlKey) stays a zoom.
|
|
1035
|
+
if (page && window.self === window.top) {
|
|
1036
|
+
let locked = false;
|
|
1037
|
+
document.addEventListener(
|
|
1038
|
+
'wheel',
|
|
1039
|
+
(e) => {
|
|
1040
|
+
if (e.ctrlKey) return;
|
|
1041
|
+
if (!across && scroller.contains(e.target)) return;
|
|
1042
|
+
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
|
|
1043
|
+
e.preventDefault();
|
|
1044
|
+
if (locked || Math.abs(e.deltaY) < 8) return;
|
|
1045
|
+
locked = true;
|
|
1046
|
+
show(current + Math.sign(e.deltaY));
|
|
1047
|
+
setTimeout(() => (locked = false), 700);
|
|
1048
|
+
},
|
|
1049
|
+
{ passive: false }
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
deck.classList.add('mw-deck-ready');
|
|
1054
|
+
show(first, false);
|
|
1055
|
+
// The entrance needs one painted frame in its hidden state
|
|
1056
|
+
requestAnimationFrame(() =>
|
|
1057
|
+
requestAnimationFrame(() => {
|
|
1058
|
+
started = true;
|
|
1059
|
+
activate(first);
|
|
1060
|
+
})
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
812
1064
|
// ===== Theme Toggle =====
|
|
813
1065
|
function initThemeToggle() {
|
|
814
1066
|
const themeToggle = document.querySelector('.mw-theme-toggle');
|
|
@@ -2195,4 +2447,518 @@
|
|
|
2195
2447
|
|
|
2196
2448
|
render();
|
|
2197
2449
|
}
|
|
2450
|
+
|
|
2451
|
+
// ===== Occasions =====
|
|
2452
|
+
// The CSS draws every piece, occasions.js in <head> picks the occasion before
|
|
2453
|
+
// the first paint. This plays the two parts that need a script once the page
|
|
2454
|
+
// is there: the intro over the whole page and the particles that drift with
|
|
2455
|
+
// the scroll. An SPA sets the classes on <html> itself - see
|
|
2456
|
+
// references/javascript.md.
|
|
2457
|
+
//
|
|
2458
|
+
// No constants at this level: start() can run before the end of this file
|
|
2459
|
+
// has been evaluated, and a const read at that point throws.
|
|
2460
|
+
function initOccasions() {
|
|
2461
|
+
const effects = [
|
|
2462
|
+
'snow',
|
|
2463
|
+
'christmas',
|
|
2464
|
+
'newyear',
|
|
2465
|
+
'spring',
|
|
2466
|
+
'easter',
|
|
2467
|
+
'summer',
|
|
2468
|
+
'autumn',
|
|
2469
|
+
'football',
|
|
2470
|
+
'birthday',
|
|
2471
|
+
'anniversary',
|
|
2472
|
+
];
|
|
2473
|
+
const html = document.documentElement;
|
|
2474
|
+
const params = new URLSearchParams(location.search);
|
|
2475
|
+
const preview = params.get('mw-occasion');
|
|
2476
|
+
|
|
2477
|
+
// A preview on a page without occasions.js - a customer's site that has
|
|
2478
|
+
// not booked anything yet - applies here, a moment later
|
|
2479
|
+
if (
|
|
2480
|
+
effects.includes(preview) &&
|
|
2481
|
+
!html.classList.contains('mw-occasion-' + preview)
|
|
2482
|
+
) {
|
|
2483
|
+
effects.forEach((name) => html.classList.remove('mw-occasion-' + name));
|
|
2484
|
+
html.classList.add('mw-occasion-' + preview);
|
|
2485
|
+
html.classList.toggle(
|
|
2486
|
+
'mw-occasion-scroll',
|
|
2487
|
+
params.has('mw-occasion-scroll')
|
|
2488
|
+
);
|
|
2489
|
+
html.classList.toggle(
|
|
2490
|
+
'mw-occasion-sm',
|
|
2491
|
+
params.get('mw-occasion-size') === 'sm'
|
|
2492
|
+
);
|
|
2493
|
+
html.classList.toggle(
|
|
2494
|
+
'mw-occasion-lg',
|
|
2495
|
+
params.get('mw-occasion-size') === 'lg'
|
|
2496
|
+
);
|
|
2497
|
+
if (params.has('mw-occasion-intro')) {
|
|
2498
|
+
html.setAttribute('data-mw-occasion-intro', 'preview');
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
const effect = effects.find((name) =>
|
|
2503
|
+
html.classList.contains('mw-occasion-' + name)
|
|
2504
|
+
);
|
|
2505
|
+
if (!effect) return;
|
|
2506
|
+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
2507
|
+
|
|
2508
|
+
if (html.classList.contains('mw-occasion-scroll')) {
|
|
2509
|
+
startOccasionDrift(effect);
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
// 'preview' plays on every load, an empty value once per visit
|
|
2513
|
+
const intro = html.getAttribute('data-mw-occasion-intro');
|
|
2514
|
+
const saveData = navigator.connection && navigator.connection.saveData;
|
|
2515
|
+
if (
|
|
2516
|
+
intro !== null &&
|
|
2517
|
+
!saveData &&
|
|
2518
|
+
(intro === 'preview' || !occasionSeen(effect))
|
|
2519
|
+
) {
|
|
2520
|
+
whenShown(() => playOccasionIntro(effect));
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
// Once per visit - on every page it would be a nuisance by the third click
|
|
2525
|
+
function occasionSeen(effect) {
|
|
2526
|
+
try {
|
|
2527
|
+
if (sessionStorage.getItem('mw-occasion-intro') === effect) return true;
|
|
2528
|
+
sessionStorage.setItem('mw-occasion-intro', effect);
|
|
2529
|
+
} catch {
|
|
2530
|
+
// Storage blocked: twice is better than never
|
|
2531
|
+
}
|
|
2532
|
+
return false;
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
// After the load, so it does not compete with the page for the first frames,
|
|
2536
|
+
// and not into a background tab, where nobody would see it
|
|
2537
|
+
function whenShown(run) {
|
|
2538
|
+
function visible() {
|
|
2539
|
+
if (!document.hidden) return run();
|
|
2540
|
+
document.addEventListener('visibilitychange', function wait() {
|
|
2541
|
+
if (document.hidden) return;
|
|
2542
|
+
document.removeEventListener('visibilitychange', wait);
|
|
2543
|
+
run();
|
|
2544
|
+
});
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
if (document.readyState === 'complete') visible();
|
|
2548
|
+
else window.addEventListener('load', visible, { once: true });
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
function between(min, max) {
|
|
2552
|
+
return min + Math.random() * (max - min);
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
function pick(list) {
|
|
2556
|
+
return list[Math.floor(Math.random() * list.length)];
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
function signed(min, max) {
|
|
2560
|
+
return (Math.random() < 0.5 ? -1 : 1) * between(min, max);
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
// CSSOM and not a style attribute - a CSP without 'unsafe-inline' allows
|
|
2564
|
+
// the one and drops the other
|
|
2565
|
+
function occasionParticle(layer, shape, motion, vars) {
|
|
2566
|
+
const el = document.createElement('span');
|
|
2567
|
+
el.className =
|
|
2568
|
+
'mw-occasion-particle mw-occasion-' + shape + ' mw-occasion-' + motion;
|
|
2569
|
+
Object.keys(vars).forEach((name) => el.style.setProperty(name, vars[name]));
|
|
2570
|
+
layer.appendChild(el);
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
// A phone gets fewer particles than a desktop, a slow device half of that
|
|
2574
|
+
function occasionDensity() {
|
|
2575
|
+
const area = (window.innerWidth * window.innerHeight) / (1440 * 900);
|
|
2576
|
+
const weak =
|
|
2577
|
+
(navigator.hardwareConcurrency || 8) <= 4 ||
|
|
2578
|
+
(navigator.deviceMemory || 8) <= 4;
|
|
2579
|
+
return (
|
|
2580
|
+
Math.min(1, Math.max(0.4, area)) * (weak ? 0.5 : 1) * occasionAmount()
|
|
2581
|
+
);
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
// --mw-occasion-amount, set by mw-occasion-sm / -lg or by the site itself
|
|
2585
|
+
function occasionAmount() {
|
|
2586
|
+
const amount = parseFloat(
|
|
2587
|
+
getComputedStyle(document.documentElement).getPropertyValue(
|
|
2588
|
+
'--mw-occasion-amount'
|
|
2589
|
+
)
|
|
2590
|
+
);
|
|
2591
|
+
return amount > 0 ? amount : 1;
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// Confetti flips end over end. A leaf or a petal that did the same would
|
|
2595
|
+
// spend half its fall edge-on, a sliver nobody reads as a leaf - it mostly
|
|
2596
|
+
// turns in the plane and only tilts.
|
|
2597
|
+
function occasionAxis(flip) {
|
|
2598
|
+
if (flip === 'soft') {
|
|
2599
|
+
return (
|
|
2600
|
+
between(0.2, 0.6).toFixed(2) + ' ' + between(0, 0.5).toFixed(2) + ' 1'
|
|
2601
|
+
);
|
|
2602
|
+
}
|
|
2603
|
+
return flip ? '1 ' + between(0, 1).toFixed(2) + ' 0.3' : 'z';
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
function occasionFall(layer, count, shapes, o) {
|
|
2607
|
+
for (let i = 0; i < count; i++) {
|
|
2608
|
+
occasionParticle(layer, pick(shapes), o.rising ? 'rising' : 'falling', {
|
|
2609
|
+
'--mw-occasion-x': between(-5, 100).toFixed(1) + 'vw',
|
|
2610
|
+
'--mw-occasion-drift':
|
|
2611
|
+
between(o.drift[0], o.drift[1]).toFixed(1) + 'vw',
|
|
2612
|
+
'--mw-occasion-size': between(o.size[0], o.size[1]).toFixed(1) + 'px',
|
|
2613
|
+
'--mw-occasion-time': between(o.time[0], o.time[1]).toFixed(2) + 's',
|
|
2614
|
+
'--mw-occasion-delay': between(0, o.spread).toFixed(2) + 's',
|
|
2615
|
+
'--mw-occasion-sway': between(6, 22).toFixed(0) + 'px',
|
|
2616
|
+
'--mw-occasion-sway-time': between(1.2, 2.4).toFixed(2) + 's',
|
|
2617
|
+
'--mw-occasion-spin': signed(0.3, o.spin || 0.3).toFixed(2) + 'turn',
|
|
2618
|
+
'--mw-occasion-spin-time': between(2.5, 5).toFixed(2) + 's',
|
|
2619
|
+
'--mw-occasion-axis': occasionAxis(o.flip),
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
// Two confetti cannons in the bottom corners, a second volley a moment later
|
|
2625
|
+
function occasionBurst(layer, count, shapes, o) {
|
|
2626
|
+
for (let i = 0; i < count; i++) {
|
|
2627
|
+
const left = i % 2 === 0;
|
|
2628
|
+
occasionParticle(layer, pick(shapes), 'bursting', {
|
|
2629
|
+
'--mw-occasion-x': left ? '-2vw' : '100vw',
|
|
2630
|
+
'--mw-occasion-dx': (left ? 1 : -1) * between(8, 62) + 'vw',
|
|
2631
|
+
'--mw-occasion-peak': between(55, 92) + 'vh',
|
|
2632
|
+
'--mw-occasion-size': between(o.size[0], o.size[1]).toFixed(1) + 'px',
|
|
2633
|
+
'--mw-occasion-time': between(2.6, 3.6).toFixed(2) + 's',
|
|
2634
|
+
'--mw-occasion-delay':
|
|
2635
|
+
(between(0, 0.35) + (i % 3 === 2 ? 0.9 : 0)).toFixed(2) + 's',
|
|
2636
|
+
'--mw-occasion-spin': signed(1.5, o.spin).toFixed(2) + 'turn',
|
|
2637
|
+
'--mw-occasion-spin-time': between(0.8, 1.6).toFixed(2) + 's',
|
|
2638
|
+
'--mw-occasion-axis': occasionAxis(o.flip),
|
|
2639
|
+
});
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
// Glints of sunlight, most of them near the sun in the top right corner
|
|
2644
|
+
function occasionTwinkle(layer, count) {
|
|
2645
|
+
for (let i = 0; i < count; i++) {
|
|
2646
|
+
occasionParticle(layer, 'glint', 'twinkling', {
|
|
2647
|
+
'--mw-occasion-x': between(35, 96).toFixed(1) + 'vw',
|
|
2648
|
+
'--mw-occasion-y': between(4, 62).toFixed(1) + 'vh',
|
|
2649
|
+
'--mw-occasion-size': between(14, 30).toFixed(1) + 'px',
|
|
2650
|
+
'--mw-occasion-time': between(1.2, 2.2).toFixed(2) + 's',
|
|
2651
|
+
'--mw-occasion-delay': between(0.4, 2.8).toFixed(2) + 's',
|
|
2652
|
+
'--mw-occasion-spin': signed(0.1, 0.25).toFixed(2) + 'turn',
|
|
2653
|
+
});
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
function occasionFireworks(layer, bursts, sparks) {
|
|
2658
|
+
const tints = ['#ffd166', '#fff1c1', '#ff6b6b', '#4cc9f0', '#c4a7ff'];
|
|
2659
|
+
|
|
2660
|
+
for (let b = 0; b < bursts; b++) {
|
|
2661
|
+
const x = between(14, 86).toFixed(1) + 'vw';
|
|
2662
|
+
const y = between(14, 46).toFixed(1) + 'vh';
|
|
2663
|
+
const start = b * 0.55 + between(0, 0.2);
|
|
2664
|
+
const bang = (start + 0.65).toFixed(2) + 's';
|
|
2665
|
+
|
|
2666
|
+
occasionParticle(layer, 'trail', 'launching', {
|
|
2667
|
+
'--mw-occasion-x': x,
|
|
2668
|
+
'--mw-occasion-y': y,
|
|
2669
|
+
'--mw-occasion-size': '6px',
|
|
2670
|
+
'--mw-occasion-delay': start.toFixed(2) + 's',
|
|
2671
|
+
});
|
|
2672
|
+
occasionParticle(layer, 'flash', 'flashing', {
|
|
2673
|
+
'--mw-occasion-x': x,
|
|
2674
|
+
'--mw-occasion-y': y,
|
|
2675
|
+
'--mw-occasion-size': '26vmin',
|
|
2676
|
+
'--mw-occasion-delay': bang,
|
|
2677
|
+
});
|
|
2678
|
+
|
|
2679
|
+
for (let i = 0; i < sparks; i++) {
|
|
2680
|
+
const inner = i % 3 === 0;
|
|
2681
|
+
const angle = (360 / sparks) * i + between(-5, 5);
|
|
2682
|
+
const reach = inner ? between(6, 10) : between(14, 22);
|
|
2683
|
+
const rad = (angle * Math.PI) / 180;
|
|
2684
|
+
occasionParticle(layer, 'spark', 'exploding', {
|
|
2685
|
+
'--mw-occasion-x': x,
|
|
2686
|
+
'--mw-occasion-y': y,
|
|
2687
|
+
'--mw-occasion-dx': (Math.cos(rad) * reach).toFixed(2) + 'vmin',
|
|
2688
|
+
'--mw-occasion-dy': (Math.sin(rad) * reach).toFixed(2) + 'vmin',
|
|
2689
|
+
'--mw-occasion-angle': (angle + 90).toFixed(0) + 'deg',
|
|
2690
|
+
'--mw-occasion-size': inner ? '12px' : '20px',
|
|
2691
|
+
'--mw-occasion-tint': tints[b % tints.length],
|
|
2692
|
+
'--mw-occasion-time': between(1.3, 1.8).toFixed(2) + 's',
|
|
2693
|
+
'--mw-occasion-delay': bang,
|
|
2694
|
+
});
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
// Attributes only - a style attribute in here would fall to the CSP
|
|
2700
|
+
function occasionTree() {
|
|
2701
|
+
const lights = [
|
|
2702
|
+
[46.5, 46.5],
|
|
2703
|
+
[61, 48.5],
|
|
2704
|
+
[74.5, 45.1],
|
|
2705
|
+
[39, 74],
|
|
2706
|
+
[53.7, 77],
|
|
2707
|
+
[68.2, 75.6],
|
|
2708
|
+
[81.3, 70.3],
|
|
2709
|
+
[31.6, 103.7],
|
|
2710
|
+
[49.8, 107.5],
|
|
2711
|
+
[69.3, 106.5],
|
|
2712
|
+
[88.6, 100.3],
|
|
2713
|
+
]
|
|
2714
|
+
.map(
|
|
2715
|
+
([x, y]) =>
|
|
2716
|
+
'<g class="mw-occasion-tree-light">' +
|
|
2717
|
+
`<circle cx="${x}" cy="${y}" r="4.6" fill="url(#mw-tree-light)"/>` +
|
|
2718
|
+
`<circle cx="${x}" cy="${y}" r="1.4" fill="#fffbe6"/></g>`
|
|
2719
|
+
)
|
|
2720
|
+
.join('');
|
|
2721
|
+
|
|
2722
|
+
return (
|
|
2723
|
+
'<svg class="mw-occasion-tree" viewBox="0 0 120 150">' +
|
|
2724
|
+
'<defs>' +
|
|
2725
|
+
'<linearGradient id="mw-tree-fir" x1="0" x2="1"><stop offset="0" stop-color="#2a7a45"/><stop offset=".55" stop-color="#17532d"/><stop offset="1" stop-color="#0d3a1e"/></linearGradient>' +
|
|
2726
|
+
'<radialGradient id="mw-tree-light"><stop offset="0" stop-color="#fffbe6"/><stop offset=".35" stop-color="#ffe08a"/><stop offset="1" stop-color="#ffd166" stop-opacity="0"/></radialGradient>' +
|
|
2727
|
+
'<radialGradient id="mw-tree-glow"><stop offset="0" stop-color="#fff3b0" stop-opacity=".9"/><stop offset="1" stop-color="#ffd166" stop-opacity="0"/></radialGradient>' +
|
|
2728
|
+
'<linearGradient id="mw-tree-gold" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff4c2"/><stop offset=".5" stop-color="#f1c84b"/><stop offset="1" stop-color="#b8860b"/></linearGradient>' +
|
|
2729
|
+
'</defs>' +
|
|
2730
|
+
'<rect x="53" y="108" width="14" height="18" rx="2" fill="#6b4226"/>' +
|
|
2731
|
+
'<g fill="url(#mw-tree-fir)">' +
|
|
2732
|
+
'<path d="M60 56C70 76 86 94 106 112C84 106 36 106 14 112C34 94 50 76 60 56Z"/>' +
|
|
2733
|
+
'<path d="M60 32C68 48 80 64 96 80C80 76 40 76 24 80C40 64 52 48 60 32Z"/>' +
|
|
2734
|
+
'<path d="M60 12C66 24 74 36 86 50C76 47 44 47 34 50C46 36 54 24 60 12Z"/>' +
|
|
2735
|
+
'</g>' +
|
|
2736
|
+
'<g fill="none" stroke="#e9c46a" stroke-width="1.4" stroke-opacity=".85">' +
|
|
2737
|
+
'<path d="M40 44Q62 54 80 42"/><path d="M30 70Q60 86 88 66"/><path d="M22 100Q62 118 100 94"/>' +
|
|
2738
|
+
'</g>' +
|
|
2739
|
+
'<circle cx="54" cy="38" r="3.4" fill="#d62839"/><circle cx="68" cy="39" r="3" fill="#e9b949"/>' +
|
|
2740
|
+
'<circle cx="46" cy="62" r="3.4" fill="#e9b949"/><circle cx="74" cy="62" r="3.4" fill="#d62839"/><circle cx="60" cy="66" r="3" fill="#cfd6de"/>' +
|
|
2741
|
+
'<circle cx="40" cy="94" r="3.6" fill="#d62839"/><circle cx="62" cy="95" r="3.6" fill="#e9b949"/><circle cx="84" cy="92" r="3.4" fill="#cfd6de"/>' +
|
|
2742
|
+
'<g>' +
|
|
2743
|
+
lights +
|
|
2744
|
+
'</g>' +
|
|
2745
|
+
'<circle class="mw-occasion-tree-glow" cx="60" cy="12" r="18" fill="url(#mw-tree-glow)"/>' +
|
|
2746
|
+
'<path class="mw-occasion-tree-star" fill="url(#mw-tree-gold)" d="M60 3L62.23 8.93L68.56 9.22L63.61 13.17L65.29 19.28L60 15.8L54.71 19.28L56.39 13.17L51.44 9.22L57.77 8.93Z"/>' +
|
|
2747
|
+
'</svg>'
|
|
2748
|
+
);
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
// One recipe per occasion: what the intro throws, how many, how fast
|
|
2752
|
+
function occasionRecipes() {
|
|
2753
|
+
const petals = {
|
|
2754
|
+
size: [18, 28],
|
|
2755
|
+
drift: [12, 40],
|
|
2756
|
+
time: [3, 4.2],
|
|
2757
|
+
spread: 1.4,
|
|
2758
|
+
spin: 1.2,
|
|
2759
|
+
flip: 'soft',
|
|
2760
|
+
};
|
|
2761
|
+
|
|
2762
|
+
return {
|
|
2763
|
+
snow: (layer, n) =>
|
|
2764
|
+
occasionFall(layer, n(70), ['flake', 'flake', 'flake', 'crystal'], {
|
|
2765
|
+
size: [6, 17],
|
|
2766
|
+
drift: [-4, 6],
|
|
2767
|
+
time: [4.2, 6.2],
|
|
2768
|
+
spread: 2.4,
|
|
2769
|
+
spin: 0.5,
|
|
2770
|
+
}),
|
|
2771
|
+
christmas: (layer, n) => {
|
|
2772
|
+
layer.insertAdjacentHTML('beforeend', occasionTree());
|
|
2773
|
+
occasionFall(layer, n(34), ['flake', 'flake', 'star'], {
|
|
2774
|
+
size: [6, 16],
|
|
2775
|
+
drift: [-4, 6],
|
|
2776
|
+
time: [4.4, 6.4],
|
|
2777
|
+
spread: 2.4,
|
|
2778
|
+
spin: 0.6,
|
|
2779
|
+
});
|
|
2780
|
+
},
|
|
2781
|
+
newyear: (layer, n) =>
|
|
2782
|
+
occasionFireworks(
|
|
2783
|
+
layer,
|
|
2784
|
+
Math.min(6, Math.max(3, n(5))),
|
|
2785
|
+
Math.max(18, n(36))
|
|
2786
|
+
),
|
|
2787
|
+
spring: (layer, n) => occasionFall(layer, n(44), ['petal'], petals),
|
|
2788
|
+
easter: (layer, n) => occasionFall(layer, n(44), ['petal'], petals),
|
|
2789
|
+
summer: (layer, n) => {
|
|
2790
|
+
const sun = document.createElement('div');
|
|
2791
|
+
sun.className = 'mw-occasion-sun';
|
|
2792
|
+
layer.appendChild(sun);
|
|
2793
|
+
occasionTwinkle(layer, n(18));
|
|
2794
|
+
},
|
|
2795
|
+
autumn: (layer, n) =>
|
|
2796
|
+
occasionFall(layer, n(32), ['maple', 'oak', 'birch'], {
|
|
2797
|
+
size: [30, 46],
|
|
2798
|
+
drift: [-8, 22],
|
|
2799
|
+
time: [3, 4.2],
|
|
2800
|
+
spread: 1.4,
|
|
2801
|
+
spin: 1.5,
|
|
2802
|
+
flip: 'soft',
|
|
2803
|
+
}),
|
|
2804
|
+
football: (layer, n) => {
|
|
2805
|
+
occasionBurst(layer, n(80), ['strip'], {
|
|
2806
|
+
size: [12, 20],
|
|
2807
|
+
spin: 5,
|
|
2808
|
+
flip: true,
|
|
2809
|
+
});
|
|
2810
|
+
occasionBurst(layer, 3, ['ball'], { size: [34, 46], spin: 3 });
|
|
2811
|
+
},
|
|
2812
|
+
birthday: (layer, n) => {
|
|
2813
|
+
occasionBurst(layer, n(90), ['confetti'], {
|
|
2814
|
+
size: [10, 15],
|
|
2815
|
+
spin: 5,
|
|
2816
|
+
flip: true,
|
|
2817
|
+
});
|
|
2818
|
+
occasionFall(layer, Math.max(4, n(8)), ['balloon'], {
|
|
2819
|
+
size: [44, 62],
|
|
2820
|
+
drift: [-6, 6],
|
|
2821
|
+
time: [3.8, 4.8],
|
|
2822
|
+
spread: 0.8,
|
|
2823
|
+
rising: true,
|
|
2824
|
+
});
|
|
2825
|
+
},
|
|
2826
|
+
anniversary: (layer, n) =>
|
|
2827
|
+
occasionBurst(layer, n(90), ['confetti', 'confetti', 'glint'], {
|
|
2828
|
+
size: [10, 16],
|
|
2829
|
+
spin: 5,
|
|
2830
|
+
flip: true,
|
|
2831
|
+
}),
|
|
2832
|
+
};
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
// Click-through from the first frame; any click, key or scroll takes it
|
|
2836
|
+
// away early instead of making the reader wait for it
|
|
2837
|
+
function playOccasionIntro(effect) {
|
|
2838
|
+
const layer = document.createElement('div');
|
|
2839
|
+
layer.className = 'mw-occasion-intro mw-occasion-intro-' + effect;
|
|
2840
|
+
layer.setAttribute('aria-hidden', 'true');
|
|
2841
|
+
const density = occasionDensity();
|
|
2842
|
+
occasionRecipes()[effect](layer, (count) =>
|
|
2843
|
+
Math.max(1, Math.round(count * density))
|
|
2844
|
+
);
|
|
2845
|
+
document.body.appendChild(layer);
|
|
2846
|
+
|
|
2847
|
+
const events = ['pointerdown', 'keydown', 'wheel', 'touchmove'];
|
|
2848
|
+
function remove() {
|
|
2849
|
+
layer.remove();
|
|
2850
|
+
events.forEach((type) => window.removeEventListener(type, leave));
|
|
2851
|
+
}
|
|
2852
|
+
function leave() {
|
|
2853
|
+
layer.classList.add('mw-occasion-leaving');
|
|
2854
|
+
setTimeout(remove, 350);
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
events.forEach((type) =>
|
|
2858
|
+
window.addEventListener(type, leave, { once: true, passive: true })
|
|
2859
|
+
);
|
|
2860
|
+
layer.addEventListener('animationend', (event) => {
|
|
2861
|
+
if (event.target === layer) remove();
|
|
2862
|
+
});
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
// Scrubbed by the scroll on the compositor, so it stands still whenever the
|
|
2866
|
+
// page does. A lap is a little more than a screen of scrolling, which makes
|
|
2867
|
+
// the count a matter of page length.
|
|
2868
|
+
function startOccasionDrift(effect) {
|
|
2869
|
+
if (!CSS.supports('animation-timeline: scroll()')) return;
|
|
2870
|
+
|
|
2871
|
+
const layer = document.createElement('div');
|
|
2872
|
+
layer.className = 'mw-occasion-drift';
|
|
2873
|
+
layer.setAttribute('aria-hidden', 'true');
|
|
2874
|
+
if (effect === 'summer') occasionBeams(layer);
|
|
2875
|
+
else occasionDriftParticles(layer, effect);
|
|
2876
|
+
document.body.appendChild(layer);
|
|
2877
|
+
|
|
2878
|
+
function laps() {
|
|
2879
|
+
const scrollable = document.documentElement.scrollHeight - innerHeight;
|
|
2880
|
+
layer.style.setProperty(
|
|
2881
|
+
'--mw-occasion-laps',
|
|
2882
|
+
String(Math.max(1, Math.round(scrollable / (innerHeight * 1.2))))
|
|
2883
|
+
);
|
|
2884
|
+
}
|
|
2885
|
+
laps();
|
|
2886
|
+
window.addEventListener('load', laps);
|
|
2887
|
+
window.addEventListener('resize', debounce(laps, 200), { passive: true });
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
// Sizes at a 1280px window, scaled with it within limits. Smaller than this a
|
|
2891
|
+
// leaf on the page reads as a speck, and a speck as a rendering fault.
|
|
2892
|
+
function occasionDriftParticles(layer, effect) {
|
|
2893
|
+
const shapes = {
|
|
2894
|
+
snow: ['flake', 'flake', 'crystal'],
|
|
2895
|
+
christmas: ['flake', 'flake', 'star'],
|
|
2896
|
+
newyear: ['glint'],
|
|
2897
|
+
spring: ['petal'],
|
|
2898
|
+
easter: ['petal'],
|
|
2899
|
+
autumn: ['maple', 'oak', 'birch'],
|
|
2900
|
+
football: ['strip'],
|
|
2901
|
+
birthday: ['confetti'],
|
|
2902
|
+
anniversary: ['confetti', 'glint'],
|
|
2903
|
+
}[effect];
|
|
2904
|
+
const sizes = {
|
|
2905
|
+
flake: [9, 16],
|
|
2906
|
+
crystal: [16, 22],
|
|
2907
|
+
star: [14, 20],
|
|
2908
|
+
glint: [16, 24],
|
|
2909
|
+
petal: [18, 26],
|
|
2910
|
+
maple: [28, 40],
|
|
2911
|
+
oak: [28, 40],
|
|
2912
|
+
birch: [24, 34],
|
|
2913
|
+
strip: [12, 18],
|
|
2914
|
+
confetti: [11, 17],
|
|
2915
|
+
};
|
|
2916
|
+
const scale = Math.min(1.15, Math.max(0.85, window.innerWidth / 1280));
|
|
2917
|
+
const count = Math.round(
|
|
2918
|
+
(window.innerWidth < 768 ? 11 : 18) * occasionAmount()
|
|
2919
|
+
);
|
|
2920
|
+
|
|
2921
|
+
for (let i = 0; i < count; i++) {
|
|
2922
|
+
const shape = shapes[i % shapes.length];
|
|
2923
|
+
const soft = ['maple', 'oak', 'birch', 'petal'].includes(shape);
|
|
2924
|
+
occasionParticle(layer, shape, 'falling', {
|
|
2925
|
+
'--mw-occasion-x':
|
|
2926
|
+
((i + between(0.1, 0.9)) * (100 / count)).toFixed(1) + 'vw',
|
|
2927
|
+
'--mw-occasion-size':
|
|
2928
|
+
(between(sizes[shape][0], sizes[shape][1]) * scale).toFixed(1) + 'px',
|
|
2929
|
+
'--mw-occasion-start': (((i * 37) % 100) * 0.3).toFixed(1) + '%',
|
|
2930
|
+
'--mw-occasion-lap': String(i % 3),
|
|
2931
|
+
'--mw-occasion-drift': between(-6, 6).toFixed(1) + 'vw',
|
|
2932
|
+
'--mw-occasion-sway': between(8, 24).toFixed(0) + 'px',
|
|
2933
|
+
'--mw-occasion-spin': signed(0.5, 2).toFixed(2) + 'turn',
|
|
2934
|
+
'--mw-occasion-axis': occasionAxis(
|
|
2935
|
+
soft ? 'soft' : ['confetti', 'strip'].includes(shape)
|
|
2936
|
+
),
|
|
2937
|
+
});
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
// Summer has no particles, it has rays - fanned out from above the top right
|
|
2942
|
+
// corner and swinging with the scroll
|
|
2943
|
+
function occasionBeams(layer) {
|
|
2944
|
+
const count = Math.round(5 * Math.min(1.4, occasionAmount()));
|
|
2945
|
+
|
|
2946
|
+
for (let i = 0; i < count; i++) {
|
|
2947
|
+
occasionParticle(layer, 'beam', 'beaming', {
|
|
2948
|
+
'--mw-occasion-size': between(9, 16).toFixed(1) + 'vw',
|
|
2949
|
+
'--mw-occasion-angle':
|
|
2950
|
+
(24 + (i * 54) / Math.max(1, count - 1) + between(-3, 3)).toFixed(1) +
|
|
2951
|
+
'deg',
|
|
2952
|
+
'--mw-occasion-lap': String(i % 2),
|
|
2953
|
+
});
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
// Last, not next to start(): a script that arrives after the parse starts at
|
|
2958
|
+
// once, and every const below start() would still be uninitialised
|
|
2959
|
+
if (document.readyState === 'loading') {
|
|
2960
|
+
document.addEventListener('DOMContentLoaded', start);
|
|
2961
|
+
} else {
|
|
2962
|
+
start();
|
|
2963
|
+
}
|
|
2198
2964
|
})();
|