Merge pull request #321 from reuters-graphics/scrolly-video

adds ScrollyVideo with docs
This commit is contained in:
Sudev Kiyada 2025-08-07 19:49:43 +05:30 committed by GitHub
commit 539d1b5e85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 5987 additions and 10 deletions

View file

@ -0,0 +1,5 @@
---
'@reuters-graphics/graphics-components': patch
---
Adds ScrollerVideo

View file

@ -76,6 +76,7 @@
"eslint-plugin-storybook": "^0.12.0",
"knip": "^5.50.5",
"mermaid": "^10.9.3",
"mp4box": "^0.5.4",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3",
@ -92,6 +93,7 @@
"svelte": "^5.28.1",
"svelte-check": "^4.1.6",
"typescript": "^5.8.3",
"ua-parser-js": "^2.0.3",
"vite": "^6.3.2"
},
"dependencies": {

View file

@ -168,6 +168,9 @@ importers:
mermaid:
specifier: ^10.9.3
version: 10.9.3
mp4box:
specifier: ^0.5.4
version: 0.5.4
postcss:
specifier: ^8.5.3
version: 8.5.3
@ -216,6 +219,9 @@ importers:
typescript:
specifier: ^5.8.3
version: 5.8.3
ua-parser-js:
specifier: ^2.0.3
version: 2.0.3
vite:
specifier: ^6.3.2
version: 6.3.2(@types/node@22.14.1)(jiti@2.4.2)(sass@1.86.3)(yaml@2.7.1)
@ -1127,6 +1133,9 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node-fetch@2.6.12':
resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==}
'@types/node@12.20.55':
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
@ -1329,6 +1338,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
atob@2.1.2:
resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==}
engines: {node: '>= 4.5.0'}
@ -1481,6 +1493,10 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
comma-separated-tokens@1.0.8:
resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==}
@ -1755,10 +1771,17 @@ packages:
delaunator@5.0.1:
resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
detect-europe-js@0.1.2:
resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==}
detect-indent@6.1.0:
resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
engines: {node: '>=8'}
@ -2149,6 +2172,10 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
form-data@4.0.2:
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
engines: {node: '>= 6'}
format@0.2.2:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
@ -2477,6 +2504,9 @@ packages:
resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
engines: {node: '>= 0.4'}
is-standalone-pwa@0.1.1:
resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==}
is-string@1.1.1:
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
engines: {node: '>= 0.4'}
@ -2887,6 +2917,14 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
min-indent@1.0.1:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
@ -2913,6 +2951,9 @@ packages:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
mp4box@0.5.4:
resolution: {integrity: sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA==}
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@ -2938,6 +2979,15 @@ packages:
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
non-layered-tidy-tree-layout@2.0.2:
resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==}
@ -3758,6 +3808,9 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
trough@2.2.0:
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
@ -3824,6 +3877,13 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
ua-is-frozen@0.1.2:
resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==}
ua-parser-js@2.0.3:
resolution: {integrity: sha512-LZyXZdNttONW8LjzEH3Z8+6TE7RfrEiJqDKyh0R11p/kxvrV2o9DrT2FGZO+KVNs3k+drcIQ6C3En6wLnzJGpw==}
hasBin: true
unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@ -3970,9 +4030,15 @@ packages:
web-worker@1.5.0:
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@ -5106,6 +5172,11 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/node-fetch@2.6.12':
dependencies:
'@types/node': 22.14.1
form-data: 4.0.2
'@types/node@12.20.55': {}
'@types/node@22.14.1':
@ -5359,6 +5430,8 @@ snapshots:
async-function@1.0.0: {}
asynckit@0.4.0: {}
atob@2.1.2: {}
available-typed-arrays@1.0.7:
@ -5480,6 +5553,10 @@ snapshots:
color-name@1.1.4: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
comma-separated-tokens@1.0.8: {}
commander@7.2.0: {}
@ -5771,8 +5848,12 @@ snapshots:
dependencies:
robust-predicates: 3.0.2
delayed-stream@1.0.0: {}
dequal@2.0.3: {}
detect-europe-js@0.1.2: {}
detect-indent@6.1.0: {}
detect-libc@1.0.3:
@ -6356,6 +6437,13 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
form-data@4.0.2:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
mime-types: 2.1.35
format@0.2.2: {}
fs-extra@7.0.1:
@ -6692,6 +6780,8 @@ snapshots:
dependencies:
call-bound: 1.0.4
is-standalone-pwa@0.1.1: {}
is-string@1.1.1:
dependencies:
call-bound: 1.0.4
@ -7387,6 +7477,12 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.1
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
min-indent@1.0.1: {}
minimatch@10.0.1:
@ -7409,6 +7505,8 @@ snapshots:
dependencies:
minimist: 1.2.8
mp4box@0.5.4: {}
mri@1.2.0: {}
mrmime@2.0.1: {}
@ -7427,6 +7525,10 @@ snapshots:
node-addon-api@7.1.1:
optional: true
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
non-layered-tidy-tree-layout@2.0.2: {}
nopt@7.2.1:
@ -8313,6 +8415,8 @@ snapshots:
totalist@3.0.1: {}
tr46@0.0.3: {}
trough@2.2.0: {}
ts-api-utils@2.1.0(typescript@5.8.3):
@ -8382,6 +8486,18 @@ snapshots:
typescript@5.8.3: {}
ua-is-frozen@0.1.2: {}
ua-parser-js@2.0.3:
dependencies:
'@types/node-fetch': 2.6.12
detect-europe-js: 0.1.2
is-standalone-pwa: 0.1.1
node-fetch: 2.7.0
ua-is-frozen: 0.1.2
transitivePeerDependencies:
- encoding
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4
@ -8559,8 +8675,15 @@ snapshots:
web-worker@1.5.0: {}
webidl-conversions@3.0.1: {}
webpack-virtual-modules@0.6.2: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0

View file

@ -1,5 +1,6 @@
import type { Component } from 'svelte';
import type { TransitionOptions } from '../ScrollerVideo/ts/ScrollerVideo.js';
import type { ScrollerVideoState } from '../ScrollerVideo/ts/state.svelte.js';
/**
* Used for the list of <option> tags nested in a <select> input.
*/
@ -56,3 +57,61 @@ export type ForegroundPosition =
| 'right'
| 'left opposite'
| 'right opposite';
export type ScrollerVideoForegroundPosition =
| 'top center'
| 'top left'
| 'top right'
| 'bottom center'
| 'bottom left'
| 'bottom right'
| 'center center'
| 'center left'
| 'center right';
// Complete ScrollerVideo instance interface
export interface ScrollerVideoInstance {
// Properties
container: HTMLElement | null;
scrollerVideoContainer: Element | string | undefined;
src: string;
transitionSpeed: number;
frameThreshold: number;
useWebCodecs: boolean;
objectFit: string;
sticky: boolean;
trackScroll: boolean;
onReady: () => void;
onChange: (percentage?: number) => void;
debug: boolean;
autoplay: boolean;
video: HTMLVideoElement | undefined;
videoPercentage: number;
isSafari: boolean;
currentTime: number;
targetTime: number;
canvas: HTMLCanvasElement | null;
context: CanvasRenderingContext2D | null;
frames: ImageBitmap[] | null;
frameRate: number;
targetScrollPosition: number | null;
currentFrame: number;
usingWebCodecs: boolean;
totalTime: number;
transitioningRaf: number | null;
componentState: ScrollerVideoState;
// Methods
updateScrollPercentage: ((jump: boolean) => void) | undefined;
resize: (() => void) | undefined;
setVideoPercentage(percentage: number, options?: TransitionOptions): void;
setCoverStyle(el: HTMLElement | HTMLCanvasElement | undefined): void;
decodeVideo(): Promise<void>;
paintCanvasFrame(frameNum: number): void;
transitionToTargetTime(options: TransitionOptions): void;
setTargetTimePercent(percentage: number, options?: TransitionOptions): void;
setScrollPercent(percentage: number): void;
destroy(): void;
autoplayScroll(): void;
updateDebugInfo(): void;
}

View file

@ -72,7 +72,7 @@ In your graphics kit project, import your ai2svelte graphics in `App.svelte` and
</script>
```
Then add the following structure to your ArchieML Doc, making sure that the names of your charts in the `aiCharts` object match the names of each step's `background` in the ArchieML doc:
Then add the following structure to your ArchieML doc, making sure that the names of your charts in the `aiCharts` object match the names of each step's `background` in the ArchieML doc:
```yaml
# ArchieML doc
@ -217,7 +217,7 @@ In your graphics kit project's `App.svelte`, import your custom foregroud compon
</script>
```
Then add the following structure to your ArchieML Doc, making sure that the names of your charts in the `aiCharts` and `foregroundComponents` objects match the names of each step's `background` and `foreground` in the ArchieML doc:
Then add the following structure to your ArchieML doc, making sure that the names of your charts in the `aiCharts` and `foregroundComponents` objects match the names of each step's `background` and `foreground` in the ArchieML doc:
```yaml
# ArchieML doc

View file

@ -64,11 +64,6 @@ The `ScrollerBase` component powers the [`Scroller` component](?path=/story/comp
<style lang="scss">
@use '@reuters-graphics/graphics-components/dist/scss/mixins' as mixins;
.scroller-demo-container {
width: mixins.$column-width-normal;
margin: auto;
}
.step-foreground-container {
height: 100vh;
width: 50%;

View file

@ -0,0 +1,224 @@
<script lang="ts">
const { componentState } = $props();
let isMoving = $state(false);
let preventDetails = $state(false);
let position = $state({ x: 8, y: 8 });
function onMouseDown(e: MouseEvent) {
isMoving = true;
e.preventDefault();
}
function onMouseMove(e: MouseEvent) {
if (isMoving) {
position = {
x: position.x + e.movementX,
y: position.y + e.movementY,
};
preventDetails = true;
}
e.preventDefault();
}
function onMouseUp(e: MouseEvent) {
if (isMoving) {
isMoving = false;
setTimeout(() => {
preventDetails = false;
}, 5);
e.stopImmediatePropagation();
}
e.preventDefault();
}
function onClick(e: MouseEvent) {
if (preventDetails) {
e.preventDefault();
}
isMoving = false;
}
</script>
<svelte:window onmousemove={onMouseMove} />
<div
style="position: absolute; top: {position.y}px; left: {position.x}px; z-index: 5; user-select: none;"
role="region"
>
<details class="debug-info" open>
<summary
class="text-xxs font-sans font-bold title"
style="grid-column: span 2;"
onmousedown={onMouseDown}
onmouseup={onMouseUp}
onclick={onClick}
>
CONSOLE
</summary>
<div class="state-debug">
<p>Source:</p>
<p class="state-value">{componentState.generalData.src}</p>
<!-- -->
<p>Progress:</p>
<div style="display: flex; flex-direction: column; gap: 4px;">
<p class="state-value">{componentState.generalData.videoPercentage}</p>
<div id="video-progress-bar">
<div
style="width: {componentState.generalData.videoPercentage *
100}%; height: 100%;"
></div>
</div>
</div>
<!-- -->
<p>Framerate:</p>
<p class="state-value">{componentState.generalData.frameRate}</p>
<!-- -->
<p>Current time:</p>
<p class="state-value">
{componentState.generalData.currentTime}/{componentState.generalData
.totalTime}
</p>
<!-- -->
{#if componentState.usingWebCodecs}
<p>Codec:</p>
<p class="state-value">
<span class="tag">{componentState.framesData.codec}</span>
</p>
<!-- -->
<p>Current frame:</p>
<p class="state-value">
{componentState.framesData.currentFrame}/{componentState.framesData
.totalFrames}
</p>
{/if}
<!-- -->
<p>Will Autoplay?:</p>
<p class="state-value">
<span class="tag">{componentState.willAutoPlay}</span>
</p>
<!-- -->
{#if componentState.willAutoPlay}
<p>Autoplaying:</p>
<p class="state-value">
<span class="tag">{componentState.isAutoPlaying}</span>
</p>
<p>Autoplay progress:</p>
<div style="display: flex; flex-direction: column; gap: 4px;">
<p class="state-value">{componentState.autoplayProgress}</p>
<div id="video-progress-bar">
<div
style="width: {componentState.autoplayProgress *
100}%; height: 100%;"
></div>
</div>
</div>
{/if}
</div>
</details>
</div>
<style lang="scss">
@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&display=swap');
.debug-info {
position: absolute;
top: 0;
left: 0;
background-color: rgba(0, 0, 0, 1);
z-index: 3;
margin: 0;
width: 50vmin;
min-width: 50vmin;
padding: 8px;
border-radius: 8px;
overflow: auto;
resize: horizontal;
opacity: 0.6;
transition: opacity 0.3s ease;
filter: drop-shadow(0 0 16px rgba(0, 0, 0, 0.5));
@media (prefers-reduced-motion: no-preference) {
interpolate-size: allow-keywords;
}
&::details-content {
opacity: 0;
block-size: 0;
overflow-y: clip;
transition:
content-visibility 0.4s allow-discrete,
opacity 0.4s,
block-size 0.4s cubic-bezier(0.87, 0, 0.13, 1);
}
&[open]::details-content {
opacity: 1;
block-size: auto;
}
.title {
width: 100%;
font-family: 'Geist Mono', monospace;
color: white;
margin: 0;
}
* {
user-select: none;
}
}
.debug-info[open] {
opacity: 1;
}
div.state-debug {
display: grid;
width: 100%;
padding: 8px 8px 16px 8px;
grid-template-columns: 20vmin 1fr;
align-items: center;
gap: 0.75rem 0.25rem;
background-color: #1e1e1e;
border-radius: 4px;
margin-top: 8px;
}
p {
font-size: var(--theme-font-size-xxs);
font-family: 'Geist Mono', monospace;
padding: 0;
margin: 0;
color: rgba(255, 255, 255, 0.7);
overflow-wrap: anywhere;
line-height: 100%;
font-variant: tabular-nums;
}
.state-value {
color: white;
}
#video-progress-bar {
width: 100%;
background-color: rgba(255, 255, 255, 0.2);
height: 2px;
border-radius: 50px;
// margin: auto;
div {
background-color: white;
border-radius: 50px;
}
}
.tag {
padding: 0.1rem 0.2rem;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.2);
text-transform: uppercase;
font-weight: 500;
}
</style>

View file

