Merge pull request #321 from reuters-graphics/scrolly-video
adds ScrollyVideo with docs
5
.changeset/seven-ducks-remain.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@reuters-graphics/graphics-components': patch
|
||||
---
|
||||
|
||||
Adds ScrollerVideo
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
123
pnpm-lock.yaml
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
224
src/components/ScrollerVideo/Debug.svelte
Normal 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>
|
||||
462
src/components/ScrollerVideo/ScrollerVideo.mdx
Normal 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>
|
||||
```
|
||||
190
src/components/ScrollerVideo/ScrollerVideo.stories.svelte
Normal 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>
|
||||
297
src/components/ScrollerVideo/ScrollerVideo.svelte
Normal 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>
|
||||
166
src/components/ScrollerVideo/ScrollerVideoForeground.svelte
Normal 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>
|
||||
118
src/components/ScrollerVideo/demo/AdvancedUsecases.svelte
Normal 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>
|
||||
100
src/components/ScrollerVideo/demo/Embedded.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
83
src/components/ScrollerVideo/demo/WithScrollerBase.svelte
Normal 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>
|
||||
78
src/components/ScrollerVideo/demo/WithTextForegrounds.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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> </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> </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> </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> </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> </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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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> </p>
|
||||
<p class="g-pstyle0">Fierro is enveloped</p>
|
||||
<p> </p>
|
||||
<p class="g-pstyle0">in the barrel for over</p>
|
||||
<p> </p>
|
||||
<p class="g-pstyle0">2 seconds, which is a</p>
|
||||
<p> </p>
|
||||
<p class="g-pstyle0">long time in such an</p>
|
||||
<p> </p>
|
||||
<p class="g-pstyle0">unstable environment</p>
|
||||
<p> </p>
|
||||
<p class="g-pstyle0">Surfers often say time distorts inside</p>
|
||||
<p> </p>
|
||||
<p class="g-pstyle0">the barrel, slowing down and that the</p>
|
||||
<p> </p>
|
||||
<p class="g-pstyle0">crashing wave noise falls silent, as</p>
|
||||
<p> </p>
|
||||
<p class="g-pstyle0">they expereince a sense of awe</p>
|
||||
<p> </p>
|
||||
<p class="g-pstyle0">In comptitions, more points are</p>
|
||||
<p> </p>
|
||||
<p class="g-pstyle0">scored for being deep inside a</p>
|
||||
<p> </p>
|
||||
<p class="g-pstyle0">barrel which raises the chance</p>
|
||||
<p> </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>
|
||||
|
|
@ -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>
|
||||
BIN
src/components/ScrollerVideo/demo/graphic/imgs/ai-chart-md.png
Normal file
|
After Width: | Height: | Size: 618 KiB |
BIN
src/components/ScrollerVideo/demo/graphic/imgs/ai-chart-sm.png
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
src/components/ScrollerVideo/demo/graphic/imgs/ai-chart-xs.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 9 KiB |
|
After Width: | Height: | Size: 5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
959
src/components/ScrollerVideo/ts/ScrollerVideo.ts
Normal 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;
|
||||
137
src/components/ScrollerVideo/ts/mp4box.d.ts
vendored
Normal 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 {};
|
||||
}
|
||||
74
src/components/ScrollerVideo/ts/state.svelte.ts
Normal 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;
|
||||
}
|
||||
146
src/components/ScrollerVideo/ts/utils.ts
Normal 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;
|
||||
}
|
||||
299
src/components/ScrollerVideo/ts/videoDecoder.ts
Normal 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');
|
||||
};
|
||||
BIN
src/components/ScrollerVideo/videos/HPO.mp4
Normal file
BIN
src/components/ScrollerVideo/videos/drone.mp4
Normal file
BIN
src/components/ScrollerVideo/videos/goldengate.mp4
Normal file
BIN
src/components/ScrollerVideo/videos/tennis.mp4
Normal file
BIN
src/components/ScrollerVideo/videos/waves_lg.mp4
Normal file
BIN
src/components/ScrollerVideo/videos/waves_md.mp4
Normal file
BIN
src/components/ScrollerVideo/videos/waves_sm.mp4
Executable 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:
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||