Merge pull request #387 from reuters-graphics/sk-horizontal-scroller
Adds HorizontalScroller component
5
.changeset/proud-camels-arrive.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@reuters-graphics/graphics-components': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Adds HorizontalScroller component
|
||||||
355
src/components/HorizontalScroller/Debug.svelte
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { map } from './utils';
|
||||||
|
|
||||||
|
const { componentState } = $props();
|
||||||
|
|
||||||
|
let isMoving = $state(false);
|
||||||
|
let preventDetails = $state(false);
|
||||||
|
let position = $state({ x: 8, y: 8 });
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat('en-US', {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalisedScrollProgress = $derived(
|
||||||
|
map(
|
||||||
|
componentState.mappedProgress,
|
||||||
|
componentState.mappedStart ?? 0,
|
||||||
|
componentState.mappedEnd ?? 1,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
let normalisedProgress = $derived(
|
||||||
|
map(
|
||||||
|
componentState.easedProgress,
|
||||||
|
componentState.mappedStart ?? 0,
|
||||||
|
componentState.mappedEnd ?? 1,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
function mappedStop(stop: number): number {
|
||||||
|
return map(
|
||||||
|
stop,
|
||||||
|
componentState.mappedStart ?? 0,
|
||||||
|
componentState.mappedEnd ?? 1,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onmousemove={onMouseMove} />
|
||||||
|
|
||||||
|
{#snippet triggerPoints()}
|
||||||
|
{#if componentState.triggerStops.length > 0}
|
||||||
|
{#if componentState.scrubbed}
|
||||||
|
{@const totalStops = componentState.triggerStops.length}
|
||||||
|
{#each Array(totalStops) as _, index}
|
||||||
|
<span
|
||||||
|
class="stops"
|
||||||
|
style={`left: ${((index + 1) / (totalStops + 1)) * 100}%;`}>|</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{@const stops = componentState.triggerStops.map((x: number) =>
|
||||||
|
mappedStop(x)
|
||||||
|
)}
|
||||||
|
{#each stops as stop, index}
|
||||||
|
{#if index < stops.length - 1}
|
||||||
|
<span
|
||||||
|
class="stops"
|
||||||
|
style={`left: ${(stop + (stops[index + 1] ?? stops[stops.length - 1])) * 0.5 * 100}%;`}
|
||||||
|
>|</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<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>Progress:</p>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<p class="state-value">
|
||||||
|
{componentState.progress}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- -->
|
||||||
|
<p>Mapped progress:</p>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<p class="state-value progress-value">
|
||||||
|
{@render triggerPoints()}
|
||||||
|
<span
|
||||||
|
class="progress-stop"
|
||||||
|
style={`left: ${normalisedScrollProgress * 100}%; transform: translateX(-50%);`}
|
||||||
|
>{fmt.format(componentState.mappedProgress)}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
<div id="video-progress-bar">
|
||||||
|
<div
|
||||||
|
style="width: {normalisedScrollProgress * 100}%; height: 100%;"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- -->
|
||||||
|
<p>Eased Progress:</p>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<p class="state-value progress-value">
|
||||||
|
{#if componentState.stops.length > 0}
|
||||||
|
{#each componentState.stops as stop}
|
||||||
|
<span class="stops" style={`left: ${mappedStop(stop) * 100}%;`}
|
||||||
|
>{stop}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
<span
|
||||||
|
class="progress-stop"
|
||||||
|
style={`left: ${normalisedProgress * 100}%; transform: translateX(-50%);`}
|
||||||
|
>{fmt.format(componentState.easedProgress)}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
<div id="video-progress-bar">
|
||||||
|
<div style="width: {normalisedProgress * 100}%; height: 100%;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- -->
|
||||||
|
<p>Direction:</p>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<p class="state-value">
|
||||||
|
<span class="tag">{componentState.direction}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- -->
|
||||||
|
{#if componentState.stops.length > 0}
|
||||||
|
<p>Stops:</p>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<p
|
||||||
|
class="state-value"
|
||||||
|
style="display: flex; gap: 4px; flex-wrap: wrap;"
|
||||||
|
>
|
||||||
|
{#each componentState.stops as stop}
|
||||||
|
<span class="tag">{stop}</span>
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<!-- -->
|
||||||
|
<p>Handle scroll:</p>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<p class="state-value">
|
||||||
|
<span class="tag">{componentState.handleScroll}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- -->
|
||||||
|
<p>Scrubbed:</p>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<p class="state-value">
|
||||||
|
<span class="tag">{componentState.scrubbed}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- -->
|
||||||
|
<p>Easing:</p>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<p class="state-value">
|
||||||
|
{componentState.easing}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- -->
|
||||||
|
<p>
|
||||||
|
Duration:
|
||||||
|
{#if componentState.scrubbed}
|
||||||
|
<span class="tag not-applicable">NA</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<p class="state-value">
|
||||||
|
<span class="tag">{componentState.duration}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- -->
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
* {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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%;
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-value {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stops {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-stop {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 1;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-applicable {
|
||||||
|
background-color: #4a0000;
|
||||||
|
color: #ff8a80;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
447
src/components/HorizontalScroller/HorizontalScroller.mdx
Normal file
|
|
@ -0,0 +1,447 @@
|
||||||
|
import { Meta } from '@storybook/blocks';
|
||||||
|
|
||||||
|
import * as HorizontalScrollerStories from './HorizontalScroller.stories.svelte';
|
||||||
|
|
||||||
|
import IllustratorScreenshot from './assets/illustrator.png';
|
||||||
|
|
||||||
|
<Meta of={HorizontalScrollerStories} />
|
||||||
|
|
||||||
|
# HorizontalScroller
|
||||||
|
|
||||||
|
The `HorizontalScroller` component creates a horizontal scrolling section that scrolls through any child content wider than `100vw`.
|
||||||
|
|
||||||
|
To use `HorizontalScroller`, wrap it around the content that you want to horizontally scroll through. The scroll length is controlled by the height of the `HorizontalScroller` container, which is set by the prop `height`. `height` defaults to `200lvh`, but you can adjust this to any valid CSS height value such as `1200px` or `400lvh`.
|
||||||
|
|
||||||
|
The child content inside the `HorizontalScroller` must be wider than `100vw` so that there is overflow to horizontal scroll through. By default, only the top `100lvh` of the child content is visible. You can use CSS `transform: translate()` on the child content to adjust its vertical positioning within the visible area.
|
||||||
|
|
||||||
|
> 💡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.
|
||||||
|
|
||||||
|
> 💡TIP: Set the `showDebugInfo` prop to `true` to visualise the scroll progress and other useful information.
|
||||||
|
|
||||||
|
See the full list of available props under the `Controls` tab in the [demo](?path=/story/components-graphics-horizontalscroller--demo).
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
HorizontalScroller,
|
||||||
|
Block,
|
||||||
|
} from '@reuters-graphics/graphics-components';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Wrap `HorizontalScroller` in a fluid container for a full bleed experience -->
|
||||||
|
<Block width="fluid">
|
||||||
|
<!-- Optionally set `height` prop to adjust scroll length. Defaults to `200lvh` -->
|
||||||
|
<HorizontalScroller>
|
||||||
|
<!-- Child content wider than 100vw. Only the top 100lvh is visible. -->
|
||||||
|
<div style="width: 400vw; height: 100lvh;">
|
||||||
|
<img
|
||||||
|
src="my-wide-image.jpg"
|
||||||
|
alt="alt text"
|
||||||
|
style="width: 100%; height: 100%; object-fit: cover; padding: 0; margin: 0;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</HorizontalScroller>
|
||||||
|
</Block>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Controlling scroll behaviour with stops and easing
|
||||||
|
|
||||||
|
The `HorizontalScroller` allows you to control the horizontal scroll behaviour and pacing with various props.
|
||||||
|
|
||||||
|
**`stops`:**
|
||||||
|
|
||||||
|
`stops` is an optional prop that accepts an array of numbers between `0` and `1`. At these points, which corresponds to the scroll `progress` values, the scrolling stops or slows down. This is useful for adding custom pauses based on progress.
|
||||||
|
|
||||||
|
For example, as shown in the demo below, if you define `stops` as `[0.2, 0.5, 0.9]`, the scrolling will pause or slow down at these `progress` values as the user scrolls through the `HorizontalScroller` section.
|
||||||
|
|
||||||
|
**`scrubbed`:**
|
||||||
|
|
||||||
|
The `scrubbed` prop controls whether the scrolling is tied exactly to the scroll position (`scrubbed: true`) or is smoothed out (`scrubbed: false`). This prop defaults to `true`.
|
||||||
|
|
||||||
|
If `scrubbed` is set to `false` and `stops` are defined, the scrolling transitions smoothly between the stop values.
|
||||||
|
|
||||||
|
**`easing`** and **`duration`**:
|
||||||
|
|
||||||
|
`easing` accepts any easing function from `svelte/easing` or a custom easing function, while `duration` sets the time, in milliseconds, for each transition between stops.
|
||||||
|
|
||||||
|
So, if the stops are at irregular intervals — for example, `[0.2, 0.9]` — the scroll to the first stop will be much quicker than the scroll to the second stop since the distance to travel is different but the duration of the transition is the same.
|
||||||
|
|
||||||
|
By default, `duration` is set to `400` milliseconds.
|
||||||
|
|
||||||
|
[Demo](?path=/story/components-graphics-horizontalscroller--with-stops)
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
HorizontalScroller,
|
||||||
|
Block,
|
||||||
|
} from '@reuters-graphics/graphics-components';
|
||||||
|
import { quartInOut } from 'svelte/easing';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Wrap `HorizontalScroller` in a fluid container for a full bleed experience -->
|
||||||
|
<Block width="fluid">
|
||||||
|
<HorizontalScroller
|
||||||
|
stops={[0.2, 0.5, 0.9]}
|
||||||
|
duration={400}
|
||||||
|
scrubbed={false}
|
||||||
|
easing={quartInOut}
|
||||||
|
showDebugInfo={true}
|
||||||
|
>
|
||||||
|
<!-- Child content wider than 100vw. Only the top 100lvh is visible. -->
|
||||||
|
<div style="width: 200vw; height: 100lvh;">
|
||||||
|
<img
|
||||||
|
src="my-wide-image.jpg"
|
||||||
|
alt="alt text"
|
||||||
|
style="width: 100%; height: 100%; object-fit: cover; padding: 0; margin: 0;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</HorizontalScroller>
|
||||||
|
</Block>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extended boundaries
|
||||||
|
|
||||||
|
`HorizontalScroller` has `mappedStart` and `mappedEnd` props, which extend the horizontal scroll boundaries beyond the default 0 to 1 range. This is useful when you want to create an overscroll effect or have more control over the horizontal scroll range. By default, these values are set to 0 and 1 respectively.
|
||||||
|
|
||||||
|
If using custom `mappedStart` and `mappedEnd` values, you must also set `stops` values that are within the mapped range.
|
||||||
|
|
||||||
|
> 💡TIP: In the debugging info box, `Progress` indicates the raw scroll progress value between `0` and `1`. `Mapped Progress` indicates the vertical progress mapped to `mappedStart` and `mappedEnd`. If they are not set, `Mapped Progress` is bound between 0 and 1 and matches `Progress`. `Eased Progress` indicates the scroll progress with any stops and easing applied. `Eased Progress` is what reflects the actual transition of the horizontal scroll position.
|
||||||
|
|
||||||
|
[Demo](?path=/story/components-graphics-horizontalscroller--extended-boundaries)
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
HorizontalScroller,
|
||||||
|
Block,
|
||||||
|
} from '@reuters-graphics/graphics-components';
|
||||||
|
import { quartInOut } from 'svelte/easing';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Wrap `HorizontalScroller` in a fluid container for a full bleed experience -->
|
||||||
|
<Block width="fluid">
|
||||||
|
<HorizontalScroller
|
||||||
|
mappedStart={-0.5}
|
||||||
|
mappedEnd={1.5}
|
||||||
|
stops={[0, 1]}
|
||||||
|
showDebugInfo={true}
|
||||||
|
easing={quartInOut}
|
||||||
|
>
|
||||||
|
<!-- Child content wider than 100vw. Only the top 100lvh is visible. -->
|
||||||
|
<div style="width: 200vw; height: 100lvh;">
|
||||||
|
<img
|
||||||
|
src="my-wide-image.jpg"
|
||||||
|
alt="alt text"
|
||||||
|
style="width: 100%; height: 100%; object-fit: cover; padding: 0; margin: 0;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</HorizontalScroller>
|
||||||
|
</Block>
|
||||||
|
```
|
||||||
|
|
||||||
|
## With ai2svelte components
|
||||||
|
|
||||||
|
With [ai2svelte](https://reuters-graphics.github.io/ai2svelte/) v1.0.3 onwards, you can export your ai2svelte graphic with a wider-than-viewport layout and use it directly inside `HorizontalScroller` to create horizontally scrolling graphics.
|
||||||
|
|
||||||
|
To do that, follow these steps:
|
||||||
|
|
||||||
|
1. In Illustrator, rename your artboard with the breakpoint at which you want that artboard to be visible on the page. For example, to make the XL artboard visible on viewports wider than 1200px, rename it to `xl:1200`. You can have multiple artboards with different breakpoints.
|
||||||
|
2. Add these properties to the ai2svelte settings and run the script to export the component.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
include_resizer_css: false
|
||||||
|
respect_height: true
|
||||||
|
allow_overflow: true
|
||||||
|
```
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={IllustratorScreenshot}
|
||||||
|
alt="Screenshot showing Illustrator document with artboard panel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
[Demo](?path=/story/components-graphics-horizontalscroller--scrollable-ai-2-svelte)
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
HorizontalScroller,
|
||||||
|
Block,
|
||||||
|
} from '@reuters-graphics/graphics-components';
|
||||||
|
import AiGraphic from './ai2svelte/ai-graphic.svelte';
|
||||||
|
|
||||||
|
// If using with the graphics kit
|
||||||
|
import { assets } from '$app/paths';
|
||||||
|
|
||||||
|
// Optional easing function
|
||||||
|
import { sineInOut } from 'svelte/easing';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Wrap `HorizontalScroller` in a fluid container for a full bleed experience -->
|
||||||
|
<Block width="fluid">
|
||||||
|
<HorizontalScroller height="800lvh" easing={sineInOut} showDebugInfo>
|
||||||
|
<AiGraphic assetsPath={assets} />
|
||||||
|
</HorizontalScroller>
|
||||||
|
</Block>
|
||||||
|
```
|
||||||
|
|
||||||
|
## With ai2svelte components: advanced
|
||||||
|
|
||||||
|
You can use the bound prop `progress` to create advanced interactivity with an ai2svelte graphic.
|
||||||
|
|
||||||
|
The demo below has 2 advanced interactions: fade in/out of caption boxes based on scroll position and parallax movement of a `png` layer.
|
||||||
|
|
||||||
|
### Captions fading in/out
|
||||||
|
|
||||||
|
Caption boxes are exported as `htext` [tagged layers](https://reuters-graphics.github.io/ai2svelte/users/tagged-layers/) in ai2svelte. In this example, we use the `handleScroll()` function to check the position of each caption box relative to the viewport width and set its opacity to `1` (visible) or `0` (hidden) based on whether the caption box is within the `threshold` of the viewport. In Adobe Illustrator, set `override_text: true` in the ai2svelte export settings to allow custom HTML content in tagged text layers.
|
||||||
|
|
||||||
|
### Parallax effect with png layer
|
||||||
|
|
||||||
|
This demo has a tagged `png` [layer](https://reuters-graphics.github.io/ai2svelte/users/tagged-layers/), which contains the foreground overlay image. The `handleScroll()` function uses the bound `progress` value to calculate a horizontal translation for the `png` layer, creating a parallax effect as the user scrolls through the `HorizontalScroller`.
|
||||||
|
|
||||||
|
[Demo](?path=/story/components-graphics-horizontalscroller--scrollable-ai-2-svelte-advanced)
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
HorizontalScroller,
|
||||||
|
Block,
|
||||||
|
} from '@reuters-graphics/graphics-components';
|
||||||
|
import AiGraphic from './ai2svelte/ai-graphic.svelte';
|
||||||
|
import { sineInOut } from 'svelte/easing';
|
||||||
|
|
||||||
|
// If using with the graphics kit
|
||||||
|
import { assets } from '$app/paths';
|
||||||
|
|
||||||
|
// bind progress for advanced interactivity
|
||||||
|
let progress: number = $state(0);
|
||||||
|
let pngLayer: HTMLElement | null;
|
||||||
|
let captions: HTMLElement[] | null;
|
||||||
|
let threshold = 0.8;
|
||||||
|
let screenWidth: number = $state(0);
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
// Create a parallax movement for the foreground png layer
|
||||||
|
if (pngLayer) {
|
||||||
|
pngLayer.style.transform = `scale(1.5) translateX(${map(progress, 0, 1, -15, 85)}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each caption, checks if position of the caption is below the threshold.
|
||||||
|
// If it is, show it. If not, hide it
|
||||||
|
if (captions?.length) {
|
||||||
|
captions.forEach((caption) => {
|
||||||
|
let captionWidth = caption.getBoundingClientRect().width;
|
||||||
|
let captionMidpoint =
|
||||||
|
caption.getBoundingClientRect().left + captionWidth / 2;
|
||||||
|
|
||||||
|
if (
|
||||||
|
captionMidpoint < screenWidth * threshold &&
|
||||||
|
caption.style.opacity !== '1'
|
||||||
|
) {
|
||||||
|
caption.style.opacity = '1';
|
||||||
|
} else if (
|
||||||
|
captionMidpoint > screenWidth * threshold &&
|
||||||
|
caption.style.opacity !== '0'
|
||||||
|
) {
|
||||||
|
caption.style.opacity = '0';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refetch new captions and png image every time the artboard changes
|
||||||
|
function onArtboardChange(artboard: HTMLElement) {
|
||||||
|
pngLayer = artboard.querySelector('.g-png-layer-overlay');
|
||||||
|
captions = Array.from(artboard.querySelectorAll('.g-captions'));
|
||||||
|
|
||||||
|
if (pngLayer) {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
window.addEventListener('scroll', handleScroll, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// to translate overlay layer on initial load
|
||||||
|
handleScroll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Wrap `HorizontalScroller` in a fluid container for a full bleed experience -->
|
||||||
|
<Block width="fluid">
|
||||||
|
<HorizontalScroller
|
||||||
|
height="800lvh"
|
||||||
|
bind:progress
|
||||||
|
easing={sineInOut}
|
||||||
|
showDebugInfo={true}
|
||||||
|
>
|
||||||
|
<AiGraphic
|
||||||
|
assetsPath={assets}
|
||||||
|
{onArtboardChange}
|
||||||
|
taggedText={{
|
||||||
|
htext: {
|
||||||
|
captions: {
|
||||||
|
caption1:
|
||||||
|
'<div class="scroller-caption"><strong>Caption 1!</strong><br/>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>',
|
||||||
|
caption2:
|
||||||
|
'<div class="scroller-caption"><strong>Caption 2!</strong><br/>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>',
|
||||||
|
caption3:
|
||||||
|
'<div class="scroller-caption"><strong>Caption 3!</strong><br/>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>',
|
||||||
|
caption4:
|
||||||
|
'<div class="scroller-caption"><strong>Caption 4!</strong><br/>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</HorizontalScroller>
|
||||||
|
</Block>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
:global(.scroller-caption) {
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
filter: drop-shadow(0px 2px 16px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
## With custom child components
|
||||||
|
|
||||||
|
You can create a custom horizontal layout with any component and pass it as a child to the `HorizontalScroller`. Here's an example with `DatawrapperChart`, `Headline` and ai2svelte components laid out in a horizontal scroll.
|
||||||
|
|
||||||
|
[Demo](?path=/story/components-graphics-horizontalscroller--custom-children)
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
Block,
|
||||||
|
DatawrapperChart,
|
||||||
|
Headline,
|
||||||
|
HorizontalScroller,
|
||||||
|
} from '@reuters-graphics/graphics-components';
|
||||||
|
import AiChart from './ai2svelte/ai-chart.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Wrap `HorizontalScroller` in a fluid container for a full bleed experience -->
|
||||||
|
<Block width="fluid">
|
||||||
|
<HorizontalScroller>
|
||||||
|
<div id="horizontal-stack">
|
||||||
|
<div style="width: 100vw;">
|
||||||
|
<DatawrapperChart
|
||||||
|
title="Global abortion access"
|
||||||
|
ariaLabel="map"
|
||||||
|
id="abortion-rights-map"
|
||||||
|
src="https://graphics.reuters.com/USA-ABORTION/lgpdwggnwvo/media-embed.html"
|
||||||
|
frameTitle=""
|
||||||
|
scrolling="no"
|
||||||
|
textWidth="normal"
|
||||||
|
width="wider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style="width: 100vw;">
|
||||||
|
<Headline
|
||||||
|
hed="Reuters Graphics Interactive"
|
||||||
|
dek="The beginning of a beautiful page"
|
||||||
|
section="World News"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style="width: 100vw;">
|
||||||
|
<Block width="normal">
|
||||||
|
<AiChart />
|
||||||
|
</Block>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</HorizontalScroller>
|
||||||
|
</Block>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
#horizontal-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
gap: 10vw;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
## With ScrollerBase
|
||||||
|
|
||||||
|
You can also integrate HorizontalScroller with `ScrollerBase` for a horizontal scroll with vertical captions.
|
||||||
|
|
||||||
|
When using `HorizontalScroller` with `ScrollerBase` or other scrollers, you must:
|
||||||
|
|
||||||
|
- Create a `progress` state variable and bind it to both `ScrollerBase` and `HorizontalScroller`
|
||||||
|
- Set `HorizontalScroller`'s `height` to `100lvh`
|
||||||
|
- Set `handleScroll` to `false`
|
||||||
|
|
||||||
|
> **⚠️ Warning:** It is not recommended to use HorizontalScroller with vertical ScrollerBase. This example is only to serve the purpose of demonstrating how to control the HorizontalScroller with an external progress value (ScrollerBase's progress in this case).
|
||||||
|
|
||||||
|
[Demo](?path=/story/components-graphics-horizontalscroller--with-scroller-base)
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
HorizontalScroller,
|
||||||
|
ScrollerBase,
|
||||||
|
Block,
|
||||||
|
} from '@reuters-graphics/graphics-components';
|
||||||
|
import AiGraphic from './ai2svelte/ai-graphic.svelte';
|
||||||
|
import { circInOut } from 'svelte/easing';
|
||||||
|
|
||||||
|
// Optional: Bind your own variables to use them in your code.
|
||||||
|
let progress = $state(0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ScrollerBase bind:progress query="div.step-foreground-container">
|
||||||
|
{#snippet backgroundSnippet()}
|
||||||
|
<!-- Wrap `HorizontalScroller` in a fluid container for a full bleed experience -->
|
||||||
|
<Block width="fluid">
|
||||||
|
<HorizontalScroller
|
||||||
|
bind:progress
|
||||||
|
height="100lvh"
|
||||||
|
handleScroll={false}
|
||||||
|
showDebugInfo={true}
|
||||||
|
>
|
||||||
|
<AiGraphic />
|
||||||
|
</HorizontalScroller>
|
||||||
|
</Block>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet foregroundSnippet()}
|
||||||
|
<!-- Add custom foreground HTML or component -->
|
||||||
|
<div class="step-foreground-container"><p>Step 1</p></div>
|
||||||
|
<div class="step-foreground-container"><p>Step 2</p></div>
|
||||||
|
<div class="step-foreground-container"><p>Step 3</p></div>
|
||||||
|
<div class="step-foreground-container"><p>Step 4</p></div>
|
||||||
|
<div class="step-foreground-container"><p>Step 5</p></div>
|
||||||
|
{/snippet}
|
||||||
|
</ScrollerBase>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.step-foreground-container {
|
||||||
|
height: 100vh;
|
||||||
|
width: 50%;
|
||||||
|
background-color: rgba(0, 0, 0, 0);
|
||||||
|
padding: 1em;
|
||||||
|
margin: 0 auto 10px 0;
|
||||||
|
position: relative;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
|
||||||
|
p {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
<script module lang="ts">
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import { quartInOut } from 'svelte/easing';
|
||||||
|
|
||||||
|
import HorizontalScroller from './HorizontalScroller.svelte';
|
||||||
|
import DemoComponent from './demo/Demo.svelte';
|
||||||
|
import DemoSnippetBlock from './demo/DemoSnippet.svelte';
|
||||||
|
import CustomChildrenBlock from './demo/CustomChildrenSnippet.svelte';
|
||||||
|
import ScrollableGraphic from './demo/ScrollableGraphic.svelte';
|
||||||
|
import AdvancedScrollableGraphic from './demo/AdvancedScrollableGraphic.svelte';
|
||||||
|
import WithScrollerBaseComponent from './demo/withScrollerBase.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Components/Graphics/HorizontalScroller',
|
||||||
|
component: HorizontalScroller,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
chromatic: {
|
||||||
|
disableSnapshot: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let width: number = $state(0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window bind:innerWidth={width} />
|
||||||
|
|
||||||
|
<Story name="Demo">
|
||||||
|
<DemoComponent>
|
||||||
|
<DemoSnippetBlock />
|
||||||
|
</DemoComponent>
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="With stops and easing" exportName="WithStops">
|
||||||
|
<DemoComponent
|
||||||
|
stops={[0.2, 0.5, 0.9]}
|
||||||
|
duration={400}
|
||||||
|
toggleScrub={true}
|
||||||
|
easing={quartInOut}
|
||||||
|
>
|
||||||
|
<DemoSnippetBlock />
|
||||||
|
</DemoComponent>
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Extended boundaries">
|
||||||
|
<DemoComponent
|
||||||
|
mappedStart={-0.5}
|
||||||
|
mappedEnd={1.5}
|
||||||
|
easing={quartInOut}
|
||||||
|
stops={[0, 1]}
|
||||||
|
>
|
||||||
|
<DemoSnippetBlock />
|
||||||
|
</DemoComponent>
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Custom children">
|
||||||
|
<DemoComponent>
|
||||||
|
<CustomChildrenBlock />
|
||||||
|
</DemoComponent>
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Scrollable ai2svelte">
|
||||||
|
<ScrollableGraphic />
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Scrollable ai2svelte (advanced)">
|
||||||
|
<AdvancedScrollableGraphic />
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="With ScrollerBase">
|
||||||
|
<WithScrollerBaseComponent />
|
||||||
|
</Story>
|
||||||
254
src/components/HorizontalScroller/HorizontalScroller.svelte
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, type Snippet } from 'svelte';
|
||||||
|
import { Tween } from 'svelte/motion';
|
||||||
|
import { clamp, map } from './utils/index';
|
||||||
|
import type { Action } from 'svelte/action';
|
||||||
|
|
||||||
|
import Debug from './Debug.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Optional id for the scroller container */
|
||||||
|
id?: string;
|
||||||
|
/** Optional additional classes for the scroller container */
|
||||||
|
class?: string;
|
||||||
|
/** Height of the scroller container in CSS `vh` units. Set it to `100lvh` when using inside ScrollerBase. */
|
||||||
|
height?: string;
|
||||||
|
/** Bindable progress value. Ideal range: `[0-1]`. Bind ScrollerBase's progress to this prop. */
|
||||||
|
progress?: number;
|
||||||
|
/** Direction of movement*/
|
||||||
|
direction?: 'left' | 'right';
|
||||||
|
/** Content to scroll*/
|
||||||
|
children?: Snippet;
|
||||||
|
/** Array of numbers desired as stops for the scroller */
|
||||||
|
stops?: number[];
|
||||||
|
/** Should the component handle scroll events? Set it to `false` when using inside ScrollerBase. */
|
||||||
|
handleScroll?: boolean;
|
||||||
|
/** Whether the stops should be scrubbed */
|
||||||
|
scrubbed?: boolean;
|
||||||
|
/** Easing function for the progress/stops */
|
||||||
|
easing?: (t: number) => number;
|
||||||
|
/** Duration of the easing animation in milliseconds. Effective only when scrubbed is false. */
|
||||||
|
duration?: number;
|
||||||
|
/** Whether to show debug info */
|
||||||
|
showDebugInfo?: boolean;
|
||||||
|
/** Modified starting scale. Default is 0 */
|
||||||
|
mappedStart?: number;
|
||||||
|
/** Modified ending scale. Default is 1 */
|
||||||
|
mappedEnd?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
id = '',
|
||||||
|
class: cls = '',
|
||||||
|
height = '200lvh',
|
||||||
|
direction = 'right',
|
||||||
|
progress = $bindable(0),
|
||||||
|
mappedStart = 0,
|
||||||
|
mappedEnd = 1,
|
||||||
|
children,
|
||||||
|
stops = [],
|
||||||
|
handleScroll = true,
|
||||||
|
scrubbed = true,
|
||||||
|
easing: ease = (t) => t,
|
||||||
|
duration = 400,
|
||||||
|
showDebugInfo = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let easedProgress: Tween<number> = $state(
|
||||||
|
new Tween(mappedStart, { duration, easing: ease })
|
||||||
|
);
|
||||||
|
let container: HTMLDivElement | undefined = $state(undefined);
|
||||||
|
let containerHeight: number = $state(0);
|
||||||
|
let containerWidth: number = $state(0);
|
||||||
|
let content: HTMLDivElement | undefined = $state(undefined);
|
||||||
|
let contentWidth: number = $state(0);
|
||||||
|
let screenHeight: number = $state(0);
|
||||||
|
let divisions: number[] = $derived(
|
||||||
|
[...stops, mappedStart, mappedEnd].sort((a, b) => a - b)
|
||||||
|
);
|
||||||
|
let divisionsCount: number = $derived.by(() => divisions.length - 1);
|
||||||
|
|
||||||
|
let mappedProgress: number = $state(0);
|
||||||
|
|
||||||
|
// handles horizontal translation of the content
|
||||||
|
let translateX: number = $derived.by(() => {
|
||||||
|
let processedProgress = clamp(
|
||||||
|
easedProgress.current,
|
||||||
|
mappedStart,
|
||||||
|
mappedEnd
|
||||||
|
);
|
||||||
|
let normalisedProgress = processedProgress;
|
||||||
|
|
||||||
|
normalisedProgress =
|
||||||
|
direction === 'right' ? processedProgress : mappedEnd - processedProgress;
|
||||||
|
|
||||||
|
const translate = -(contentWidth - containerWidth) * normalisedProgress;
|
||||||
|
|
||||||
|
return translate;
|
||||||
|
});
|
||||||
|
|
||||||
|
let componentState = $derived.by(() => ({
|
||||||
|
progress,
|
||||||
|
mappedProgress,
|
||||||
|
easedProgress: easedProgress.current,
|
||||||
|
direction,
|
||||||
|
mappedStart,
|
||||||
|
mappedEnd,
|
||||||
|
triggerStops: scrubbed ? stops : divisions,
|
||||||
|
stops: stops,
|
||||||
|
handleScroll,
|
||||||
|
scrubbed,
|
||||||
|
easing: ease,
|
||||||
|
duration,
|
||||||
|
}));
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Initialize mappedProgress to mappedStart on mount
|
||||||
|
mappedProgress = mappedStart;
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrollListener: Action = () => {
|
||||||
|
if (handleScroll) {
|
||||||
|
window.addEventListener('scroll', handleScrollFunction, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.addEventListener('scroll', () => handleStops(progress), {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// calculates distance scrolled inside the container
|
||||||
|
function handleScrollFunction() {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
progress =
|
||||||
|
(-container?.offsetTop + window?.scrollY) /
|
||||||
|
(containerHeight - screenHeight);
|
||||||
|
|
||||||
|
handleStops(progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
// updates easedProgress based on stops and scrubbed settings
|
||||||
|
function handleStops(rawProgress: number) {
|
||||||
|
mappedProgress = map(rawProgress, 0, 1, mappedStart, mappedEnd);
|
||||||
|
|
||||||
|
if (!stops || stops.length === 0) {
|
||||||
|
easedProgress.set(ease(map(rawProgress, 0, 1, mappedStart, mappedEnd)), {
|
||||||
|
duration: 0,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scrubbed) {
|
||||||
|
for (let i = 0; i < divisions.length; i++) {
|
||||||
|
if (
|
||||||
|
mappedProgress > divisions[i] &&
|
||||||
|
mappedProgress <=
|
||||||
|
(divisions[i + 1] ?? divisions[divisions.length - 1])
|
||||||
|
) {
|
||||||
|
const midPoint =
|
||||||
|
divisions[i] +
|
||||||
|
((divisions[i + 1] ?? divisions[divisions.length - 1]) -
|
||||||
|
divisions[i]) *
|
||||||
|
0.5;
|
||||||
|
if (
|
||||||
|
mappedProgress >= midPoint &&
|
||||||
|
easedProgress.target !==
|
||||||
|
(divisions[i + 1] ?? divisions[divisions.length - 1])
|
||||||
|
) {
|
||||||
|
easedProgress.set(
|
||||||
|
divisions[i + 1] ?? divisions[divisions.length - 1]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} else if (
|
||||||
|
mappedProgress < midPoint &&
|
||||||
|
easedProgress.target !== divisions[i]
|
||||||
|
) {
|
||||||
|
easedProgress.set(divisions[i]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
mappedProgress <
|
||||||
|
divisions[0] + (divisions[1] ?? mappedStart) * 0.5
|
||||||
|
) {
|
||||||
|
if (easedProgress.target !== divisions[0]) {
|
||||||
|
easedProgress.set(divisions[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < divisions.length; i++) {
|
||||||
|
let oneByDivCount = 1 / divisionsCount;
|
||||||
|
|
||||||
|
let normalStart = i == 0 ? mappedStart : oneByDivCount * i;
|
||||||
|
let normalEnd =
|
||||||
|
i == divisionsCount - 1 ? mappedEnd : oneByDivCount * (i + 1);
|
||||||
|
|
||||||
|
if (mappedProgress >= normalStart && mappedProgress < normalEnd) {
|
||||||
|
let stopStart = divisions[i];
|
||||||
|
let stopEnd = divisions[i + 1] ?? mappedEnd;
|
||||||
|
let newProgressVal =
|
||||||
|
stopStart +
|
||||||
|
ease(map(mappedProgress, normalStart, normalEnd, 0, 1)) *
|
||||||
|
(stopEnd - stopStart);
|
||||||
|
|
||||||
|
easedProgress.set(newProgressVal, { duration: 0 });
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window bind:innerHeight={screenHeight} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
{id}
|
||||||
|
class="horizontal-scroller-container {cls}"
|
||||||
|
style="height: {height};"
|
||||||
|
bind:this={container}
|
||||||
|
bind:clientHeight={containerHeight}
|
||||||
|
bind:clientWidth={containerWidth}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="horizontal-scroller-content"
|
||||||
|
bind:this={content}
|
||||||
|
bind:clientWidth={contentWidth}
|
||||||
|
style="transform: translateX({translateX}px);"
|
||||||
|
use:scrollListener
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
{#if showDebugInfo}
|
||||||
|
<div
|
||||||
|
class="debug-info"
|
||||||
|
style={`position: absolute; left: ${-translateX}px; top: 0px;`}
|
||||||
|
>
|
||||||
|
<Debug {componentState} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.horizontal-scroller-container {
|
||||||
|
width: 100%;
|
||||||
|
contain: paint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-scroller-content {
|
||||||
|
display: inline-block;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100lvh;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
BIN
src/components/HorizontalScroller/assets/illustrator.png
Normal file
|
After Width: | Height: | Size: 574 KiB |
|
|
@ -0,0 +1,103 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Demo from './graphic/ai2svelte/demo.svelte';
|
||||||
|
import BodyText from '../../BodyText/BodyText.svelte';
|
||||||
|
import Block from '../../Block/Block.svelte';
|
||||||
|
|
||||||
|
import HorizontalScroller from '../HorizontalScroller.svelte';
|
||||||
|
import { map } from '../utils/index';
|
||||||
|
import { sineInOut } from 'svelte/easing';
|
||||||
|
|
||||||
|
const foobarText: string =
|
||||||
|
'In the mystical land of Foobaristan, the legendary hero Foo set out on an epic quest to find his missing semicolon, only to discover that Bar had accidentally used it as a bookmark inside a JSON file. Naturally, the entire kingdom crashed immediately. As the villagers panicked, Foo and Bar tried to fix the situation by turning everything off and on again, but all that did was anger the ancient deity known as “The Build System,” which now demanded three sacrifices: a clean cache, a fresh node_modules folder, and someone’s weekend. And thus began the saga nobody asked for, yet every developer somehow relates to.';
|
||||||
|
|
||||||
|
let progress: number = $state(0);
|
||||||
|
let pngLayer: HTMLElement | null;
|
||||||
|
let captions: HTMLElement[] | null;
|
||||||
|
let threshold = 0.8;
|
||||||
|
let screenWidth: number = $state(0);
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
if (pngLayer) {
|
||||||
|
pngLayer.style.transform = `scale(1.5) translateX(${map(progress, 0, 1, -15, 85)}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (captions?.length) {
|
||||||
|
captions.forEach((caption) => {
|
||||||
|
let captionWidth = caption.getBoundingClientRect().width;
|
||||||
|
let captionMidpoint =
|
||||||
|
caption.getBoundingClientRect().left + captionWidth / 2;
|
||||||
|
|
||||||
|
if (
|
||||||
|
captionMidpoint < screenWidth * threshold &&
|
||||||
|
caption.style.opacity !== '1'
|
||||||
|
) {
|
||||||
|
caption.style.opacity = '1';
|
||||||
|
} else if (
|
||||||
|
captionMidpoint > screenWidth * threshold &&
|
||||||
|
caption.style.opacity !== '0'
|
||||||
|
) {
|
||||||
|
caption.style.opacity = '0';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onArtboardChange(artboard: HTMLElement) {
|
||||||
|
pngLayer = artboard.querySelector('.g-png-layer-overlay');
|
||||||
|
captions = Array.from(artboard.querySelectorAll('.g-captions'));
|
||||||
|
|
||||||
|
if (pngLayer) {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
window.addEventListener('scroll', handleScroll, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// to translate overlay layer on initial load
|
||||||
|
handleScroll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window bind:innerWidth={screenWidth} />
|
||||||
|
|
||||||
|
<BodyText text={foobarText} />
|
||||||
|
|
||||||
|
<Block width="fluid">
|
||||||
|
<HorizontalScroller
|
||||||
|
height="800lvh"
|
||||||
|
bind:progress
|
||||||
|
easing={sineInOut}
|
||||||
|
showDebugInfo
|
||||||
|
>
|
||||||
|
<Demo
|
||||||
|
{onArtboardChange}
|
||||||
|
taggedText={{
|
||||||
|
htext: {
|
||||||
|
captions: {
|
||||||
|
caption1:
|
||||||
|
'<div class="scroller-caption"><strong>Caption 1!</strong><br/>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>',
|
||||||
|
caption2:
|
||||||
|
'<div class="scroller-caption"><strong>Caption 2!</strong><br/>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>',
|
||||||
|
caption3:
|
||||||
|
'<div class="scroller-caption"><strong>Caption 3!</strong><br/>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>',
|
||||||
|
caption4:
|
||||||
|
'<div class="scroller-caption"><strong>Caption 4!</strong><br/>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</HorizontalScroller>
|
||||||
|
</Block>
|
||||||
|
|
||||||
|
<BodyText text={foobarText} />
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
:global(.scroller-caption) {
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
filter: drop-shadow(0px 2px 16px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import DatawrapperChart from '../../DatawrapperChart/DatawrapperChart.svelte';
|
||||||
|
import Headline from '../../Headline/Headline.svelte';
|
||||||
|
import AiChart from './graphic/ai2svelte/ai-chart.svelte';
|
||||||
|
import Block from '../../Block/Block.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="horizontal-stack">
|
||||||
|
<div style="width: 100vw;">
|
||||||
|
<DatawrapperChart
|
||||||
|
title="Global abortion access"
|
||||||
|
ariaLabel="map"
|
||||||
|
id="abortion-rights-map"
|
||||||
|
src="https://graphics.reuters.com/USA-ABORTION/lgpdwggnwvo/media-embed.html"
|
||||||
|
frameTitle=""
|
||||||
|
scrolling="no"
|
||||||
|
textWidth="normal"
|
||||||
|
width="wider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style="width: 100vw;">
|
||||||
|
<Headline
|
||||||
|
hed="Reuters Graphics Interactive"
|
||||||
|
dek="The beginning of a beautiful page"
|
||||||
|
section="World News"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style="width: 100vw;">
|
||||||
|
<Block width="normal">
|
||||||
|
<AiChart />
|
||||||
|
</Block>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
#horizontal-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
gap: 10vw;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
42
src/components/HorizontalScroller/demo/Demo.svelte
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import HorizontalScroller from '../HorizontalScroller.svelte';
|
||||||
|
import BodyText from '../../BodyText/BodyText.svelte';
|
||||||
|
import Block from '../../Block/Block.svelte';
|
||||||
|
|
||||||
|
let { ...args } = $props();
|
||||||
|
|
||||||
|
const foobarText: string =
|
||||||
|
'In the mystical land of Foobaristan, the legendary hero Foo set out on an epic quest to find his missing semicolon, only to discover that Bar had accidentally used it as a bookmark inside a JSON file. Naturally, the entire kingdom crashed immediately. As the villagers panicked, Foo and Bar tried to fix the situation by turning everything off and on again, but all that did was anger the ancient deity known as “The Build System,” which now demanded three sacrifices: a clean cache, a fresh node_modules folder, and someone’s weekend. And thus began the saga nobody asked for, yet every developer somehow relates to.';
|
||||||
|
|
||||||
|
// For the `scrubbed` demo
|
||||||
|
let scrubbed: boolean = $state(true);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BodyText text={foobarText} />
|
||||||
|
|
||||||
|
{#if args.toggleScrub}
|
||||||
|
<Block>
|
||||||
|
<button onclick={() => (scrubbed = !scrubbed)}>
|
||||||
|
Toggle scrubbed: {scrubbed}
|
||||||
|
</button>
|
||||||
|
</Block>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Block width="fluid">
|
||||||
|
<HorizontalScroller showDebugInfo={true} {...args} {scrubbed} />
|
||||||
|
</Block>
|
||||||
|
|
||||||
|
<BodyText text={foobarText} />
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Geist Mono', monospace;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border: 2px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<div style="width: 400vw; height: 100lvh;">
|
||||||
|
<img
|
||||||
|
src="https://images.unsplash.com/photo-1533282960533-51328aa49826?q=80&w=3642&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
|
||||||
|
alt="An ultra wide scenic view of cityscape"
|
||||||
|
style="width: 100%; height: 100%; object-fit: cover; padding: 0; margin: 0; background-color: #ccc;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Demo from './graphic/ai2svelte/demo.svelte';
|
||||||
|
import BodyText from '../../BodyText/BodyText.svelte';
|
||||||
|
import Block from '../../Block/Block.svelte';
|
||||||
|
import HorizontalScroller from '../HorizontalScroller.svelte';
|
||||||
|
import { sineInOut } from 'svelte/easing';
|
||||||
|
|
||||||
|
const foobarText: string =
|
||||||
|
'In the mystical land of Foobaristan, the legendary hero Foo set out on an epic quest to find his missing semicolon, only to discover that Bar had accidentally used it as a bookmark inside a JSON file. Naturally, the entire kingdom crashed immediately. As the villagers panicked, Foo and Bar tried to fix the situation by turning everything off and on again, but all that did was anger the ancient deity known as “The Build System,” which now demanded three sacrifices: a clean cache, a fresh node_modules folder, and someone’s weekend. And thus began the saga nobody asked for, yet every developer somehow relates to.';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BodyText text={foobarText} />
|
||||||
|
|
||||||
|
<Block width="fluid">
|
||||||
|
<HorizontalScroller height="800lvh" easing={sineInOut}>
|
||||||
|
<Demo />
|
||||||
|
</HorizontalScroller>
|
||||||
|
</Block>
|
||||||
|
<BodyText text={foobarText} />
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
:global(.scroller-caption) {
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
background-color: rgba(255, 255, 255, 0.8);
|
||||||
|
border-radius: 8px;
|
||||||
|
filter: drop-shadow(0px 2px 16px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,630 @@
|
||||||
|
<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 */
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
<script lang="ts">
|
||||||
|
// For demo purposes only, hard-wiring img paths from Vite
|
||||||
|
// @ts-ignore img
|
||||||
|
import imageXl from '../imgs/demo-xl.jpg';
|
||||||
|
// @ts-ignore img
|
||||||
|
import imageLg from '../imgs/demo-lg.jpg';
|
||||||
|
// @ts-ignore img
|
||||||
|
import imagePngOverlayXl from '../imgs/layer-overlay-xl.png';
|
||||||
|
// @ts-ignore img
|
||||||
|
import imagePngOverlayLg from '../imgs/layer-overlay-lg.png';
|
||||||
|
|
||||||
|
let {
|
||||||
|
onAiMounted = () => {},
|
||||||
|
onArtboardChange = () => {},
|
||||||
|
taggedText = { text: {}, htext: {} },
|
||||||
|
debugTaggedText = false,
|
||||||
|
artboardWidth = $bindable(undefined),
|
||||||
|
} = $props();
|
||||||
|
import { onMount, untrack } from 'svelte';
|
||||||
|
let aiBox: HTMLElement | undefined;
|
||||||
|
let screenWidth = $state(0);
|
||||||
|
let aiBoxWidth = $derived(artboardWidth ?? screenWidth);
|
||||||
|
let activeArtboard: HTMLElement | undefined = $state(undefined);
|
||||||
|
onMount(() => {
|
||||||
|
onAiMounted();
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (aiBoxWidth) {
|
||||||
|
const currentArtboard = (aiBox as HTMLElement).querySelectorAll(
|
||||||
|
'.g-artboard'
|
||||||
|
)[0] as HTMLElement;
|
||||||
|
if (currentArtboard?.id !== activeArtboard?.id) {
|
||||||
|
activeArtboard = untrack(() => currentArtboard);
|
||||||
|
onArtboardChange(activeArtboard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window bind:innerWidth={screenWidth} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="g-demo-box"
|
||||||
|
class="ai2svelte"
|
||||||
|
bind:this={aiBox}
|
||||||
|
style:--debug-tagged-text={debugTaggedText ? 'visible' : 'hidden'}
|
||||||
|
style:--debug-stroke={debugTaggedText ? '2px' : '0px'}
|
||||||
|
>
|
||||||
|
<!-- Artboard: lg -->
|
||||||
|
{#if aiBoxWidth && aiBoxWidth >= 0 && aiBoxWidth < 1200}
|
||||||
|
<div
|
||||||
|
id="g-demo-lg"
|
||||||
|
class="g-artboard"
|
||||||
|
style="max-width: 1199px;aspect-ratio: 2.75483870967742;"
|
||||||
|
data-aspect-ratio="2.755"
|
||||||
|
data-min-width="0"
|
||||||
|
data-max-width="1199"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="g-demo-lg-img"
|
||||||
|
class="g-demo-lg-img g-aiImg"
|
||||||
|
style="background-image: url({imageLg});"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
id="g-png-layer-overlay-lg"
|
||||||
|
class="g-png-layer-overlay g-aiImg"
|
||||||
|
style="opacity:1;;background-image: url({imagePngOverlayLg});"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<!-- Artboard: xl -->
|
||||||
|
{#if aiBoxWidth && aiBoxWidth >= 1200}
|
||||||
|
<div
|
||||||
|
id="g-demo-xl"
|
||||||
|
class="g-artboard"
|
||||||
|
style="min-width: 1200px;aspect-ratio: 4.80806451612903;"
|
||||||
|
data-aspect-ratio="4.808"
|
||||||
|
data-min-width="1200"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="g-demo-xl-img"
|
||||||
|
class="g-demo-xl-img g-aiImg"
|
||||||
|
style="background-image: url({imageXl});"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
id="g-png-layer-overlay-xl"
|
||||||
|
class="g-png-layer-overlay g-aiImg"
|
||||||
|
style="opacity:1;;background-image: url({imagePngOverlayXl});"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
id="g-caption2"
|
||||||
|
class="g-captions g-aiAbs"
|
||||||
|
style="top:14.3548%;left:34.8126%;width:7.2794%;"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="g-pstyle0 g-taggedText g-htext"
|
||||||
|
data-tagged-type="HTEXT"
|
||||||
|
data-tagged-prop="captions.caption2"
|
||||||
|
>
|
||||||
|
{@html taggedText?.htext?.captions?.caption2 || ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="g-caption3"
|
||||||
|
class="g-captions g-aiAbs"
|
||||||
|
style="top:14.3548%;left:60.6764%;width:7.2794%;"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="g-pstyle0 g-taggedText g-htext"
|
||||||
|
data-tagged-type="HTEXT"
|
||||||
|
data-tagged-prop="captions.caption3"
|
||||||
|
>
|
||||||
|
{@html taggedText?.htext?.captions?.caption3 || ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="g-caption4"
|
||||||
|
class="g-captions g-aiAbs"
|
||||||
|
style="top:14.3548%;left:84.0914%;width:7.2794%;"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="g-pstyle0 g-taggedText g-htext"
|
||||||
|
data-tagged-type="HTEXT"
|
||||||
|
data-tagged-prop="captions.caption4"
|
||||||
|
>
|
||||||
|
{@html taggedText?.htext?.captions?.caption4 || ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="g-caption1"
|
||||||
|
class="g-captions g-aiAbs"
|
||||||
|
style="top:14.3548%;left:4.1182%;width:4.8306%;"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="g-pstyle0 g-taggedText g-htext"
|
||||||
|
data-tagged-type="HTEXT"
|
||||||
|
data-tagged-prop="captions.caption1"
|
||||||
|
>
|
||||||
|
{@html taggedText?.htext?.captions?.caption1 || ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
|
||||||
|
TAGGED TEXT PROPS
|
||||||
|
|
||||||
|
taggedText={
|
||||||
|
{
|
||||||
|
"text": {
|
||||||
|
|
||||||
|
},
|
||||||
|
"htext": {
|
||||||
|
"captions": {
|
||||||
|
"caption1": "",
|
||||||
|
"caption2": "",
|
||||||
|
"caption3": "",
|
||||||
|
"caption4": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
-->
|
||||||
|
<!-- End ai2svelte - 2026-01-13 11:12 -->
|
||||||
|
|
||||||
|
<!-- Generated by ai2svelte v1.0.3 - 2026-01-13 11:12 -->
|
||||||
|
<!-- ai file: demo.ai -->
|
||||||
|
<style lang="scss">
|
||||||
|
#g-demo-box,
|
||||||
|
#g-demo-box .g-artboard {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
#g-demo-box .g-artboard {
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100%;
|
||||||
|
min-width: unset !important;
|
||||||
|
max-width: unset !important;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
#g-demo-box p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
#g-demo-box .g-aiAbs {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
#g-demo-box .g-aiImg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
#g-demo-box {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#g-demo-lg {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#g-demo-xl {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#g-demo-xl p {
|
||||||
|
font-family: var(--theme-font-family-sans-serif), Knowledge, sans-serif;
|
||||||
|
font-weight: regular;
|
||||||
|
line-height: 22px;
|
||||||
|
opacity: 1;
|
||||||
|
letter-spacing: 0em;
|
||||||
|
font-size: 18px;
|
||||||
|
text-align: left;
|
||||||
|
color: rgb(0, 0, 0);
|
||||||
|
text-transform: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
font-style: normal;
|
||||||
|
height: auto;
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
#g-demo-xl .g-pstyle0 {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom CSS */
|
||||||
|
|
||||||
|
.g-taggedText:empty::before {
|
||||||
|
content: attr(data-tagged-type);
|
||||||
|
padding: 0px 4px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-style: normal;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #ee0000;
|
||||||
|
display: block;
|
||||||
|
font-weight: 800;
|
||||||
|
visibility: var(--debug-tagged-text, hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-taggedText:empty::after {
|
||||||
|
content: attr(data-tagged-prop);
|
||||||
|
padding: 0px 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: normal;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
visibility: var(--debug-tagged-text, hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-taggedText:empty {
|
||||||
|
background-color: #440000;
|
||||||
|
outline: 2px solid #ee0000;
|
||||||
|
visibility: var(--debug-tagged-text, hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-taggedText:not(:empty)::before {
|
||||||
|
content: attr(data-tagged-type);
|
||||||
|
position: absolute;
|
||||||
|
width: calc(100% + 4px);
|
||||||
|
transform: translateY(-100%) translateX(-2px);
|
||||||
|
padding: 0px 4px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-style: normal;
|
||||||
|
color: #fff;
|
||||||
|
background-color: black;
|
||||||
|
display: block;
|
||||||
|
font-weight: 800;
|
||||||
|
visibility: var(--debug-tagged-text, hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-taggedText {
|
||||||
|
outline: var(--debug-stroke, 0px) solid black;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
After Width: | Height: | Size: 618 KiB |
|
After Width: | Height: | Size: 388 KiB |
|
After Width: | Height: | Size: 226 KiB |
BIN
src/components/HorizontalScroller/demo/graphic/imgs/demo-lg.jpg
Normal file
|
After Width: | Height: | Size: 881 KiB |
BIN
src/components/HorizontalScroller/demo/graphic/imgs/demo-xl.jpg
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
BIN
src/components/HorizontalScroller/demo/graphic/placeholder.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
|
|
@ -0,0 +1,63 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Demo from './graphic/ai2svelte/demo.svelte';
|
||||||
|
import BodyText from '../../BodyText/BodyText.svelte';
|
||||||
|
import Block from '../../Block/Block.svelte';
|
||||||
|
import HorizontalScroller from '../HorizontalScroller.svelte';
|
||||||
|
import ScrollerBase from '../../ScrollerBase/ScrollerBase.svelte';
|
||||||
|
|
||||||
|
const foobarText: string =
|
||||||
|
'In the mystical land of Foobaristan, the legendary hero Foo set out on an epic quest to find his missing semicolon, only to discover that Bar had accidentally used it as a bookmark inside a JSON file. Naturally, the entire kingdom crashed immediately. As the villagers panicked, Foo and Bar tried to fix the situation by turning everything off and on again, but all that did was anger the ancient deity known as “The Build System,” which now demanded three sacrifices: a clean cache, a fresh node_modules folder, and someone’s weekend. And thus began the saga nobody asked for, yet every developer somehow relates to.';
|
||||||
|
|
||||||
|
// Optional: Bind your own variables to use them in your code.
|
||||||
|
let progress = $state(0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BodyText text={foobarText} />
|
||||||
|
|
||||||
|
<ScrollerBase bind:progress query="div.step-foreground-container">
|
||||||
|
{#snippet backgroundSnippet()}
|
||||||
|
<Block width="fluid">
|
||||||
|
<HorizontalScroller
|
||||||
|
bind:progress
|
||||||
|
height="100lvh"
|
||||||
|
handleScroll={false}
|
||||||
|
showDebugInfo
|
||||||
|
>
|
||||||
|
<Demo />
|
||||||
|
</HorizontalScroller>
|
||||||
|
</Block>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet foregroundSnippet()}
|
||||||
|
<!-- Add custom foreground HTML or component -->
|
||||||
|
<div class="step-foreground-container"><p>Step 1</p></div>
|
||||||
|
<div class="step-foreground-container"><p>Step 2</p></div>
|
||||||
|
<div class="step-foreground-container"><p>Step 3</p></div>
|
||||||
|
<div class="step-foreground-container"><p>Step 4</p></div>
|
||||||
|
<div class="step-foreground-container"><p>Step 5</p></div>
|
||||||
|
{/snippet}
|
||||||
|
</ScrollerBase>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.step-foreground-container {
|
||||||
|
height: 100vh;
|
||||||
|
width: 50%;
|
||||||
|
background-color: rgba(0, 0, 0, 0);
|
||||||
|
padding: 1em;
|
||||||
|
margin: 0 auto 10px 0;
|
||||||
|
position: relative;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
|
||||||
|
p {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
40
src/components/HorizontalScroller/utils/index.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
/**
|
||||||
|
* Clamp a number `n` to the inclusive range [low, high].
|
||||||
|
*/
|
||||||
|
export function clamp(n: number, low: number, high: number): number {
|
||||||
|
// Ensure low <= high even if caller swaps them
|
||||||
|
const min = Math.min(low, high);
|
||||||
|
const max = Math.max(low, high);
|
||||||
|
return Math.max(min, Math.min(n, max));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linearly maps a value `n` from range [inStart, inEnd] to [outStart, outEnd].
|
||||||
|
*
|
||||||
|
* @param {number} n - The input value to map.
|
||||||
|
* @param {number} inStart - Input range start.
|
||||||
|
* @param {number} inEnd - Input range end.
|
||||||
|
* @param {number} outStart - Output range start.
|
||||||
|
* @param {number} outEnd - Output range end.
|
||||||
|
* @param {boolean} withinBounds - If true, clamp the mapped value to [outStart, outEnd].
|
||||||
|
* @returns {number} - Mapped (and optionally clamped) value.
|
||||||
|
*/
|
||||||
|
export function map(
|
||||||
|
n: number,
|
||||||
|
inStart: number,
|
||||||
|
inEnd: number,
|
||||||
|
outStart: number,
|
||||||
|
outEnd: number,
|
||||||
|
withinBounds: boolean = true
|
||||||
|
): number {
|
||||||
|
// Avoid division by zero: when input range is degenerate, return outStart
|
||||||
|
const inSpan = inEnd - inStart;
|
||||||
|
if (inSpan === 0) {
|
||||||
|
return withinBounds ? clamp(outStart, outStart, outEnd) : outStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = (n - inStart) / inSpan; // normalized 0..1 in input space (or beyond)
|
||||||
|
const out = t * (outEnd - outStart) + outStart;
|
||||||
|
|
||||||
|
return withinBounds ? clamp(out, outStart, outEnd) : out;
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ export { default as GraphicBlock } from './components/GraphicBlock/GraphicBlock.
|
||||||
export { default as Headline } from './components/Headline/Headline.svelte';
|
export { default as Headline } from './components/Headline/Headline.svelte';
|
||||||
export { default as Headpile } from './components/Headpile/Headpile.svelte';
|
export { default as Headpile } from './components/Headpile/Headpile.svelte';
|
||||||
export { default as HeroHeadline } from './components/HeroHeadline/HeroHeadline.svelte';
|
export { default as HeroHeadline } from './components/HeroHeadline/HeroHeadline.svelte';
|
||||||
|
export { default as HorizontalScroller } from './components/HorizontalScroller/HorizontalScroller.svelte';
|
||||||
export { default as EndNotes } from './components/EndNotes/EndNotes.svelte';
|
export { default as EndNotes } from './components/EndNotes/EndNotes.svelte';
|
||||||
export { default as InfoBox } from './components/InfoBox/InfoBox.svelte';
|
export { default as InfoBox } from './components/InfoBox/InfoBox.svelte';
|
||||||
export { default as InlineAd } from './components/AdSlot/InlineAd.svelte';
|
export { default as InlineAd } from './components/AdSlot/InlineAd.svelte';
|
||||||
|
|
|
||||||