@ -0,0 +1,462 @@
import { Meta } from '@storybook/blocks';
import * as ScrollerVideoStories from './ScrollerVideo.stories.svelte';
<Meta of={ScrollerVideoStories} />
# ScrollerVideo
The `ScrollerVideo` component creates interactive video experiences that respond to user scrolling. It is built on top of [ScrollyVideo.js](https://scrollyvideo.js.org/) and is designed to work seamlessly with Svelte.
## Basic demo
To use the `ScrollerVideo` component, import it and provide the video source. The scroll height defaults to `200lvh`, but you can adjust this to any valid CSS height value such as `1200px` or `200lvh` with the `height` prop.
> 💡TIP: Use `lvh` or `svh` units instead of `vh` unit for the height, as [these units](https://www.w3.org/TR/css-values-4/#large-viewport-size) are more reliable on mobile or other devices where elements such as the address bar toggle between being shown and hidden.
[Demo](?path=/story/components-graphics-scrollervideo--demo)
```svelte
<script lang="ts">
import { ScrollerVideo } from '@reuters-graphics/graphics-components';
</script>
<!-- Optionally set `height` to adjust scroll height -->
<ScrollerVideo src="my-video.mp4" height="500lvh" />
```
## Optimising videos
When using the `ScrollerVideo` component, minimise the video file size and ensure that the video is encoded in a format that is widely supported across browsers. Videos encoded at higher frame rates (FPS) are bound to crash on mobile devices, so 24 FPS is recommended.
If at any point your page crashes while using this component (happens often only on phone devices), it is likely due to the video being too large or encoded at a high frame rate. You could also try separate videos for desktop and phone devices to save quality for the desktop viewing experience.
> 💡**TIP:** Set the `showDebugInfo` prop to `true` to see video encoding information
To optimise your video for the web, you can use `ffmpeg` to convert the video to a suitable format. Here is an example terminal command that converts a video to H.264 format with a resolution of 720p and a frame rate of 24 FPS:
```bash
npx ffmpeg -y -i <input_video_src>.mp4 -c:v libx264 -movflags +faststart -crf 24 -r 24 -g 72 -vf scale=720:-1 -profile:v high -preset veryslow -pix_fmt yuv420p -color_primaries 1 -color_trc 1 -colorspace 1 -an <output_video>.mp4
```
Adjust the `-crf` value to control the quality. A lower `-crf` value means higher quality, with 20-24 being a generally good balance. The video framerate can be altered by using `-r` flag. Set keyframe intervals using the `-g` flag. It is advisable to keep it around 3 seconds (3 \* video framerate) for a good output. See [FFmpeg documentation](https://ffmpeg.org/ffmpeg.html) and [Testing Media Capabilities](https://cconcolato.github.io/media-mime-support/mediacapabilities.html) for more.
## Responsive videos
To show different videos based on the screen width, use the `ScrollerVideo` component with conditional logic that uses a different video source depending on the [window width](https://svelte.dev/docs/svelte/svelte-window).
[Demo](?path=/story/components-graphics-scrollervideo--responsive-videos)
```svelte
<script lang="ts">
import { ScrollerVideo } from '@reuters-graphics/graphics-components';
let width = $state(0);
</script>
<svelte:window bind:innerWidth={width} />
{#if width < 600}
<!-- Video with aspect ratio 9:16 for window width smaller than 600px -->
<ScrollerVideo src="my-video-sm.mp4" height="500lvh" />
{:else if width < 1200}
<!-- Video with aspect ratio 1:1 for window width between 600px and 1200px -->
<ScrollerVideo src="my-video-md.mp4" height="500lvh" />
{:else}
<!-- Video with aspect ratio 16:9 for window width above 1200px -->
<ScrollerVideo src="my-video-lg.mp4" height="500lvh" />
{/if}
```
## Embeds
Setting `embedded` to `true` will turn `ScrollerVideo` into an embeddable version, where the video autoplays when the user scrolls upon it. Optionally, you can control the embed video behaviour by passing `embeddedProps` to control the autoplay `delay`, `threshold` for triggering autoplay, and the `duration` of the video.
> 💡**TIP:** Another way to recreate the ScrollerVideo experience for embeds is to record the desktop screen with [Scroll Capture](https://chromewebstore.google.com/detail/scroll-capture/egmhoeaacclmanaimofoooiamhpkimkk?hl=en) while scrolling through the video and use that video instead as an HTML video component.
[Demo](?path=/story/components-graphics-scrollervideo--embed)
```svelte
<script lang="ts">
import { ScrollerVideo } from '@reuters-graphics/graphics-components';
let embedded = $state(true); // Set to true to enable embedded mode
</script>
<ScrollerVideo
src="my-video.mp4"
{embedded}
embeddedProps={{
delay: 200, // Optional: Delay before autoplay starts. Defaults to 200ms.
threshold: 0.5, // Optional: Threshold for triggering the autoplay. Defaults to 0.5.
duration: 5000, // Optional: Defaults to the duration of the video.
}}
/>
```
## Autoplay
The `autoplay` option combines the autoplay and scrollytelling experience. If set to `true`, the video will start playing automatically when the component is mounted, but switch to scrollytelling when the user starts scrolling. The scroll height is calculated based on how much of the video remains, which means that if the user lets the video autoplay to near the end, the user would only have to scroll through a small height to get to the end. If the user lets the video autoplay to the end, there will be no scrolling effect.
[Demo](?path=/story/components-graphics-scrollervideo--autoplay)
```svelte
<script lang="ts">
import { ScrollerVideo } from '@reuters-graphics/graphics-components';
</script>
<ScrollerVideo src="my-video.mp4" autoplay={true} />
```
## Time-based text foregrounds with ArchieML
The `ScrollerVideo` component can also be used to display text as foregrounds at specific times in the video. To do so, use the `text` prop in `ScrollerVideoForeground` component.
[Demo](?path=/story/components-graphics-scrollervideo--archie-ml-foregrounds)
With the graphics kit, you'll likely get your text and prop values from an ArchieML doc...
```yaml
# ArchieML doc
[blocks]
type: scroller-video
id: alps-scroller
src: videos/alps.mp4
height: 800lvh
# Array of foregrounds
[.foregrounds]
startTime: 3 # When in the video to start showing the foreground
endTime: 7 # When to stop showing the foreground
width: normal # text container width
position: bottom center # Position of the text. Optional; defaults to 'center center'. Must be a combination of `top/bottom/center center/left/right`
backgroundColour: rgba(0, 0, 0, 0.8) # Optional; defaults to white
text: #### The Alps
The Alps stretch across eight countries: France, Switzerland, Italy, Monaco, Liechtenstein, Austria, Germany, and Slovenia, covering about 1,200 kilometers (750 miles).
:end
startTime: 9
endTime: 12
width: normal
position: bottom center
backgroundColour: rgba(0, 0, 0, 0.8)
text: Mont Blanc, standing at 4,809 meters (15,777 feet), is the highest peak in the Alps and Western Europe, though there's ongoing debate between France and Italy about exactly where the summit lies.
:end
startTime: 16
endTime: 20
width: normal
position: bottom center
backgroundColour: rgba(0, 0, 0, 0.8)
text: #### History
The Alps were formed around **65 million years** ago when the African and Eurasian tectonic plates collided, pushing the land upward. Over 14 million people live in the Alpine region, with tourism supporting approximately 120 million visitors annually.
:end
[]
[]
```
... which you'll parse out of a ArchieML block object before passing to the `ScrollerVideo` and `ScrollerVideoForeground` components.
```svelte
<script lang="ts">
import {
ScrollerVideo,
ScrollerVideoForeground,
} from '@reuters-graphics/graphics-components';
import { assets } from '$app/paths'; // 👈 If using in the graphics kit...
</script>
{#each content.blocks as block}
<!-- Inside the content.blocks for loop... -->
{#if block.type == 'scroller-video'}
<ScrollerVideo
id={block.id}
src={`${assets}/${block.src}}`}
height={block.height}
>
<!-- Loop through foregrounds to add text blurbs that appear/disappear at specific times -->
{#each block.foregrounds as foreground}
<ScrollerVideoForeground
startTime={parseFloat(foreground.startTime)}
endTime={parseFloat(foreground.endTime)}
width={foreground.width}
position={foreground.position}
backgroundColour={foreground.backgroundColour}
text={foreground.text}
/>
{/each}
</ScrollerVideo>
{/if}
{/each}
```
## Time-based component foregrounds with ArchieML
The `ScrollerVideo` component can also be used to display components, such as `Headline` or ai2svelte files, as foregrounds at specific times in the video. To do so, use the `Foreground` prop in `ScrollerVideoForeground` component.
> **IMPORTANT❗**: When layering ai2svelte files over a video, the aspect ratio of the ai2svelte graphics should match that of the video. If the ai2svelte graphic is responsive and has, for example, small, medium and large versions — which is generally the case — make sure to also render small, medium and large versions of the video at the appropriate screen sizes. See [Responsive videos](#responsive-videos) for more details.
[Demo](?path=/story/components-graphics-scrollervideo--component-archie-ml-foregrounds)
With the graphics kit, you'll likely get your text and prop values from an ArchieML doc...
```yaml
# ArchieML doc
# Headline
hed: Wind and waves
[authors]
* Jane Doe
[]
publishTime: 2020-01-01T00:00:00Z
startTime: 0 # When in the video to start showing the headline
endTime: 0.3 # When to stop showing the headline
[blocks]
type: scroller-video
id: my-scroller-video
height: 800lvh
# Adjust prop names as needed
srcSm: videos/my-video-sm.mp4
srcMd: videos/my-video-md.mp4
srcLg: videos/my-video-lg.mp4
# Array of foregrounds
[.foregrounds]
startTime: 0.3 # When in the video to start showing the foreground
endTime: 2.2 # When to stop showing the foreground
width: fluid # foreground container width
Foreground: Foreground1 # Name of the ai2svelte component to render
startTime: 2.2
endTime: 3.2
width: fluid
Foreground: Foreground2
startTime: 3.2
endTime: 4.5
width: fluid
Foreground: Foreground3
startTime: 6.5
endTime: 8
width: fluid
Foreground: Foreground4
[]
[]
```
... which you'll parse out of a ArchieML block object before passing to the `ScrollerVideo` and `ScrollerVideoForeground` components.
```svelte
<script lang="ts">
import { assets } from '$app/paths'; // 👈 If using in the graphics kit...
import {
Headline,
GraphicBlock,
ScrollerVideo,
Foreground,
} from '@reuters-graphics/graphics-components';
// Foreground ai2svelte components
import Foreground1 from './ai2svelte/foreground1.svelte';
import Foreground2 from './ai2svelte/foreground2.svelte';
import Foreground3 from './ai2svelte/foreground3.svelte';
import Foreground4 from './ai2svelte/foreground4.svelte';
// Add your imported foreground ai2svelte charts to this object
const aiChartsForeground = {
Foreground1,
Foreground2,
Foreground3,
Foreground4,
};
// Window width for responsive videos
let width = $state(1);
</script>
<svelte:window bind:innerWidth={width} />
<!-- Loop through content blocks... -->
{#each content.blocks as block}
{#if block.type == 'scroller-video'}
<!-- ScrollVideo snippet to render responsive videos -->
{#snippet ScrollVideo(height: string, src: string)}
<ScrollerVideo id={block.id} {height} {src}>
<!-- Headline component as foreground -->
<ScrollerVideoForeground
startTime={parseFloat(content.startTime)}
endTime={parseFloat(content.endTime)}
>
<Headline
hed={content.hed}
authors={content.authors}
publishTime={new Date(content.publishTime).toISOString()}
/>
</ScrollerVideoForeground>
<!-- Loop through block.foregrounds to render each foreground component -->
{#each block.foregrounds as foreground}
<ScrollerVideoForeground
startTime={parseFloat(foreground.startTime)}
endTime={parseFloat(foreground.endTime)}
width={foreground.width}
Foreground={aiChartsForeground[
foreground.foreground as keyof typeof aiChartsForeground
]}
/>
{/each}
</ScrollerVideo>
{/snippet}
<!-- Render the ScrollVideo snippet for different screen sizes -->
{#if width < 600}
{@render ScrollVideo(block.height, `${assets}/${block.srcSm}`)}
{:else if width < 1200}
{@render ScrollVideo(block.height, `${assets}/${block.srcMd}`)}
{:else}
{@render ScrollVideo(block.height, `${assets}/${block.srcLg}`)}
{/if}
{/if}
{/each}
```
## Using with `ScrollerBase`
The `ScrollerVideo` component can be used inside the [ScrollerBase](?path=/story/components-graphics-scrollerbase--docs) component to add foreground content. This allows for a foreground that scrolls up and down over the video, instead of fading in and out at specific times.
> **Note**: To use `ScrollerVideo` with `ScrollerBase`, set `trackScroll` to `false` and pass the bindable prop `progress` from `ScrollerBase` as `videoPercentage` to `ScrollerVideo`.
[Demo](?path=/story/components-graphics-scrollervideo--scroller-base)
```svelte
<script lang="ts">
import {
ScrollerVideo,
ScrollerBase,
} from '@reuters-graphics/graphics-components';
// Pass `progress` as `videoPercentage` to ScrollerVideo
let progress = $state(0);
</script>
<ScrollerBase bind:progress query="div.step-foreground-container">
{#snippet backgroundSnippet()}
<!-- Pass bindable prop `progress` as `videoPercentage` and set `trackScroll` to `false` -->
<ScrollerVideo
src="my-video.mp4"
videoPercentage={progress}
trackScroll={false}
/>
{/snippet}
{#snippet foregroundSnippet()}
<!-- Add custom foreground HTML or component -->
<div class="step-foreground-container">
<h3 class="text-center">Step 1</h3>
</div>
<div class="step-foreground-container">
<h3 class="text-center">Step 2</h3>
</div>
<div class="step-foreground-container">
<h3 class="text-center">Step 3</h3>
</div>
{/snippet}
</ScrollerBase>
<style lang="scss">
.step-foreground-container {
height: 100lvh;
width: 50%;
padding: 1em;
margin: auto;
h3 {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: white;
}
}
</style>
```
## Advanced usecases
Using the methods attached to the bindable prop `scrollerVideo` allows for advanced customisation of the scroll video behaviour. For example, you can create a looping video that plays a specific section of the video repeatedly, or jump to a specific time in the video when the user scrolls to a certain point.
This code below would make the video smoothly jump to the halfway point of the video. Setting `jump` to `true` will make the video jump to the specified percentage abruptly:
```js
scrollerVideo.setVideoPercentage(
0.5, // progress set to 50%
{
transitionSpeed: 12, // playback rate for the video
jump: false, // flag to change transition video abruptly
easing: (t) => t, // linear easing. Can also pass d3 easing functions - d3.easeLinear
}
);
```
> **Note**: When using these methods, it's recommended to set `trackScroll` to `false` to avoid video playback on scroll and pass functions to the `onReady` prop to ensure that the video is ready before calling any methods on it.
Here is a demo that uses `ScrollerVideo` with `ScrollerBase` to make the video jump to the start or the end of the video depending on what step of the scroller the user is on.
[Demo](?path=/story/components-graphics-scrollervideo--advanced)
```svelte
<script lang="ts">
import {
ScrollerVideo,
ScrollerBase,
type ScrollerVideoInstance,
} from '@reuters-graphics/graphics-components';
import { onDestroy } from 'svelte';
let scrollerVideo: ScrollerVideoInstance | undefined = $state(undefined);
let animationFrame = $state(0);
let index = $state(0); // index for the current step in ScrollerBase
// If ScrollerBase is on index 0, jump to the start of the video.
// Otherwise, jump to 1, or 100% (the end), of the video.
function jumpVideo() {
if (index === 0) {
scrollerVideo?.setVideoPercentage(0, {
jump: false, // Eases the jump
});
} else {
scrollerVideo?.setVideoPercentage(1, {
jump: false,
});
}
}
</script>
<ScrollerBase bind:index query="div.step-foreground-container">
<!-- ScrollerVideo as background -->
{#snippet backgroundSnippet()}
<!-- Pass `jumpVideo` to `onReady` and set `trackScroll` to `false` -->
<ScrollerVideo
bind:scrollerVideo
src={Tennis}
height="100lvh"
trackScroll={false}
showDebugInfo
onReady={jumpVideo}
/>
{/snippet}
<!-- Simple text foregrounds -->
{#snippet foregroundSnippet()}
<div class="step-foreground-container">
<h3 class="text-center">Index {index}</h3>
</div>
<div class="step-foreground-container">
<h3 class="text-center">Index {index}</h3>
</div>
{/snippet}
</ScrollerBase>
```

View file

@ -0,0 +1,190 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import ScrollerVideo from './ScrollerVideo.svelte';
import WithScrollerBase from './demo/WithScrollerBase.svelte';
import WithAi2svelteForegrounds from './demo/WithAi2svelteForegrounds.svelte';
import WithTextForegrounds from './demo/WithTextForegrounds.svelte';
import Embedded from './demo/Embedded.svelte';
const { Story } = defineMeta({
title: 'Components/Graphics/ScrollerVideo',
component: ScrollerVideo,
argTypes: {
autoplay: {
control: 'boolean',
table: {
defaultValue: { summary: 'false' },
},
},
class: {
control: 'text',
table: {
defaultValue: { summary: '' },
},
},
debug: {
control: 'boolean',
table: {
defaultValue: { summary: 'false' },
},
},
frameThreshold: {
control: 'number',
table: {
defaultValue: { summary: '0.1' },
},
},
full: {
control: 'boolean',
table: {
defaultValue: { summary: 'true' },
},
},
lockScroll: {
control: 'boolean',
table: {
defaultValue: { summary: 'true' },
},
},
objectFit: {
control: 'select',
options: ['contain', 'cover'],
table: {
defaultValue: { summary: 'cover' },
},
},
onChange: {
table: {
type: { summary: 'function' },
defaultValue: { summary: '() => {}' },
category: 'Bindable states',
},
},
onReady: {
table: {
type: { summary: 'function' },
defaultValue: { summary: '() => {}' },
category: 'Bindable states',
},
},
scrollerVideo: {
table: {
category: 'Bindable states',
},
},
showDebugInfo: {
control: 'boolean',
table: {
defaultValue: { summary: 'false' },
},
},
src: {
control: 'text',
table: {
defaultValue: {
summary: 'https://scrollyvideo.js.org/goldengate.mp4',
},
},
},
sticky: {
control: 'boolean',
table: {
defaultValue: { summary: 'true' },
},
},
trackScroll: {
control: 'boolean',
table: {
defaultValue: { summary: 'true' },
},
},
transitionSpeed: {
control: 'number',
table: {
defaultValue: { summary: '8' },
},
},
useWebCodecs: {
control: 'boolean',
table: {
defaultValue: { summary: 'true' },
},
},
videoPercentage: {
control: 'number',
table: {
category: 'Bindable states',
},
},
},
});
let width: number = $state(0);
</script>
<script>
import Video_SM from './videos/waves_sm.mp4';
import Video_MD from './videos/waves_md.mp4';
import Video_LG from './videos/waves_lg.mp4';
import Goldengate from './videos/goldengate.mp4';
import AdvancedUsecases from './demo/AdvancedUsecases.svelte';
const videoSrc = {
Video_SM,
Video_MD,
Video_LG,
Goldengate,
};
const args = {
showDebugInfo: true,
};
</script>
<svelte:window bind:innerWidth={width} />
<Story name="Demo">
<ScrollerVideo {...args} src={videoSrc.Goldengate} />
</Story>
<Story name="Responsive videos" exportName="ResponsiveVideos">
{#if width < 600}
<ScrollerVideo {...args} src={videoSrc.Video_SM} />
{:else if width < 1200}
<ScrollerVideo {...args} src={videoSrc.Video_MD} />
{:else}
<ScrollerVideo {...args} src={videoSrc.Video_LG} />
{/if}
</Story>
<Story name="Embed version" exportName="Embed">
<Embedded />
</Story>
<Story name="Autoplay">
<ScrollerVideo {...args} src={videoSrc.Goldengate} autoplay={true} />
</Story>
<Story
name="Time-based foregrounds with ArchieML"
exportName="ArchieMLForegrounds"
{args}
>
<WithTextForegrounds />
</Story>
<Story
name="Time-based component foregrounds with ArchieML"
exportName="ComponentArchieMLForegrounds"
{args}
>
<WithAi2svelteForegrounds />
</Story>
<Story name="Using with ScrollerBase" exportName="ScrollerBase" {args}>
<WithScrollerBase />
</Story>
<Story name="Advanced usecases" exportName="Advanced" {args}>
<AdvancedUsecases />
</Story>

View file

@ -0,0 +1,297 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import ScrollerVideo from './ts/ScrollerVideo';
import Debug from './Debug.svelte';
import type { Snippet } from 'svelte';
import { setContext } from 'svelte';
import { dev } from '$app/environment';
import { Tween } from 'svelte/motion';
interface Props {
/** CSS class for scroller container */
class?: string;
/** ID of the scroller container */
id?: string;
/** Bindable instance of ScrollerVideo */
scrollerVideo?: ScrollerVideo;
/** Video source URL */
src: string;
/** Bindable percentage value to control video playback. **Ranges from 0 to 1** */
videoPercentage?: number;
/** Sets the maximum playbackRate for this video */
transitionSpeed?: number;
/** When to stop the video animation, in seconds */
frameThreshold?: number;
/** How the video should be resized to fit its container */
objectFit?: string;
/** Whether the video should have position: sticky */
sticky?: boolean;
/** Whether the video should take up the entire viewport */
full?: boolean;
/** Whether this object should automatically respond to scroll. Set this to **false** while manually controlling `videoPercentage` prop. */
trackScroll?: boolean;
/** Whether it ignores human scroll while it runs setVideoPercentage with enabled trackScroll */
lockScroll?: boolean;
/** Whether the library should use the webcodecs method. For more info, visit https://scrollyvideo.js.org/ */
useWebCodecs?: boolean;
/** The callback when it's ready to scroll */
onReady?: () => void;
/** The callback for video percentage change */
onChange?: () => void;
/** Whether to log debug information. Internal library logs. */
debug?: boolean;
/** Shows debug information on page */
showDebugInfo?: boolean;
/** Height of the video container. Set it to 100lvh when using inside `ScrollerBase` */
height?: string;
/** Whether the video should autoplay */
autoplay?: boolean;
/** Variable to control component rendering on embed page */
embedded?: boolean;
/** Additional properties for embedded videos */
embeddedProps?: {
/** When to start the playback in terms of the component's position */
threshold?: number;
/** Duration of ScrollerVideo experience as a video */
duration?: number;
/** Delay before the playback */
delay?: number;
};
/** Children render function */
children?: Snippet;
}
/** Default properties for embedded videos */
const defaultEmbedProps = {
threshold: 0.5,
delay: 200,
};
/**
* Main logic for ScrollerVideo Svelte component.
* Handles instantiation, prop changes, and cleanup.
*/
let {
class: cls = '',
id = '',
src,
scrollerVideo = $bindable(),
videoPercentage,
onReady = $bindable(() => {}),
onChange = $bindable(() => {}),
height = '200lvh',
showDebugInfo = false,
embedded = false,
embeddedProps,
children,
...restProps
}: Props = $props();
// variable to hold the DOM element
/**
* Reference to the scroller video container DOM element.
* @type {HTMLDivElement | undefined}
*/
let scrollerVideoContainer = $state<HTMLDivElement | undefined>(undefined);
// Store the props so we know when things change
let lastPropsString = '';
// Concatenate default and passed embedded props
let allEmbedProps = {
...defaultEmbedProps,
...embeddedProps,
};
// Holds regular scroller video component
// and scrolls automatically for embedded version
let embeddedContainer = $state<HTMLDivElement | undefined>(undefined);
let embeddedContainerHeight = $state<number | undefined>(undefined);
let embeddedContainerScrollHeight: number = $derived.by(() => {
let scrollHeight = 1;
if (embeddedContainer && embeddedContainerHeight) {
scrollHeight = embeddedContainer.scrollHeight - embeddedContainerHeight;
}
return scrollHeight;
});
const embeddedContainerScrollY = new Tween(0, {
duration: 1000,
delay: allEmbedProps.delay,
easing: (t) => +t,
});
$effect(() => {
if (embeddedContainer) {
embeddedContainer.scrollTop = embeddedContainerScrollY.current;
}
});
$effect(() => {
if (scrollerVideoContainer) {
if (JSON.stringify(restProps) !== lastPropsString) {
// if scrollervideo already exists and any parameter is updated, destroy and recreate.
if (scrollerVideo && scrollerVideo.destroy) scrollerVideo.destroy();
scrollerVideo = new ScrollerVideo({
src,
scrollerVideoContainer,
onReady,
onChange,
...restProps,
trackScroll: embedded ? false : restProps.trackScroll, // trackScroll disabled for embedded version
autoplay: embedded ? false : restProps.autoplay, // autoplay disabled for embedded version
});
// if embedded prop is set,
// play the video when it crosses the threshold
// and reset it to zero when it crosses the threshold in opposite direction
if (embedded) {
const updatedOnReady = () => {
// add user defined onReady
onReady();
window?.addEventListener('scroll', () => {
if (
embeddedContainer &&
embeddedContainer.getBoundingClientRect().top <
window.innerHeight * allEmbedProps.threshold
) {
if (
embeddedContainerScrollY.current == 0 &&
embeddedContainerHeight &&
scrollerVideo?.componentState
) {
const scrollDuration =
allEmbedProps.duration ||
scrollerVideo.componentState.generalData.totalTime * 1000;
embeddedContainerScrollY.set(embeddedContainerScrollHeight, {
duration: scrollDuration,
delay: allEmbedProps.delay,
});
}
} else if (
embeddedContainer &&
embeddedContainer.getBoundingClientRect().top >
window.innerHeight * allEmbedProps.threshold
) {
if (embeddedContainerScrollY.current > 0) {
embeddedContainerScrollY.set(0, { duration: 0 });
}
}
});
};
scrollerVideo.onReady = updatedOnReady;
}
// pass on component state to child components
// this controls fade in and out of foregrounds
setContext('scrollerVideoState', scrollerVideo.componentState);
// Save the new props
lastPropsString = JSON.stringify(restProps);
}
// If we need to update the target time percent
if (
scrollerVideo &&
videoPercentage &&
videoPercentage >= 0 &&
videoPercentage <= 1
) {
scrollerVideo.setVideoPercentage(videoPercentage);
}
}
});
/**
* Cleanup the component on destroy.
*/
onDestroy(() => {
if (scrollerVideo && scrollerVideo.destroy) scrollerVideo.destroy();
});
/**
* heightChange drives the height of the component when autoplay is set to true.
* @type {string}
*/
let heightChange = $derived.by(() => {
if (scrollerVideo) {
return `calc(${height} * ${1 - scrollerVideo?.componentState.autoplayProgress})`;
} else {
return height;
}
});
</script>
<!-- snippet to avoid redundancy between regular and embedded versions -->
<!-- renders Debug component and children foregrounds -->
{#snippet supportingElements()}
{#if scrollerVideo}
{#if showDebugInfo && dev}
<div class="debug-info">
<Debug componentState={scrollerVideo.componentState} />
</div>
{/if}
<!-- renders foregrounds -->
{#if children}
{@render children()}
{/if}
{/if}
{/snippet}
{#if embedded}
<div
class="embedded-scroller-video-container"
bind:this={embeddedContainer}
bind:clientHeight={embeddedContainerHeight}
onscroll={() => {
if (scrollerVideo && embeddedContainer) {
let scrollProgress =
embeddedContainer.scrollTop / embeddedContainerScrollHeight;
scrollerVideo.setVideoPercentage(scrollProgress, {
jump: scrollProgress == 0,
easing: (t) => t,
});
}
}}
>
<div {id} class="scroller-video-container embedded {cls}">
<div bind:this={scrollerVideoContainer} data-scroller-container>
{@render supportingElements()}
</div>
</div>
</div>
{:else}
<div
{id}
class="scroller-video-container {cls}"
style="height: {heightChange}"
>
<div bind:this={scrollerVideoContainer} data-scroller-container>
{@render supportingElements()}
</div>
</div>
{/if}
<style lang="scss">
.scroller-video-container {
width: 100%;
// Needs to be >= 100lvh to allow child element to scroll
// 200lvh provides smoother scrolling experience -->
&.embedded {
height: 200lvh;
}
&:not(.embedded) {
min-height: 100lvh;
}
}
.embedded-scroller-video-container {
max-height: 100lvh;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,166 @@
<script lang="ts">
import Block from '../Block/Block.svelte';
import { fade } from 'svelte/transition';
import { getContext } from 'svelte';
import { Markdown } from '@reuters-graphics/svelte-markdown';
// Types
import type { Component, Snippet } from 'svelte';
import type { ScrollerVideoState } from './ts/state.svelte';
import type {
ContainerWidth,
ScrollerVideoForegroundPosition,
} from '../@types/global';
interface ForegroundProps {
id?: string;
class?: string;
startTime?: number;
endTime?: number;
children?: Snippet;
backgroundColour?: string;
width?: ContainerWidth;
position?: ScrollerVideoForegroundPosition | string;
text?: string;
Foreground?: Component;
}
let {
id = '',
class: cls = '',
startTime = 0,
endTime = 1,
children,
backgroundColour = '#000',
width = 'normal',
position = 'center center',
text,
Foreground,
}: ForegroundProps = $props();
let componentState: ScrollerVideoState = getContext('scrollerVideoState');
</script>
<Block class={`scroller-video-foreground ${cls}`} {id}>
{#if componentState.generalData.currentTime >= startTime && componentState.generalData.currentTime <= endTime}
<div
class="scroller-foreground"
in:fade={{ delay: 100, duration: 200 }}
out:fade={{ delay: 0, duration: 100 }}
>
<!-- Text blurb foreground -->
{#if text}
<Block
class="scroller-video-foreground-text {position.split(' ')[1]}"
{width}
>
<div
style="background-color: {backgroundColour};"
class="foreground-text {position.split(' ')[0]}"
>
<Markdown source={text} />
</div>
</Block>
<!-- Render children snippet -->
{:else if children}
<div class="scroller-video-foreground-item">
{@render children()}
</div>
<!-- Render Foreground component -->
{:else if Foreground}
<div class="scroller-video-foreground-item">
<Block width="fluid">
<Foreground />
</Block>
</div>
{/if}
</div>
{/if}
</Block>
<style lang="scss">
@use './../../scss/mixins' as mixins;
.scroller-foreground {
width: 100%;
height: 100%;
}
:global(.scroller-video-foreground) {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 2;
}
.scroller-video-foreground-item {
width: 100%;
height: 100%;
position: absolute;
display: flex;
justify-content: center;
align-items: center;
}
:global {
.scroller-video-foreground-text {
position: absolute;
width: 100%;
max-width: calc(mixins.$column-width-normal * 0.9);
height: 100%;
@media (max-width: 1200px) {
left: 50%;
transform: translateX(-50%);
}
&.left {
left: 0;
}
&.right {
right: 0;
}
&.center {
left: 50%;
transform: translateX(-50%);
}
}
.foreground-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 0.25rem;
background-color: white;
width: 100%;
@include mixins.fpy-5;
@include mixins.fpx-4;
@include mixins.fm-0;
:global(*) {
margin: 0;
padding: 0;
}
&.center {
top: 50%;
}
&.top {
top: 0;
transform: translate(-50%, 50%);
}
&.bottom {
top: 100%;
transform: translate(-50%, -150%);
}
}
}
</style>

View file

@ -0,0 +1,118 @@
<script lang="ts">
import ScrollerVideo from '../ScrollerVideo.svelte';
import ScrollerBase from '../../ScrollerBase/ScrollerBase.svelte';
import Tennis from '../videos/tennis.mp4';
import { onDestroy } from 'svelte';
// Types
import type { ScrollerVideoInstance } from '../../@types/global.ts';
let scrollerVideo: ScrollerVideoInstance | undefined = $state(undefined);
let animationFrame = $state(0);
let index = $state(0); // index for the current step in ScrollerBase
/**
* If ScrollerBase is on index 0, jump to the start of the video.
* Otherwise, jump to 1, or 100% (the end), of the video.
*/
function jumpVideo() {
if (index === 0) {
scrollerVideo?.setVideoPercentage(0, {
jump: false, // Eases the jump
});
} else {
scrollerVideo?.setVideoPercentage(1, {
jump: false,
});
}
animationFrame = requestAnimationFrame(jumpVideo);
}
// cancel requestAnimationFrame on destroy
onDestroy(() => {
cancelAnimationFrame(animationFrame);
});
</script>
<ScrollerBase bind:index query="div.step-foreground-container">
<!-- ScrollerVideo as background -->
{#snippet backgroundSnippet()}
<!-- Pass `jumpVideo` to `onReady` and set `trackScroll` to `false` -->
<ScrollerVideo
bind:scrollerVideo
src={Tennis}
height="100lvh"
trackScroll={false}
showDebugInfo
onReady={jumpVideo}
/>
{/snippet}
<!-- Simple text foregrounds -->
{#snippet foregroundSnippet()}
<div class="step-foreground-container">
<h3 class="text-center">Index {index}</h3>
</div>
<div class="step-foreground-container">
<h3 class="text-center">Index {index}</h3>
</div>
{/snippet}
</ScrollerBase>
<style lang="scss">
@use '../../../scss/mixins' as mixins;
// svelte-scroller-background
#progress-bar {
background-color: rgba(0, 0, 0, 0.8);
position: absolute;
z-index: 4;
right: 0;
padding: 1rem;
top: 0;
progress {
height: 6px;
background-color: #ff000044; /* Background color of the entire bar */
margin: 0;
}
progress::-webkit-progress-value {
background-color: white;
border-radius: 10px;
}
progress::-webkit-progress-bar {
background-color: #444444;
border-radius: 10px;
}
p {
font-family: var(--theme-font-family-sans-serif);
color: white;
font-size: var(--theme-font-size-xs);
padding: 0;
margin: 0;
}
}
.step-foreground-container {
height: 100lvh;
width: 50%;
padding: 1em;
margin: auto;
h3 {
// align center
display: flex;
align-items: center;
justify-content: center;
margin: 70% auto 0 auto;
height: 60px;
max-width: 400px;
color: white;
background: steelblue;
}
}
</style>

View file

@ -0,0 +1,100 @@
<script lang="ts">
import ScrollerVideo from '../ScrollerVideo.svelte';
import ScrollerVideoForeground from '../ScrollerVideoForeground.svelte';
import Goldengate from '../videos/goldengate.mp4';
import BodyText from '../../BodyText/BodyText.svelte';
import type { ContainerWidth } from '../../@types/global';
const content = {
blocks: [
{
type: 'scroller-video',
id: 'goldengate-scroller',
src: '../videos/goldengate.mp4',
height: '200lvh',
foregrounds: [
{
startTime: '0',
endTime: '2',
width: 'normal',
position: 'bottom center',
backgroundColour: 'rgba(0, 0, 0, 0.8)',
text: '#### Golden Gate Bridge\n\nThe Golden Gate Bridge took over 4 years to build (1933-1937) and was the longest suspension bridge in the world at the time of its completion, spanning 4,200 feet between its towers.',
},
{
startTime: '4',
endTime: '7',
width: 'normal',
position: 'bottom center',
backgroundColour: 'rgba(0, 0, 0, 0.8)',
text: "The bridge's iconic International Orange color was chosen partly for visibility in San Francisco's frequent fog. The paint job requires constant maintenance, with a dedicated crew painting the bridge year-round to protect it from rust and corrosion.",
},
{
startTime: '8',
endTime: '11',
width: 'normal',
position: 'bottom center',
backgroundColour: 'rgba(0, 0, 0, 0.8)',
text: '#### Engineering Marvel\n\nThe Golden Gate Bridge sways up to **27 feet** sideways in strong winds and can handle winds up to 100 mph. On foggy days, the bridge can collect enough moisture to drip like rain, and it has been struck by ships only once in its history.',
},
],
},
],
};
const openerText =
'**In embedded mode, `ScrollerVideo` behaves like a normal video player, autoplaying the video when the user scrolls onto it.**';
const dummyText =
'Reprehenderit hamburger pork bresaola, dolore chuck sirloin landjaeger ham hock tempor meatball alcatra nostrud pork belly. Culpa pork belly doner ea jowl, elit deserunt leberkas cow shoulder ham hock dolore.';
const scrollerVideoBlock = content.blocks[0];
let embedded = $state(true);
</script>
<BodyText text={openerText} />
<BodyText text={dummyText} />
<BodyText text={dummyText} />
<BodyText text={dummyText} />
<BodyText text={dummyText} />
<BodyText text={dummyText} />
<ScrollerVideo
src={Goldengate}
class="embedded-demo"
showDebugInfo={true}
{embedded}
embeddedProps={{
threshold: 0.5,
duration: 12000,
delay: 200,
}}
>
{#each scrollerVideoBlock.foregrounds as foreground}
<ScrollerVideoForeground
startTime={parseFloat(foreground.startTime)}
endTime={parseFloat(foreground.endTime)}
width={foreground.width as ContainerWidth}
position={foreground.position}
backgroundColour={foreground.backgroundColour}
text={foreground.text}
/>
{/each}
</ScrollerVideo>
<BodyText text={dummyText} />
<BodyText text={dummyText} />
<BodyText text={dummyText} />
<BodyText text={dummyText} />
<BodyText text={dummyText} />
<BodyText text={dummyText} />
<style lang="scss">
:global {
.embedded-demo .foreground-text {
h4,
p {
color: white;
}
}
}
</style>

View file

@ -0,0 +1,119 @@
<script lang="ts">
import ScrollerVideo from '../ScrollerVideo.svelte';
import ScrollerVideoForeground from '../ScrollerVideoForeground.svelte';
import SM from '../videos/waves_sm.mp4';
import MD from '../videos/waves_md.mp4';
import LG from '../videos/waves_lg.mp4';
import Headline from '../../Headline/Headline.svelte';
// Foreground ai2svelte components
import Foreground1 from './graphic/ai2svelte/annotation1.svelte';
import Foreground2 from './graphic/ai2svelte/annotation2.svelte';
import Foreground3 from './graphic/ai2svelte/annotation3.svelte';
import Foreground4 from './graphic/ai2svelte/annotation4.svelte';
// Foreground ai2svelte graphics components
const aiChartsForeground = {
Foreground1,
Foreground2,
Foreground3,
Foreground4,
};
const content = {
hed: 'Wind and waves',
authors: ['Jane Doe'],
publishTime: '2020-01-01T00:00:00Z',
startTime: '0',
endTime: '0.3',
blocks: [
{
type: 'scroller-video',
id: 'surf-scroller',
height: '800lvh',
foregrounds: [
{
startTime: '0.3',
endTime: '2.2',
width: 'fluid',
foreground: 'Foreground1',
},
{
startTime: '2.2',
endTime: '3.2',
width: 'fluid',
foreground: 'Foreground2',
},
{
startTime: '3.2',
endTime: '4.5',
width: 'fluid',
foreground: 'Foreground3',
},
{
startTime: '6.5',
endTime: '8',
width: 'fluid',
foreground: 'Foreground4',
},
],
},
],
};
const scrollerVideoBlock = content.blocks[0];
let width = $state(1);
</script>
<svelte:window bind:innerWidth={width} />
{#snippet ScrollVideo(height: string, src: string)}
<ScrollerVideo
id={scrollerVideoBlock.id}
{height}
{src}
useWebCodecs={true}
showDebugInfo
>
<ScrollerVideoForeground
startTime={parseFloat(content.startTime)}
endTime={parseFloat(content.endTime)}
>
<Headline
class="custom-headline"
hed={content.hed}
authors={content.authors}
publishTime={new Date(content.publishTime).toISOString()}
/>
</ScrollerVideoForeground>
{#each scrollerVideoBlock.foregrounds as foreground}
<ScrollerVideoForeground
startTime={parseFloat(foreground.startTime)}
endTime={parseFloat(foreground.endTime)}
width={foreground.width as 'fluid'}
Foreground={aiChartsForeground[
foreground.foreground as keyof typeof aiChartsForeground
]}
/>
{/each}
</ScrollerVideo>
{/snippet}
{#if width < 600}
{@render ScrollVideo(scrollerVideoBlock.height, SM)}
{:else if width < 1200}
{@render ScrollVideo(scrollerVideoBlock.height, MD)}
{:else}
{@render ScrollVideo(scrollerVideoBlock.height, LG)}
{/if}
<style lang="scss">
:global(.custom-headline *) {
color: white;
}
:global(.surf-scroller .scroller-video-foreground) {
filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.85));
}
</style>

View file

@ -0,0 +1,83 @@
<script lang="ts">
import ScrollerVideo from '../ScrollerVideo.svelte';
import ScrollerBase from '../../ScrollerBase/ScrollerBase.svelte';
import Goldengate from '../videos/goldengate.mp4';
// ScrollerBase props
let progress = $state(0);
</script>
<ScrollerBase bind:progress query="div.step-foreground-container" visible>
{#snippet backgroundSnippet()}
<ScrollerVideo
src={Goldengate}
height="100lvh"
trackScroll={false}
videoPercentage={progress}
showDebugInfo
/>
{/snippet}
{#snippet foregroundSnippet()}
<!-- Add custom foreground HTML or component -->
<div class="step-foreground-container">
<h3 class="text-center">Step 1</h3>
</div>
<div class="step-foreground-container">
<h3 class="text-center">Step 2</h3>
</div>
<div class="step-foreground-container">
<h3 class="text-center">Step 3</h3>
</div>
{/snippet}
</ScrollerBase>
<style lang="scss">
@use '../../../scss/mixins' as mixins;
#progress-bar {
background-color: rgba(0, 0, 0, 0.8);
position: absolute;
z-index: 2;
right: 0;
padding: 1rem;
progress {
height: 6px;
background-color: #ff000044; /* Background color of the entire bar */
}
progress::-webkit-progress-value {
background-color: white;
border-radius: 10px;
}
progress::-webkit-progress-bar {
background-color: #444444;
border-radius: 10px;
}
p {
font-family: var(--theme-font-family-sans-serif);
color: white;
font-size: var(--theme-font-size-xs);
padding: 0;
margin: 0;
}
}
.step-foreground-container {
height: 100lvh;
width: 50%;
padding: 1em;
margin: auto;
h3 {
// align center
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: white;
}
}
</style>

View file

@ -0,0 +1,78 @@
<script lang="ts">
import ScrollerVideo from '../ScrollerVideo.svelte';
import ScrollerVideoForeground from '../ScrollerVideoForeground.svelte';
import Drone from '../videos/drone.mp4';
import type { ContainerWidth } from '../../@types/global';
const content = {
blocks: [
{
type: 'scroller-video',
id: 'alps-scroller',
src: 'videos/alps.mp4',
height: '800lvh',
foregrounds: [
{
startTime: '0',
endTime: '5',
width: 'normal',
position: 'bottom center',
backgroundColour: 'rgba(0, 0, 0, 0.8)',
text: '#### The Alps\n\nThe Alps stretch across eight countries: France, Switzerland, Italy, Monaco, Liechtenstein, Austria, Germany, and Slovenia, covering about 1,200 kilometers (750 miles).',
},
{
startTime: '7',
endTime: '12',
width: 'normal',
position: 'bottom center',
backgroundColour: 'rgba(0, 0, 0, 0.8)',
text: "Mont Blanc, standing at 4,809 meters (15,777 feet), is the highest peak in the Alps and Western Europe, though there's ongoing debate between France and Italy about exactly where the summit lies.",
},
{
startTime: '14',
endTime: '20',
width: 'normal',
position: 'bottom center',
backgroundColour: 'rgba(0, 0, 0, 0.8)',
text: '#### History\n\nThe Alps were formed around **65 million years** ago when the African and Eurasian tectonic plates collided, pushing the land upward. Over 14 million people live in the Alpine region, with tourism supporting approximately 120 million visitors annually.',
},
],
},
],
};
const scrollerVideoBlock = content.blocks[0];
</script>
<ScrollerVideo
id={scrollerVideoBlock.id}
height={scrollerVideoBlock.height}
src={Drone}
useWebCodecs={true}
showDebugInfo
>
{#each scrollerVideoBlock.foregrounds as foreground}
<ScrollerVideoForeground
startTime={parseFloat(foreground.startTime)}
endTime={parseFloat(foreground.endTime)}
width={foreground.width as ContainerWidth}
position={foreground.position}
backgroundColour={foreground.backgroundColour}
text={foreground.text}
/>
{/each}
</ScrollerVideo>
<style lang="scss">
:global(.custom-headline *) {
text-shadow: 0 0 8px rgba(0, 0, 0, 0.75);
}
:global {
#alps-scroller .foreground-text {
* {
color: white;
}
}
}
</style>

View file

@ -0,0 +1,633 @@
<script lang="ts">
// For demo purposes only, hard-wiring img paths from Vite
// @ts-ignore img
import chartXs from '../imgs/ai-chart-xs.png';
// @ts-ignore img
import chartSm from '../imgs/ai-chart-sm.png';
// @ts-ignore img
import chartMd from '../imgs/ai-chart-md.png';
let width = $state<number>();
</script>
<!-- Generated by ai2html v0.100.0 - 2021-09-29 12:37 -->
<div id="g-_ai-chart-box" bind:clientWidth={width}>
<!-- Artboard: xs -->
{#if width && width >= 0 && width < 510}
<div id="g-_ai-chart-xs" class="g-artboard" style="">
<div style="padding: 0 0 91.7004% 0;"></div>
<div
id="g-_ai-chart-xs-img"
class="g-aiImg"
style={`background-image: url(${chartXs});`}
></div>
<div
id="g-ai0-1"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:3.216%;margin-top:-7.7px;left:0.5952%;width:99px;"
>
<p class="g-pstyle0">Shake intensity</p>
</div>
<div
id="g-ai0-2"
class="g-legend g-aiAbs g-aiPointText"
style="top:9.8251%;margin-top:-7.7px;left:4.9821%;width:47px;"
>
<p class="g-pstyle0">Light</p>
</div>
<div
id="g-ai0-3"
class="g-legend g-aiAbs g-aiPointText"
style="top:15.7733%;margin-top:-7.7px;left:4.9821%;width:69px;"
>
<p class="g-pstyle0">Moderate</p>
</div>
<div
id="g-ai0-4"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:16.4343%;margin-top:-7.7px;left:79.0675%;width:82px;"
>
<p class="g-pstyle0">Cap-Haitien</p>
</div>
<div
id="g-ai0-5"
class="g-legend g-aiAbs g-aiPointText"
style="top:21.7216%;margin-top:-7.7px;left:4.9821%;width:55px;"
>
<p class="g-pstyle0">Strong</p>
</div>
<div
id="g-ai0-6"
class="g-legend g-aiAbs g-aiPointText"
style="top:28.0002%;margin-top:-7.7px;left:4.9821%;width:78px;"
>
<p class="g-pstyle0">Very strong</p>
</div>
<div
id="g-ai0-7"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:28.9916%;margin-top:-7.7px;left:62.2348%;width:68px;"
>
<p class="g-pstyle0">Gonaïves</p>
</div>
<div
id="g-ai0-8"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:39.9449%;margin-top:-14.9px;left:28.714%;margin-left:-36.5px;width:73px;"
>
<p class="g-pstyle1">Caribbean</p>
<p class="g-pstyle1">Sea</p>
</div>
<div
id="g-ai0-9"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:42.6579%;margin-top:-10.1px;left:68.5061%;width:77px;"
>
<p class="g-pstyle2">HAITI</p>
</div>
<div
id="g-ai0-10"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:59.0632%;margin-top:-7.7px;left:11.2526%;width:63px;"
>
<p class="g-pstyle0">Jeremie</p>
</div>
<div
id="g-ai0-11"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:61.1155%;margin-top:-8.9px;left:70.5455%;width:106px;"
>
<p class="g-pstyle3">Port-au-Prince</p>
</div>
<div
id="g-ai0-12"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:62.1069%;margin-top:-8.9px;left:32.6015%;width:77px;"
>
<p class="g-pstyle3">Epicenter</p>
</div>
<div
id="g-ai0-13"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:78.8906%;margin-top:-7.7px;left:63.9138%;width:58px;"
>
<p class="g-pstyle0">Jacmel</p>
</div>
<div
id="g-ai0-14"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:80.2124%;margin-top:-7.7px;left:22.5649%;width:71px;"
>
<p class="g-pstyle0">Les Cayes</p>
</div>
<div
id="g-ai0-15"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:87.8129%;margin-top:-7.7px;left:0.6179%;width:49px;"
>
<p class="g-pstyle0">50 mi</p>
</div>
<div
id="g-ai0-16"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:91.0202%;margin-top:-11.4px;right:10.4418%;width:70px;"
>
<p class="g-pstyle4">Dominican</p>
<p class="g-pstyle4">Republic</p>
</div>
<div
id="g-ai0-17"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:93.7611%;margin-top:-7.7px;left:0.6179%;width:52px;"
>
<p class="g-pstyle0">50 km</p>
</div>
</div>
{/if}
<!-- Artboard: sm -->
{#if width && width >= 510 && width < 660}
<div id="g-_ai-chart-sm" class="g-artboard" style="">
<div style="padding: 0 0 82.703% 0;"></div>
<div
id="g-_ai-chart-sm-img"
class="g-aiImg"
style={`background-image: url(${chartSm});`}
></div>
<div
id="g-ai1-1"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:3.8773%;margin-top:-9.4px;left:0.3278%;width:111px;"
>
<p class="g-pstyle0">Shake intensity</p>
</div>
<div
id="g-ai1-2"
class="g-legend g-aiAbs g-aiPointText"
style="top:9.0933%;margin-top:-9.4px;left:3.0258%;width:52px;"
>
<p class="g-pstyle0">Light</p>
</div>
<div
id="g-ai1-3"
class="g-legend g-aiAbs g-aiPointText"
style="top:13.5979%;margin-top:-9.4px;left:3.0259%;width:77px;"
>
<p class="g-pstyle0">Moderate</p>
</div>
<div
id="g-ai1-4"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:16.6801%;margin-top:-9.4px;left:70.3255%;width:92px;"
>
<p class="g-pstyle0">Cap-Haitien</p>
</div>
<div
id="g-ai1-5"
class="g-legend g-aiAbs g-aiPointText"
style="top:18.3397%;margin-top:-9.4px;left:3.0258%;width:61px;"
>
<p class="g-pstyle0">Strong</p>
</div>
<div
id="g-ai1-6"
class="g-legend g-aiAbs g-aiPointText"
style="top:22.6073%;margin-top:-9.4px;left:3.0258%;width:88px;"
>
<p class="g-pstyle0">Very strong</p>
</div>
<div
id="g-ai1-7"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:28.5344%;margin-top:-9.4px;left:55.9181%;width:76px;"
>
<p class="g-pstyle0">Gonaïves</p>
</div>
<div
id="g-ai1-8"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:38.8091%;margin-top:-17.7px;left:27.2818%;margin-left:-41px;width:82px;"
>
<p class="g-pstyle1">Caribbean</p>
<p class="g-pstyle1">Sea</p>
</div>
<div
id="g-ai1-9"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:39.9724%;margin-top:-8.6px;left:61.2858%;width:67px;"
>
<p class="g-pstyle2">HAITI</p>
</div>
<div
id="g-ai1-10"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:56.985%;margin-top:-9.4px;left:12.2815%;width:69px;"
>
<p class="g-pstyle0">Jeremie</p>
</div>
<div
id="g-ai1-11"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:59.1569%;margin-top:-9.5px;left:63.0314%;width:112px;"
>
<p class="g-pstyle3">Port-au-Prince</p>
</div>
<div
id="g-ai1-12"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:60.1053%;margin-top:-9.5px;left:30.5543%;width:81px;"
>
<p class="g-pstyle3">Epicenter</p>
</div>
<div
id="g-ai1-13"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:62.7194%;margin-top:-16.5px;left:91.2282%;margin-left:-57px;width:114px;"
>
<p class="g-pstyle4">Dominican</p>
<p class="g-pstyle4">Republic</p>
</div>
<div
id="g-ai1-14"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:75.4778%;margin-top:-9.4px;left:57.3552%;width:64px;"
>
<p class="g-pstyle0">Jacmel</p>
</div>
<div
id="g-ai1-15"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:76.6632%;margin-top:-9.4px;left:21.9639%;width:79px;"
>
<p class="g-pstyle0">Les Cayes</p>
</div>
<div
id="g-ai1-16"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:85.5251%;margin-top:-7.7px;left:0.1344%;width:49px;"
>
<p class="g-pstyle5">50 mi</p>
</div>
<div
id="g-ai1-17"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:90.0297%;margin-top:-7.7px;left:0.1344%;width:52px;"
>
<p class="g-pstyle5">50 km</p>
</div>
</div>
{/if}
<!-- Artboard: md -->
{#if width && width >= 660}
<div id="g-_ai-chart-md" class="g-artboard" style="">
<div style="padding: 0 0 79.6009% 0;"></div>
<div
id="g-_ai-chart-md-img"
class="g-aiImg"
style={`background-image: url(${chartMd});`}
></div>
<div
id="g-ai2-1"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:2.3515%;margin-top:-9.4px;left:0.3608%;width:111px;"
>
<p class="g-pstyle0">Shake intensity</p>
</div>
<div
id="g-ai2-2"
class="g-legend g-aiAbs g-aiPointText"
style="top:7.6811%;margin-top:-9.4px;left:2.6603%;width:52px;"
>
<p class="g-pstyle0">Light</p>
</div>
<div
id="g-ai2-3"
class="g-legend g-aiAbs g-aiPointText"
style="top:12.2494%;margin-top:-9.4px;left:2.6604%;width:77px;"
>
<p class="g-pstyle0">Moderate</p>
</div>
<div
id="g-ai2-4"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:15.4852%;margin-top:-9.4px;left:70.3606%;width:92px;"
>
<p class="g-pstyle0">Cap-Haitien</p>
</div>
<div
id="g-ai2-5"
class="g-legend g-aiAbs g-aiPointText"
style="top:17.1983%;margin-top:-9.4px;left:2.6603%;width:61px;"
>
<p class="g-pstyle0">Strong</p>
</div>
<div
id="g-ai2-6"
class="g-legend g-aiAbs g-aiPointText"
style="top:21.7666%;margin-top:-9.4px;left:2.6603%;width:88px;"
>
<p class="g-pstyle0">Very strong</p>
</div>
<div
id="g-ai2-7"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:27.6672%;margin-top:-9.4px;left:55.993%;width:76px;"
>
<p class="g-pstyle0">Gonaïves</p>
</div>
<div
id="g-ai2-8"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:38.0099%;margin-top:-17.7px;left:27.2388%;margin-left:-41px;width:82px;"
>
<p class="g-pstyle1">Caribbean</p>
<p class="g-pstyle1">Sea</p>
</div>
<div
id="g-ai2-9"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:42.7626%;margin-top:-10.7px;left:62.8914%;width:80px;"
>
<p class="g-pstyle2">HAITI</p>
</div>
<div
id="g-ai2-10"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:50.0029%;margin-top:-17.7px;left:92.295%;margin-left:-60.5px;width:121px;"
>
<p class="g-pstyle3">Dominican</p>
<p class="g-pstyle3">Republic</p>
</div>
<div
id="g-ai2-11"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:57.3608%;margin-top:-9.4px;left:12.2815%;width:69px;"
>
<p class="g-pstyle0">Jeremie</p>
</div>
<div
id="g-ai2-12"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:60.2742%;margin-top:-10.7px;left:30.6995%;width:89px;"
>
<p class="g-pstyle4">Epicenter</p>
</div>
<div
id="g-ai2-13"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:62.5583%;margin-top:-10.7px;left:66.3403%;width:125px;"
>
<p class="g-pstyle4">Port-au-Prince</p>
</div>
<div
id="g-ai2-14"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:75.6338%;margin-top:-9.4px;left:57.8174%;width:64px;"
>
<p class="g-pstyle0">Jacmel</p>
</div>
<div
id="g-ai2-15"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:77.3469%;margin-top:-9.4px;left:22.5239%;width:79px;"
>
<p class="g-pstyle0">Les Cayes</p>
</div>
<div
id="g-ai2-16"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:86.936%;margin-top:-7.7px;left:0.1678%;width:49px;"
>
<p class="g-pstyle5">50 mi</p>
</div>
<div
id="g-ai2-17"
class="g-map-labels g-aiAbs g-aiPointText"
style="top:91.5043%;margin-top:-7.7px;left:0.1678%;width:52px;"
>
<p class="g-pstyle5">50 km</p>
</div>
</div>
{/if}
</div>
<!-- End ai2html - 2021-09-29 12:37 -->
<!-- ai file: _ai-chart.ai -->
<style lang="scss">
#g-_ai-chart-box,
#g-_ai-chart-box .g-artboard {
margin: 0 auto;
}
#g-_ai-chart-box p {
margin: 0;
}
#g-_ai-chart-box .g-aiAbs {
position: absolute;
}
#g-_ai-chart-box .g-aiImg {
position: absolute;
top: 0;
display: block;
width: 100% !important;
height: 100%;
background-size: contain;
background-repeat: no-repeat;
}
#g-_ai-chart-box .g-aiPointText p {
white-space: nowrap;
}
#g-_ai-chart-xs {
position: relative;
overflow: hidden;
}
#g-_ai-chart-xs p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 14px;
height: auto;
opacity: 1;
letter-spacing: 0em;
font-size: 12px;
text-align: left;
color: rgb(51, 51, 51);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
position: static;
}
#g-_ai-chart-xs .g-pstyle0 {
height: 14px;
}
#g-_ai-chart-xs .g-pstyle1 {
font-style: italic;
height: 14px;
text-align: center;
color: rgb(113, 115, 117);
}
#g-_ai-chart-xs .g-pstyle2 {
font-weight: 700;
line-height: 18px;
height: 18px;
letter-spacing: 0.25em;
font-size: 15px;
}
#g-_ai-chart-xs .g-pstyle3 {
font-weight: 700;
line-height: 16px;
height: 16px;
font-size: 13px;
}
#g-_ai-chart-xs .g-pstyle4 {
line-height: 11px;
height: 11px;
font-size: 11px;
text-align: right;
}
#g-_ai-chart-sm {
position: relative;
overflow: hidden;
}
#g-_ai-chart-sm p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 17px;
height: auto;
opacity: 1;
letter-spacing: 0em;
font-size: 14px;
text-align: left;
color: rgb(51, 51, 51);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
position: static;
}
#g-_ai-chart-sm .g-pstyle0 {
height: 17px;
}
#g-_ai-chart-sm .g-pstyle1 {
font-style: italic;
height: 17px;
text-align: center;
color: rgb(113, 115, 117);
}
#g-_ai-chart-sm .g-pstyle2 {
font-weight: 700;
line-height: 15px;
height: 15px;
letter-spacing: 0.25em;
font-size: 12px;
}
#g-_ai-chart-sm .g-pstyle3 {
font-weight: 700;
height: 17px;
}
#g-_ai-chart-sm .g-pstyle4 {
font-weight: 300;
line-height: 16px;
height: 16px;
letter-spacing: 0.25em;
font-size: 13px;
text-align: center;
text-transform: uppercase;
color: rgb(134, 136, 139);
}
#g-_ai-chart-sm .g-pstyle5 {
line-height: 14px;
height: 14px;
font-size: 12px;
}
#g-_ai-chart-md {
position: relative;
overflow: hidden;
}
#g-_ai-chart-md p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 17px;
height: auto;
opacity: 1;
letter-spacing: 0em;
font-size: 14px;
text-align: left;
color: rgb(51, 51, 51);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
position: static;
}
#g-_ai-chart-md .g-pstyle0 {
height: 17px;
}
#g-_ai-chart-md .g-pstyle1 {
font-style: italic;
height: 17px;
text-align: center;
color: rgb(113, 115, 117);
}
#g-_ai-chart-md .g-pstyle2 {
font-weight: 700;
line-height: 19px;
height: 19px;
letter-spacing: 0.25em;
font-size: 16px;
}
#g-_ai-chart-md .g-pstyle3 {
font-weight: 300;
height: 17px;
letter-spacing: 0.25em;
text-align: center;
text-transform: uppercase;
color: rgb(134, 136, 139);
}
#g-_ai-chart-md .g-pstyle4 {
font-weight: 700;
line-height: 19px;
height: 19px;
font-size: 16px;
}
#g-_ai-chart-md .g-pstyle5 {
line-height: 14px;
height: 14px;
font-size: 12px;
}
/* Custom CSS */
* {
color: white !important;
}
</style>

View file

@ -0,0 +1,453 @@
<script>
// @ts-ignore img
import chartXs from '../imgs/annotation1-xs.png';
// @ts-ignore img
import chartSm from '../imgs/annotation1-sm.png';
// @ts-ignore img
import chartMd from '../imgs/annotation1-md.png';
// @ts-ignore img
import chartLg from '../imgs/annotation1-md.png';
// @ts-ignore img
import chartXl from '../imgs/annotation1-xl.png';
let width = null;
</script>
<div id="g-annotation1-box" bind:clientWidth={width}>
<!-- Artboard: xs -->
{#if width && width >= 0 && width < 510}
<div id="g-annotation1-xs" class="g-artboard" style="">
<div style="padding: 0 0 187.8788% 0;"></div>
<div
id="g-annotation1-xs-img"
class="g-aiImg"
style={`background-image: url(${chartXs});`}
></div>
<div
id="g-ai0-1"
class="g-text g-aiAbs g-aiPointText"
style="top:16.5284%;margin-top:-8.5px;left:31.2%;width:166px;"
>
<p class="g-pstyle0">Solid southwest swell</p>
</div>
<div
id="g-ai0-2"
class="g-text g-aiAbs"
style="top:56.9355%;left:20.6061%;width:60.303%;"
>
<p>
Tahitian local Vahine Fierro drops into a double overhead wave during
a recentcompetition. Timing and position are critical to make the
take-off and maximize scoring
</p>
<p></p>
<p>&nbsp;</p>
</div>
<div
id="g-ai0-3"
class="g-text g-aiAbs g-aiPointText"
style="top:85.9639%;margin-top:-17px;left:12.7641%;width:156px;"
>
<p class="g-pstyle1">Shallow water hides</p>
<p class="g-pstyle1">a sharp reef</p>
</div>
</div>
{/if}
<!-- Artboard: sm -->
{#if width && width >= 510 && width < 660}
<div id="g-annotation1-sm" class="g-artboard" style="">
<div style="padding: 0 0 56.2745% 0;"></div>
<div
id="g-annotation1-sm-img"
class="g-aiImg"
style={`background-image: url(${chartSm});`}
></div>
<div
id="g-ai1-1"
class="g-text g-aiAbs g-aiPointText"
style="top:18.807%;margin-top:-17px;left:20.9079%;width:247px;"
>
<p class="g-pstyle0">Wave forms from hitting reef, and</p>
<p class="g-pstyle0">tube will track the line of the shelf</p>
</div>
<div
id="g-ai1-2"
class="g-text g-aiAbs g-aiPointText"
style="top:24.9046%;margin-top:-8.5px;left:69.9921%;width:166px;"
>
<p class="g-pstyle1">Solid southwest swell</p>
</div>
<div
id="g-ai1-3"
class="g-text g-aiAbs g-aiPointText"
style="top:81.8732%;margin-top:-57px;left:39.9944%;width:321px;"
>
<p class="g-pstyle0">Tahitian local Vahine Fierro drops into</p>
<p class="g-pstyle0">a double overhead wave during a recent</p>
<p class="g-pstyle0">competition. Timing and position are critical</p>
<p class="g-pstyle0">to make the take-off and maximize scoring</p>
<p class="g-pstyle0"></p>
<p>&nbsp;</p>
</div>
<div
id="g-ai1-4"
class="g-text g-aiAbs g-aiPointText"
style="top:81.5248%;margin-top:-17px;left:6.6905%;width:156px;"
>
<p class="g-pstyle0">Shallow water hides</p>
<p class="g-pstyle0">a sharp reef</p>
</div>
</div>
{/if}
<!-- Artboard: md -->
{#if width && width >= 660 && width < 930}
<div id="g-annotation1-md" class="g-artboard" style="">
<div style="padding: 0 0 56.3636% 0;"></div>
<div
id="g-annotation1-md-img"
class="g-aiImg"
style={`background-image: url(${chartMd});`}
></div>
<div
id="g-ai2-1"
class="g-text g-aiAbs g-aiPointText"
style="top:19.0796%;margin-top:-17px;left:20.696%;width:247px;"
>
<p class="g-pstyle0">Wave forms from hitting reef, and</p>
<p class="g-pstyle0">tube will track the line of the shelf</p>
</div>
<div
id="g-ai2-2"
class="g-text g-aiAbs g-aiPointText"
style="top:21.6334%;margin-top:-7.5px;left:70.3415%;width:166px;"
>
<p class="g-pstyle1">Solid southwest swell</p>
</div>
<div
id="g-ai2-3"
class="g-text g-aiAbs g-aiPointText"
style="top:66.6602%;margin-top:-57px;left:48.1533%;width:321px;"
>
<p class="g-pstyle0">Tahitian local Vahine Fierro drops into</p>
<p class="g-pstyle0">a double overhead wave during a recent</p>
<p class="g-pstyle0">competition. Timing and position are critical</p>
<p class="g-pstyle0">to make the take-off and maximize scoring</p>
<p class="g-pstyle0"></p>
<p>&nbsp;</p>
</div>
<div
id="g-ai2-4"
class="g-text g-aiAbs g-aiPointText"
style="top:81.714%;margin-top:-17px;left:17.2641%;width:156px;"
>
<p class="g-pstyle0">Shallow water hides</p>
<p class="g-pstyle0">a sharp reef</p>
</div>
</div>
{/if}
<!-- Artboard: lg -->
{#if width && width >= 930 && width < 1200}
<div id="g-annotation1-lg" class="g-artboard" style="">
<div style="padding: 0 0 56.0215% 0;"></div>
<div
id="g-annotation1-lg-img"
class="g-aiImg"
style={`background-image: url(${chartLg});`}
></div>
<div
id="g-ai3-1"
class="g-text g-aiAbs g-aiPointText"
style="top:18.3754%;margin-top:-19.7px;left:20.5759%;width:247px;"
>
<p class="g-pstyle0">Wave forms from hitting reef, and</p>
<p class="g-pstyle0">tube will track the line of the shelf</p>
</div>
<div
id="g-ai3-2"
class="g-text g-aiAbs g-aiPointText"
style="top:25.2353%;margin-top:-10.5px;left:73.3785%;width:166px;"
>
<p class="g-pstyle0">Solid southwest swell</p>
</div>
<div
id="g-ai3-3"
class="g-text g-aiAbs g-aiPointText"
style="top:65.458%;margin-top:-66px;left:46.9484%;width:321px;"
>
<p class="g-pstyle0">Tahitian local Vahine Fierro drops into</p>
<p class="g-pstyle0">a double overhead wave during a recent</p>
<p class="g-pstyle0">competition. Timing and position are critical</p>
<p class="g-pstyle0">to make the take-off and maximize scoring</p>
<p class="g-pstyle0"></p>
<p>&nbsp;</p>
</div>
<div
id="g-ai3-4"
class="g-text g-aiAbs g-aiPointText"
style="top:80.1797%;margin-top:-19.7px;left:17.5746%;width:156px;"
>
<p class="g-pstyle0">Shallow water hides</p>
<p class="g-pstyle0">a sharp reef</p>
</div>
</div>
{/if}
<!-- Artboard: xl -->
{#if width && width >= 1200}
<div id="g-annotation1-xl" class="g-artboard" style="">
<div style="padding: 0 0 51.6667% 0;"></div>
<div
id="g-annotation1-xl-img"
class="g-aiImg"
style={`background-image: url(${chartXl});`}
></div>
<div
id="g-ai4-1"
class="g-text g-aiAbs g-aiPointText"
style="top:20.0355%;margin-top:-25.2px;left:20.6647%;width:304px;"
>
<p class="g-pstyle0">Wave forms from hitting reef, and</p>
<p class="g-pstyle0">tube will track the line of the shelf</p>
</div>
<div
id="g-ai4-2"
class="g-text g-aiAbs g-aiPointText"
style="top:27.6162%;margin-top:-13.2px;left:81.5814%;width:202px;"
>
<p class="g-pstyle0">Solid southwest swell</p>
</div>
<div
id="g-ai4-3"
class="g-text g-aiAbs g-aiPointText"
style="top:63.1%;margin-top:-85.2px;left:47.4753%;width:396px;"
>
<p class="g-pstyle0">Tahitian local Vahine Fierro drops into</p>
<p class="g-pstyle0">a double overhead wave during a recent</p>
<p class="g-pstyle0">competition. Timing and position are critical</p>
<p class="g-pstyle0">to make the take-off and maximize scoring</p>
<p class="g-pstyle0"></p>
<p>&nbsp;</p>
</div>
<div
id="g-ai4-4"
class="g-text g-aiAbs g-aiPointText"
style="top:94.0678%;margin-top:-25.2px;left:20.5662%;width:189px;"
>
<p class="g-pstyle0">Shallow water hides</p>
<p
class="g-
pstyle0"
>
a sharp reef
</p>
</div>
</div>
{/if}
</div>
<!-- End ai2html - 2024-07-19 13:12 -->
<!-- Generated by ai2html v0.100.0 - 2024-07-19 13:12 -->
<!-- ai file: annotation1.ai -->
<style lang="scss">
#g-annotation1-box,
#g-annotation1-box .g-artboard {
margin: 0 auto;
}
#g-annotation1-box p {
margin: 0;
}
#g-annotation1-box .g-aiAbs {
position: absolute;
}
#g-annotation1-box .g-aiImg {
position: absolute;
top: 0;
display: block;
width: 100% !important;
height: 100%;
background-size: contain;
background-repeat: no-repeat;
}
#g-annotation1-box .g-aiPointText p {
white-space: nowrap;
}
#g-annotation1-xs {
position: relative;
overflow: hidden;
}
#g-annotation1-xs p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 16px;
opacity: 1;
letter-spacing: 0em;
font-size: 16px;
text-align: left;
color: rgb(255, 255, 255);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
height: auto;
position: static;
}
#g-annotation1-xs .g-pstyle0 {
line-height: 15px;
height: 15px;
}
#g-annotation1-xs .g-pstyle1 {
height: 16px;
}
#g-annotation1-sm {
position: relative;
overflow: hidden;
}
#g-annotation1-sm p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 16px;
height: auto;
opacity: 1;
letter-spacing: 0em;
font-size: 16px;
text-align: left;
color: rgb(255, 255, 255);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
position: static;
}
#g-annotation1-sm .g-pstyle0 {
height: 16px;
}
#g-annotation1-sm .g-pstyle1 {
line-height: 15px;
height: 15px;
}
#g-annotation1-md {
position: relative;
overflow: hidden;
}
#g-annotation1-md p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 16px;
height: auto;
opacity: 1;
letter-spacing: 0em;
font-size: 16px;
text-align: left;
color: rgb(255, 255, 255);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
position: static;
}
#g-annotation1-md .g-pstyle0 {
height: 16px;
}
#g-annotation1-md .g-pstyle1 {
line-height: 13px;
height: 13px;
}
#g-annotation1-lg {
position: relative;
overflow: hidden;
}
#g-annotation1-lg p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 19px;
height: auto;
opacity: 1;
letter-spacing: 0em;
font-size: 16px;
text-align: left;
color: rgb(255, 255, 255);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
position: static;
}
#g-annotation1-lg .g-pstyle0 {
height: 19px;
}
#g-annotation1-xl {
position: relative;
overflow: hidden;
}
#g-annotation1-xl p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 24px;
height: auto;
opacity: 1;
letter-spacing: 0em;
font-size: 20px;
text-align: left;
color: rgb(255, 255, 255);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
position: static;
}
#g-annotation1-xl .g-pstyle0 {
height: 24px;
}
/* Custom CSS */
</style>

View file

@ -0,0 +1,423 @@
<script>
// @ts-ignore img
import chartXs from '../imgs/annotation2-xs.png';
// @ts-ignore img
import chartSm from '../imgs/annotation2-sm.png';
// @ts-ignore img
import chartMd from '../imgs/annotation2-md.png';
// @ts{width}mg
import chartLg from '../imgs/annotation2-md.png';
// @ts-ignore img
import chartXl from '../imgs/annotation2-xl.png';
let width = null;
</script>
<div id="g-annotation2-box" bind:clientWidth={width}>
<!-- Artboard: xs -->
{#if width && width >= 0 && width < 510}
<div id="g-annotation2-xs" class="g-artboard" style="">
<div style="padding: 0 0 187.8788% 0;"></div>
<div
id="g-annotation2-xs-img"
class="g-aiImg"
style={`background-image: url(${chartXs});`}
></div>
<div
id="g-ai0-1"
class="g-text g-aiAbs g-aiPointText"
style="top:9.0284%;margin-top:-41px;left:17.9779%;width:219px;"
>
<p class="g-pstyle0">The wave at Teahupo'o is</p>
<p class="g-pstyle0">known to be very heavy</p>
<p class="g-pstyle0">with a thick lip that can</p>
<p class="g-pstyle0">easily knock a surfer off the</p>
<p class="g-pstyle0">wave and onto the reef below</p>
</div>
<div
id="g-ai0-2"
class="g-text g-aiAbs g-aiPointText"
style="top:68.8671%;margin-top:-25px;left:12.9181%;width:144px;"
>
<p class="g-pstyle0">Surfer speed is up</p>
<p class="g-pstyle0">to 35 km per hour</p>
<p class="g-pstyle0">(20 mph)</p>
</div>
<div
id="g-ai0-3"
class="g-text g-aiAbs g-aiPointText"
style="top:68.8671%;margin-top:-25px;left:62.8912%;width:149px;"
>
<p class="g-pstyle0">One cubic meter</p>
<p class="g-pstyle0">of water weighs</p>
<p class="g-pstyle0">1,000 kg. (2,200 lb)</p>
</div>
<div
id="g-ai0-4"
class="g-text g-aiAbs"
style="top:78.7097%;left:17.9779%;width:59.0909%;"
>
<p>
A surfer will adjust speed to stay in the tube by dragging a hand to
slow, or pumping the board to speed up
</p>
</div>
</div>
{/if}
<!-- Artboard: sm -->
{#if width && width >= 510 && width < 660}
<div id="g-annotation2-sm" class="g-artboard" style="">
<div style="padding: 0 0 56.2745% 0;"></div>
<div
id="g-annotation2-sm-img"
class="g-aiImg"
style={`background-image: url(${chartSm});`}
></div>
</div>
{/if}
<!-- Artboard: md -->
{#if width && width >= 660 && width < 930}
<div id="g-annotation2-md" class="g-artboard" style="">
<div style="padding: 0 0 56.3636% 0;"></div>
<div
id="g-annotation2-md-img"
class="g-aiImg"
style={`background-image: url(${chartMd});`}
></div>
<div
id="g-ai2-1"
class="g-text g-aiAbs g-aiPointText"
style="top:23.9183%;margin-top:-41px;left:67.1629%;width:219px;"
>
<p class="g-pstyle0">The wave at Teahupo'o is</p>
<p class="g-pstyle0">known to be very heavy</p>
<p class="g-pstyle0">with a thick lip that can</p>
<p class="g-pstyle0">easily knock a surfer off the</p>
<p class="g-pstyle0">wave and onto the reef below</p>
</div>
<div
id="g-ai2-2"
class="g-text g-aiAbs g-aiPointText"
style="top:65.585%;margin-top:-17px;left:51.2917%;width:189px;"
>
<p class="g-pstyle0">Surfer speed is up to</p>
<p class="g-pstyle0">35 km per hour (20 mph)</p>
</div>
<div
id="g-ai2-3"
class="g-text g-aiAbs g-aiPointText"
style="top:74.4559%;margin-top:-25px;left:5.9007%;width:263px;"
>
<p class="g-pstyle0">A surfer will adjust speed to stay in</p>
<p class="g-pstyle0">the tube by dragging a hand to slow,</p>
<p class="g-pstyle0">or pumping the board to speed up</p>
</div>
<div
id="g-ai2-4"
class="g-text g-aiAbs g-aiPointText"
style="top:79.5635%;margin-top:-17px;left:70.1381%;width:199px;"
>
<p class="g-pstyle0">One cubic meter of water</p>
<p class="g-pstyle0">weighs 1,000 kg. (2,200 lb)</p>
</div>
</div>
{/if}
<!-- Artboard: lg -->
{#if width && width >= 930 && width < 1200}
<div id="g-annotation2-lg" class="g-artboard" style="">
<div style="padding: 0 0 56.0215% 0;"></div>
<div
id="g-annotation2-lg-img"
class="g-aiImg"
style={`background-image: url(${chartLg});`}
></div>
<div
id="g-ai3-1"
class="g-text g-aiAbs g-aiPointText"
style="top:22.7846%;margin-top:-47.7px;left:73.261%;width:219px;"
>
<p class="g-pstyle0">The wave at Teahupo'o is</p>
<p class="g-pstyle0">known to be very heavy</p>
<p class="g-pstyle0">with a thick lip that can</p>
<p class="g-pstyle0">easily knock a surfer off the</p>
<p class="g-pstyle0">wave and onto the reef below</p>
</div>
<div
id="g-ai3-2"
class="g-text g-aiAbs g-aiPointText"
style="top:22.7846%;margin-top:-47.7px;left:73.261%;width:219px;"
>
<p class="g-pstyle0">The wave at Teahupo'o is</p>
<p class="g-pstyle0">known to be very heavy</p>
<p class="g-pstyle0">with a thick lip that can</p>
<p class="g-pstyle0">easily knock a surfer off the</p>
<p class="g-pstyle0">wave and onto the reef below</p>
</div>
<div
id="g-ai3-3"
class="g-text g-aiAbs g-aiPointText"
style="top:61.187%;margin-top:-19.8px;left:54.1451%;width:189px;"
>
<p class="g-pstyle0">Surfer speed is up to</p>
<p class="g-pstyle0">35 km per hour (20 mph)</p>
</div>
<div
id="g-ai3-4"
class="g-text g-aiAbs g-aiPointText"
style="top:61.187%;margin-top:-19.8px;left:54.1451%;width:189px;"
>
<p class="g-pstyle0">Surfer speed is up to</p>
<p class="g-pstyle0">35 km per hour (20 mph)</p>
</div>
<div
id="g-ai3-5"
class="g-text g-aiAbs g-aiPointText"
style="top:72.3786%;margin-top:-29.1px;left:22.2898%;width:263px;"
>
<p class="g-pstyle0">A surfer will adjust speed to stay in</p>
<p class="g-pstyle0">the tube by dragging a hand to slow,</p>
<p class="g-pstyle0">or pumping the board to speed up</p>
</div>
<div
id="g-ai3-6"
class="g-text g-aiAbs g-aiPointText"
style="top:72.3786%;margin-top:-29.1px;left:22.2898%;width:263px;"
>
<p class="g-pstyle0">A surfer will adjust speed to stay in</p>
<p class="g-pstyle0">the tube by dragging a hand to slow,</p>
<p class="g-pstyle0">or pumping the board to speed up</p>
</div>
<div
id="g-ai3-7"
class="g-text g-aiAbs g-aiPointText"
style="top:79.6131%;margin-top:-19.8px;left:76.246%;width:199px;"
>
<p class="g-pstyle0">One cubic meter of water</p>
<p class="g-pstyle0">weighs 1,000 kg. (2,200 lb)</p>
</div>
<div
id="g-ai3-8"
class="g-text g-aiAbs g-aiPointText"
style="top:79.6131%;margin-top:-19.8px;left:76.246%;width:199px;"
>
<p class="g-pstyle0">One cubic meter of water</p>
<p class="g-pstyle0">weighs 1,000 kg. (2,200 lb)</p>
</div>
</div>
{/if}
<!-- Artboard: xl -->
{#if width && width >= 1200}
<div id="g-annotation2-xl" class="g-artboard" style="">
<div style="padding: 0 0 51.6667% 0;"></div>
<div
id="g-annotation2-xl-img"
class="g-aiImg"
style={`background-image: url(${chartXl});`}
></div>
<div
id="g-ai4-1"
class="g-text g-aiAbs g-aiPointText"
style="top:23.2613%;margin-top:-61.2px;left:79.2253%;width:268px;"
>
<p class="g-pstyle0">The wave at Teahupo'o is</p>
<p class="g-pstyle0">known to be very heavy</p>
<p class="g-pstyle0">with a thick lip that can</p>
<p class="g-pstyle0">easily knock a surfer off the</p>
<p class="g-pstyle0">wave and onto the reef below</p>
</div>
<div
id="g-ai4-2"
class="g-text g-aiAbs g-aiPointText"
style="top:65.3581%;margin-top:-25.2px;left:54.3241%;width:230px;"
>
<p class="g-pstyle0">Surfer speed is up to</p>
<p class="g-pstyle0">35 km per hour (20 mph)</p>
</div>
<div
id="g-ai4-3"
class="g-text g-aiAbs g-aiPointText"
style="top:76.3258%;margin-top:-37.2px;left:22.0662%;width:323px;"
>
<p class="g-pstyle0">A surfer will adjust speed to stay in</p>
<p class="g-pstyle0">the tube by dragging a hand to slow,</p>
<p class="g-pstyle0">or pumping the board to speed up</p>
</div>
<div
id="g-ai4-4"
class="g-text g-aiAbs g-aiPointText"
style="top:83.2613%;
margin-top:-25.2px;left:81.2408%;width:243px;"
>
<p class="g-pstyle0">One cubic meter of water</p>
<p class="g-pstyle0">weighs 1,000 kg. (2,200 lb)</p>
</div>
</div>
{/if}
</div>
<!-- End ai2html - 2024-07-19 13:14 -->
<!-- Generated by ai2html v0.100.0 - 2024-07-19 13:14 -->
<!-- ai file: annotation2.ai -->
<style lang="scss">
#g-annotation2-box,
#g-annotation2-box .g-artboard {
margin: 0 auto;
}
#g-annotation2-box p {
margin: 0;
}
#g-annotation2-box .g-aiAbs {
position: absolute;
}
#g-annotation2-box .g-aiImg {
position: absolute;
top: 0;
display: block;
width: 100% !important;
height: 100%;
background-size: contain;
background-repeat: no-repeat;
}
#g-annotation2-box .g-aiPointText p {
white-space: nowrap;
}
#g-annotation2-xs {
position: relative;
overflow: hidden;
}
#g-annotation2-xs p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 16px;
height: auto;
opacity: 1;
letter-spacing: 0em;
font-size: 16px;
text-align: left;
color: rgb(255, 255, 255);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
position: static;
}
#g-annotation2-xs .g-pstyle0 {
height: 16px;
}
#g-annotation2-sm {
position: relative;
overflow: hidden;
}
#g-annotation2-md {
position: relative;
overflow: hidden;
}
#g-annotation2-md p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 16px;
height: auto;
opacity: 1;
letter-spacing: 0em;
font-size: 16px;
text-align: left;
color: rgb(255, 255, 255);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
position: static;
}
#g-annotation2-md .g-pstyle0 {
height: 16px;
}
#g-annotation2-lg {
position: relative;
overflow: hidden;
}
#g-annotation2-lg p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 19px;
height: auto;
opacity: 1;
letter-spacing: 0em;
font-size: 16px;
text-align: left;
color: rgb(255, 255, 255);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
position: static;
}
#g-annotation2-lg .g-pstyle0 {
height: 19px;
}
#g-annotation2-xl {
position: relative;
overflow: hidden;
}
#g-annotation2-xl p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 24px;
height: auto;
opacity: 1;
letter-spacing: 0em;
font-size: 20px;
text-align: left;
color: rgb(255, 255, 255);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
position: static;
}
#g-annotation2-xl .g-pstyle0 {
height: 24px;
}
/* Custom CSS */
</style>

View file

@ -0,0 +1,431 @@
<script>
// @ts-ignore img
import chartXs from '../imgs/annotation3-xs.png';
// @ts-ignore img
import chartSm from '../imgs/annotation3-sm.png';
// @ts-ignore img
import chartMd from '../imgs/annotation3-md.png';
// @ts{width}mg
import chartLg from '../imgs/annotation3-md.png';
// @ts-ignore img
import chartXl from '../imgs/annotation3-xl.png';
let width = null;
</script>
<div id="g-annotation3-box" bind:clientWidth={width}>
<!-- Artboard: xs -->
{#if width && width >= 0 && width < 510}
<div id="g-annotation3-xs" class="g-artboard" style="">
<div style="padding: 0 0 187.8788% 0;"></div>
<div
id="g-annotation3-xs-img"
class="g-aiImg"
style={`background-image: url(${chartXs});`}
></div>
<div
id="g-ai0-1"
class="g-text g-aiAbs"
style="top:9.8387%;left:19.697%;width:65.4545%;"
>
<p>
Fierro is envelopedin the barrel for over two seconds, which is a long
time in such an unstable environment
</p>
</div>
<div
id="g-ai0-2"
class="g-text g-aiAbs"
style="top:72.2581%;left:20.303%;width:61.2121%;"
>
<p>
In comptitions, more points are scored for being deep inside a barrel
which raises the chance the wave will close out on top of them.
</p>
</div>
</div>
{/if}
<!-- Artboard: sm -->
{#if width && width >= 510 && width < 660}
<div id="g-annotation3-sm" class="g-artboard" style="">
<div style="padding: 0 0 56.2745% 0;"></div>
<div
id="g-annotation3-sm-img"
class="g-aiImg"
style={`background-image: url(${chartSm});`}
></div>
<div
id="g-ai1-1"
class="g-text g-aiAbs"
style="top:1.7422%;left:46.7588%;width:51.1765%;"
>
<p>
Fierro is envelopedin the barrel for over two seconds, which is a long
time in such an unstable environment
</p>
</div>
<div
id="g-ai1-2"
class="g-text g-aiAbs"
style="top:33.4495%;left:66.1788%;width:29.8039%;"
>
<p>
Surfers often say time distorts inside the barrel, slowing down and
that the crashing wave noise falls silent, asthey expereince a sense
of awe
</p>
</div>
<div
id="g-ai1-3"
class="g-text g-aiAbs"
style="top:72.8223%;left:13.3333%;width:48.2353%;"
>
<p>
In comptitions, more points are scored for being deep inside a barrel
which raises the chance the wave will close out on top of them.
</p>
</div>
</div>
{/if}
<!-- Artboard: md -->
{#if width && width >= 660 && width < 930}
<div id="g-annotation3-md" class="g-artboard" style="">
<div style="padding: 0 0 56.3636% 0;"></div>
<div
id="g-annotation3-md-img"
class="g-aiImg"
style={`background-image: url(${chartMd});`}
></div>
<div
id="g-ai2-1"
class="g-text g-aiAbs"
style="top:7.7957%;left:58.355%;width:28.0303%;"
>
<p>
Fierro is envelopedin the barrel for over two seconds, which is a long
time in such an unstable environment
</p>
</div>
<div
id="g-ai2-2"
class="g-text g-aiAbs"
style="top:45.9677%;left:69.837%;width:26.5152%;"
>
<p>
Surfers often say time distorts inside the barrel, slowing down and
that the crashing wave noise falls silent, as they expereince a sense
of awe
</p>
</div>
<div
id="g-ai2-3"
class="g-text g-aiAbs"
style="top:72.3118%;left:31.3158%;width:28.6364%;"
>
<p>
In comptitions, more points are scored for being deep inside a barrel
which raises the chance the wave will close out on top of them.
</p>
</div>
</div>
{/if}
<!-- Artboard: lg -->
{#if width && width >= 930 && width < 1200}
<div id="g-annotation3-lg" class="g-artboard" style="">
<div style="padding: 0 0 56.0215% 0;"></div>
<div
id="g-annotation3-lg-img"
class="g-aiImg"
style={`background-image: url(${chartLg});`}
></div>
<div
id="g-ai3-1"
class="g-text g-aiAbs"
style="top:16.8906%;left:70.7526%;width:20.7527%;"
>
<p>
Fierro is envelopedin the barrel for over two seconds, which is a long
time in such an unstable environment
</p>
</div>
<div
id="g-ai3-2"
class="g-text g-aiAbs"
style="top:51.4395%;left:68.4341%;width:26.9892%;"
>
<p>
Surfers often say time distorts inside the barrel, slowing down and
that the crashing wave noise falls silent, as they expereince a sense
of awe
</p>
</div>
<div
id="g-ai3-3"
class="g-text g-aiAbs"
style="top:68.5221%;left:38.9959%;width:25.2688%;"
>
<p>
In comptitions, more points are scored for being deep inside a barrel
which raises the chance the wave will close out on top of them.
</p>
</div>
</div>
{/if}
<!-- Artboard: xl -->
{#if width && width >= 1200}
<div id="g-annotation3-xl" class="g-artboard" style="">
<div style="padding: 0 0 51.6667% 0;"></div>
<div
id="g-annotation3-xl-img"
class="g-aiImg"
style={`background-image: url(${chartXl});`}
></div>
<div
id="g-ai4-1"
class="g-text g-aiAbs"
style="top:18.0645%;left:70.725%;width:20.6667%;"
>
<p>
Fierro is envelopedin the barrel for over two seconds, which is a long
time in such an unstable environment
</p>
</div>
<div
id="g-ai4-2"
class="g-text g-aiAbs"
style="top:NaN%;left:5.7255%;width:0.1667%;"
>
<p>&nbsp;</p>
<p class="g-pstyle0">Fierro is enveloped</p>
<p>&nbsp;</p>
<p class="g-pstyle0">in the barrel for over</p>
<p>&nbsp;</p>
<p class="g-pstyle0">2 seconds, which is a</p>
<p>&nbsp;</p>
<p class="g-pstyle0">long time in such an</p>
<p>&nbsp;</p>
<p class="g-pstyle0">unstable environment</p>
<p>&nbsp;</p>
<p class="g-pstyle0">Surfers often say time distorts inside</p>
<p>&nbsp;</p>
<p class="g-pstyle0">the barrel, slowing down and that the</p>
<p>&nbsp;</p>
<p class="g-pstyle0">crashing wave noise falls silent, as</p>
<p>&nbsp;</p>
<p class="g-pstyle0">they expereince a sense of awe</p>
<p>&nbsp;</p>
<p class="g-pstyle0">In comptitions, more points are</p>
<p>&nbsp;</p>
<p class="g-pstyle0">scored for being deep inside a</p>
<p>&nbsp;</p>
<p class="g-pstyle0">barrel which raises the chance</p>
<p>&nbsp;</p>
<p class="g-pstyle0">the wave will close out on top of them.</p>
</div>
<div
id="g-ai4-3"
class="g-text g-aiAbs"
style="top:55.4839%;left:68.4081%;width:26.0833%;"
>
<p>
Surfers often say time distorts inside the barrel, slowing down and
that the crashing wave noise falls silent, as they expereince a sense
of awe
</p>
</div>
<div
id="g-ai4-4"
class="g-text g-aiAbs"
style="top:73.871%;left:38.9915%;width:24.6667%;"
>
<p>
In comptitions, more points are scored for being deep inside a barrel
which raises the chance the wave will close out on top of them.
</p>
</div>
</div>
{/if}
</div>
<!-- End ai2html - 2024-07-19 13:15 -->
<!-- Generated by ai2html v0.100.0 - 2024-07-19 13:15 -->
<!-- ai file: annotation3.ai -->
<style lang="scss">
#g-annotation3-box,
#g-annotation3-box .g-artboard {
margin: 0 auto;
}
#g-annotation3-box p {
margin: 0;
}
#g-annotation3-box .g-aiAbs {
position: absolute;
}
#g-annotation3-box .g-aiImg {
position: absolute;
top: 0;
display: block;
width: 100% !important;
height: 100%;
background-size: contain;
background-repeat: no-repeat;
}
#g-annotation3-xs {
position: relative;
overflow: hidden;
}
#g-annotation3-xs p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 16px;
opacity: 1;
letter-spacing: 0em;
font-size: 16px;
text-align: left;
color: rgb(252, 252, 252);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
height: auto;
position: static;
}
#g-annotation3-sm {
position: relative;
overflow: hidden;
}
#g-annotation3-sm p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 16px;
opacity: 1;
letter-spacing: 0em;
font-size: 16px;
text-align: left;
color: rgb(252, 252, 252);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
height: auto;
position: static;
}
#g-annotation3-md {
position: relative;
overflow: hidden;
}
#g-annotation3-md p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 16px;
opacity: 1;
letter-spacing: 0em;
font-size: 16px;
text-align: left;
color: rgb(252, 252, 252);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
height: auto;
position: static;
}
#g-annotation3-lg {
position: relative;
overflow: hidden;
}
#g-annotation3-lg p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 16px;
opacity: 1;
letter-spacing: 0em;
font-size: 16px;
text-align: left;
color: rgb(252, 252, 252);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
height: auto;
position: static;
}
#g-annotation3-xl {
position: relative;
overflow: hidden;
}
#g-annotation3-xl p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 24px;
opacity: 1;
letter-spacing: 0em;
font-size: 20px;
text-align: left;
color: rgb(252, 252, 252);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
height: auto;
position: static;
}
#g-annotation3-xl .g-pstyle0 {
line-height: 11px;
font-size: 9px;
color: rgb(0, 0, 0);
}
/* Custom CSS */
</style>

View file

@ -0,0 +1,392 @@
<script>
// @ts-ignore img
import chartXs from '../imgs/annotation4-xs.png';
// @ts-ignore img
import chartSm from '../imgs/annotation4-sm.png';
// @ts-ignore img
import chartMd from '../imgs/annotation4-md.png';
// @ts{width}mg
import chartLg from '../imgs/annotation4-md.png';
// @ts-ignore img
import chartXl from '../imgs/annotation4-xl.png';
let width = null;
</script>
<div id="g-annotation4-box" bind:clientWidth={width}>
<!-- Artboard: xs -->
{#if width && width >= 0 && width < 510}
<div id="g-annotation4-xs" class="g-artboard" style="">
<div style="padding: 0 0 187.8788% 0;"></div>
<div
id="g-annotation4-xs-img"
class="g-aiImg"
style={`background-image: url(${chartXs});`}
></div>
<div
id="g-ai0-1"
class="g-text g-aiAbs"
style="top:9.5161%;left:16.1999%;width:61.5152%;"
>
<p>The wave path is a short one, only about 100 yards long</p>
</div>
<div
id="g-ai0-2"
class="g-text g-aiAbs"
style="top:73.871%;left:16.1999%;width:61.5152%;"
>
<p>
Fierro exits the barrel with a score of 9.33 out of 10, as she was
deep in the tube, had good speed and went over the foam ball on her
exit
</p>
</div>
</div>
{/if}
<!-- Artboard: sm -->
{#if width && width >= 510 && width < 660}
<div id="g-annotation4-sm" class="g-artboard" style="">
<div style="padding: 0 0 56.2745% 0;"></div>
<div
id="g-annotation4-sm-img"
class="g-aiImg"
style={`background-image: url(${chartSm});`}
></div>
<div
id="g-ai1-1"
class="g-text g-aiAbs"
style="top:8.3624%;left:8.9136%;width:39.8039%;"
>
<p>The wave path is a short one, only about 100 yards long</p>
</div>
<div
id="g-ai1-2"
class="g-text g-aiAbs"
style="top:34.8432%;left:62.1168%;width:32.3529%;"
>
<p>
Fierro exits the barrel with a score of 9.33 out of 10, as she was
deep in the tube, had good speed and went over the foam ball on her
exit
</p>
</div>
<div
id="g-ai1-3"
class="g-text g-aiAbs"
style="top:68.9895%;left:9.1341%;width:47.2549%;"
>
<p>
The wave heaves so quickly that as it forms, it pulls water off the
reef, creating a ride that is often below sea level - seen as the
surfer exits up and over the wave to higher water
</p>
</div>
</div>
{/if}
<!-- Artboard: md -->
{#if width && width >= 660 && width < 930}
<div id="g-annotation4-md" class="g-artboard" style="">
<div style="padding: 0 0 56.3636% 0;"></div>
<div
id="g-annotation4-md-img"
class="g-aiImg"
style={`background-image: url(${chartMd});`}
></div>
<div
id="g-ai2-1"
class="g-text g-aiAbs"
style="top:9.6774%;left:17.6103%;width:30%;"
>
<p>The wave path is a short one, only about 100 yards long</p>
</div>
<div
id="g-ai2-2"
class="g-text g-aiAbs"
style="top:37.3656%;left:62.5253%;width:30.303%;"
>
<p>
Fierro exits the barrel with a score of 9.33 out of 10, as she was
deep in the tube, had good speed and went over the foam ball on her
exit
</p>
</div>
<div
id="g-ai2-3"
class="g-text g-aiAbs"
style="top:71.2366%;left:15.8065%;width:36.8182%;"
>
<p>
The wave heaves so quickly that as it forms, it pulls water off the
reef, creating a ride that is often below sea level - seen as the
surfer exits up and over the wave to higher water
</p>
</div>
</div>
{/if}
<!-- Artboard: lg -->
{#if width && width >= 930 && width < 1200}
<div id="g-annotation4-lg" class="g-artboard" style="">
<div style="padding: 0 0 56.0215% 0;"></div>
<div
id="g-annotation4-lg-img"
class="g-aiImg"
style={`background-image: url(${chartLg});`}
></div>
<div
id="g-ai3-1"
class="g-text g-aiAbs"
style="top:15.3551%;left:24.4264%;width:23.2258%;"
>
<p>The wave path is a short one,</p>
<p>only about 100 yards long</p>
</div>
<div
id="g-ai3-2"
class="g-text g-aiAbs"
style="top:38.5797%;left:69.5876%;width:25.3763%;"
>
<p>
Fierro exits the barrel with a score of 9.33 out of 10, as she was
deep in the tube, had good speed and went over the foam ball on her
exit
</p>
</div>
<div
id="g-ai3-3"
class="g-text g-aiAbs"
style="top:77.1593%;left:30.5554%;width:26.0215%;"
>
<p>
The wave heaves so quickly that as it forms, it pulls water off the
reef, creating a ride that is often below sea level - seen as the
surfer exits up and over the wave to higher water
</p>
</div>
</div>
{/if}
<!-- Artboard: xl -->
{#if width && width >= 1200}
<div id="g-annotation4-xl" class="g-artboard" style="">
<div style="padding: 0 0 51.6667% 0;"></div>
<div
id="g-annotation4-xl-img"
class="g-aiImg"
style={`background-image: url(${chartXl});`}
></div>
<div
id="g-ai4-1"
class="g-text g-aiAbs g-aiPointText"
style="top:19.5123%;margin-top:-17px;left:27.8891%;width:218px;"
>
<p class="g-pstyle0">The wave path is a short one,</p>
<p class="g-pstyle0">only about 100 yards long</p>
</div>
<div
id="g-ai4-2"
class="g-text g-aiAbs g-aiPointText"
style="top:60.48%;margin-top:-33px;left:68.0232%;width:274px;"
>
<p class="g-pstyle0">Fierro exits the barrel with a score of</p>
<p class="g-pstyle0">9.33 out of 10, as she was deep in the</p>
<p class="g-pstyle0">tube, had good speed and went over</p>
<p class="g-pstyle0">the foam ball on her exit</p>
</div>
<div
id="g-ai4-3"
class="g-text g-aiAbs g-aiPointText"
style="top:82.5768%;margin-top:-33px;left:38.7732%;width:319px;"
>
<p class="g-pstyle0">The wave heaves so quickly that as it forms,</p>
<p class="g-pstyle0">
it pulls water off the reef, creating a rid e that
</p>
<p class="g-pstyle0">is often below sea level - seen as the surfer</p>
<p class="g-pstyle0">exits up and over the wave to higher water</p>
</div>
</div>
{/if}
</div>
<!-- End ai2html - 2024-07-19 13:16 -->
<!-- Generated by ai2html v0.100.0 - 2024-07-19 13:16 -->
<!-- ai file: annotation4.ai -->
<style lang="scss">
#g-annotation4-box,
#g-annotation4-box .g-artboard {
margin: 0 auto;
}
#g-annotation4-box p {
margin: 0;
}
#g-annotation4-box .g-aiAbs {
position: absolute;
}
#g-annotation4-box .g-aiImg {
position: absolute;
top: 0;
display: block;
width: 100% !important;
height: 100%;
background-size: contain;
background-repeat: no-repeat;
}
#g-annotation4-box .g-aiPointText p {
white-space: nowrap;
}
#g-annotation4-xs {
position: relative;
overflow: hidden;
}
#g-annotation4-xs p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 16px;
opacity: 1;
letter-spacing: 0em;
font-size: 16px;
text-align: left;
color: rgb(255, 255, 255);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
height: auto;
position: static;
}
#g-annotation4-sm {
position: relative;
overflow: hidden;
}
#g-annotation4-sm p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 16px;
opacity: 1;
letter-spacing: 0em;
font-size: 16px;
text-align: left;
color: rgb(255, 255, 255);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
height: auto;
position: static;
}
#g-annotation4-md {
position: relative;
overflow: hidden;
}
#g-annotation4-md p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 16px;
opacity: 1;
letter-spacing: 0em;
font-size: 16px;
text-align: left;
color: rgb(255, 255, 255);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
height: auto;
position: static;
}
#g-annotation4-lg {
position: relative;
overflow: hidden;
}
#g-annotation4-lg p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 16px;
opacity: 1;
letter-spacing: 0em;
font-size: 16px;
text-align: left;
color: rgb(255, 255, 255);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
height: auto;
position: static;
}
#g-annotation4-xl {
position: relative;
overflow: hidden;
}
#g-annotation4-xl p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 400;
line-height: 16px;
height: auto;
opacity: 1;
letter-spacing: 0em;
font-size: 16px;
text-align: left;
color: rgb(255, 255, 255);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
position: static;
}
#g-annotation4-xl .g-pstyle0 {
height: 16px;
}
/* Custom CSS */
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View file

@ -0,0 +1,959 @@
import { UAParser } from 'ua-parser-js';
import videoDecoder from './videoDecoder';
import { debounce, isScrollPositionAtTarget, map, constrain } from './utils';
import { createComponentState, type ScrollerVideoState } from './state.svelte';
interface ScrollerVideoArgs {
src: string;
scrollerVideoContainer: HTMLElement | string;
objectFit?: string;
sticky?: boolean;
full?: boolean;
trackScroll?: boolean;
lockScroll?: boolean;
transitionSpeed?: number;
frameThreshold?: number;
useWebCodecs?: boolean;
onReady?: () => void;
onChange?: (percentage?: number) => void;
debug?: boolean;
autoplay?: boolean;
setVideoPercentage?: (
percentage: number,
options?: TransitionOptions
) => void;
resize?: () => void;
}
export interface TransitionOptions {
jump: boolean;
transitionSpeed?: number;
easing?: ((progress: number) => number) | null;
autoplay?: boolean;
}
/**
* ScrollerVideo class for scroll-driven or programmatic video playback with Svelte integration.
*/
class ScrollerVideo {
/**
* The container element for the video or canvas.
* @type {HTMLElement | null}
*/
container: HTMLElement | null;
/**
* The original container argument (element or string ID).
* @type {Element | string | undefined}
*/
scrollerVideoContainer: Element | string | undefined;
/**
* Video source URL.
* @type {string}
*/
src: string;
/**
* Speed of transitions.
* @type {number}
*/
transitionSpeed: number;
/**
* Threshold for frame transitions.
* @type {number}
*/
frameThreshold: number;
/**
* Whether to use WebCodecs for decoding.
* @type {boolean}
*/
useWebCodecs: boolean;
/**
* CSS object-fit property for video/canvas.
* @type {string}
*/
objectFit: string;
/**
* Whether to use sticky positioning.
* @type {boolean}
*/
sticky: boolean;
/**
* Whether to track scroll position.
* @type {boolean}
*/
trackScroll: boolean;
/**
* Callback when ready.
* @type {() => void}
*/
onReady: () => void;
/**
* Callback on scroll percentage change.
* @type {(percentage?: number) => void}
*/
onChange: (percentage?: number) => void;
/**
* Enable debug logging.
* @type {boolean}
*/
debug: boolean;
/**
* Enable autoplay.
* @type {boolean}
*/
autoplay: boolean;
/**
* The HTML video element.
* @type {HTMLVideoElement | undefined}
*/
video: HTMLVideoElement | undefined;
/**
* Current scroll/video percentage (0-1).
* @type {number}
*/
videoPercentage: number;
/**
* True if browser is Safari.
* @type {boolean}
*/
isSafari: boolean;
/**
* Current video time in seconds.
* @type {number}
*/
currentTime: number;
/**
* Target video time in seconds.
* @type {number}
*/
targetTime: number;
/**
* Canvas for rendering frames (if using WebCodecs).
* @type {HTMLCanvasElement | null}
*/
canvas: HTMLCanvasElement | null;
/**
* 2D context for the canvas.
* @type {CanvasRenderingContext2D | null}
*/
context: CanvasRenderingContext2D | null;
/**
* Decoded video frames (if using WebCodecs).
* @type {ImageBitmap[] | null}
*/
frames: ImageBitmap[] | null;
/**
* Video frame rate.
* @type {number}
*/
frameRate: number;
/**
* Target scroll position in pixels, if set.
* @type {number | null}
*/
targetScrollPosition: number | null = null;
/**
* Current frame index (if using WebCodecs).
* @type {number}
*/
currentFrame: number;
/**
* True if using WebCodecs for decoding.
* @type {boolean}
*/
usingWebCodecs: boolean;
/**
* Total video duration in seconds.
* @type {number}
*/
totalTime: number;
/**
* RequestAnimationFrame ID for transitions.
* @type {number | null}
*/
transitioningRaf: number | null;
/**
* State object for component-level state.
* @type {ScrollerVideoState}
*/
componentState: ScrollerVideoState;
/**
* Function to update scroll percentage (set in constructor).
* @type {((jump: boolean) => void) | undefined}
*/
updateScrollPercentage: ((jump: boolean) => void) | undefined;
/**
* Function to handle resize events (set in constructor).
* @type {(() => void) | undefined}
*/
resize: (() => void) | undefined;
/**
* Creates a new ScrollerVideo instance.
* @param {ScrollerVideoArgs} args - The arguments for initialization.
*/
constructor({
src,
scrollerVideoContainer,
objectFit = 'cover',
sticky = true,
full = true,
trackScroll = true,
lockScroll = true,
transitionSpeed = 8,
frameThreshold = 0.1,
useWebCodecs = true,
onReady = () => {},
onChange = (_percentage?: number) => {},
debug = false,
autoplay = false,
}: ScrollerVideoArgs) {
this.src = src;
this.scrollerVideoContainer = scrollerVideoContainer;
this.objectFit = objectFit;
this.sticky = sticky;
this.trackScroll = trackScroll;
this.transitionSpeed = transitionSpeed;
this.frameThreshold = frameThreshold;
this.useWebCodecs = useWebCodecs;
this.onReady = onReady;
this.onChange = onChange;
this.debug = debug;
this.autoplay = autoplay;
this.videoPercentage = 0;
this.isSafari = false;
this.currentTime = 0;
this.targetTime = 0;
this.canvas = null;
this.context = null;
this.container = null;
this.frames = null;
this.frameRate = 0;
this.currentTime = 0; // Saves the currentTime of the video, synced with this.video.currentTime
this.targetTime = 0; // The target time before a transition happens
this.canvas = null; // The canvas for drawing the frames decoded by webCodecs
this.context = null; // The canvas context
this.frames = []; // The frames decoded by webCodecs
this.frameRate = 0; // Calculation of frameRate so we know which frame to paint
this.currentFrame = 0;
this.videoPercentage = 0;
this.usingWebCodecs = false; // Whether we are using webCodecs
this.totalTime = 0; // The total time of the video, used for calculating percentage
this.transitioningRaf = null;
this.componentState = createComponentState();
this.componentState.willAutoPlay = autoplay;
// Save the container. If the container is a string we get the element
if (scrollerVideoContainer && scrollerVideoContainer instanceof HTMLElement)
this.container = scrollerVideoContainer;
// otherwise it should better be an element
else if (typeof scrollerVideoContainer === 'string') {
this.container = document.getElementById(scrollerVideoContainer) || null;
if (!this.container)
throw new Error('scrollerVideoContainer must be a valid DOM object');
} else {
throw new Error('scrollerVideoContainer must be a valid DOM object');
}
// Create the initial video object. Even if we are going to use webcodecs,
// we start with a paused video object
this.video = document.createElement('video');
this.video.src = src;
this.video.preload = 'auto';
this.video.tabIndex = 0;
this.video.preload = 'auto';
this.video.playsInline = true;
this.video.muted = true;
this.video.pause();
this.video.load();
this.video.addEventListener(
'canplaythrough',
() => {
this.onReady();
if (this.autoplay && !this.useWebCodecs) {
this.autoplayScroll();
}
},
{ once: true }
);
// Start the video percentage at 0
this.videoPercentage = 0;
// Adds the video to the container
this.container.appendChild(this.video);
// Setting CSS properties for sticky
if (sticky) {
this.container.style.display = 'block';
this.container.style.position = 'sticky';
this.container.style.top = '0';
}
// Setting CSS properties for full
if (full) {
this.container.style.width = '100%';
this.container.style.height = '100lvh';
this.container.style.overflow = 'hidden';
}
// Setting CSS properties for cover
if (objectFit) this.setCoverStyle(this.video);
// Detect webkit (safari), because webkit requires special attention
const browserEngine = new UAParser().getEngine();
this.isSafari = browserEngine.name === 'WebKit';
if (debug && this.isSafari) console.info('Safari browser detected');
const debouncedScroll = debounce(() => {
window.requestAnimationFrame(() => {
this.setScrollPercent(this.videoPercentage);
});
}, 100);
// Add scroll listener for responding to scroll position
this.updateScrollPercentage = (jump = false) => {
// Used for internally setting the scroll percentage based on built-in listeners
let containerBoundingClientRect;
if (
this.container &&
this.container.parentNode &&
(this.container.parentNode as Element).getBoundingClientRect
) {
containerBoundingClientRect = (
this.container.parentNode as Element
).getBoundingClientRect();
} else {
if (this.debug) {
console.error(
'ScrollerVideo: container or parentNode is null or invalid.'
);
}
return;
}
// Calculate the current scroll percent of the video
let scrollPercent =
-containerBoundingClientRect.top /
(containerBoundingClientRect.height - window.innerHeight);
// if autplay, trim the playing time to last locked video position
if (this.componentState.autoplayProgress > 0) {
scrollPercent = map(
scrollPercent,
0,
1,
this.componentState.autoplayProgress,
1
);
}
if (this.debug) {
console.info('ScrollerVideo scrolled to', scrollPercent);
}
// toggle autoplaying state on manual intervention
if (this.componentState.isAutoPlaying && this.frames) {
if (this.debug) console.warn('Stopping autoplay due to manual scroll');
if (this.usingWebCodecs) {
this.componentState.autoplayProgress = parseFloat(
(this.currentFrame / this.frames.length).toFixed(4)
);
} else {
this.componentState.autoplayProgress = parseFloat(
(this.currentTime / this.totalTime).toFixed(4)
);
}
this.componentState.isAutoPlaying = false;
}
this.videoPercentage = scrollPercent;
if (this.targetScrollPosition == null) {
this.setTargetTimePercent(scrollPercent, { jump });
this.onChange(scrollPercent);
} else if (isScrollPositionAtTarget(this.targetScrollPosition)) {
this.targetScrollPosition = null;
} else if (lockScroll && this.targetScrollPosition != null) {
debouncedScroll();
}
this.updateDebugInfo();
};
// Add our event listeners for handling changes to the window or scroll
if (this.trackScroll) {
window.addEventListener('scroll', () => {
if (this.updateScrollPercentage) {
this.updateScrollPercentage(false);
}
});
// Set the initial scroll percentage
this.video.addEventListener(
'loadedmetadata',
() => {
if (this.updateScrollPercentage) {
this.updateScrollPercentage(true);
}
if (this.video) {
this.totalTime = this.video.duration;
}
this.setCoverStyle(this.canvas || this.video);
},
{ once: true }
);
} else {
this.video.addEventListener(
'loadedmetadata',
() => {
this.setTargetTimePercent(0, { jump: true });
if (this.video) {
this.totalTime = this.video.duration;
}
this.setCoverStyle(this.canvas || this.video);
},
{ once: true }
);
}
// Add resize function
this.resize = () => {
if (this.debug) console.info('ScrollerVideo resizing...');
// On resize, we need to reset the cover style
if (this.objectFit) this.setCoverStyle(this.canvas || this.video);
// Then repaint the canvas, if we are in useWebcodecs
this.paintCanvasFrame(Math.floor(this.currentTime * this.frameRate));
};
window.addEventListener('resize', this.resize);
this.video.addEventListener('progress', this.resize);
// Calls decode video to attempt webcodecs method
this.decodeVideo();
this.updateDebugInfo();
}
/**
* Sets the currentTime of the video as a specified percentage of its total duration.
*
* @param percentage - The percentage of the video duration to set as the current time.
* @param options - Configuration options for adjusting the video playback.
* - autoplay: boolean - If true, the video will start playing immediately after setting the percentage. Default is false.
* - jump: boolean - If true, the video currentTime will jump directly to the specified percentage. If false, the change will be animated over time.
* - transitionSpeed: number - Defines the speed of the transition when `jump` is false. Represents the duration of the transition in milliseconds. Default is 8.
* - easing: (progress: number) => number - A function that defines the easing curve for the transition. It takes the progress ratio (a number between 0 and 1) as an argument and returns the eased value, affecting the playback speed during the transition.
*/
setVideoPercentage(
percentage: number,
options: TransitionOptions = { jump: false, transitionSpeed: 8 }
) {
// Early termination if the video percentage is already at the percentage that is intended.
if (this.videoPercentage === percentage) return;
if (this.transitioningRaf) {
window.cancelAnimationFrame(this.transitioningRaf);
}
this.videoPercentage = percentage;
this.onChange(percentage);
if (this.trackScroll && !options.autoplay) {
this.setScrollPercent(percentage);
}
this.setTargetTimePercent(percentage, options);
}
/**
* Sets the style of the video or canvas to "cover" its container.
* @param {HTMLElement | HTMLCanvasElement | undefined} el - The element to style.
*/
setCoverStyle(el: HTMLElement | HTMLCanvasElement | undefined): void {
if (!el) {
if (this.debug) console.warn('No element to set cover style on');
return;
}
if (this.objectFit) {
el.style.position = 'absolute';
el.style.top = '50%';
el.style.left = '50%';
el.style.transform = 'translate(-50%, -50%)';
// el.style.minWidth = '101%';
// el.style.minHeight = '101%';
// Gets the width and height of the container
const { width: containerWidth, height: containerHeight } =
this.container?.getBoundingClientRect() || { width: 0, height: 0 };
let width = 0,
height = 0;
if (el instanceof HTMLVideoElement) {
width = el.videoWidth;
height = el.videoHeight;
} else if (el instanceof HTMLCanvasElement) {
width = el.width;
height = el.height;
}
if (this.debug)
console.info('Container dimensions:', [
containerWidth,
containerHeight,
]);
if (this.debug) console.info('Element dimensions:', [width, height]);
// Determines which axis needs to be 100% and which needs to be scaled
if (this.objectFit == 'cover') {
if (containerWidth / containerHeight > width / height) {
el.style.width = '100%';
el.style.height = 'auto';
} else {
el.style.height = '100%';
el.style.width = 'auto';
}
} else if (this.objectFit == 'contain') {
if (containerWidth / containerHeight > width / height) {
el.style.height = '100%';
el.style.width = 'auto';
} else {
el.style.width = '100%';
el.style.height = 'auto';
}
}
}
}
/**
* Uses webCodecs to decode the video into frames.
* @returns {Promise<void>} Resolves when decoding is complete.
*/
async decodeVideo(): Promise<void> {
if (!this.useWebCodecs) {
if (this.debug)
console.warn('Cannot perform video decode: `useWebCodes` disabled');
return;
}
if (!this.src) {
if (this.debug)
console.warn('Cannot perform video decode: no `src` found');
return;
}
try {
await videoDecoder(
this.src,
(frame) => {
this.frames?.push(frame);
},
this.debug
).then((codec) => {
this.usingWebCodecs = true;
if (typeof codec == 'string') {
this.componentState.framesData.codec = codec;
}
});
} catch (error) {
if (this.debug)
console.error('Error encountered while decoding video', error);
// Remove all decoded frames if a failure happens during decoding
this.frames = [];
// Force a video reload when videoDecoder fails
this.video?.load();
}
// If no frames, something went wrong
if (this.frames?.length === 0) {
if (this.debug) console.error('No frames were received from webCodecs');
this.onReady();
return;
}
// Calculate the frameRate based on number of frames and the duration
this.frameRate =
this.frames && this.video ? this.frames.length / this.video.duration : 0;
if (this.debug)
console.info(
'Received',
this.frames?.length,
'frames. Video frame rate:',
this.frameRate
);
// Remove the video and add the canvas
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext('2d');
// Hide the video and add the canvas to the container
if (this.video) {
this.video.style.display = 'none';
}
if (this.container) {
this.container.appendChild(this.canvas);
}
if (this.objectFit) this.setCoverStyle(this.canvas);
// Paint our first frame
this.paintCanvasFrame(Math.floor(this.currentTime * this.frameRate));
this.onReady();
if (this.autoplay) this.autoplayScroll();
}
/**
* Paints the frame to the canvas.
* @param {number} frameNum - The frame index to paint.
*/
paintCanvasFrame(frameNum: number): void {
if (!this.frames) {
if (this.debug) console.warn('No frames available to paint');
return;
}
// Get the frame and paint it to the canvas
const currFrame = this.frames[frameNum];
this.currentFrame = frameNum;
if (!this.canvas || !currFrame) {
return;
}
if (this.debug) {
console.info('Painting frame', frameNum);
}
// Make sure the canvas is scaled properly, similar to setCoverStyle
this.canvas.width = currFrame.width;
this.canvas.height = currFrame.height;
const { width, height } = this.container?.getBoundingClientRect() || {
width: 0,
height: 0,
};
if (this.objectFit == 'cover') {
if (width / height > currFrame.width / currFrame.height) {
this.canvas.style.width = '100%';
this.canvas.style.height = 'auto';
} else {
this.canvas.style.height = '100%';
this.canvas.style.width = 'auto';
}
} else if (this.objectFit == 'contain') {
if (width / height > currFrame.width / currFrame.height) {
this.canvas.style.height = '100%';
this.canvas.style.width = 'auto';
} else {
this.canvas.style.width = '100%';
this.canvas.style.height = 'auto';
}
}
// Draw the frame to the canvas context
if (!this.context) {
if (this.debug) console.warn('No canvas context available to paint');
return;
}
this.context.drawImage(currFrame, 0, 0, currFrame.width, currFrame.height);
this.updateDebugInfo();
}
/**
* Transitions the video or the canvas to the proper frame.
*
* @param options - Configuration options for adjusting the video playback.
* - jump: boolean - If true, the video currentTime will jump directly to the specified percentage. If false, the change will be animated over time.
* - transitionSpeed: number - Defines the speed of the transition when `jump` is false. Represents the duration of the transition in milliseconds. Default is 8.
* - easing: (progress: number) => number - A function that defines the easing curve for the transition. It takes the progress ratio (a number between 0 and 1) as an argument and returns the eased value, affecting the playback speed during the transition.
*/
transitionToTargetTime({
jump,
transitionSpeed = this.transitionSpeed,
easing = null,
}: TransitionOptions) {
if (!this.video) {
console.warn('No video found');
return;
}
if (this.debug) {
console.info(
'Transitioning targetTime:',
this.targetTime,
'currentTime:',
this.currentTime
);
}
const diff = this.targetTime - this.currentTime;
const distance = Math.abs(diff);
const duration = distance * 1000;
const isForwardTransition = diff > 0;
const tick = ({
startCurrentTime,
startTimestamp,
timestamp,
}: {
startCurrentTime: number;
startTimestamp: number;
timestamp: number;
}) => {
if (!this.video) {
console.warn('No video found during transition tick');
return;
}
const progress = (timestamp - startTimestamp) / duration;
// if frameThreshold is too low to catch condition Math.abs(this.targetTime - this.currentTime) < this.frameThreshold
const hasPassedThreshold =
isForwardTransition ?
this.currentTime >= this.targetTime
: this.currentTime <= this.targetTime;
if (this.componentState.isAutoPlaying) {
this.componentState.autoplayProgress = parseFloat(
(this.currentTime / this.totalTime).toFixed(4)
);
}
// If we are already close enough to our target, pause the video and return.
// This is the base case of the recursive function
if (
isNaN(this.targetTime) ||
// If the currentTime is already close enough to the targetTime
Math.abs(this.targetTime - this.currentTime) < this.frameThreshold ||
hasPassedThreshold
) {
this.video?.pause();
if (this.transitioningRaf) {
cancelAnimationFrame(this.transitioningRaf);
this.transitioningRaf = null;
}
return;
}
// Make sure we don't go out of time bounds
if (this.targetTime > this.video.duration)
this.targetTime = this.video.duration;
if (this.targetTime < 0) this.targetTime = 0;
// How far forward we need to transition
const transitionForward = this.targetTime - this.currentTime;
const easedProgress =
easing && Number.isFinite(progress) ? easing(progress) : 0;
const easedCurrentTime =
isForwardTransition ?
startCurrentTime +
easedProgress * Math.abs(distance) * transitionSpeed
: startCurrentTime -
easedProgress * Math.abs(distance) * transitionSpeed;
if (this.canvas) {
if (jump) {
// If jump, we go directly to the frame
this.currentTime = this.targetTime;
} else if (easedProgress) {
this.currentTime = easedCurrentTime;
} else {
this.currentTime += transitionForward / (256 / transitionSpeed);
}
this.paintCanvasFrame(Math.floor(this.currentTime * this.frameRate));
} else if (jump || this.isSafari || !isForwardTransition) {
// We can't use a negative playbackRate, so if the video needs to go backwards,
// We have to use the inefficient method of modifying currentTime rapidly to
// get an effect.
this.video.pause();
if (easedProgress) {
this.currentTime = easedCurrentTime;
} else {
this.currentTime += transitionForward / (64 / transitionSpeed);
}
// If jump, we go directly to the frame
if (jump) {
this.currentTime = this.targetTime;
}
this.video.currentTime = this.currentTime;
} else {
// Otherwise, we play the video and adjust the playbackRate to get a smoother
// animation effect.
const playbackRate = Math.max(
Math.min(transitionForward * 4, transitionSpeed, 16),
1
);
if (this.debug)
console.info('ScrollerVideo playbackRate:', playbackRate);
if (!isNaN(playbackRate)) {
this.video.playbackRate = playbackRate;
this.video.play();
}
// Set the currentTime to the video's currentTime
this.currentTime = this.video.currentTime;
}
// Recursively calls ourselves until the animation is done.
if (typeof requestAnimationFrame === 'function') {
this.transitioningRaf = requestAnimationFrame((currentTimestamp) =>
tick({
startCurrentTime,
startTimestamp,
timestamp: currentTimestamp,
})
);
}
};
if (typeof requestAnimationFrame === 'function') {
this.transitioningRaf = requestAnimationFrame((startTimestamp) => {
tick({
startCurrentTime: this.currentTime,
startTimestamp,
timestamp: startTimestamp,
});
});
}
}
/**
* Sets the currentTime of the video as a specified percentage of its total duration.
*
* @param percentage - The percentage of the video duration to set as the current time.
* @param options - Configuration options for adjusting the video playback.
* - jump: boolean - If true, the video currentTime will jump directly to the specified percentage. If false, the change will be animated over time.
* - transitionSpeed: number - Defines the speed of the transition when `jump` is false. Represents the duration of the transition in milliseconds. Default is 8.
* - easing: (progress: number) => number - A function that defines the easing curve for the transition. It takes the progress ratio (a number between 0 and 1) as an argument and returns the eased value, affecting the playback speed during the transition.
*/
setTargetTimePercent(
percentage: number,
options: TransitionOptions = { jump: false, transitionSpeed: 8 }
) {
const targetDuration =
this.frames?.length && this.frameRate ?
this.frames.length / this.frameRate
: this.video?.duration || 0;
// The time we want to transition to
this.targetTime = Math.max(Math.min(percentage, 1), 0) * targetDuration;
// If we are close enough, return early
if (
!options.jump &&
Math.abs(this.currentTime - this.targetTime) < this.frameThreshold
)
return;
// Play the video if we are in video mode
if (!this.canvas && !this.video?.paused) this.video?.play();
this.transitionToTargetTime(options);
}
/**
* Simulate trackScroll programmatically (scrolls on page by percentage of video).
* @param {number} percentage - The percentage of the video to scroll to.
*/
setScrollPercent(percentage: number) {
if (!this.trackScroll) {
console.warn('`setScrollPercent` requires enabled `trackScroll`');
return;
}
const parent = this.container?.parentNode;
let top = 0,
height = 0;
if (parent && parent instanceof Element) {
const rect = parent.getBoundingClientRect();
top = rect.top;
height = rect.height;
}
const startPoint = top + window.pageYOffset;
const containerHeightInViewport = height - window.innerHeight;
const targetPosition = startPoint + containerHeightInViewport * percentage;
if (isScrollPositionAtTarget(targetPosition)) {
this.targetScrollPosition = null;
} else {
window.scrollTo({ top: targetPosition, behavior: 'smooth' });
this.targetScrollPosition = targetPosition;
}
}
/**
* Call to destroy this ScrollerVideo object.
*/
destroy() {
if (this.debug) console.info('Destroying ScrollerVideo');
if (this.trackScroll && this.updateScrollPercentage)
window.removeEventListener('scroll', () => this.updateScrollPercentage);
if (this.resize) {
window.removeEventListener('resize', this.resize);
}
// Clear component
if (this.container) this.container.innerHTML = '';
}
/**
* Autoplay the video by scrolling to the end.
*/
autoplayScroll() {
this.setVideoPercentage(1, {
jump: false,
transitionSpeed: this.totalTime * 0.1,
easing: (i) => i,
autoplay: true,
});
this.componentState.isAutoPlaying = true;
}
/**
* Updates debug information in the component state.
*/
updateDebugInfo() {
this.componentState.generalData.src = this.src;
this.componentState.generalData.videoPercentage = constrain(
parseFloat(this.videoPercentage.toFixed(4)),
0,
1
);
this.componentState.generalData.frameRate = parseFloat(
this.frameRate.toFixed(2)
);
this.componentState.generalData.currentTime = parseFloat(
this.currentTime.toFixed(4)
);
this.componentState.generalData.totalTime = parseFloat(
this.totalTime.toFixed(4)
);
this.componentState.usingWebCodecs = this.usingWebCodecs;
this.componentState.framesData.currentFrame = this.currentFrame;
this.componentState.framesData.totalFrames = this.frames?.length || 0;
}
}
export default ScrollerVideo;

View file

@ -0,0 +1,137 @@
declare module 'mp4box' {
export interface MP4MediaTrack {
id: number;
created: Date;
modified: Date;
movie_duration: number;
movie_timescale: number;
layer: number;
alternate_group: number;
volume: number;
track_width: number;
track_height: number;
timescale: number;
duration: number;
bitrate: number;
codec: string;
language: string;
nb_samples: number;
}
export interface MP4VideoData {
width: number;
height: number;
}
export interface MP4VideoTrack extends MP4MediaTrack {
video: MP4VideoData;
}
export interface MP4AudioData {
sample_rate: number;
channel_count: number;
sample_size: number;
}
export interface MP4AudioTrack extends MP4MediaTrack {
audio: MP4AudioData;
}
export type MP4Track = MP4VideoTrack | MP4AudioTrack;
export interface MP4Info {
duration: number;
timescale: number;
fragment_duration: number;
isFragmented: boolean;
isProgressive: boolean;
hasIOD: boolean;
brands: string[];
created: Date;
modified: Date;
tracks: MP4Track[];
audioTracks: MP4AudioTrack[];
videoTracks: MP4VideoTrack[];
}
export interface MP4Sample {
alreadyRead: number;
chunk_index: number;
chunk_run_index: number;
cts: number;
data: Uint8Array;
degradation_priority: number;
depends_on: number;
description: unknown;
description_index: number;
dts: number;
duration: number;
has_redundancy: number;
is_depended_on: number;
is_leading: number;
is_sync: boolean;
number: number;
offset: number;
size: number;
timescale: number;
track_id: number;
}
export type MP4ArrayBuffer = ArrayBuffer & { fileStart: number };
export class DataStream {
static BIG_ENDIAN: boolean;
static LITTLE_ENDIAN: boolean;
buffer: ArrayBuffer;
constructor(
arrayBuffer?: ArrayBuffer,
byteOffset: number,
endianness: boolean
): void;
// TODO: Complete interface
}
export interface Trak {
mdia?: {
minf?: {
stbl?: {
stsd?: {
entries: {
avcC?: {
write: (stream: DataStream) => void;
};
hvcC?: {
write: (stream: DataStream) => void;
};
}[];
};
};
};
};
// TODO: Complete interface
}
export interface MP4File {
[x: string]: unknown;
onMoovStart?: () => void;
onReady?: (info: MP4Info) => void;
onError?: (e: string) => void;
onSamples?: (id: number, user: unknown, samples: MP4Sample[]) => unknown;
appendBuffer(data: MP4ArrayBuffer): number;
start(): void;
stop(): void;
flush(): void;
releaseUsedSamples(trackId: number, sampleNumber: number): void;
setExtractionOptions(
trackId: number,
user?: unknown,
options?: { nbSamples?: number; rapAlignment?: number }
): void;
getTrackById(trackId: number): Trak;
}
export function createFile(): MP4File;
export {};
}

View file

@ -0,0 +1,74 @@
/**
* General video data for ScrollerVideo state.
* @typedef {Object} GeneralData
* @property {string} src - Video source URL.
* @property {number} videoPercentage - Current video percentage (0-1).
* @property {number} frameRate - Video frame rate.
* @property {number} currentTime - Current video time in seconds.
* @property {number} totalTime - Total video duration in seconds.
*/
type GeneralData = {
src: string;
videoPercentage: number;
frameRate: number;
currentTime: number;
totalTime: number;
};
/**
* Frame-level data for ScrollerVideo state.
* @typedef {Object} FramesData
* @property {string} codec - Video codec string.
* @property {number} currentFrame - Current frame index.
* @property {number} totalFrames - Total number of frames.
*/
type FramesData = {
codec: string;
currentFrame: number;
totalFrames: number;
};
/**
* State object for ScrollerVideo component.
* @typedef {Object} ScrollerVideoState
* @property {GeneralData} generalData - General video data.
* @property {boolean} usingWebCodecs - Whether WebCodecs is used.
* @property {FramesData} framesData - Frame-level data.
* @property {boolean} isAutoPlaying - Whether video is autoplaying.
* @property {number} autoplayProgress - Progress of autoplay (0-1).
*/
export type ScrollerVideoState = {
generalData: GeneralData;
usingWebCodecs: boolean;
framesData: FramesData;
willAutoPlay: boolean;
isAutoPlaying: boolean;
autoplayProgress: number;
};
/**
* Creates a new ScrollerVideoState object with default values.
* @returns {ScrollerVideoState} The initialized state object.
*/
export function createComponentState(): ScrollerVideoState {
const scrollerVideoState = $state<ScrollerVideoState>({
generalData: {
src: '',
videoPercentage: 0,
frameRate: 0,
currentTime: 0,
totalTime: 0,
},
usingWebCodecs: false,
framesData: {
codec: '',
currentFrame: 0,
totalFrames: 0,
},
willAutoPlay: false,
isAutoPlaying: false,
autoplayProgress: 0,
});
return scrollerVideoState;
}

View file

@ -0,0 +1,146 @@
import type { ScrollerVideoState } from './state.svelte';
/**
* Flattened version of ScrollerVideoState for easier access to all properties.
* @typedef {Object} FlattenedScrollerVideoState
* @property {string} src - Video source URL.
* @property {number} videoPercentage - Current video percentage (0-1).
* @property {number} frameRate - Video frame rate.
* @property {number} currentTime - Current video time in seconds.
* @property {number} totalTime - Total video duration in seconds.
* @property {boolean} usingWebCodecs - Whether WebCodecs is used.
* @property {string} codec - Video codec string.
* @property {number} currentFrame - Current frame index.
* @property {number} totalFrames - Total number of frames.
* @property {boolean} isAutoPlaying - Whether video is autoplaying.
* @property {number} autoplayProgress - Progress of autoplay (0-1).
*/
type FlattenedScrollerVideoState = {
src: string;
videoPercentage: number;
frameRate: number;
currentTime: number;
totalTime: number;
usingWebCodecs: boolean;
codec: string;
currentFrame: number;
totalFrames: number;
isAutoPlaying: boolean;
autoplayProgress: number;
};
/**
* Returns a debounced version of the given function.
* @template T
* @param {T} func - The function to debounce.
* @param {number} [delay=0] - The debounce delay in milliseconds.
* @returns {(...args: Parameters<T>) => void} The debounced function.
*/
export function debounce<T extends (...args: unknown[]) => void>(
func: T,
delay = 0
) {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func(...args);
}, delay);
};
}
/**
* Checks if the current scroll position is at the target position within a threshold.
* @param {number} targetScrollPosition - The target scroll position in pixels.
* @param {number} [threshold=1] - The allowed threshold in pixels.
* @returns {boolean} True if the current scroll position is within the threshold of the target.
*/
export const isScrollPositionAtTarget = (
targetScrollPosition: number,
threshold: number = 1
) => {
const currentScrollPosition = window.pageYOffset;
const difference = Math.abs(currentScrollPosition - targetScrollPosition);
return difference < threshold;
};
/**
* Constrains a number between a lower and upper bound.
* @param {number} n - The number to constrain.
* @param {number} low - The lower bound.
* @param {number} high - The upper bound.
* @returns {number} The constrained value.
*/
export function constrain(n: number, low: number, high: number): number {
return Math.max(Math.min(n, high), low);
}
/**
* Maps a number from one range to another.
* @param {number} n - The number to map.
* @param {number} start1 - Lower bound of the value's current range.
* @param {number} stop1 - Upper bound of the value's current range.
* @param {number} start2 - Lower bound of the value's target range.
* @param {number} stop2 - Upper bound of the value's target range.
* @param {boolean} [withinBounds=true] - Whether to constrain the result within the target range.
* @returns {number} The mapped value.
*/
export function map(
n: number,
start1: number,
stop1: number,
start2: number,
stop2: number,
withinBounds: boolean = true
): number {
const newval = ((n - start1) / (stop1 - start1)) * (stop2 - start2) + start2;
if (!withinBounds) {
return newval;
}
if (start2 < stop2) {
return constrain(newval, start2, stop2);
} else {
return constrain(newval, stop2, start2);
}
}
/**
* Flattens a ScrollerVideoState object into a single-level object for easier access.
* @param {ScrollerVideoState} obj - The state object to flatten.
* @returns {FlattenedScrollerVideoState} The flattened state object.
*/
export function flattenObject(
obj: ScrollerVideoState
): FlattenedScrollerVideoState {
const result: { [key: string]: unknown } = {};
function flatten(current: string | unknown[] | object, property: string) {
if (Object(current) !== current) {
result[property] = current;
} else if (Array.isArray(current)) {
for (let i = 0, l = current.length; i < l; i++) {
flatten(current[i], property + '[' + i + ']');
if (l === 0) {
result[property] = [];
}
}
} else if (typeof current === 'object') {
let isEmpty = true;
for (const p in current) {
isEmpty = false;
flatten(
(current as { [key: string]: string | object | unknown[] })[p],
p
);
}
if (isEmpty && property) {
result[property] = {};
}
}
}
flatten(obj, '');
return result as FlattenedScrollerVideoState;
}

View file

@ -0,0 +1,299 @@
import * as MP4Box from 'mp4box';
interface MP4BoxBuffer extends ArrayBuffer {
fileStart: number;
}
/**
* Taken from https://github.com/w3c/webcodecs/blob/main/samples/mp4-decode/mp4_demuxer.js
*/
class Writer {
data: Uint8Array;
idx: number;
size: number;
constructor(size: number) {
this.data = new Uint8Array(size);
this.idx = 0;
this.size = size;
}
getData(): Uint8Array {
if (this.idx !== this.size)
throw new Error('Mismatch between size reserved and sized used');
return this.data.slice(0, this.idx);
}
writeUint8(value: number): void {
this.data.set([value], this.idx);
this.idx += 1;
}
writeUint16(value: number): void {
const arr = new Uint16Array(1);
arr[0] = value;
const buffer = new Uint8Array(arr.buffer);
this.data.set([buffer[1], buffer[0]], this.idx);
this.idx += 2;
}
writeUint8Array(value: number[]): void {
this.data.set(value, this.idx);
this.idx += value.length;
}
}
/**
* Taken from https://github.com/w3c/webcodecs/blob/main/samples/mp4-decode/mp4_demuxer.js
*
* @param avccBox
* @returns {*}
*/
interface NALUnit {
length: number;
nalu: number[];
}
const getExtradata = (avccBox: {
SPS: NALUnit[];
PPS: NALUnit[];
configurationVersion: number;
AVCProfileIndication: number;
profile_compatibility: number;
AVCLevelIndication: number;
lengthSizeMinusOne: number;
nb_SPS_nalus: number;
nb_PPS_nalus: number;
}) => {
let i;
let size = 7;
for (i = 0; i < avccBox.SPS.length; i += 1) {
// nalu length is encoded as a uint16.
size += 2 + (avccBox.SPS[i] as { length: number }).length;
}
for (i = 0; i < avccBox.PPS.length; i += 1) {
// nalu length is encoded as a uint16.
size += 2 + (avccBox.PPS[i] as { length: number }).length;
}
const writer = new Writer(size);
writer.writeUint8(avccBox.configurationVersion);
writer.writeUint8(avccBox.AVCProfileIndication);
writer.writeUint8(avccBox.profile_compatibility);
writer.writeUint8(avccBox.AVCLevelIndication);
writer.writeUint8(avccBox.lengthSizeMinusOne + (63 << 2));
writer.writeUint8(avccBox.nb_SPS_nalus + (7 << 5));
for (i = 0; i < avccBox.SPS.length; i += 1) {
writer.writeUint16(avccBox.SPS[i].length);
writer.writeUint8Array(avccBox.SPS[i].nalu);
}
writer.writeUint8(avccBox.nb_PPS_nalus);
for (i = 0; i < avccBox.PPS.length; i += 1) {
writer.writeUint16(avccBox.PPS[i].length);
writer.writeUint8Array(avccBox.PPS[i].nalu);
}
return writer.getData();
};
/**
* decodeVideo takes an url to a mp4 file and converts it into frames.
*
* The steps for this are:
* 1. Determine the codec for this video file and demux it into chunks.
* 2. Read the chunks with VideoDecoder as fast as possible.
* 3. Return an array of frames that we can efficiently draw to a canvas.
*
* @param src
* @param VideoDecoder
* @param EncodedVideoChunk
* @param emitFrame
* @param debug
* @returns {Promise<unknown>}
*/
const decodeVideo = (
src: string,
emitFrame: (frame: ImageBitmap) => void,
{
VideoDecoder,
EncodedVideoChunk,
debug,
}: {
VideoDecoder: typeof window.VideoDecoder;
EncodedVideoChunk: typeof window.EncodedVideoChunk;
debug: boolean;
}
): Promise<unknown> =>
new Promise<string>((resolve, reject) => {
if (debug) console.info('Decoding video from', src);
try {
// Uses mp4box for demuxing
const mp4boxfile = MP4Box.createFile();
// Holds the codec value
let codec = 'N/A';
// Creates a VideoDecoder instance
const decoder = new VideoDecoder({
output: (frame) => {
createImageBitmap(frame, { resizeQuality: 'high' }).then((bitmap) => {
emitFrame(bitmap);
frame.close();
if (decoder.decodeQueueSize <= 0) {
// Give it an extra half second to finish everything
setTimeout(() => {
if (decoder.state !== 'closed') {
decoder.close();
resolve(codec);
}
}, 500);
}
});
},
error: (e) => {
console.error(e);
reject(e);
},
});
mp4boxfile.onReady = (info) => {
if (info && info.videoTracks && info.videoTracks[0]) {
[{ codec }] = info.videoTracks;
if (debug) console.info('Video with codec:', codec);
// Define a type for moov to avoid using 'any'
interface AvcCBox {
SPS: NALUnit[];
PPS: NALUnit[];
configurationVersion: number;
AVCProfileIndication: number;
profile_compatibility: number;
AVCLevelIndication: number;
lengthSizeMinusOne: number;
nb_SPS_nalus: number;
nb_PPS_nalus: number;
}
interface StsdEntry {
avcC: AvcCBox;
}
interface Stsd {
entries: StsdEntry[];
}
interface Stbl {
stsd: Stsd;
}
interface Minf {
stbl: Stbl;
}
interface Mdia {
minf: Minf;
}
interface Trak {
mdia: Mdia;
}
interface Moov {
traks: Trak[];
}
// Gets the avccbox used for reading extradata
const moov = mp4boxfile.moov as Moov | undefined;
const avccBox = moov?.traks[0].mdia.minf.stbl.stsd.entries[0].avcC;
if (!avccBox) {
reject(new Error('Could not find avcC box for extradata.'));
return;
}
const extradata = getExtradata(avccBox);
// configure decoder
decoder.configure({ codec, description: extradata });
// Setup mp4box file for breaking it into chunks
mp4boxfile.setExtractionOptions(info.videoTracks[0].id);
mp4boxfile.start();
} else reject(new Error('URL provided is not a valid mp4 video file.'));
};
mp4boxfile.onSamples = (_track_id, _ref, samples) => {
for (let i = 0; i < samples.length; i += 1) {
const sample = samples[i];
const type = sample.is_sync ? 'key' : 'delta';
const chunk = new EncodedVideoChunk({
type,
timestamp: sample.cts,
duration: sample.duration,
data: sample.data,
});
decoder.decode(chunk);
}
};
// Fetches the file into arraybuffers
fetch(src).then((res) => {
if (!res.body) throw new Error('Response body is null');
const reader = res.body.getReader();
let offset = 0;
function appendBuffers(
result: ReadableStreamReadResult<Uint8Array>
): Promise<void | null> {
if (result.done) {
mp4boxfile.flush();
return Promise.resolve();
}
const buf = result.value.buffer as MP4BoxBuffer;
buf.fileStart = offset;
offset += buf.byteLength;
mp4boxfile.appendBuffer(buf);
return reader.read().then(appendBuffers);
}
return reader.read().then(appendBuffers);
});
} catch (e) {
reject(e);
}
});
/**
* The main function for decoding video. Deals with the polyfill cases first,
* then calls our decodeVideo.
*
* @param src
* @param emitFrame
* @param debug
* @returns {Promise<never>|Promise<void>}
*/
export default (
src: string,
emitFrame: (frame: ImageBitmap) => void,
debug: boolean = false
) => {
// If our browser supports WebCodecs natively
if (
typeof VideoDecoder === 'function' &&
typeof EncodedVideoChunk === 'function'
) {
if (debug)
console.info('WebCodecs is natively supported, using native version...');
return decodeVideo(src, emitFrame, {
VideoDecoder,
EncodedVideoChunk,
debug,
});
}
// Otherwise, resolve nothing
if (debug) console.info('WebCodecs is not available in this browser.');
return Promise.resolve('N/A');
};

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -39,7 +39,10 @@ With the graphics kit, you'll likely get your text value from an ArchieML doc...
section: Global News # Optional
sectionUrl: https://www.reuters.com/graphics/ # Optional
hed: A beautiful page
authors: Samuel Granados, Dea Bankova
[authors]
* Samuel Granados
* Dea Bankova
[]
published: 2022-09-12T08:30:00.000Z
updated:
```

View file

@ -42,6 +42,8 @@ export { default as SiteHeader } from './components/SiteHeader/SiteHeader.svelte
export { default as SiteHeadline } from './components/SiteHeadline/SiteHeadline.svelte';
export { default as Spinner } from './components/Spinner/Spinner.svelte';
export { default as ScrollerBase } from './components/ScrollerBase/ScrollerBase.svelte';
export { default as ScrollerVideo } from './components/ScrollerVideo/ScrollerVideo.svelte';
export { default as ScrollerVideoForeground } from './components/ScrollerVideo/ScrollerVideoForeground.svelte';
export { default as SponsorshipAd } from './components/AdSlot/SponsorshipAd.svelte';
export { default as Table } from './components/Table/Table.svelte';
export { default as Theme, themes } from './components/Theme/Theme.svelte';
@ -49,4 +51,8 @@ export { default as ToolsHeader } from './components/ToolsHeader/ToolsHeader.sve
export { default as Video } from './components/Video/Video.svelte';
export { default as Visible } from './components/Visible/Visible.svelte';
export type { ContainerWidth, HeadlineSize } from './components/@types/global';
export type {
ContainerWidth,
HeadlineSize,
ScrollerVideoInstance,
} from './components/@types/global';