This commit is contained in:
wires 2026-05-09 19:00:30 -04:00
parent 5dede09c35
commit ac1c2ea8d1
330 changed files with 31241 additions and 46 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

4
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

View file

@ -1,46 +1 @@
# Astro Starter Kit: Basics
```sh
npm create astro@latest -- --template basics
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
│ └── favicon.svg
├── src
│   ├── assets
│   │   └── astro.svg
│   ├── components
│   │   └── Welcome.astro
│   ├── layouts
│   │   └── Layout.astro
│   └── pages
│   └── index.astro
└── package.json
```
To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
doodly fuck

12
astro.config.mjs Normal file
View file

@ -0,0 +1,12 @@
// @ts-check
import { defineConfig } from 'astro/config';
import svelte from '@astrojs/svelte';
// https://astro.build/config
export default defineConfig({
integrations: [svelte()],
devToolbar: {
enabled: false
}
});

24
package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "Scratch",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/svelte": "^8.1.0",
"@rferl/veronica": "github:rferl/veronica",
"astro": "^6.3.1",
"svelte": "^5.55.5",
"typescript": "^5.9.3"
},
"devDependencies": {
"sass-embedded": "^1.99.0"
}
}

3702
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

3
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,3 @@
allowBuilds:
esbuild: true
sharp: true

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

9
public/favicon.svg Normal file
View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

BIN
src/assets/Photo-3468.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 MiB

BIN
src/assets/Photo-3471.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

BIN
src/assets/Photo-3515.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 MiB

BIN
src/assets/Photo-3585.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 MiB

View file

@ -0,0 +1,128 @@
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.
*/
export type Option = {
value: string;
text: string;
};
/**
* Used for any props that restrict width of a container to one of pre-fab widths.
*/
export type ContainerWidth =
| 'narrower'
| 'narrow'
| 'normal'
| 'wide'
| 'wider'
| 'widest'
| 'fluid';
/**
* Used to set headline class fluid size from text-2xl to text-6xl
*/
export type HeadlineSize = 'small' | 'normal' | 'big' | 'bigger' | 'biggest';
/**
* A step in the Scroller component.
*/
export interface ScrollerStep {
/**
* A background component
*/
background: Component | undefined;
/**
* Optional props for background component
*/
backgroundProps?: object;
/**
* A component or markdown-formatted string
*/
foreground: Component | string;
/**
* Optional props for foreground component
*/
foregroundProps?: object;
/**
* Optional alt text for the background, read aloud after the foreground text. You can add it to each step or just to the first step to describe the entire scroller graphic.
*/
altText?: string;
}
export type ForegroundPosition =
| 'middle'
| 'left'
| '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';
export type LottieForegroundPosition =
| 'top center'
| 'top left'
| 'top right'
| 'bottom center'
| 'bottom left'
| 'bottom right'
| 'center center'
| 'center left'
| 'center right';
// Complete ScrollerVideo instance interface
export interface ScrollerVideoInstance {
// Properties
container: HTMLElement | null;
scrollerVideoContainer: Element | string | undefined;
src: string;
transitionSpeed: number;
frameThreshold: number;
useWebCodecs: boolean;
objectFit: string;
sticky: boolean;
trackScroll: boolean;
onReady: () => void;
onChange: (percentage?: number) => void;
debug: boolean;
autoplay: boolean;
video: HTMLVideoElement | undefined;
videoPercentage: number;
isSafari: boolean;
currentTime: number;
targetTime: number;
canvas: HTMLCanvasElement | null;
context: CanvasRenderingContext2D | null;
frames: ImageBitmap[] | null;
frameRate: number;
targetScrollPosition: number | null;
currentFrame: number;
usingWebCodecs: boolean;
totalTime: number;
transitioningRaf: number | null;
componentState: ScrollerVideoState;
// Methods
updateScrollPercentage: ((jump: boolean) => void) | undefined;
resize: (() => void) | undefined;
setVideoPercentage(percentage: number, options?: TransitionOptions): void;
setCoverStyle(el: HTMLElement | HTMLCanvasElement | undefined): void;
decodeVideo(): Promise<void>;
paintCanvasFrame(frameNum: number): void;
transitionToTargetTime(options: TransitionOptions): void;
setTargetTimePercent(percentage: number, options?: TransitionOptions): void;
setScrollPercent(percentage: number): void;
destroy(): void;
autoplayScroll(): void;
updateDebugInfo(): void;
}

View file

@ -0,0 +1,117 @@
import { Meta, Canvas } from '@storybook/blocks';
import * as ArticleStories from './Article.stories.svelte';
<Meta of={ArticleStories} />
# Article
The `Article` component contains all the contents of our story.
> 📌 In most cases, **you don't need to mess with the `Article` component** because it's already set up in the graphics kit.
```svelte
<script>
import { Article } from '@reuters-graphics/graphics-components';
</script>
<Article>
<!-- The story content goes here! -->
</Article>
```
<Canvas of={ArticleStories.Demo} />
## Custom column widths
The `Article` component also establishes the widths of columns that contain individual sections of the story, such as text, photos, and charts. The default column widths follow a basic class scheme:
- `narrower` The narrowest...
- `narrow` A bit narrower than the default body text column
- `normal` **The default width of the body text column**
- `wide` A bit wider
- `wider` A bit wider than wide...
- `widest` Edge-to-edge, but _excluding_ the left and right padding on `Article`
- `fluid` Fully edge-to-edge
You can set custom column widths by passing an object to the `columnWidths` prop with pixel values for the `narrower`, `narrow`, `normal`, `wide` and `wider` classes. These can then be used by the `Block` component or other elements housed inside `<Article>`.
> **For most graphics kit pages, you shouldn't customise the column widths.** Other Reuters tools, like our AI templates, use our default column widths, so customising those widths here has downstream consequences for graphics made outside graphics kit. The main exception is SREP stories.
```svelte
<!-- Set custom column widths -->
<Article
columnWidths={{
narrower: 310,
narrow: 450,
normal: 550,
wide: 675,
wider: 1400,
}}
>
<!-- Custom column widths get passed down to the `Block` component -->
<Block width="narrower" />
<Block width="narrow" />
<Block width="normal" />
<Block width="wide" />
<Block width="wider" />
<Block width="widest" />
<Block width="fluid" />
</Article>
```
If you're not using our `Block` component, you can still inherit the column widths from `Article` and create your own custom containers by using [CSS variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) like this:
```svelte
<div class="my-special-container">
<!-- Story content -->
</div>
<style lang="scss">
div.my-special-container {
max-width: var(--wide-column-width);
}
</style>
```
... or you can make your column widths entirely configurable by adding classes and manually specifying widths:
```svelte
<script>
let { width = 'normal' } = $props();
</script>
<div class="my-special-container {width}">
<!-- Story content -->
</div>
<style lang="scss">
div.my-special-container {
max-width: var(--normal-column-width);
&.narrower {
max-width: var(--narrower-column-width);
}
&.narrow {
max-width: var(--narrow-column-width);
}
&.wide {
max-width: var(--wide-column-width);
}
&.wider {
max-width: var(--wider-column-width);
}
&.widest {
max-width: 100%;
}
&.fluid {
width: calc(100% + 30px);
margin-inline-start: -15px;
max-width: none;
}
}
</style>
```
Here's an example of how <span className='custom'>custom</span> `columnWidths` can be used to change column widths:
<Canvas of={ArticleStories.CustomColumns} />

View file

@ -0,0 +1,117 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Block from '../Block/Block.svelte';
import Article from './Article.svelte';
const { Story } = defineMeta({
title: 'Components/Page layout/Article',
component: Article,
});
</script>
<Story name="Demo">
<Article id="article-story-basic">
<div class="demo-container">
<div class="background-label">Article well</div>
<div class="padding-label"><span></span>15px padding</div>
</div>
</Article>
</Story>
<Story name="Custom columns" exportName="CustomColumns">
<h3>Default column widths</h3>
<Article id="article-column-widths-demo">
<div class="article-boundaries">
<Block id="section-demo" width="narrower">narrower</Block>
<Block id="section-demo" width="narrow">narrow</Block>
<Block id="section-demo">normal</Block>
<Block id="section-demo" width="wide">wide</Block>
<Block id="section-demo" width="wider">wider</Block>
<Block id="section-demo" width="widest">widest</Block>
<Block id="section-demo" width="fluid">fluid</Block>
</div>
</Article>
<h3>Custom column widths</h3>
<Article
id="article-column-widths-demo"
columnWidths={{
narrower: 250,
narrow: 400,
normal: 500,
wide: 675,
wider: 1400,
}}
>
<div class="article-boundaries custom">
<Block id="section-demo" width="narrower">narrower</Block>
<Block id="section-demo" width="narrow">narrow</Block>
<Block id="section-demo">normal</Block>
<Block id="section-demo" width="wide">wide</Block>
<Block id="section-demo" width="wider">wider</Block>
<Block id="section-demo" width="widest">widest</Block>
<Block id="section-demo" width="fluid">fluid</Block>
</div>
</Article>
</Story>
<style lang="scss">
h3 {
text-align: center;
}
:global(span.custom) {
color: rgb(211, 132, 123);
font-weight: 600;
}
:global(#article-story-basic, #article-column-widths-demo) {
width: calc(100% + 30px);
margin-inline-start: -15px;
}
:global(#article-column-widths-demo) {
background-color: #ddd;
position: relative;
margin-block-end: 10px;
}
:global(#article-column-widths-demo .article-boundaries) {
padding: 0;
width: 100%;
height: 100%;
background-color: #bbb;
}
:global(
#article-column-widths-demo .article-boundaries.custom div.article-block
) {
background: rgb(211, 132, 123);
}
:global(#article-column-widths-demo div.article-block) {
height: 300px;
background: #81a1c1;
margin-block-end: 2px;
height: 50px;
padding-inline-start: 3px;
color: white;
font-size: 1rem;
}
div.demo-container {
height: 300px;
background: #ccc;
position: relative;
.background-label {
font-size: 1.5rem;
position: absolute;
top: 40%;
left: 40%;
color: #666;
}
.padding-label {
font-size: 1rem;
position: absolute;
top: 0;
left: -17px;
span {
font-size: 18px;
}
}
}
</style>

View file

@ -0,0 +1,70 @@
<!-- @component `Article` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-page-layout-article--docs) -->
<script lang="ts">
interface ColumnWidths {
/** Narrower column width */
narrower: number;
/** Narrow column width */
narrow: number;
/** Normal column width */
normal: number;
/** Wide column width */
wide: number;
/** Wider column width */
wider: number;
}
import cssVariables from '../../actions/cssVariables/index';
interface Props {
/** Set to true for embeddables. */
embedded?: boolean;
/** Add an id to the article tag to target it with custom CSS. */
id?: string;
/** ARIA role of the article */
role?: string | null;
/** Set custom widths for the normal, wide and wider column dimensions */
columnWidths?: ColumnWidths;
children?: import('svelte').Snippet;
}
let {
embedded = false,
id = '',
role = null,
columnWidths = {
narrower: 330,
narrow: 510,
normal: 660,
wide: 930,
wider: 1200,
},
children,
}: Props = $props();
let columnWidthVars = $derived({
'narrower-column-width': columnWidths.narrower + 'px',
'narrow-column-width': columnWidths.narrow + 'px',
'normal-column-width': columnWidths.normal + 'px',
'wide-column-width': columnWidths.wide + 'px',
'wider-column-width': columnWidths.wider + 'px',
});
</script>
<main id="main-content">
<article {id} class:embedded {role} use:cssVariables={columnWidthVars}>
<!-- Article content -->
{@render children?.()}
</article>
</main>
<style lang="scss">
article {
width: 100%;
display: block;
margin: 0;
padding: 0 15px;
background-color: var(--theme-colour-background, transparent);
&.embedded {
overflow: auto;
}
}
</style>

View file

@ -0,0 +1,99 @@
import { Meta, Canvas } from '@storybook/blocks';
import * as BlockStories from './Block.stories.svelte';
<Meta of={BlockStories} />
# Block
The `Block` component is the basic building block of pages, a responsive container that wraps around each section of your piece.
Blocks are stacked vertically within the well created by the [Article](./?path=/docs/components-page-layout-article--docs) component. They can have different widths on larger screens depending on the `width` prop.
> 📌 Many of our other components already use the `Block` component internally. You'll usually only need to use it yourself if you're making something custom.
```svelte
<script>
import { Block } from '@reuters-graphics/graphics-components';
</script>
<Block>
<!-- Contents for this block goes here -->
</Block>
```
<Canvas of={BlockStories.Demo} />
## Custom layouts
Our article well is designed to provide a basic responsive layout for you, but it also lets you customise.
The radical but easiest way to do this is to create a `Block` with a `fluid` width -- which basically cancels out the article well dimensions -- and then code whatever you need from scratch or with another framework.
The demo below does exactly that to create an edge-to-edge grid with [flexbox](https://css-tricks.com/snippets/css/a-guide-to-flexbox/).
```svelte
<Block width="fluid">
<div class="my-radical-container">
<!-- Now, you have full control over layout! -->
</div>
</Block>
```
<Canvas of={BlockStories.CustomLayout} />
## Snap widths
Normally, `Block` containers resize fluidly below the original `width`. Sometimes, though, you may want the container to snap to the next breakpoint -- for example, if you have a static graphic that looks fine at the set block breakpoints, but isn't so great at widths inbetween.
You can use the `snap` prop to force the container to snap to each block width successively as the window sizes down.
```svelte
<Block width="wider" snap={true}>
<!-- Contents for this block -->
</Block>
```
<Canvas of={BlockStories.SnapWidthsBasic} />
If you want to skip certain block widths entirely, you can add one or more class of `skip-{block width class}` to the `Block`.
> **NOTE:** The snap width breakpoints only work on `Block` components with widths `wider` and below. `widest` and `fluid` are both **always** fluid, since they go edge-to-edge.
```svelte
<!-- Will skip wide and go straight to normal column width on resize. -->
<Block width="wider" snap={true} class="skip-wide">
<!-- Contents for this block -->
</Block>
```
This is probably easier to see in action than explain in words, so [resize the demo](./?path=/docs/components-page-layout-block--snap-skip-widths) to get a better picture of how it all works.
## Using with custom column widths
Snap width breakpoints are hard-coded to the default article well column widths, so if you set custom `columnWidths` on the [Article](./?path=/docs/components-page-layout-article--docs) component (**rare!**), you need to do a littie work to use this functionality.
Luckily, it's still pretty easy. Just add a `cls` or `id` to your `Block` so you can target it with some custom SCSS. Then define a few SCSS variables corresponding to your custom column widths, and use the `block-snap-widths` SCSS mixin to get the same functionality at your custom breakpoints.
```svelte
<Block width="wider" snap={true} class="custom-blocks">
<!-- Contents for this block -->
</Block>
<style lang="scss">
// Define custom column widths
$column-width-narrower: 310px;
$column-width-narrow: 450px;
$column-width-normal: 600px;
$column-width-wide: 860px;
$column-width-wider: 1400px;
@use '@reuters-graphics/graphics-components/scss/mixins' as mixins;
:global {
div.custom-blocks {
@include mixins.block-snap-widths; // Use the `block-snap-widths` mixin
}
}
</style>
```

View file

@ -0,0 +1,181 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Block from './Block.svelte';
const { Story } = defineMeta({
title: 'Components/Page layout/Block',
component: Block,
tags: ['autodocs'],
argTypes: {
width: {
control: 'select',
options: [
'narrower',
'narrow',
'normal',
'wide',
'wider',
'widest',
'fluid',
],
},
},
});
</script>
<script>
import Article from '../Article/Article.svelte';
</script>
<Story name="Demo">
<Article id="block-demo-article">
<div class="article-boundaries">
<div class="label">Article</div>
<Block>
<div class="label">Block</div>
</Block>
</div>
</Article>
</Story>
<Story name="Custom layout" exportName="CustomLayout">
<Block width="fluid">
<!-- Enter bootstrap grid! -->
<div id="block-flex-example">
<div class="row">
<div class="col">Column</div>
<div class="col-6">Column</div>
<div class="col">Column</div>
</div>
<div class="row">
<div class="col">Column</div>
<div class="col">Column</div>
</div>
</div>
</Block>
</Story>
<Story name="Snap widths" exportName="SnapWidthsBasic">
<Article id="block-demo-article">
<div class="article-boundaries">
<div class="label">Article</div>
<h4>snap widths</h4>
<Block snap={true}>
<div class="label">Block</div>
</Block>
</div>
</Article>
</Story>
<Story name="Snap and skip widths" exportName="SnapSkipWidths">
<Article id="block-demo-article">
<div class="article-boundaries">
<div class="label">Article</div>
<h4>Regular layout</h4>
<Block width="narrower" snap={true} class="block-snap-widths-demo">
narrower
</Block>
<Block width="narrow" snap={true} class="block-snap-widths-demo">
narrow
</Block>
<Block width="normal" snap={true} class="block-snap-widths-demo">
normal
</Block>
<Block width="wide" snap={true} class="block-snap-widths-demo">
wide
</Block>
<Block width="wider" snap={true} class="block-snap-widths-demo">
wider
</Block>
<h4>with snap and skip</h4>
<Block width="narrower" snap={true} class="block-snap-widths-demo even">
narrower
</Block>
<Block width="narrow" snap={true} class="block-snap-widths-demo even">
narrow
</Block>
<Block
width="normal"
snap={true}
class="block-snap-widths-demo even skip-narrow"
>
normal.skip-narrow
</Block>
<Block
width="wide"
snap={true}
class="block-snap-widths-demo even skip-normal skip-narrow"
>
wide.skip-normal.skip-narrow
</Block>
<Block
width="wider"
snap={true}
class="block-snap-widths-demo even skip-wide"
>
wider.skip-wide
</Block>
</div>
</Article>
</Story>
<style lang="scss">
h4 {
text-align: center;
}
:global(#block-demo-article) {
background-color: #ddd;
position: relative;
width: calc(100% + 30px);
margin-inline-start: -15px;
}
:global(#block-demo-article .article-boundaries) {
padding: 0 0 18px;
width: 100%;
height: 100%;
background-color: #bbb;
}
:global(#block-demo-article div.article-block) {
height: 100px;
background: #81a1c1;
}
:global(#block-demo-article div.article-block.block-snap-widths-demo) {
margin-block-end: 2px;
height: 40px;
font-size: 11px;
}
:global(#block-demo-article div.article-block.block-snap-widths-demo.even) {
background: rgb(211, 132, 123);
}
:global(
#block-demo-article .label,
#block-demo-article div.article-block.block-snap-widths-demo
) {
padding-inline-start: 3px;
color: white;
font-weight: 500;
}
div#block-flex-example {
padding: 25px 0;
div.row {
display: flex;
}
div.row > div {
background-color: rgb(211, 132, 123);
border: 1px solid white;
border-radius: 4px;
padding: 20px;
color: white;
text-align: center;
flex-grow: 1;
}
div.row:first-child {
div {
background: #81a1c1;
}
}
}
</style>

View file

@ -0,0 +1,82 @@
<!-- @component `Block` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-page-layout-block--docs) -->
<script lang="ts">
import type { Snippet } from 'svelte';
import type { ContainerWidth } from '../@types/global';
interface Props {
/** Content that goes inside `<Block>`*/
children: Snippet;
/** Width of the block within the article well. */
width?: ContainerWidth;
/** Add an id to the block tag to target it with custom CSS. */
id?: string;
/** Add custom classes to the block tag to target it with custom CSS. */
class?: string;
/** Snap block to column widths, rather than fluidly resizing them. */
snap?: boolean;
/** ARIA [role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles) for the block */
role?: string;
/** ARIA [label](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label) for the block */
ariaLabel?: string;
}
let {
children,
width = 'normal',
id = '',
class: cls = '',
snap = false,
role,
ariaLabel,
}: Props = $props();
</script>
<div
{id}
class="article-block fmx-auto {width} {cls}"
class:snap={snap && width !== 'fluid' && width !== 'widest'}
{role}
aria-label={ariaLabel}
>
{@render children()}
</div>
<style lang="scss">
@use '../../scss/mixins' as mixins;
.article-block {
max-width: var(--normal-column-width, 660px);
&.narrower {
max-width: var(--narrower-column-width, 330px);
}
&.narrow {
max-width: var(--narrow-column-width, 510px);
}
&.wide {
max-width: var(--wide-column-width, 930px);
}
&.wider {
max-width: var(--wider-column-width, 1200px);
}
&.widest {
max-width: 100%;
}
&.fluid {
width: calc(100% + 30px);
margin-inline-start: -15px;
max-width: none;
}
// Only setup for the default column widths, b/c we can't use
// CSS vars in media queries.
&.snap {
@include mixins.block-snap-widths;
}
}
</style>

View file

@ -0,0 +1,115 @@
import { Meta, Canvas } from '@storybook/blocks';
import * as BodyTextStories from './BodyText.stories.svelte';
<Meta of={BodyTextStories} />
# BodyText
The `BodyText` component creates the main text of your page. You can pass the `text` prop a [markdown-formatted](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) string, which will be parsed into paragraphs, headers, lists, links, blockquotes and other markdown-supported elements.
```svelte
<script>
import { BodyText } from '@reuters-graphics/graphics-components';
const markdownText = `Bacon ipsum **dolor amet** cow tongue tri-tip.
Biltong turducken ground round kevin [hamburger turkey](https://reuters.com) pig.
- Steak
- [Pork chop](https://www.google.com)
- Fillet
Venison shoulder *ham hock ham leberkas*. Flank beef ribs fatback, jerky meatball ham hock.`;
</script>
<BodyText text={markdownText} />
```
<Canvas of={BodyTextStories.Demo} />
## Using with ArchieML docs
With the graphics kit, you'll likely get your text value from an ArchieML doc...
```yaml
# ArchieML doc
[blocks]
type: text
text: Bacon ipsum ...
... etc.
:end
[]
```
... which you'll parse out of a ArchieML block object before passing to the `BodyText` component.
```svelte
<!-- App.svelte -->
<script>
import { BodyText } from '@reuters-graphics/graphics-components';
import content from '$locales/en/content.json';
</script>
{#each content.blocks as block}
{#if block.type === 'text'}
<BodyText text={block.text} />
{/if}
{/each}
```
<Canvas of={BodyTextStories.Demo} />
## Styling text
Styles are built in for many text elements created by `BodyText`, including headings, ordered and unordered lists, links, blockquotes and even drop caps (using a `"drop-cap"` classed span).
```svelte
<BodyText
text="<span class='drop-cap'>R</span>eprehenderit hamburger pork bresaola ..."
/>
```
<Canvas of={BodyTextStories.TypographySample} />
### Custom styles
To add your own styling, you can write styles in a global SCSS stylesheet:
```svelte
<BodyText
text="Venison shoulder <span class='highlight'>ham hock</span> ham leberkas."
/>
```
```scss
// global.scss
span.highlight {
background: palegoldenrod;
padding: 2px 4px;
}
```
<Canvas of={BodyTextStories.CustomStyles} />
If you want to make sure styles for one portion of text don't apply other parts of the page, add a `class` to BodyText to use as an additional selector.
```svelte highlight=2
<BodyText
class="my-special-text-block"
text="Venison shoulder <span class='highlight'>ham hock</span> ham leberkas."
/>
```
```scss
// global.scss
.my-special-text-block {
span.highlight {
background: palegoldenrod;
padding: 2px 4px;
}
}
```

View file

@ -0,0 +1,102 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import BodyText from './BodyText.svelte';
const { Story } = defineMeta({
title: 'Components/Text elements/BodyText',
component: BodyText,
});
</script>
<Story
name="Demo"
args={{
text: `Bacon ipsum **dolor amet** cow tongue tri-tip.
Biltong turducken ground round kevin [hamburger turkey](https://reuters.com) pig.
- Steak
- [Pork chop](https://www.google.com)
- Fillet
Venison shoulder *ham hock ham leberkas*. Flank beef ribs fatback, jerky meatball ham hock.`,
}}
/>
<Story
name="Typography sample"
exportName="TypographySample"
tags={['!autodocs', '!dev']}
args={{
class: 'body-text-typography-example-story',
text: `<span class='drop-cap'>R</span>eprehenderit hamburger pork bresaola, dolore chuck sirloin landjaeger ham hock [tempor meatball](https://baconipsum.com/) alcatra nostrud pork belly. Culpa pork belly doner ea jowl, elit deserunt leberkas cow shoulder ham hock dolore.
## Biltong turducken ground round kevin
Pig est irure buffalo ullamco. Sunt beef ribs tri-tip, chislic officia sint dolor. Spare ribs drumstick ground round, irure duis cillum id chicken est ipsum ut.
Qui cupidatat chislic buffalo consequat deserunt.
Andouille sint shankle quis velit nostrud chislic meatloaf culpa labore corned beef chuck spare ribs. Filet mignon eu shankle in, meatloaf ut dolor ham hock ut.
### Venison shoulder ham hock ham leberkas flank beef ribs fatback, jerky meatball ham hock
Biltong turducken ground round kevin [hamburger turkey](https://reuters.com) pig. Veniam laboris sunt chislic. Aute doner porchetta nulla, tongue venison ad ex in do.
- Steak
- Capicola
- [Pork chop](https://www.google.com)
- Fillet landjaeger commodo
Venison shoulder *ham hock ham leberkas*. Flank beef ribs fatback, jerky meatball ham hock.
Minim id buffalo dolore ad, **boudin chicken laboris** excepteur qui eiusmod.
#### Jerky prosciutto burgdoggen
Sirloin beef flank labore cillum venison pariatur cow nulla ut irure in consequat proident velit. Jerky meatball pig nulla irure laboris fatback et rump ut dolore.
Biltong enim consequat pork chop, flank ea.
> Officia ball tip sed tenderloin dolore. Est magna enim, turkey in turducken flank jowl ad lorem buffalo ground
> > Ronald McDonald
Flank bacon sint dolore porchetta strip steak. Tail capicola flank nostrud meatball consequat pastrami lorem cupidatat chuck drumstick ham hock bresaola sint.
##### Venison pork chop
Alcatra bacon mollit boudin. Capicola ut tongue biltong, cow cillum pariatur sausage.
1. Minim ribeye
2. Prosciutto laborum
3. Salami doner irure
Consectetur ribeye consequat pork capicola. T-bone ad laborum beef ribs picanha.
###### Alcatra bacon mollit boudin
Tempor tail doner chicken incididunt beef ribs. Ad ullamco in cupim venison. Leberkas rump ullamco adipisicing, laboris excepteur voluptate.
Ham hock id porchetta elit. Sint spare ribs aute buffalo.
<p class='body-correction'>Correction: Lorem ispsum dolor sit amet ameno dorime.</p>
`,
}}
/>
<Story
name="Custom styles"
exportName="CustomStyles"
tags={['!autodocs', '!dev']}
args={{
class: 'body-text-custom-styles-story',
text: `Venison shoulder <span class="highlight">ham hock</span> ham leberkas.`,
}}
/>
<style lang="scss">
:global(.body-text-custom-styles-story span.highlight) {
background: palegoldenrod;
padding: 2px 4px;
}
</style>

View file

@ -0,0 +1,19 @@
<!-- @component `BodyText` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-text-elements-bodytext--docs) -->
<script lang="ts">
import { Markdown } from '@reuters-graphics/svelte-markdown';
import Block from '../Block/Block.svelte';
interface Props {
/** A markdown text string. */
text: string;
/** Add a class to target with SCSS. */
class?: string;
/** Add an id to the block tag to target it with custom CSS. */
id?: string;
}
let { text, class: cls = '', id = '' }: Props = $props();
</script>
<Block {id} class="fmy-6 {cls}">
<Markdown source={text} />
</Block>

View file

@ -0,0 +1,50 @@
<script lang="ts">
interface Props {
data: {
index: number;
embed: string;
title: string;
}[];
selected: string;
}
let { data = [], selected = $bindable() }: Props = $props();
</script>
<form>
<label for="embed-options">Select an embed</label>
<select id="embed-options" bind:value={selected}>
{#each data as d (d.index)}
<option value={d.embed}>{d.title}</option>
{/each}
</select>
</form>
<style lang="scss">
@use '../../../scss/mixins' as mixins;
label {
margin-bottom: 0.25rem;
display: inline-flex;
font-size: 0.75rem;
color: #aaa;
@include mixins.font-sans;
}
select {
width: 100%;
padding: 0.5rem 0.75rem;
background: none;
font-size: 1rem;
border: 0;
border-radius: 0 !important;
background-color: #fff;
border: 1px solid #ddd;
@include mixins.font-sans;
}
select:focus {
outline: none;
border: 1px solid #ccc;
}
</style>

View file

@ -0,0 +1,19 @@
import { Meta } from '@storybook/blocks';
import * as FramerStories from './Framer.stories.svelte';
<Meta of={FramerStories} />
# FeaturePhoto
An embed tool for development in the graphics kit.
```svelte
<script>
import { Framer } from '@reuters-graphics/graphics-components';
const embeds = ['/embeds/my-chart/index.html'];
</script>
<Framer {embeds} />
```

View file

@ -0,0 +1,19 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Framer from './Framer.svelte';
const { Story } = defineMeta({
title: 'Components/Utilities/Framer',
component: Framer,
});
</script>
<Story
name="Demo"
args={{
embeds: [
'https://graphics.reuters.com/USA-CONGRESS/FUNDRAISING/zjvqkawjlvx/embeds/en/embed/?zzz',
'https://www.reuters.com/graphics/UKRAINE-CRISIS/MAP/klvymdzdrvg/embeds/en/map/',
],
}}
/>

View file

@ -0,0 +1,225 @@
<script lang="ts">
import Fa from 'svelte-fa';
import { faDesktop, faLink } from '@fortawesome/free-solid-svg-icons';
import pym from 'pym.js';
import urljoin from 'proper-url-join';
import Resizer from './Resizer/index.svelte';
import { width } from './stores';
import getUniqNames from './uniqNames';
import Typeahead from './Typeahead/index.svelte';
import Dropdown from './Dropdown/index.svelte';
import ReutersGraphicsLogo from '../ReutersGraphicsLogo/ReutersGraphicsLogo.svelte';
interface Props {
embeds: string[];
breakpoints?: number[];
minFrameWidth?: number;
maxFrameWidth?: number;
searchType?: 'dropdown' | 'typeahead';
}
let {
embeds = [],
breakpoints = [330, 510, 660, 930, 1200],
minFrameWidth = 320,
maxFrameWidth = 1200,
searchType = 'dropdown',
}: Props = $props();
const getDefaultEmbed = (embeds: Props['embeds']) => {
if (embeds.length === 0) return '';
if (typeof window === 'undefined') return embeds[0];
const lastActiveEmbed = window.localStorage.getItem('framer-active-embed');
if (!lastActiveEmbed) return embeds[0];
if (embeds.indexOf(lastActiveEmbed) > -1) return lastActiveEmbed;
return embeds[0];
};
let activeEmbed = $state(getDefaultEmbed(embeds));
let activeEmbedIndex = $derived(embeds.indexOf(activeEmbed));
let embedTitles = $derived.by(() => {
if (embeds.length === 0) return '';
return getUniqNames(embeds);
});
const reframe = (embed: string) => {
if (!embed) return;
// Bit of hack for handling adding query strings dynamically to embeds.
// cf. also the value prop on the Typeahead component...
const activeEmbed =
embeds.indexOf(embed) > -1 ?
embed
: embeds[activeEmbedIndex] || embeds[0];
new pym.Parent(
'frame-parent',
/^http/.test(activeEmbed) ? activeEmbed : (
urljoin(window.location.origin, activeEmbed, { trailingSlash: true })
)
);
};
$effect(() => {
reframe(activeEmbed);
});
</script>
<div class="container">
<header>
<ReutersGraphicsLogo width="120px" />
</header>
{#if embeds.length === 0}
<div class="no-embeds">
<p>No embeds to show.</p>
</div>
{:else}
{#if searchType === 'typeahead'}
<div id="typeahead-container">
<div class="embed-link">
<a
rel="external"
target="_blank"
href={activeEmbed}
title={activeEmbed}
>
Live link <Fa icon={faLink} />
</a>
</div>
<Typeahead
label="Select an embed"
value={embedTitles[embeds.indexOf(activeEmbed)] ||
embedTitles[activeEmbedIndex] ||
embedTitles[0]}
extract={(d) => embedTitles[d.index]}
data={embeds.map((embed, index) => ({ index, embed }))}
showDropdownOnFocus={true}
onselect={(detail) => {
if (typeof window !== 'undefined') {
window.localStorage.setItem(
'framer-active-embed',
detail.original.embed
);
}
activeEmbed = detail.original.embed;
// activeEmbedIndex = detail.original.index;
}}
/>
</div>
{:else}
<div id="dropdown-container">
<div>
<div class="embed-link">
<a
rel="external"
target="_blank"
href={activeEmbed}
title={activeEmbed}
>
Live link <Fa icon={faLink} />
</a>
</div>
</div>
<Dropdown
data={embeds.map((embed, index) => ({
index,
embed,
title: embedTitles[index],
}))}
bind:selected={activeEmbed}
/>
</div>
{/if}
<div id="preview-label" style="width:{$width}px;">
<p>Preview</p>
</div>
<div id="frame-parent" style="width:{$width}px;"></div>
{/if}
</div>
<div id="home-link">
<a rel="external" href="./../">
<Fa icon={faDesktop} />
</a>
</div>
{#if embeds.length > 0}
<Resizer {breakpoints} {minFrameWidth} {maxFrameWidth} />
{/if}
<style lang="scss">
@use '../../scss/mixins' as mixins;
header {
text-align: center;
margin: 20px 0;
}
.no-embeds {
background-color: #efefef;
border-radius: 4px;
padding: 5px 10px;
p {
text-align: center;
margin: 0;
@include mixins.font-note;
}
}
div#typeahead-container,
div#dropdown-container {
max-width: 660px;
margin: 0 auto 15px;
position: relative;
div.embed-link {
position: absolute;
top: 0;
right: 0;
display: inline-block;
z-index: 2;
a {
font-family: 'Knowledge', 'Source Sans Pro', Arial, sans-serif;
color: #bbb;
font-size: 12px;
text-decoration: none !important;
&:hover {
color: #666;
}
}
}
}
div#preview-label {
margin: 0 auto;
}
div#preview-label p {
font-family: 'Knowledge', 'Source Sans Pro', Arial, sans-serif;
color: #aaa;
font-size: 0.75rem;
margin: 0 0 0.25rem;
}
#frame-parent {
box-sizing: content-box;
border: 1px solid #ddd;
margin: 0 auto;
width: var(--width);
:global(iframe) {
display: block;
}
}
div#home-link {
position: fixed;
bottom: 5px;
left: 10px;
font-size: 18px;
}
div#home-link a {
color: #ccc;
}
div#home-link a:hover {
color: #666;
}
</style>

View file

@ -0,0 +1,243 @@
<script lang="ts">
import { faDesktop, faMobileAlt } from '@fortawesome/free-solid-svg-icons';
import Fa from 'svelte-fa';
import { width } from '../stores.js';
interface Props {
breakpoints?: number[];
maxFrameWidth?: number;
minFrameWidth?: number;
}
let {
breakpoints = [330, 510, 660, 930, 1200],
maxFrameWidth = 1200,
minFrameWidth = 320,
}: Props = $props();
let container: HTMLElement | undefined = $state();
const sliderWidth = 90;
let windowInnerWidth = $state(1200);
let minWidth = $derived(minFrameWidth);
let maxWidth = $derived(Math.min(windowInnerWidth - 70, maxFrameWidth));
let pixelRange = $derived(maxWidth - minWidth);
$effect(() => {
if ($width > maxWidth) width.set(maxWidth);
});
let offset = $derived(($width - minWidth) / pixelRange);
let sliding = $state(false);
let isFocused = $state(false);
const roundToNearestFive = (d: number) => Math.ceil(d / 5) * 5;
const getPx = () => Math.round(pixelRange * offset + minWidth);
let pixelLabel: null | number = $state(null);
const move = (e: MouseEvent) => {
if (!sliding || !container) return;
const { left } = container.getBoundingClientRect();
offset = Math.min(Math.max(0, e.pageX - left), sliderWidth) / sliderWidth;
pixelLabel = roundToNearestFive(getPx());
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isFocused) return;
const { keyCode } = e;
const pixelWidth = sliderWidth / pixelRange;
// right
if (keyCode === 39) {
offset = Math.min(1, offset + pixelWidth / sliderWidth);
// left
} else if (keyCode === 37) {
offset = Math.max(0, offset - pixelWidth / sliderWidth);
}
$width = getPx();
};
const start = (e: MouseEvent) => {
sliding = true;
move(e);
};
const end = () => {
sliding = false;
pixelLabel = null;
width.set(roundToNearestFive(getPx()));
};
const onFocus = () => {
isFocused = true;
};
const onBlur = () => {
isFocused = false;
};
const increment = () => {
const availableBreakpoints = breakpoints
.filter((b) => b <= maxWidth)
.filter((b) => b > $width);
if (availableBreakpoints.length === 0) {
$width = maxWidth;
} else {
$width = availableBreakpoints[0];
}
};
const decrement = () => {
const availableBreakpoints = breakpoints.filter((b) => b < $width);
if (availableBreakpoints.length === 0) {
$width = minWidth;
} else {
$width = availableBreakpoints.slice(-1)[0];
}
};
</script>
<svelte:window
onmousemove={move}
onmouseup={end}
onkeydown={handleKeyDown}
bind:innerWidth={windowInnerWidth}
/>
<div id="resizer">
<div class="slider">
<div class="label" style={`opacity: ${sliding || isFocused ? 1 : 0};`}>
{pixelLabel || $width}px
</div>
<button
class="icon left"
disabled={$width === minWidth}
onclick={decrement}
onfocus={onFocus}
onmouseover={onFocus}
onmouseleave={onBlur}
>
<Fa icon={faMobileAlt} fw />
</button>
<div class="slider-container" bind:this={container}>
<div class="track"></div>
<div
class="handle"
tabindex="0"
role="button"
style="left: calc({offset * 100}% - 5px);"
onmousedown={start}
onfocus={onFocus}
onblur={onBlur}
></div>
</div>
<button
class="icon right"
disabled={$width === maxWidth}
onclick={increment}
onfocus={onFocus}
onmouseover={onFocus}
onmouseleave={onBlur}
>
<Fa icon={faDesktop} fw />
</button>
</div>
</div>
<style lang="scss">
#resizer {
width: 250px;
position: fixed;
bottom: 0px;
right: 0px;
padding: 15px;
.slider {
width: 100%;
display: flex;
justify-content: flex-end;
align-items: center;
& > div,
button {
display: inline-block;
}
}
div.label {
font-family: monospace;
font-size: 13px;
line-height: 13px;
text-align: center;
transition: opacity 0.2s;
color: grey;
background-color: rgba(255, 255, 255, 0.8);
padding: 4px 8px;
border-radius: 4px;
margin-inline-end: 5px;
}
button.icon {
font-size: 14px;
line-height: 14px;
color: #bbb;
cursor: pointer;
background-color: transparent;
border: 0;
&:active,
&:focus {
outline: none;
}
&:hover {
color: #999;
}
&:active {
transform: translate(1px, 1px);
}
&[disabled] {
color: #ccc;
cursor: default;
&:hover {
color: #ccc;
}
&:active {
transform: translate(0px, 0px);
}
}
&.left {
text-align: right;
padding-inline-end: 3px;
}
&.right {
padding-inline-start: 6px;
text-align: left;
}
}
div.slider-container {
width: 90px;
height: 20px;
position: relative;
div.track {
height: 4px;
width: 100%;
position: absolute;
border-radius: 2px;
top: calc(50% - 2px);
background-color: lightgrey;
}
}
}
.handle {
z-index: 30;
width: 10px;
height: 20px;
cursor: ew-resize;
background: #bbb;
user-select: none;
position: absolute;
border-radius: 4px;
border: 1px solid grey;
top: calc(50% - 10px);
box-shadow:
0 10px 20px rgba(0, 0, 0, 0.19),
0 6px 6px rgba(0, 0, 0, 0.23);
&:active,
&:focus {
outline: none;
}
}
</style>

View file

@ -0,0 +1,101 @@
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
interface Props {
value: string;
autofocus?: boolean;
debounce?: number;
label?: string | Snippet;
hideLabel?: boolean;
id?: string;
ref: HTMLElement;
removeFormAriaAttributes?: boolean;
ontype?: (value: string) => void;
onclear?: () => void;
}
let {
value = $bindable(''),
autofocus = false,
debounce = 0,
label = 'Label',
hideLabel = false,
id = 'search' + Math.random().toString(36),
ref = $bindable(),
removeFormAriaAttributes = false,
ontype = (_value: string) => {},
onclear = () => {},
...restProps
}: Props & HTMLInputAttributes = $props();
import { onMount } from 'svelte';
let prevValue = value;
let timeout: ReturnType<typeof setTimeout>;
let calling = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function debounceFn(fn: () => any) {
if (calling) return;
calling = true;
timeout = setTimeout(() => {
fn();
calling = false;
}, debounce);
}
onMount(() => {
if (autofocus) window.requestAnimationFrame(() => ref?.focus());
return () => clearTimeout(timeout);
});
$effect(() => {
if (value.length > 0 && value !== prevValue) {
if (debounce > 0) {
debounceFn(() => ontype(value));
} else {
ontype(value);
}
}
if (value.length === 0 && prevValue.length > 0) onclear();
prevValue = value;
});
</script>
<form
data-svelte-search
role={removeFormAriaAttributes ? null : 'search'}
aria-labelledby={removeFormAriaAttributes ? null : id}
action=""
onsubmit={(e) => {
e.preventDefault();
}}
>
<label
id="{id}-label"
for={id}
style={hideLabel ?
'position: absolute;height: 1px;width: 1px;overflow: hidden;clip: rect(1px 1px 1px 1px);clip: rect(1px, 1px, 1px, 1px);white-space: nowrap;'
: undefined}
>
{#if typeof label === 'string'}
{label}
{:else}
{@render label()}
{/if}
</label>
<input
bind:this={ref}
bind:value
{id}
name="search"
type="search"
placeholder="Search..."
autocomplete="off"
spellcheck="false"
{...restProps}
/>
</form>

View file

@ -0,0 +1,108 @@
interface MatchOptions {
pre?: string;
post?: string;
caseSensitive?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
extract?: (arg: any) => string;
}
interface MatchResult {
rendered: string;
score: number;
}
interface FilterResult<T> {
string: string;
score: number;
index: number;
original: T;
}
const fuzzy = {
simpleFilter(pattern: string, array: string[]): string[] {
return array.filter((str) => this.test(pattern, str));
},
test(pattern: string, str: string): boolean {
return this.match(pattern, str) !== null;
},
match(
pattern: string,
str: string,
opts: MatchOptions = {}
): MatchResult | null {
let patternIdx = 0;
const result: string[] = [];
const len = str.length;
let totalScore = 0;
let currScore = 0;
const pre = opts.pre || '';
const post = opts.post || '';
const compareString = opts.caseSensitive ? str : str.toLowerCase();
pattern = opts.caseSensitive ? pattern : pattern.toLowerCase();
for (let idx = 0; idx < len; idx++) {
let ch = str[idx];
if (compareString[idx] === pattern[patternIdx]) {
ch = pre + ch + post;
patternIdx += 1;
currScore += 1 + currScore;
} else {
currScore = 0;
}
totalScore += currScore;
result[result.length] = ch;
}
if (patternIdx === pattern.length) {
totalScore = compareString === pattern ? Infinity : totalScore;
return { rendered: result.join(''), score: totalScore };
}
return null;
},
filter<T>(
pattern: string,
arr: T[],
opts: MatchOptions = {}
): FilterResult<T>[] {
if (!arr || arr.length === 0) {
return [];
}
if (typeof pattern !== 'string') {
return arr.map((element, index) => ({
string: element as unknown as string,
score: 0,
index,
original: element,
}));
}
return arr
.reduce<FilterResult<T>[]>((prev, element, idx) => {
let str = element as unknown as string;
if (opts.extract) {
str = opts.extract(element);
}
const rendered = this.match(pattern, str, opts);
if (rendered != null) {
prev.push({
string: rendered.rendered,
score: rendered.score,
index: idx,
original: element,
});
}
return prev;
}, [])
.sort((a, b) => {
const compare = b.score - a.score;
if (compare) return compare;
return a.index - b.index;
});
},
};
export default fuzzy;

View file

@ -0,0 +1,375 @@
<script lang="ts">
type TItem = {
index: number;
embed: string;
disabled?: boolean;
};
type SelectedItem = {
selectedIndex: number;
searched: string;
selected: string;
original: TItem;
originalIndex: number;
};
interface Props {
id?: string;
value: string;
label: string;
data: TItem[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
extract?: (item: TItem) => any;
disable?: (item: TItem) => boolean;
filter?: (item: TItem) => boolean;
onselect: (item: SelectedItem) => void;
autoselect?: boolean;
inputAfterSelect?: 'update' | 'clear' | 'keep';
focusAfterSelect?: boolean;
showDropdownOnFocus?: boolean;
limit?: number;
noResults?: Snippet;
}
let {
id = 'typeahead-' + Math.random().toString(36),
value = '',
label = '',
data = [],
extract = (item) => item,
disable = (_item) => false,
filter = (_item) => false,
autoselect = true,
// Set to `keep` to keep the search field unchanged after select, set to `clear` to auto-clear search field
inputAfterSelect = 'update',
/** Set to `true` to re-focus the input after selecting a result */
focusAfterSelect = false,
/** Set to `true` to only show results when the input is focused */
showDropdownOnFocus = false,
/** Specify the maximum number of results to return */
limit = Infinity,
noResults,
onselect,
...restProps
}: Props = $props();
import fuzzy from './fuzzy';
import Search from './Search.svelte';
import { tick, type Snippet } from 'svelte';
let comboboxRef: HTMLElement | null = $state(null);
let searchRef: HTMLElement | null = $state(null);
let hideDropdown = $state(true);
let selectedIndex = $state(-1);
let prevResults = $state('');
let isFocused = $state(false);
let options = $derived({ pre: '<mark>', post: '</mark>', extract });
let results = $derived.by(() => {
return value !== '' ?
fuzzy
.filter(value, data, options)
.filter(({ score }) => score > 0)
.slice(0, limit)
.filter((result) => !filter(result.original))
.map((result) => ({ ...result, disabled: disable(result.original) }))
: data.map((d, index) => ({
index,
string: extract(d),
original: d,
disabled: disable(d),
}));
});
let resultsId = $derived(
results.map((result) => extract(result.original)).join('')
);
let showResults: boolean = $state(
// svelte-ignore state_referenced_locally
!hideDropdown && results.length > 0 && isFocused
);
$effect(() => {
if (showDropdownOnFocus) {
showResults = showResults && isFocused;
}
});
$effect(() => {
if (prevResults !== resultsId && autoselect) {
selectedIndex = getNextNonDisabledIndex();
}
if (prevResults !== resultsId && !noResults) {
hideDropdown = results.length === 0;
}
prevResults = resultsId;
});
async function select() {
const result = results[selectedIndex];
if (result.original.disabled) return;
const selectedValue = extract(result.original);
const searchedValue = value;
if (inputAfterSelect === 'clear') value = '';
if (inputAfterSelect === 'update') value = selectedValue;
onselect({
selectedIndex,
searched: searchedValue,
selected: selectedValue,
original: result.original,
originalIndex: result.index,
});
await tick();
if (focusAfterSelect) searchRef?.focus();
close();
}
function getNextNonDisabledIndex() {
let index = 0;
let disabled = results[index]?.disabled ?? false;
while (disabled) {
if (index === results.length) {
index = 0;
} else {
index += 1;
}
disabled = results[index]?.disabled ?? false;
}
return index;
}
function change(direction: -1 | 1) {
let index =
direction === 1 && selectedIndex === results.length - 1 ?
0
: selectedIndex + direction;
if (index < 0) index = results.length - 1;
let disabled = results[index].disabled;
while (disabled) {
if (index === results.length) {
index = 0;
} else {
index += direction;
}
disabled = results[index].disabled;
}
selectedIndex = index;
}
const open = () => (hideDropdown = false);
const close = () => (hideDropdown = true);
</script>
<svelte:window
onclick={({ target }) => {
if (!hideDropdown && !comboboxRef?.contains(target as Node)) {
close();
}
}}
/>
<div
data-svelte-typeahead
bind:this={comboboxRef}
role="combobox"
aria-haspopup="listbox"
aria-owns="{id}-listbox"
class:dropdown={results.length > 0}
aria-controls="{id}-listbox"
aria-expanded={showResults ||
(isFocused && value.length > 0 && results.length === 0)}
id="{id}-typeahead"
>
<Search
bind:value
{id}
{label}
removeFormAriaAttributes={true}
bind:ref={searchRef!}
aria-autocomplete="list"
aria-controls="{id}-listbox"
aria-labelledby="{id}-label"
aria-activedescendant={(
selectedIndex >= 0 && !hideDropdown && results.length > 0
) ?
`${id}-result-${selectedIndex}`
: null}
onfocus={() => {
open();
if (showDropdownOnFocus) {
showResults = true;
isFocused = true;
}
}}
onclear={open}
onkeydown={(e: KeyboardEvent) => {
if (results.length === 0) return;
switch (e.key) {
case 'Enter':
select();
break;
case 'ArrowDown':
e.preventDefault();
change(1);
break;
case 'ArrowUp':
e.preventDefault();
change(-1);
break;
case 'Escape':
e.preventDefault();
value = '';
searchRef?.focus();
close();
break;
}
}}
{...restProps}
/>
<ul
class:svelte-typeahead-list={true}
role="listbox"
aria-labelledby="{id}-label"
id="{id}-listbox"
>
{#if showResults && !hideDropdown}
{#each results as result, index}
<li
role="option"
id="{id}-result-{index}"
class:selected={selectedIndex === index}
class:disabled={result.disabled}
aria-selected={selectedIndex === index}
onclick={() => {
if (result.disabled) return;
selectedIndex = index;
select();
}}
onkeyup={(e) => {
if (e.key !== 'Enter') return;
if (result.disabled) return;
selectedIndex = index;
select();
}}
onmouseenter={() => {
if (result.disabled) return;
selectedIndex = index;
}}
>
{@html result.string}
</li>
{/each}
{/if}
{#if value.length > 0 && results.length === 0}
<li class="no-results disabled">No embeds found...</li>
{/if}
</ul>
</div>
<style lang="scss">
@use '../../../scss/mixins' as mixins;
[data-svelte-typeahead] {
position: relative;
}
ul {
position: absolute;
top: 100%;
left: 0;
width: 100%;
padding: 0;
margin: 0;
list-style: none;
background-color: #fff;
}
[aria-expanded='true'] ul {
z-index: 1;
border: 1px solid #ddd;
max-height: 50vh;
overflow-y: scroll;
}
li,
li.no-results {
padding: 0.25rem 1rem;
@include mixins.font-sans;
color: #333;
}
li.no-results {
color: #333;
font-size: 0.85rem;
font-style: italic;
}
li {
cursor: pointer;
:global(mark) {
padding: 0;
background-color: #ffff9a;
}
}
li:not(:last-of-type) {
border-bottom: 1px solid #e0e0e0;
}
li:hover {
background-color: #efefef;
}
.selected {
background-color: #efefef;
}
.selected:hover {
background-color: #e5e5e5;
}
.disabled {
opacity: 0.4;
cursor: not-allowed;
}
:global([data-svelte-search] label) {
margin-block-end: 0.25rem;
display: inline-flex;
font-size: 0.75rem;
color: #aaa;
@include mixins.font-sans;
}
:global([data-svelte-search] input) {
width: 100%;
padding: 0.5rem 0.75rem;
background: none;
font-size: 1rem;
border: 0;
border-radius: 0 !important;
background-color: #fff;
border: 1px solid #ddd;
@include mixins.font-sans;
}
:global([data-svelte-search] input:focus) {
outline: none;
border: 1px solid #ccc;
}
</style>

View file

@ -0,0 +1,3 @@
import { writable } from 'svelte/store';
export const width = writable(660);

View file

@ -0,0 +1,54 @@
export default (embeds: string[]) => {
const nakedEmbeds = embeds
.map((e) => e.replace(/\?.+$/, ''))
.map((e) => e.replace(/index\.html$/, ''))
.map((e) => e.replace(/^http[s]*:\/\/[\w.]+\.com/, ''));
// If just one, get the last path part
if (nakedEmbeds.length === 1) {
return [
nakedEmbeds[0]
.split('/')
.filter((d) => d)
.slice(-1)[0],
];
}
// If many, test each path part for unique-ness
const test = nakedEmbeds[0];
let replacementForward = 0;
for (const i in test.split('/')) {
const pathPart = test.split('/')[i];
const notUniq = nakedEmbeds.every((e) => e.split('/')[i] === pathPart);
if (notUniq) {
replacementForward += 1;
} else {
break;
}
}
if (replacementForward === test.split('/').length) return nakedEmbeds;
let replacementBackward = 0;
for (const i in test.split('/').reverse()) {
const pathPart = test.split('/').reverse()[i];
const notUniq = nakedEmbeds.every(
(e) => e.split('/').reverse()[i] === pathPart
);
if (notUniq) {
replacementBackward += 1;
} else {
break;
}
}
return nakedEmbeds.map((e) => {
if (replacementBackward > 0) {
return e
.split('/')
.slice(replacementForward, replacementBackward * -1)
.join('/');
}
return e.split('/').slice(replacementForward).join('/');
});
};

View file

@ -0,0 +1,27 @@
import { Meta, Canvas } from '@storybook/blocks';
import * as GeocoderStories from './Geocoder.stories.svelte';
<Meta of={GeocoderStories} />
# Geocoder
The `Geocoder` component provides an autocomplete location search powered by the [Mapbox Geocoding v6 API](https://docs.mapbox.com/api/search/geocoding-v6/). It returns coordinates and a place name via the `onselect` callback.
```svelte
<script>
import { Geocoder } from '@reuters-graphics/graphics-components';
const MAPBOX_TOKEN = 'your_mapbox_token';
function handleSelect(location) {
console.log(location.name, location.lng, location.lat);
}
</script>
<Geocoder accessToken={MAPBOX_TOKEN} onselect={handleSelect} />
```
The `accessToken` prop is required. Look up the Reuters Mapbox token in the team's 1Password vault. All [Mapbox forward geocoding options](https://docs.mapbox.com/api/search/geocoding-v6/#forward-geocoding) are available as props, including `country`, `language`, `types`, `bbox`, `proximity`, `limit`, and `worldview`.
<Canvas of={GeocoderStories.Demo} />

View file

@ -0,0 +1,23 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Geocoder from './Geocoder.svelte';
const { Story } = defineMeta({
title: 'Components/Controls/Geocoder',
component: Geocoder,
});
</script>
<script lang="ts">
const accessToken = import.meta.env.VITE_MAPBOX_ACCESS_TOKEN ?? '';
function handleSelect(location: { lng: number; lat: number; name: string }) {
console.log('Selected:', location);
}
</script>
<Story name="Demo">
<div style="min-height: 330px; padding-top: 1rem;">
<Geocoder {accessToken} onselect={handleSelect} />
</div>
</Story>

View file

@ -0,0 +1,303 @@
<!-- @component `Geocoder` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-controls-geocoder--docs) -->
<script lang="ts">
import { onDestroy } from 'svelte';
import { geocode, type GeocodeFeature, type GeocodeOptions } from './geocode';
import MagnifyingGlass from '../SearchInput/components/MagnifyingGlass.svelte';
import X from '../SearchInput/components/X.svelte';
interface Props extends Omit<GeocodeOptions, 'accessToken'> {
/** Mapbox public access token. */
accessToken: string;
/** Placeholder text shown in the search input. */
searchPlaceholder?: string;
/** Callback fired when a location is selected from the results. */
onselect?: (location: { lng: number; lat: number; name: string }) => void;
}
let {
accessToken,
searchPlaceholder = 'Search for a location',
onselect,
autocomplete = true,
bbox,
country,
language = ['en'],
limit = 5,
proximity,
types,
worldview,
permanent,
entrances,
}: Props = $props();
let query = $state('');
let suggestions: GeocodeFeature[] = $state([]);
let selectedIndex = $state(-1);
let focused = $state(false);
let showDropdown = $derived(suggestions.length > 0 && focused);
const instanceId = Math.random().toString(36).slice(2, 9);
const listboxId = `geocoder-listbox-${instanceId}`;
let inputEl: HTMLInputElement;
let debounceTimer: ReturnType<typeof setTimeout>;
let abortController: AbortController | null = null;
function handleInput() {
selectedIndex = -1;
clearTimeout(debounceTimer);
abortController?.abort();
if (query.length < 2) {
suggestions = [];
return;
}
debounceTimer = setTimeout(async () => {
abortController = new AbortController();
try {
suggestions = await geocode(
query,
{
accessToken,
autocomplete,
bbox,
country,
language,
limit,
proximity,
types,
worldview,
permanent,
entrances,
},
abortController.signal
);
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
console.error('Geocoder error:', e);
}
}, 300);
}
onDestroy(() => {
clearTimeout(debounceTimer);
abortController?.abort();
});
let active = $derived(query !== '');
let statusMessage = $derived(
showDropdown ?
`${suggestions.length} result${suggestions.length === 1 ? '' : 's'} available`
: ''
);
function selectSuggestion(feature: GeocodeFeature) {
const { longitude: lng, latitude: lat } = feature.properties.coordinates;
query = feature.properties.name;
suggestions = [];
onselect?.({ lng, lat, name: feature.properties.name });
}
function clear() {
query = '';
suggestions = [];
inputEl?.focus();
}
function handleKeydown(e: KeyboardEvent) {
if (!showDropdown) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = (selectedIndex + 1) % suggestions.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex =
selectedIndex <= 0 ? suggestions.length - 1 : selectedIndex - 1;
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0) selectSuggestion(suggestions[selectedIndex]);
} else if (e.key === 'Escape') {
e.preventDefault();
suggestions = [];
selectedIndex = -1;
}
}
</script>
<div class="geocoder-input-wrapper">
<div class="geocoder-icon">
<MagnifyingGlass />
</div>
<input
bind:this={inputEl}
bind:value={query}
oninput={handleInput}
onkeydown={handleKeydown}
onfocus={() => (focused = true)}
onblur={() => (focused = false)}
type="text"
autocapitalize="words"
autocomplete="off"
enterkeyhint="search"
spellcheck="false"
role="combobox"
aria-expanded={showDropdown}
aria-label={searchPlaceholder}
aria-controls={showDropdown ? listboxId : undefined}
aria-activedescendant={selectedIndex >= 0 ?
`${listboxId}-option-${selectedIndex}`
: undefined}
aria-autocomplete="list"
placeholder={searchPlaceholder}
class="geocoder-input"
/>
{#if active}
<button
class="geocoder-clear"
type="button"
aria-label="Clear search"
onmousedown={(e) => {
e.preventDefault();
clear();
}}
>
<X />
</button>
{/if}
<div class="sr-only" role="status" aria-live="polite" aria-atomic="true">
{statusMessage}
</div>
{#if showDropdown}
<ul class="geocoder-results" role="listbox" id={listboxId}>
{#each suggestions as suggestion, i}
<li
id="{listboxId}-option-{i}"
role="option"
aria-selected={i === selectedIndex}
class:active={i === selectedIndex}
onmousedown={(e) => {
e.preventDefault();
selectSuggestion(suggestion);
}}
onmouseenter={() => (selectedIndex = i)}
>
{suggestion.properties.full_address || suggestion.properties.name}
</li>
{/each}
</ul>
{/if}
</div>
<style lang="scss">
@use '../../scss/mixins' as mixins;
.geocoder-input-wrapper {
position: relative;
width: 100%;
}
.geocoder-icon {
position: absolute;
top: 50%;
left: 12px;
transform: translateY(-50%);
width: 22px;
height: 22px;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
.geocoder-clear {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
width: 22px;
height: 22px;
padding: 0;
border: none;
background: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.geocoder-input {
font-family: var(--theme-font-family-sans-serif);
font-size: var(--theme-font-size-sm);
width: 100%;
height: 46px;
line-height: 22px;
padding: 12px 36px 12px 42px;
border: 1px solid mixins.$theme-colour-brand-rules;
border-radius: 0.25rem;
background: mixins.$theme-colour-background;
box-sizing: border-box;
&::placeholder {
color: mixins.$theme-colour-text-secondary;
}
}
.geocoder-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin: 4px 0 0;
padding: 0;
list-style: none;
background: mixins.$theme-colour-background;
border-radius: 0.25rem;
box-shadow: 0 2px 6px mixins.$theme-colour-brand-shadow;
z-index: 10000;
overflow: hidden;
}
.geocoder-results li {
padding: 10px 14px;
cursor: pointer;
font-family: var(--theme-font-family-sans-serif);
font-size: var(--theme-font-size-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.active {
background-color: var(--tr-hover-background-grey);
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 767px) {
.geocoder-icon {
left: 10px;
width: 20px;
height: 20px;
}
.geocoder-input {
height: 42px;
font-size: var(--theme-font-size-xs);
padding: 10px 12px 10px 38px;
border-radius: 0.25rem;
}
}
</style>

View file

@ -0,0 +1,94 @@
export type GeocodeFeatureType =
| 'country'
| 'region'
| 'postcode'
| 'district'
| 'place'
| 'locality'
| 'neighborhood'
| 'street'
| 'address';
export interface GeocodeOptions {
/** Mapbox public access token. */
accessToken: string;
/** Return partial prefix matches (true) or exact matches only (false). Defaults to true. */
autocomplete?: boolean;
/** Limit results to a bounding box: [minLon, minLat, maxLon, maxLat]. Cannot cross the 180th meridian. */
bbox?: [number, number, number, number];
/** Filter results to one or more countries using ISO 3166-1 alpha-2 codes. */
country?: string[];
/** IETF language tags for the response. Also influences result scoring. Max 20. */
language?: string[];
/** Maximum number of results to return (110). Defaults to 5. */
limit?: number;
/** Bias results toward a location: [lon, lat] coordinates or 'ip' to use the request IP. */
proximity?: [number, number] | 'ip';
/** Filter results by feature type. */
types?: GeocodeFeatureType[];
/** Geopolitical worldview for boundary representation (e.g. 'us', 'cn', 'in'). Defaults to 'us'. */
worldview?: string;
/** Set to true if results will be stored/cached permanently. Defaults to false. */
permanent?: boolean;
/** Return building entrance data when available (public preview). Defaults to false. */
entrances?: boolean;
}
export interface GeocodeFeature {
type: 'Feature';
properties: {
mapbox_id: string;
feature_type: GeocodeFeatureType;
name: string;
name_preferred?: string;
place_formatted?: string;
full_address?: string;
coordinates: { longitude: number; latitude: number };
context: Record<
string,
{ mapbox_id: string; name: string; [key: string]: unknown }
>;
};
geometry: {
type: 'Point';
coordinates: [number, number];
};
}
const BASE_URL = 'https://api.mapbox.com/search/geocode/v6/forward';
export async function geocode(
query: string,
options: GeocodeOptions,
signal?: AbortSignal
): Promise<GeocodeFeature[]> {
const params = new URLSearchParams({
q: query,
access_token: options.accessToken,
});
if (options.autocomplete !== undefined)
params.set('autocomplete', String(options.autocomplete));
if (options.bbox) params.set('bbox', options.bbox.join(','));
if (options.country) params.set('country', options.country.join(','));
if (options.language) params.set('language', options.language.join(','));
if (options.limit !== undefined) params.set('limit', String(options.limit));
if (options.proximity)
params.set(
'proximity',
Array.isArray(options.proximity) ?
options.proximity.join(',')
: options.proximity
);
if (options.types) params.set('types', options.types.join(','));
if (options.worldview) params.set('worldview', options.worldview);
if (options.permanent !== undefined)
params.set('permanent', String(options.permanent));
if (options.entrances !== undefined)
params.set('entrances', String(options.entrances));
const res = await fetch(`${BASE_URL}?${params}`, { signal });
if (!res.ok) throw new Error(`Geocode request failed: ${res.status}`);
const data = await res.json();
return data.features ?? [];
}

View file

@ -0,0 +1,215 @@
import { Meta, Canvas } from '@storybook/blocks';
import * as GraphicBlockStories from './GraphicBlock.stories.svelte';
<Meta of={GraphicBlockStories} />
# GraphicBlock
The `GraphicBlock` component is a special derivative of the [Block](?path=/docs/components-page-layout-block--docs) component that wraps around your graphic. It also adds a title, description, notes and other text elements.
Many other Reuters Graphics components use `GraphicBlock` to wrap graphics with styled text.
```svelte
<script>
import { GraphicBlock } from '@reuters-graphics/graphics-components';
</script>
<GraphicBlock
title="Title for my chart"
description="Some description for your chart."
notes={`Note: Data current as of Aug. 2, 2022.\n\nSource: [Google research](https://google.com)`}
>
<!-- Your chart goes here -->
<div id="my-chart" />
</GraphicBlock>
```
<Canvas of={GraphicBlockStories.Demo} />
## Using with ai2svelte and ArchieML docs
The `GraphicBlock` component is built to handle [ai2svelte](https://github.com/reuters-graphics/ai2svelte) graphics in graphics kit.
You'll likely get your text value from an ArchieML doc...
```yaml
# ArchieML doc
[blocks]
type: ai-graphic
width: normal
chart: AiMap # IMPORTANT: This must match the name of the ai2svelte chart you import in App.svelte
title: Earthquake in Haiti
description: The 7.2-magnitude earthquake struck at 8:29 a.m. EST, Aug. 14, 2021.
notes: \Note: A shakemap represents the ground shaking produced by an earthquake.
\Source: USGIS
:end
altText: A map that shows the shake intensity of the earthquake, which was worst in central Haiti.
:end
[]
```
... which you'll parse out of a ArchieML block object before passing to the `GraphicBlock` component.
To pass your ai2svelte graphic into `GraphicBlock` component, import your ai2svelte graphic at the top of `App.svelte` and add it to the `aiCharts` object.
> **Important❗:** Make sure that the value for `chart` in the ArchieML doc matches the name of the ai2svelte file imported in `App.svelte`.
```svelte
<!-- App.svelte -->
<script>
// IMPORTANT: The name of your ai2svelte chart must match `chart` in your ArchieML doc
import AiMap from './ai2svelte/my-map.svelte';
// Error handler for missing ai2svelte charts
import LogBlock from './components/dev/LogBlock.svelte';
// If using with the graphics kit
import { assets } from '$app/paths';
// A built-in helper function in Graphis Kit for validating container width
import { containerWidth } from '$utils/propValidators';
// Add your imported ai2svelte charts to this object
const aiCharts = {
AiMap,
// Other ai2svelte graphics...
};
</script>
<!-- Loop through ArchieML blocks -->
{#each content.blocks as block}
{#if block.type === 'ai-graphic'}
{#if !aiCharts[block.chart]}
<!-- Error message for when the ai2svelte chart is missing -->
<LogBlock message={`Unable to find "${block.chart}" in aiCharts`} />
{:else}
<!-- Get the ai2svelte graphic specified by `chart` in ArchieML -->
{@const AiChart = aiCharts[block.chart]}
<GraphicBlock
id={block.chart}
width={containerWidth(block.width)}
title={block.title}
description={block.description}
notes={block.notes}
ariaDescription={block.altText}
>
<!-- In graphics kit, pass the `assetsPath` prop -->
<AiChart assetsPath={assets || '/'} />
</GraphicBlock>
{/if}
{/if}
{/each}
```
<Canvas of={GraphicBlockStories.Ai2SvelteAndArchieML} />
## Custom text
You can override the default styles for title and notes by making your own custom elements and passing them as `title` and `notes` [snippets](https://svelte.dev/docs/svelte/snippet) instead of as strings:
```svelte
<GraphicBlock>
<!-- Custom title snippet -->
{#snippet title()}
<h5>My smaller title</h5>
{/snippet}
<!-- Your graphic -->
<div id="my-chart"></div>
<!-- Custom notes snippet -->
{#snippet notes()}
<aside>
<p><strong>Note:</strong> Data current as of Aug. 2, 2022.</p>
</aside>
{/snippet}
</GraphicBlock>
```
<Canvas of={GraphicBlockStories.CustomText} />
## ARIA descriptions
If the text in your chart isn't easily read by screen readers — for example, a map with annotations that wouldn't make sense without the visual — add an `ariaDescription` that describes the chart.
The `ariaDescription` string will be processed as markdown, so you can add multiple paragraphs, links, headers, etc. in markdown.
> **Note:** When you set an `ariaDescription`, your graphic will be automatically wrapped in a div with [aria-hidden="true"](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-hidden), which tells screen readers to read the hidden ARIA description and skip the text in the graphic.
```svelte
<GraphicBlock
title="Earthquake in Haiti"
description="The 7.2-magnitude earthquake struck at 8:29 a.m. EST, Aug. 14, 2021."
notes="Note: A shakemap represents the ground shaking produced by an earthquake."
ariaDescription="A map showing the shake intensity produced by the earthquake."
>
<!-- In graphics kit, pass the `assetsPath` prop -->
<AiChart assetsPath={assets || '/'} />
</GraphicBlock>
```
<Canvas of={GraphicBlockStories.AriaDescription} />
## Custom ARIA descriptions
Sometimes, instead of a simple sentence, we want to provide a data table or something more complex as an ARIA description. To do this, pass the custom elements as an `ariaDescription` [snippet](https://svelte.dev/docs/svelte/snippet) instead of as a string, as in the [example above](?path=/docs/components-graphics-graphicblock--docs#aria-descriptions).
[Read this](https://accessibility.psu.edu/images/charts/) for more information on using screen reader data tables for charts.
> **Note:** The `customAria` snippet will override the `ariaDescription` and will also hide the text in your graphic from screen readers.
```svelte
<GraphicBlock
title="Earthquake in Haiti"
description="The 7.2-magnitude earthquake struck at 8:29 a.m. EST, Aug. 14, 2021."
notes="Note: A shakemap represents the ground shaking produced by an earthquake."
>
<!-- In graphics kit, pass the `assetsPath` prop -->
<AiChart assetsPath={assets || '/'} />
<!-- Custom ARIA description snippet -->
{#snippet ariaDescription()}
<p>
A shakemap shows the intensity of the 7.2-magnitude earthquake that struck
Haiti at 8:29 a.m. EST, Aug. 14, 2021.
</p>
<table>
<tbody>
<tr>
<th>City</th>
<th>Felt shake strength</th>
</tr>
<tr>
<td>Les Cayes</td>
<td>Very strong</td>
</tr>
<tr>
<td>Jeremie</td>
<td>Strong</td>
</tr>
</tbody>
</table>
{/snippet}
</GraphicBlock>
<!-- Optionally, style the visually hidden table nicely for sighted readers who use screen readers -->
<style lang="scss">
table {
width: 100%;
border-collapse: collapse;
th,
td {
border: 1px solid #ddd;
padding: 8px;
}
th {
background-color: #f2f2f2;
}
}
</style>
```
<Canvas of={GraphicBlockStories.CustomAriaDescription} />

View file

@ -0,0 +1,134 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import GraphicBlock from './GraphicBlock.svelte';
const { Story } = defineMeta({
title: 'Components/Graphics/GraphicBlock',
component: GraphicBlock,
argTypes: {
width: {
control: 'select',
options: ['normal', 'wide', 'wider', 'widest', 'fluid'],
},
textWidth: {
control: 'select',
options: ['normal', 'wide', 'wider', 'widest', 'fluid'],
},
},
});
</script>
<script>
import AiMap from './demo/ai2svelte/ai-chart.svelte';
import PlaceholderImg from './demo/placeholder.png';
</script>
<Story name="Demo">
<GraphicBlock
title="Title for my chart"
description="Some description for your chart."
notes={`Note: Data current as of Aug. 2, 2022.\n\nSource: [Google research](https://google.com)`}
>
<div id="my-chart">
<img src={PlaceholderImg} alt="placeholder" />
</div>
</GraphicBlock>
</Story>
<Story name="Ai2svelte and ArchieML" exportName="Ai2SvelteAndArchieML">
<GraphicBlock
title="Earthquake in Haiti"
description="The 7.2-magnitude earthquake struck at 8:29 a.m. EST, Aug. 14, 2021."
notes="Note: A shakemap represents the ground shaking produced by an earthquake."
>
<AiMap />
</GraphicBlock>
</Story>
<Story name="Custom text" exportName="CustomText">
<GraphicBlock>
<div class="demo-graphic">
<img src={PlaceholderImg} alt="placeholder" />
</div>
{#snippet title()}
<h5>My smaller title</h5>
{/snippet}
{#snippet notes()}
<aside>
<p><strong>Note:</strong> Data current as of Aug. 2, 2022.</p>
</aside>
{/snippet}
</GraphicBlock>
</Story>
<Story name="AREA description" exportName="AriaDescription">
<GraphicBlock
title="Earthquake in Haiti"
description="The 7.2-magnitude earthquake struck at 8:29 a.m. EST, Aug. 14, 2021."
notes="Note: A shakemap represents the ground shaking produced by an earthquake."
ariaDescription="A map showing the shake intensity produced by the earthquake."
>
<AiMap />
</GraphicBlock>
</Story>
<Story name="Custom AREA description" exportName="CustomAriaDescription">
<GraphicBlock
title="Earthquake in Haiti"
description="The 7.2-magnitude earthquake struck at 8:29 a.m. EST, Aug. 14, 2021."
notes="Note: A shakemap represents the ground shaking produced by an earthquake."
>
<AiMap />
{#snippet ariaDescription()}
<p>
A shakemap shows the intensity of the 7.2-magnitude earthquake that
struck Haiti at 8:29 a.m. EST, Aug. 14, 2021.
</p>
<table>
<tbody>
<tr>
<th>City</th>
<th>Felt shake strength</th>
</tr>
<tr>
<td>Les Cayes</td>
<td>Very strong</td>
</tr>
<tr>
<td>Jeremie</td>
<td>Strong</td>
</tr>
</tbody>
</table>
{/snippet}
</GraphicBlock>
</Story>
<style lang="scss">
div.demo-graphic {
height: 400px;
background-color: #ddd;
display: flex;
align-items: center;
justify-content: center;
img {
opacity: 0.4;
}
}
// Style the table nicely
table {
width: 100%;
border-collapse: collapse;
th,
td {
border: 1px solid #ddd;
padding: 8px;
}
th {
background-color: #f2f2f2;
}
}
</style>

View file

@ -0,0 +1,148 @@
<!-- @component `GraphicBlock` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-graphics-graphicblock--docs) -->
<script lang="ts">
// Types
import type { ContainerWidth } from '../@types/global';
import type { Snippet } from 'svelte';
// Components
import AriaHidden from './components/AriaHidden.svelte';
import TextBlock from './components/TextBlock.svelte';
import Block from '../Block/Block.svelte';
import PaddingReset from '../PaddingReset/PaddingReset.svelte';
import { Markdown } from '@reuters-graphics/svelte-markdown';
interface Props {
/** Content to place inside `GraphicBlock` */
children: Snippet;
/**
* Add an id to the block tag to target it with custom CSS.
*/
id?: string;
/**
* Add classes to the block tag to target it with custom CSS.
*/
class?: string;
/** Snap block to column widths, rather than fluidly resizing them. */
snap?: boolean;
/**
* ARIA [role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles) for the block
*/
role?: string;
/**
* Notes to the graphic, passed in as a markdown string OR as a custom snippet.
*/
notes?: string | Snippet;
/**
* Width of the component within the text well.
*/
width?: ContainerWidth;
/**
* Set a different width for the text within the text well, for example, "normal" to keep the title, description and notes inline with the rest of the text well. Can't ever be wider than `width`.
*/
textWidth?: ContainerWidth;
/**
* Title of the graphic as a string or a custom snippet.
*/
title?: string | Snippet;
/**
* Description of the graphic, passed in as a markdown string.
*/
description?: string;
/**
* ARIA [label](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label) for the block
*/
ariaLabel?: string;
/**
* ARIA description, passed in as a markdown string OR as a custom snippet.
*/
ariaDescription?: string | Snippet;
}
let {
children,
id = '',
class: cls = '',
snap = false,
role,
notes,
width = 'normal',
textWidth = 'normal',
title,
description,
ariaLabel = 'chart',
ariaDescription,
}: Props = $props();
</script>
<div class="container">
<Block {id} {snap} {role} {width} {ariaLabel} class="graphic fmy-6 {cls}">
<!-- Check if `title` is a snippet -->
{#if typeof title === 'string'}
<PaddingReset containerIsFluid={width === 'fluid'}>
<TextBlock width={textWidth}>
<h3>{title}</h3>
{#if description}
<Markdown source={description} />
{/if}
</TextBlock>
</PaddingReset>
{:else if title}
<PaddingReset containerIsFluid={width === 'fluid'}>
<TextBlock width={textWidth}>
<!-- Custom title snippet -->
{@render title()}
</TextBlock>
</PaddingReset>
{/if}
<AriaHidden hidden={!!ariaDescription}>
<!-- Graphic content -->
{@render children()}
</AriaHidden>
{#if ariaDescription}
<div class="visually-hidden">
{#if typeof ariaDescription === 'string'}
<Markdown source={ariaDescription} />
{:else}
<!-- Custom ARIA snippet -->
{@render ariaDescription()}
{/if}
</div>
{/if}
{#if typeof notes === 'string'}
<PaddingReset containerIsFluid={width === 'fluid'}>
<TextBlock width={textWidth}>
<aside>
<Markdown source={notes} />
</aside>
</TextBlock>
</PaddingReset>
{:else if notes}
<PaddingReset containerIsFluid={width === 'fluid'}>
<TextBlock width={textWidth}>
<!-- Custom notes content -->
{@render notes()}
</TextBlock>
</PaddingReset>
{/if}
</Block>
</div>
<style lang="scss">
@use '../../scss/mixins' as mixins;
div.container {
display: contents;
// Dek
:global(.article-block.graphic p) {
@include mixins.body-note;
@include mixins.font-light;
}
// Caption and Sources
:global(.article-block.graphic aside p) {
@include mixins.body-caption;
@include mixins.fmy-1;
}
}
</style>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/**
* Whether to wrap the graphic with an aria hidden tag.
*/
hidden?: boolean;
/** Content to put inside `AriaHidden`*/
children: Snippet;
}
let { hidden = false, children }: Props = $props();
</script>
{#if hidden}
<div aria-hidden="true">
{@render children()}
</div>
{:else}
{@render children()}
{/if}

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { ContainerWidth } from '../../@types/global';
import Block from '../../Block/Block.svelte';
interface Props {
/** Width of the component within the text well. */
width?: ContainerWidth;
/** Content to put inside `TextBlock`*/
children: Snippet;
}
let { width, children }: Props = $props();
</script>
{#if width}
<Block {width} class="notes fmy-0">
{@render children()}
</Block>
{:else}
{@render children()}
{/if}

View file

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1,150 @@
import { Meta, Canvas } from '@storybook/blocks';
import * as HeadlineStories from './Headline.stories.svelte';
<Meta of={HeadlineStories} />
# Headline
The `Headline` component creates headlines in the legacy Reuters Graphics style, with the text centred on the page.
```svelte
<script>
import { Headline } from '@reuters-graphics/graphics-components';
</script>
<Headline
hed="Reuters Graphics Interactive"
dek="The beginning of a beautiful page"
section="World News"
/>
```
<Canvas of={HeadlineStories.Demo} />
## With bylines and dateline
Optionally, you can add authors and a publish time to the headline, which the `Headline` component internally renders with the [Byline](./?path=/docs/components-text-elements-byline--docs) component.
> **Note**: Since `Headline` uses `Byline`, you can customise the author page hyperlink and bylines with the `getAuthorPage`, `byline`, `published` and `updated` props.
```svelte
<script>
import { Headline } from '@reuters-graphics/graphics-components';
</script>
<Headline
hed={'Reuters Graphics Interactive'}
dek={'The beginning of a beautiful page'}
section={'Global news'}
authors={['Jane Doe']}
publishTime={new Date('2020-01-01').toISOString()}
getAuthorPage={(author) => `mailto:${author.replace(' ', '')}@example.com`}
/>
```
<Canvas of={HeadlineStories.Byline} />
## Custom hed and dek
Use the `hed` and/or `dek` [snippets](https://svelte.dev/docs/svelte/snippet) to override those elements with custom elements.
```svelte
<script>
import { Headline } from '@reuters-graphics/graphics-components';
</script>
<Headline width="wide">
<!-- Custom hed snippet -->
{#snippet hed()}
<h1 class="custom-hed">
<span class="small block text-base">The secret to</span>
“The Nutcracker's”
<span class="small block text-base fpt-1">success</span>
</h1>
{/snippet}
<!-- Custom dek snippet -->
{#snippet dek()}
<p class="custom-dek !fmt-3">
How “The Nutcracker” ballet became an<span
class="font-medium mx-1 px-1.5 py-1">American holday staple</span
>and a financial pillar of ballet companies across the country
</p>
{/snippet}
</Headline>
<!-- Custom styles -->
<style lang="scss">
.custom-hed {
line-height: 0.9;
}
.custom-dek {
span {
background-color: #fde68a;
}
}
</style>
```
<Canvas of={HeadlineStories.CustomHedDek} />
## With crown image
To add a crown image, use the `crown` [snippet](https://svelte.dev/docs/svelte/snippet).
```svelte
<script>
import { Headline } from '@reuters-graphics/graphics-components';
import { assets } from '$app/paths';
</script>
<Headline
class="!fmt-3"
hed="Europa"
publishTime={new Date('2020-01-01').toISOString()}
>
<!-- Add a crown -->
{#snippet crown()}
<img
src={crownImgSrc}
width="100"
class="mx-auto mb-0"
alt="Illustration of Europe"
/>
{/snippet}
</Headline>
```
<Canvas of={HeadlineStories.CrownImage} />
## With crown graphic
Add a full graphic or any other component in the crown.
```svelte
<script>
import { Headline } from '@reuters-graphics/graphics-components';
import { assets } from '$app/paths'; // If in Graphis Kit
import Map from './ai2svelte/graphic.svelte'; // Import the crown graphic component
</script>
<Headline
width="wider"
class="!fmt-1"
hed={'Unfriendly skies'}
dek={'How Russias invasion of Ukraine is redrawing air routes'}
section={'Ukraine Crisis'}
authors={['Simon Scarr', 'Vijdan Mohammad Kawoosa']}
publishTime={new Date('2022-03-04').toISOString()}
>
<!-- Add a crown graphic -->
{#snippet crown()}
<!-- Pass `assetsPath` if in graphics kit -->
<Map assetsPath={assets || '/'} />
{/snippet}
</Headline>
```
<Canvas of={HeadlineStories.CrownGraphic} />

View file

@ -0,0 +1,111 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Headline from './Headline.svelte';
const { Story } = defineMeta({
title: 'Components/Text elements/Headline',
component: Headline,
argTypes: {
hedSize: {
control: 'select',
options: ['small', 'normal', 'big', 'bigger', 'biggest'],
},
width: {
control: 'select',
options: ['normal', 'wide', 'wider', 'widest'],
},
},
});
</script>
<script>
import crownImgSrc from './demo/crown.png';
import Map from './demo/graphic.svelte';
</script>
<Story
name="Demo"
args={{
hed: 'Reuters Graphics interactive',
dek: 'The beginning of a beautiful page',
section: 'World News',
}}
/>
<Story name="With byline and dateline" exportName="Byline">
<Headline
hed={'Reuters Graphics Interactive'}
dek={'The beginning of a beautiful page'}
section={'Global news'}
authors={['Jane Doe']}
publishTime={new Date('2020-01-01').toISOString()}
getAuthorPage={(author: string) => {
return `mailto:${author.replace(' ', '')}@example.com`;
}}
/>
</Story>
<Story name="Custom hed and dek" exportName="CustomHedDek">
<Headline width="wide">
{#snippet hed()}
<h1 class="custom-hed">
<span class="small block text-base">The secret to</span>
“The Nutcracker's”
<span class="small block text-base fpt-1">success</span>
</h1>
{/snippet}
{#snippet dek()}
<p class="custom-dek !fmt-3">
How “The Nutcracker” ballet became an<span
class="font-medium mx-1 px-1.5 py-1">American holday staple</span
>and a financial pillar of ballet companies across the country
</p>
{/snippet}
</Headline>
</Story>
<Story name="Crown image" exportName="CrownImage">
<Headline
class="!fmt-3"
hed="Europa"
publishTime={new Date('2020-01-01').toISOString()}
>
<!-- Add a crown -->
{#snippet crown()}
<img
src={crownImgSrc}
width="100"
class="mx-auto mb-0"
alt="Illustration of Europe"
/>
{/snippet}
</Headline>
</Story>
<Story name="Crown graphic" exportName="CrownGraphic">
<Headline
width="wider"
class="!fmt-1"
hed={'Unfriendly skies'}
dek={'How Russias invasion of Ukraine is redrawing air routes'}
section={'Ukraine Crisis'}
authors={['Simon Scarr', 'Vijdan Mohammad Kawoosa']}
publishTime={new Date('2022-03-04').toISOString()}
>
<!-- Add a crown graphic -->
{#snippet crown()}
<Map />
{/snippet}
</Headline>
</Story>
<style lang="scss">
.custom-hed {
line-height: 0.9;
}
.custom-dek {
span {
background-color: #fde68a;
}
}
</style>

View file

@ -0,0 +1,161 @@
<!-- @component `Headline` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-text-elements-headline--docs) -->
<script lang="ts">
// Types
import type { HeadlineSize } from './../@types/global';
import type { Snippet } from 'svelte';
// Components
import Block from '../Block/Block.svelte';
import Byline from '../Byline/Byline.svelte';
import { Markdown } from '@reuters-graphics/svelte-markdown';
interface Props {
/** Headline, parsed as an _inline_ markdown string in an `h1` element OR as a custom snippet. */
hed: string | Snippet;
/** Add extra classes to the block tag to target it with custom CSS. */
class?: string;
/** Headline size: small, normal, big, bigger, biggest */
hedSize?: HeadlineSize;
/** Dek, parsed as a markdown string OR as a custom snippet. */
dek?: string | Snippet;
/** Section title */
section?: string;
/** Array of author names, which will be slugified to create links to Reuters author pages */
authors?: string[];
/** Publish time as a datetime string. */
publishTime?: string;
/** Update time as a datetime string. */
updateTime?: string;
/** Width of the headline: normal, wide, wider, widest */
width?: 'normal' | 'wide' | 'wider' | 'widest';
/**
* Custom function that returns an author page URL.
*/
getAuthorPage?: (author: string) => string;
/** Custom crown snippet */
crown?: Snippet;
/**
* Optional snippet for a custom byline.
*/
byline?: Snippet;
/**
* Optional snippet for a custom published dateline.
*/
published?: Snippet;
/**
* Optional snippet for a custom updated dateline.
*/
updated?: Snippet;
}
let {
hed = 'Reuters Graphics Interactive',
class: cls = '',
hedSize = 'normal',
dek,
section,
authors = [],
publishTime = '',
updateTime = '',
width = 'normal',
getAuthorPage,
crown,
byline,
published,
updated,
}: Props = $props();
// Set the headline text size class based on the `hedSize` prop
let hedClass = $state('text-3xl');
$effect(() => {
switch (hedSize) {
case 'biggest':
hedClass = 'text-6xl';
break;
case 'bigger':
hedClass = 'text-5xl';
break;
case 'big':
hedClass = 'text-4xl';
break;
case 'small':
hedClass = 'text-2xl';
break;
default:
hedClass = 'text-3xl';
}
});
</script>
<div class="headline-wrapper" style="display:contents;">
<Block {width} class="headline text-center fmt-7 fmb-6 {cls}">
<header class="relative">
{#if crown}
<div class="crown-container">
<!-- Crown snippet -->
{@render crown()}
</div>
{/if}
<div class="title">
{#if section}
<p
class="section-title mb-0 font-subhed text-xs text-secondary font-bold uppercase whitespace-nowrap tracking-wider"
>
{section}
</p>
{/if}
{#if typeof hed === 'string'}
<h1 class={hedClass}>
<Markdown source={hed} inline />
</h1>
{:else if hed}
<!-- Headline snippet -->
{@render hed()}
{/if}
{#if typeof dek === 'string'}
<div class="dek fmx-auto fmb-6">
<Markdown source={dek} />
</div>
{:else if dek}
<!-- Dek snippet-->
<div class="dek fmx-auto fmb-6">
{@render dek()}
</div>
{/if}
</div>
{#if authors.length > 0 || publishTime}
<Byline
cls="fmy-4"
{authors}
{publishTime}
{updateTime}
{getAuthorPage}
{published}
{updated}
align="center"
/>
{:else if byline}
<!-- Custom byline/dateline -->
{@render byline()}
{/if}
</header>
</Block>
</div>
<style lang="scss">
@use '../../scss/mixins' as mixins;
.headline-wrapper {
:global(.dek) {
max-width: mixins.$column-width-normal;
}
:global(.dek p) {
@include mixins.fmt-0;
@include mixins.font-note;
@include mixins.leading-tight;
@include mixins.font-light;
@include mixins.fmb-3;
}
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View file

@ -0,0 +1,962 @@
<script>
// @ts-ignore img
import chartXs from './graphic-xs.png';
// @ts-ignore img
import chartSm from './graphic-sm.png';
// @ts-ignore img
import chartMd from './graphic-md.png';
// @ts-ignore img
import chartLg from './graphic-lg.png';
// @ts-ignore img
import chartXl from './graphic-xl.png';
let width = $state();
</script>
<div id="g-graphic-box" bind:clientWidth={width}>
<!-- Artboard: xs -->
{#if width && width >= 0 && width < 510}
<div id="g-graphic-xs" class="g-artboard" style="">
<div style="padding: 0 0 156.6667% 0;"></div>
<div
id="g-graphic-xs-img"
class="g-aiImg"
style={`background-image: url(${chartXs});`}
></div>
<div
id="g-ai0-3"
class="g-sm g-aiAbs g-aiPointText"
style="top:55.3926%;margin-top:-9.4px;left:46.8034%;margin-left:-41px;width:82px;"
>
<p class="g-pstyle1">GREENLAND</p>
</div>
<div
id="g-ai0-4"
class="g-sm g-aiAbs g-aiPointText"
style="top:58.4874%;margin-top:-9.4px;right:1.3922%;width:65px;"
>
<p class="g-pstyle2">UKRAINE</p>
</div>
<div
id="g-ai0-5"
class="g-sm g-aiAbs g-aiPointText"
style="top:59.2611%;margin-top:-9.4px;left:74.2151%;margin-left:-28.5px;width:57px;"
>
<p class="g-pstyle1">RUSSIA</p>
</div>
<div
id="g-ai0-6"
class="g-sm g-aiAbs g-aiPointText"
style="top:64.2901%;margin-top:-9.4px;left:75.4898%;margin-left:-31.5px;width:63px;"
>
<p class="g-pstyle3">Moscow</p>
</div>
<div
id="g-ai0-7"
class="g-sm g-aiAbs g-aiPointText"
style="top:69.8994%;margin-top:-9.4px;left:24.0115%;margin-left:-31px;width:62px;"
>
<p class="g-pstyle1">CANADA</p>
</div>
<div
id="g-ai0-8"
class="g-sm g-aiAbs g-aiPointText"
style="top:70.1563%;margin-top:-8.7px;left:10.3976%;margin-left:-20.5px;width:41px;"
>
<p class="g-pstyle1">U.S.</p>
</div>
<div
id="g-ai0-9"
class="g-sm g-aiAbs g-aiPointText"
style="top:71.8336%;margin-top:-9.4px;left:46.9832%;margin-left:-33.5px;width:67px;"
>
<p class="g-pstyle1">BELARUS</p>
</div>
<div
id="g-ai0-10"
class="g-sm g-aiAbs g-aiPointText"
style="top:80.7311%;margin-top:-9.4px;left:54.3834%;margin-left:-25.5px;width:51px;"
>
<p class="g-pstyle1">SPAIN</p>
</div>
</div>
{/if}
<!-- Artboard: sm -->
{#if width && width >= 510 && width < 660}
<div id="g-graphic-sm" class="g-artboard" style="">
<div style="padding: 0 0 54.1176% 0;"></div>
<div
id="g-graphic-sm-img"
class="g-aiImg"
style={`background-image: url(${chartSm});`}
></div>
<div
id="g-ai1-1"
class="g-sm g-aiAbs g-aiPointText"
style="top:14.6304%;margin-top:-9.4px;left:41.7507%;width:82px;"
>
<p class="g-pstyle0">GREENLAND</p>
</div>
<div
id="g-ai1-2"
class="g-sm g-aiAbs g-aiPointText"
style="top:25.8623%;margin-top:-9.4px;left:62.7041%;margin-left:-32.5px;width:65px;"
>
<p class="g-pstyle1">FINLAND</p>
</div>
<div
id="g-ai1-3"
class="g-sm g-aiAbs g-aiPointText"
style="top:26.9492%;margin-top:-9.4px;left:72.6333%;margin-left:-28.5px;width:57px;"
>
<p class="g-pstyle1">RUSSIA</p>
</div>
<div
id="g-ai1-4"
class="g-sm g-aiAbs g-aiPointText"
style="top:37.4565%;margin-top:-9.4px;right:39.632%;width:64px;"
>
<p class="g-pstyle2">NORWAY</p>
</div>
<div
id="g-ai1-5"
class="g-sm g-aiAbs g-aiPointText"
style="top:38.5434%;margin-top:-9.4px;left:73.7517%;margin-left:-31.5px;width:63px;"
>
<p class="g-pstyle3">Moscow</p>
</div>
<div
id="g-ai1-6"
class="g-sm g-aiAbs g-aiPointText"
style="top:44.7029%;margin-top:-9.4px;left:73.385%;width:67px;"
>
<p class="g-pstyle0">BELARUS</p>
</div>
<div
id="g-ai1-7"
class="g-sm g-aiAbs g-aiPointText"
style="top:46.8768%;margin-top:-9.4px;left:51.9252%;margin-left:-32px;width:64px;"
>
<p class="g-pstyle1">ICELAND</p>
</div>
<div
id="g-ai1-8"
class="g-sm g-aiAbs g-aiPointText"
style="top:53.3985%;margin-top:-17.4px;left:14.7718%;margin-left:-29.5px;width:59px;"
>
<p class="g-pstyle1">UNITED</p>
<p class="g-pstyle1">STATES</p>
</div>
<div
id="g-ai1-9"
class="g-sm g-aiAbs g-aiPointText"
style="top:50.8623%;margin-top:-9.4px;left:61.4922%;margin-left:-18px;width:36px;"
>
<p class="g-pstyle1">UK</p>
</div>
<div
id="g-ai1-10"
class="g-sm g-aiAbs g-aiPointText"
style="top:51.2246%;margin-top:-9.4px;left:75.5452%;margin-left:-32.5px;width:65px;"
>
<p class="g-pstyle1">UKRAINE</p>
</div>
<div
id="g-ai1-11"
class="g-sm g-aiAbs g-aiPointText"
style="top:51.5869%;margin-top:-9.4px;left:27.6044%;margin-left:-31px;width:62px;"
>
<p class="g-pstyle1">CANADA</p>
</div>
<div
id="g-ai1-12"
class="g-sm g-aiAbs g-aiPointText"
style="top:57.7463%;margin-top:-9.4px;right:43.8463%;width:64px;"
>
<p class="g-pstyle2">IRELAND</p>
</div>
<div
id="g-ai1-13"
class="g-sm g-aiAbs g-aiPointText"
style="top:59.5579%;margin-top:-9.4px;left:86.9951%;width:45px;"
>
<p class="g-pstyle0">IRAN</p>
</div>
<div
id="g-ai1-14"
class="g-sm g-aiAbs g-aiPointText"
style="top:67.5289%;margin-top:-9.4px;left:57.414%;margin-left:-30.5px;width:61px;"
>
<p class="g-pstyle1">FRANCE</p>
</div>
<div
id="g-ai1-15"
class="g-sm g-aiAbs g-aiPointText"
style="top:75.5%;margin-top:-9.4px;left:75.0057%;width:47px;"
>
<p class="g-pstyle0">ITALY</p>
</div>
<div
id="g-ai1-16"
class="g-sm g-aiAbs g-aiPointText"
style="top:77.6739%;margin-top:-9.4px;left:56.0011%;margin-left:-25.5px;width:51px;"
>
<p class="g-pstyle1">SPAIN</p>
</div>
</div>
{/if}
<!-- Artboard: md -->
{#if width && width >= 660 && width < 930}
<div id="g-graphic-md" class="g-artboard" style="">
<div style="padding: 0 0 53.0303% 0;"></div>
<div
id="g-graphic-md-img"
class="g-aiImg"
style={`background-image: url(${chartMd});`}
></div>
<div
id="g-ai2-2"
class="g-md g-aiAbs g-aiPointText"
style="top:13.2514%;margin-top:-9.4px;left:43.1404%;width:82px;"
>
<p class="g-pstyle0">GREENLAND</p>
</div>
<div
id="g-ai2-3"
class="g-md g-aiAbs g-aiPointText"
style="top:23.5371%;margin-top:-9.4px;left:62.9528%;margin-left:-32.5px;width:65px;"
>
<p class="g-pstyle1">FINLAND</p>
</div>
<div
id="g-ai2-4"
class="g-md g-aiAbs g-aiPointText"
style="top:25.5371%;margin-top:-9.4px;left:72.5549%;margin-left:-28.5px;width:57px;"
>
<p class="g-pstyle1">RUSSIA</p>
</div>
<div
id="g-ai2-5"
class="g-md g-aiAbs g-aiPointText"
style="top:36.9657%;margin-top:-9.4px;right:39.2195%;width:64px;"
>
<p class="g-pstyle2">NORWAY</p>
</div>
<div
id="g-ai2-6"
class="g-md g-aiAbs g-aiPointText"
style="top:37.2514%;margin-top:-9.4px;left:73.679%;margin-left:-31.5px;width:63px;"
>
<p class="g-pstyle3">Moscow</p>
</div>
<div
id="g-ai2-7"
class="g-md g-aiAbs g-aiPointText"
style="top:42.68%;margin-top:-9.4px;left:73.3033%;width:67px;"
>
<p class="g-pstyle0">BELARUS</p>
</div>
<div
id="g-ai2-8"
class="g-md g-aiAbs g-aiPointText"
style="top:44.9657%;margin-top:-9.4px;left:51.859%;margin-left:-32px;width:64px;"
>
<p class="g-pstyle1">ICELAND</p>
</div>
<div
id="g-ai2-9"
class="g-md g-aiAbs g-aiPointText"
style="top:48.68%;margin-top:-9.4px;left:29.292%;margin-left:-31px;width:62px;"
>
<p class="g-pstyle1">CANADA</p>
</div>
<div
id="g-ai2-10"
class="g-md g-aiAbs g-aiPointText"
style="top:51.8228%;margin-top:-17.4px;left:14.7246%;margin-left:-29.5px;width:59px;"
>
<p class="g-pstyle1">UNITED</p>
<p class="g-pstyle1">STATES</p>
</div>
<div
id="g-ai2-11"
class="g-md g-aiAbs g-aiPointText"
style="top:49.8228%;margin-top:-9.4px;left:61.2229%;margin-left:-18px;width:36px;"
>
<p class="g-pstyle1">UK</p>
</div>
<div
id="g-ai2-12"
class="g-md g-aiAbs g-aiPointText"
style="top:50.1085%;margin-top:-9.4px;left:75.469%;margin-left:-32.5px;width:65px;"
>
<p class="g-pstyle1">UKRAINE</p>
</div>
<div
id="g-ai2-13"
class="g-md g-aiAbs g-aiPointText"
style="top:56.3943%;margin-top:-9.4px;right:43.9222%;width:64px;"
>
<p class="g-pstyle2">IRELAND</p>
</div>
<div
id="g-ai2-14"
class="g-md g-aiAbs g-aiPointText"
style="top:57.2514%;margin-top:-9.4px;left:87.1782%;width:45px;"
>
<p class="g-pstyle0">IRAN</p>
</div>
<div
id="g-ai2-15"
class="g-md g-aiAbs g-aiPointText"
style="top:66.3943%;margin-top:-9.4px;left:59.2927%;margin-left:-30.5px;width:61px;"
>
<p class="g-pstyle1">FRANCE</p>
</div>
<div
id="g-ai2-16"
class="g-md g-aiAbs g-aiPointText"
style="top:75.2514%;margin-top:-17.4px;left:90.7521%;margin-left:-28.5px;width:57px;"
>
<p class="g-pstyle1">SAUDI</p>
<p class="g-pstyle1">ARABIA</p>
</div>
<div
id="g-ai2-17"
class="g-md g-aiAbs g-aiPointText"
style="top:76.1085%;margin-top:-9.4px;left:76.2209%;margin-left:-23.5px;width:47px;"
>
<p class="g-pstyle1">ITALY</p>
</div>
<div
id="g-ai2-18"
class="g-md g-aiAbs g-aiPointText"
style="top:78.3943%;margin-top:-9.4px;left:56.2528%;margin-left:-25.5px;width:51px;"
>
<p class="g-pstyle1">SPAIN</p>
</div>
</div>
{/if}
<!-- Artboard: lg -->
{#if width && width >= 930 && width < 1200}
<div id="g-graphic-lg" class="g-artboard" style="">
<div style="padding: 0 0 51.0753% 0;"></div>
<div
id="g-graphic-lg-img"
class="g-aiImg"
style={`background-image: url(${chartLg});`}
></div>
<div
id="g-ai3-1"
class="g-lg g-aiAbs g-aiPointText"
style="top:26.6548%;margin-top:-10.6px;left:62.9209%;margin-left:-36px;width:72px;"
>
<p class="g-pstyle0">FINLAND</p>
</div>
<div
id="g-ai3-2"
class="g-lg g-aiAbs g-aiPointText"
style="top:28.5495%;margin-top:-10.6px;left:72.3568%;margin-left:-31.5px;width:63px;"
>
<p class="g-pstyle0">RUSSIA</p>
</div>
<div
id="g-ai3-3"
class="g-lg g-aiAbs g-aiPointText"
style="top:31.9179%;margin-top:-10.6px;left:46.515%;margin-left:-46px;width:92px;"
>
<p class="g-pstyle0">GREENLAND</p>
</div>
<div
id="g-ai3-4"
class="g-lg g-aiAbs g-aiPointText"
style="top:31.9179%;margin-top:-10.6px;left:79.29%;width:98px;"
>
<p class="g-pstyle1">KAZAKHSTAN</p>
</div>
<div
id="g-ai3-5"
class="g-lg g-aiAbs g-aiPointText"
style="top:40.5495%;margin-top:-10.6px;right:39.4047%;width:71px;"
>
<p class="g-pstyle2">NORWAY</p>
</div>
<div
id="g-ai3-6"
class="g-lg g-aiAbs g-aiPointText"
style="top:40.76%;margin-top:-10.6px;left:74.0694%;margin-left:-35px;width:70px;"
>
<p class="g-pstyle3">Moscow</p>
</div>
<div
id="g-ai3-7"
class="g-lg g-aiAbs g-aiPointText"
style="top:46.2337%;margin-top:-10.6px;left:73.2012%;width:74px;"
>
<p class="g-pstyle1">BELARUS</p>
</div>
<div
id="g-ai3-8"
class="g-lg g-aiAbs g-aiPointText"
style="top:48.5495%;margin-top:-10.6px;left:51.6769%;margin-left:-35.5px;width:71px;"
>
<p class="g-pstyle0">ICELAND</p>
</div>
<div
id="g-ai3-9"
class="g-lg g-aiAbs g-aiPointText"
style="top:52.339%;margin-top:-10.6px;left:29.15%;margin-left:-34.5px;width:69px;"
>
<p class="g-pstyle0">CANADA</p>
</div>
<div
id="g-ai3-10"
class="g-lg g-aiAbs g-aiPointText"
style="top:55.2863%;margin-top:-19.6px;left:14.6303%;margin-left:-33px;width:66px;"
>
<p class="g-pstyle0">UNITED</p>
<p class="g-pstyle0">STATES</p>
</div>
<div
id="g-ai3-11"
class="g-lg g-aiAbs g-aiPointText"
style="top:53.3916%;margin-top:-10.6px;left:61.0447%;margin-left:-19.5px;width:39px;"
>
<p class="g-pstyle0">UK</p>
</div>
<div
id="g-ai3-12"
class="g-lg g-aiAbs g-aiPointText"
style="top:53.6021%;margin-top:-10.6px;left:75.4186%;margin-left:-36.5px;width:73px;"
>
<p class="g-pstyle0">UKRAINE</p>
</div>
<div
id="g-ai3-13"
class="g-lg g-aiAbs g-aiPointText"
style="top:60.5495%;margin-top:-10.6px;right:44.1135%;width:71px;"
>
<p class="g-pstyle2">IRELAND</p>
</div>
<div
id="g-ai3-14"
class="g-lg g-aiAbs g-aiPointText"
style="top:61.3916%;margin-top:-10.6px;left:87.5589%;width:49px;"
>
<p class="g-pstyle1">IRAN</p>
</div>
<div
id="g-ai3-15"
class="g-lg g-aiAbs g-aiPointText"
style="top:70.6548%;margin-top:-10.6px;left:59.2197%;margin-left:-33.5px;width:67px;"
>
<p class="g-pstyle0">FRANCE</p>
</div>
<div
id="g-ai3-16"
class="g-lg g-aiAbs g-aiPointText"
style="top:76.5495%;margin-top:-10.6px;left:62.7536%;margin-left:-27.5px;width:55px;"
>
<p class="g-pstyle0">SPAIN</p>
</div>
<div
id="g-ai3-17"
class="g-lg g-aiAbs g-aiPointText"
style="top:78.8653%;margin-top:-19.6px;left:89.7929%;margin-left:-31.5px;width:63px;"
>
<p class="g-pstyle0">SAUDI</p>
<p class="g-pstyle0">ARABIA</p>
</div>
<div
id="g-ai3-18"
class="g-lg g-aiAbs g-aiPointText"
style="top:80.76%;margin-top:-10.6px;left:75.992%;margin-left:-25.5px;width:51px;"
>
<p class="g-pstyle0">ITALY</p>
</div>
</div>
{/if}
<!-- Artboard: xl -->
{#if width && width >= 1200}
<div id="g-graphic-xl" class="g-artboard" style="">
<div style="padding: 0 0 50.75% 0;"></div>
<div
id="g-graphic-xl-img"
class="g-aiImg"
style={`background-image: url(${chartXl});`}
></div>
<div
id="g-ai4-1"
class="g-xl g-aiAbs g-aiPointText"
style="top:27.1938%;margin-top:-10.6px;left:63.4365%;margin-left:-36px;width:72px;"
>
<p class="g-pstyle0">FINLAND</p>
</div>
<div
id="g-ai4-2"
class="g-xl g-aiAbs g-aiPointText"
style="top:28.5074%;margin-top:-10.6px;left:72.4779%;margin-left:-31.5px;width:63px;"
>
<p class="g-pstyle0">RUSSIA</p>
</div>
<div
id="g-ai4-3"
class="g-xl g-aiAbs g-aiPointText"
style="top:29.821%;margin-top:-10.6px;left:46.6513%;margin-left:-46px;width:92px;"
>
<p class="g-pstyle0">GREENLAND</p>
</div>
<div
id="g-ai4-4"
class="g-xl g-aiAbs g-aiPointText"
style="top:31.6273%;margin-top:-10.6px;left:79.93%;width:98px;"
>
<p class="g-pstyle1">KAZAKHSTAN</p>
</div>
<div
id="g-ai4-5"
class="g-xl g-aiAbs g-aiPointText"
style="top:40.6585%;margin-top:-10.6px;right:39.3056%;width:71px;"
>
<p class="g-pstyle2">NORWAY</p>
</div>
<div
id="g-ai4-6"
class="g-xl g-aiAbs g-aiPointText"
style="top:40.8227%;margin-top:-10.6px;left:73.6128%;margin-left:-35px;width:70px;"
>
<p class="g-pstyle3">Moscow</p>
</div>
<div
id="g-ai4-7"
class="g-xl g-aiAbs g-aiPointText"
style="top:47.8834%;margin-top:-10.6px;left:71.2465%;width:74px;"
>
<p class="g-pstyle1">BELARUS</p>
</div>
<div
id="g-ai4-8"
class="g-xl g-aiAbs g-aiPointText"
style="top:48.376%;margin-top:-10.6px;left:51.7432%;margin-left:-35.5px;width:71px;"
>
<p class="g-pstyle0">ICELAND</p>
</div>
<div
id="g-ai4-9"
class="g-xl g-aiAbs g-aiPointText"
style="top:52.6453%;margin-top:-10.6px;left:29.1449%;margin-left:-34.5px;width:69px;"
>
<p class="g-pstyle0">CANADA</p>
</div>
<div
id="g-ai4-10"
class="g-xl g-aiAbs g-aiPointText"
style="top:53.6306%;margin-top:-10.6px;left:75.5426%;margin-left:-36.5px;width:73px;"
>
<p class="g-pstyle0">UKRAINE</p>
</div>
<div
id="g-ai4-11"
class="g-xl g-aiAbs g-aiPointText"
style="top:53.7947%;margin-top:-10.6px;left:60.8327%;margin-left:-19.5px;width:39px;"
>
<p class="g-pstyle0">UK</p>
</div>
<div
id="g-ai4-12"
class="g-xl g-aiAbs g-aiPointText"
style="top:55.2726%;margin-top:-19.6px;left:14.5826%;margin-left:-33px;width:66px;"
>
<p class="g-pstyle0">UNITED</p>
<p class="g-pstyle0">STATES</p>
</div>
<div
id="g-ai4-13"
class="g-xl g-aiAbs g-aiPointText"
style="top:54.1231%;margin-top:-10.6px;left:69.7159%;margin-left:-35px;width:70px;"
>
<p class="g-pstyle0">POLAND</p>
</div>
<div
id="g-ai4-14"
class="g-xl g-aiAbs g-aiPointText"
style="top:57.8998%;margin-top:-10.6px;left:67.4399%;margin-left:-38.5px;width:77px;"
>
<p class="g-pstyle0">GERMANY</p>
</div>
<div
id="g-ai4-15"
class="g-xl g-aiAbs g-aiPointText"
style="top:60.8556%;margin-top:-10.6px;right:44.0157%;width:71px;"
>
<p class="g-pstyle2">IRELAND</p>
</div>
<div
id="g-ai4-16"
class="g-xl g-aiAbs g-aiPointText"
style="top:61.6766%;margin-top:-10.6px;left:76.0595%;margin-left:-37.5px;width:75px;"
>
<p class="g-pstyle0">ROMANIA</p>
</div>
<div
id="g-ai4-17"
class="g-xl g-aiAbs g-aiPointText"
style="top:61.8407%;margin-top:-10.6px;left:87.7238%;width:49px;"
>
<p class="g-pstyle1">IRAN</p>
</div>
<div
id="g-ai4-18"
class="g-xl g-aiAbs g-aiPointText"
style="top:66.7668%;margin-top:-10.6px;left:64.1171%;margin-left:-33.5px;width:67px;"
>
<p class="g-pstyle0">FRANCE</p>
</div>
<div
id="g-ai4-19"
class="g-xl g-aiAbs g-aiPointText"
style="top:68.4089%;margin-top:-10.6px;left:68.6444%;margin-left:-25.5px;width:51px;"
>
<p class="g-pstyle0">ITALY</p>
</div>
<div
id="g-ai4-20"
class="g-xl g-aiAbs g-aiPointText"
style="top:78.0969%;margin-top:-19.6px;left:89.3184%;margin-left:-31.5px;width:63px;"
>
<p class="g-pstyle0">SAUDI</p>
<p class="g-pstyle0">ARABIA</p>
</div>
<div
id="g-ai4-21"
class="g-xl g-aiAbs g-aiPointText"
style="top:78.7537%;margin-top:-10.6px;left:62.5553%;margin-left:-27.5px;width:55px;"
>
<p class="g-pstyle0">SPAIN</p>
</div>
<div
id="g-ai4-22"
class="g-xl g-aiAbs g-aiPointText"
style="top:79.0822%;margin-top:-10.6px;left:79.6558%;margin-left:-33.5px;width:67px;"
>
<p class="g-pstyle0">GREECE</p>
</div>
</div>
{/if}
</div>
<!-- End ai2html - 2023-08-23 14:09 -->
<!-- Generated by ai2html v0.100.0 - 2023-08-23 14:09 -->
<!-- ai file: graphic.ai -->
<!-- svelte-ignore css_unused_selector -->
<style lang="scss">
#g-graphic-box,
#g-graphic-box .g-artboard {
margin: 0 auto;
}
#g-graphic-box p {
margin: 0;
}
#g-graphic-box .g-aiAbs {
position: absolute;
}
#g-graphic-box .g-aiImg {
position: absolute;
top: 0;
display: block;
width: 100% !important;
height: 100%;
background-size: contain;
background-repeat: no-repeat;
}
#g-graphic-box .g-aiPointText p {
white-space: nowrap;
}
#g-graphic-xs {
position: relative;
overflow: hidden;
}
#g-graphic-xs p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 300;
line-height: 16px;
opacity: 1;
letter-spacing: 0em;
font-size: 14px;
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-graphic-xs .g-pstyle0 {
font-weight: 700;
line-height: 24px;
font-size: 22px;
}
#g-graphic-xs .g-pstyle1 {
font-weight: 200;
height: 16px;
font-size: 12px;
text-align: center;
}
#g-graphic-xs .g-pstyle2 {
font-weight: 200;
height: 16px;
font-size: 12px;
text-align: right;
}
#g-graphic-xs .g-pstyle3 {
font-weight: 600;
height: 16px;
font-size: 12px;
text-align: center;
}
#g-graphic-xs .g-pstyle4 {
line-height: 13px;
font-size: 12px;
}
#g-graphic-xs .g-pstyle5 {
line-height: 14px;
padding-bottom: 4px;
font-size: 12px;
}
#g-graphic-sm {
position: relative;
overflow: hidden;
}
#g-graphic-sm p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 200;
line-height: 16px;
height: auto;
opacity: 1;
letter-spacing: 0em;
font-size: 12px;
text-align: left;
color: rgb(0, 0, 0);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
position: static;
}
#g-graphic-sm .g-pstyle0 {
height: 16px;
}
#g-graphic-sm .g-pstyle1 {
height: 16px;
text-align: center;
}
#g-graphic-sm .g-pstyle2 {
height: 16px;
text-align: right;
}
#g-graphic-sm .g-pstyle3 {
font-weight: 600;
height: 16px;
text-align: center;
}
#g-graphic-sm .g-pstyle4 {
font-weight: 300;
line-height: 13px;
}
#g-graphic-sm .g-pstyle5 {
font-weight: 300;
line-height: 14px;
padding-bottom: 4px;
}
#g-graphic-md {
position: relative;
overflow: hidden;
}
#g-graphic-md p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 300;
line-height: 16px;
opacity: 1;
letter-spacing: 0em;
font-size: 14px;
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-graphic-md .g-pstyle0 {
font-weight: 200;
height: 16px;
font-size: 12px;
}
#g-graphic-md .g-pstyle1 {
font-weight: 200;
height: 16px;
font-size: 12px;
text-align: center;
}
#g-graphic-md .g-pstyle2 {
font-weight: 200;
height: 16px;
font-size: 12px;
text-align: right;
}
#g-graphic-md .g-pstyle3 {
font-weight: 600;
height: 16px;
font-size: 12px;
text-align: center;
}
#g-graphic-md .g-pstyle4 {
line-height: 13px;
font-size: 12px;
}
#g-graphic-md .g-pstyle5 {
line-height: 14px;
padding-bottom: 4px;
font-size: 12px;
}
#g-graphic-lg {
position: relative;
overflow: hidden;
}
#g-graphic-lg p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 200;
line-height: 18px;
height: auto;
opacity: 1;
letter-spacing: 0em;
font-size: 14px;
text-align: left;
color: rgb(0, 0, 0);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
position: static;
}
#g-graphic-lg .g-pstyle0 {
height: 18px;
text-align: center;
}
#g-graphic-lg .g-pstyle1 {
height: 18px;
}
#g-graphic-lg .g-pstyle2 {
height: 18px;
text-align: right;
}
#g-graphic-lg .g-pstyle3 {
font-weight: 600;
height: 18px;
text-align: center;
}
#g-graphic-lg .g-pstyle4 {
font-weight: 300;
line-height: 13px;
font-size: 12px;
}
#g-graphic-lg .g-pstyle5 {
font-weight: 300;
line-height: 14px;
padding-bottom: 4px;
font-size: 12px;
}
#g-graphic-xl {
position: relative;
overflow: hidden;
}
#g-graphic-xl p {
font-family:
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Helvetica,
Arial,
sans-serif;
font-weight: 200;
line-height: 18px;
height: auto;
opacity: 1;
letter-spacing: 0em;
font-size: 14px;
text-align: left;
color: rgb(0, 0, 0);
text-transform: none;
padding-bottom: 0;
padding-top: 0;
mix-blend-mode: normal;
font-style: normal;
position: static;
}
#g-graphic-xl .g-pstyle0 {
height: 18px;
text-align: center;
}
#g-graphic-xl .g-pstyle1 {
height: 18px;
}
#g-graphic-xl .g-pstyle2 {
height: 18px;
text-align: right;
}
#g-graphic-xl .g-pstyle3 {
font-weight: 600;
height: 18px;
text-align: center;
}
/* Custom CSS */
</style>

View 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
>
&nbsp;
</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
>
&nbsp;
</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>

View 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>
```

View file

@ -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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

View file

@ -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 someones 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>

View file

@ -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>

View 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 someones 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>

View file

@ -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>

View file

@ -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 someones 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>

View file

@ -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>

View file

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -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 someones 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>

View 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;
}

View file

@ -0,0 +1,269 @@
<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>Progress:</p>
<div style="display: flex; flex-direction: column; gap: 4px;">
<p class="state-value">{componentState.progress}</p>
<div id="video-progress-bar">
<div
style="width: {componentState.progress * 100}%; height: 100%;"
></div>
</div>
</div>
<!-- -->
<p>Duration:</p>
<p class="state-value">
{componentState.duration}s
</p>
<!-- -->
{#if componentState.segment}
<p>Segment:</p>
<p class="state-value">
{componentState.segment[0]} -- {componentState.segment[1]}
</p>
{/if}
<!-- -->
<p>Current frame:</p>
<p class="state-value">
{componentState.currentFrame}/{componentState.totalFrames}
</p>
<!-- -->
<p>Speed:</p>
<p class="state-value">
{componentState.speed}
</p>
<!-- -->
<p>Autoplay:</p>
<p class="state-value">
<span class="tag">{componentState.autoplay}</span>
</p>
<!-- -->
<p>Loop:</p>
<p class="state-value">
<span class="tag">{componentState.loop}</span>
{componentState.loop ? `(Loop count: ${componentState.loopCount})` : ''}
</p>
<!-- -->
<p>Mode:</p>
<p class="state-value">
<span class="tag">{componentState.mode}</span>
</p>
<!-- -->
<p>Layout:</p>
<p class="state-value">
{JSON.stringify(componentState.layout)}
</p>
<!-- -->
{#if Object.keys(componentState.allMarkers).length}
<p>All markers:</p>
<p class="state-value">
{componentState.allMarkers}
</p>
{/if}
<!-- -->
{#if componentState.marker}
<p>Active marker:</p>
<p class="state-value">
{componentState.marker}
</p>
{/if}
<!-- -->
{#if componentState.allThemes.length}
<p>All themes:</p>
<p class="state-value">
{componentState.allThemes.join(', ')}
</p>
{/if}
{#if componentState.activeThemeId}
<p>Active theme ID:</p>
<p class="state-value">
{componentState.activeThemeId}
</p>
{/if}
<!-- -->
<p>isPaused:</p>
<p class="state-value">
<span class="tag">{componentState.isPaused}</span>
</p>
<!-- -->
<p>isPlaying:</p>
<p class="state-value">
<span class="tag">{componentState.isPlaying}</span>
</p>
<!-- -->
<p>isStopped:</p>
<p class="state-value">
<span class="tag">{componentState.isStopped}</span>
</p>
<!-- -->
<p>isLoaded:</p>
<p class="state-value">
<span class="tag">{componentState.isLoaded}</span>
</p>
<!-- -->
<p>isFrozen:</p>
<p class="state-value">
<span class="tag">{componentState.isFrozen}</span>
</p>
</div>
</details>
</div>
<style lang="scss">
.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: var(--theme-font-family-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: var(--theme-font-family-monospace);
padding: 0;
margin: 0;
color: rgba(255, 255, 255, 0.7);
overflow-wrap: anywhere;
line-height: 100%;
font-variant: tabular-nums;
}
.state-value {
color: white;
}
#video-progress-bar {
width: 100%;
background-color: rgba(255, 255, 255, 0.2);
height: 2px;
border-radius: 50px;
// margin: auto;
div {
background-color: white;
border-radius: 50px;
}
}
.tag {
padding: 0.1rem 0.2rem;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.2);
text-transform: uppercase;
font-weight: 500;
}
</style>

View file

@ -0,0 +1,386 @@
import { Meta } from '@storybook/blocks';
import * as LottieStories from './Lottie.stories.svelte';
import CompositionMarkerImage from './assets/marker.jpg?url';
<Meta of={LottieStories} />
# Lottie
The `Lottie` component uses the [dotLottie-web](https://developers.lottiefiles.com/docs/dotlottie-player/dotlottie-web/) library to render Lottie animations.
## How to prepare Lottie files
[LottieFiles](https://lottiefiles.com/) is the official platform for creating and editing Lottie animations. The free version of LottieFiles has limited features, so [Bodymovin](https://exchange.adobe.com/apps/cc/12557/bodymovin) remains a popular, free way to export Lottie animations as JSON files.
[dotLottie](https://dotlottie.io/) is another common format for Lottie files. This format bundles the Lottie JSON file and any associated assets, such as images and fonts, into a single compressed file with the extension `.lottie`.
This `Lottie` component is flexible and supports both `dotLottie` and JSON Lottie files. For best performance it is recommended that you convert your Lottie JSON file into a `.zip` file by following these steps:
1. Export your Lottie animation as a JSON file using [Bodymovin](https://exchange.adobe.com/apps/cc/12557/bodymovin) or another Lottie exporter.
2. Use the [LottieFiles converter](https://lottiefiles.com/tools/lottie-to-dotlottie) to convert the JSON file into a `.lottie` file.
3. Change the file extension to `.zip` from `.lottie`. This ensures full compatibility with the Reuters graphics publisher while maintaining the benefits of dotLottie format's compression and optimisation.
## When not to use Lottie
Lottie animations are great for lightweight, scalable animations. However, they may not be suitable for all use cases. Consider the following before using Lottie:
- **Huge raster images**: Lottie is best suited for simple to moderately complex animations. Animations with large raster images may not render well or could lead to performance issues. In such cases, consider using a [Video](?path=/docs/components-multimedia-video--docs) component or a [ScrollerVideo](?path=/docs/components-graphics-scrollervideo--docs) component instead.
- **Complex effects**: Some advanced effects and features available in After Effects may not be fully supported in Lottie, which could lead to discrepancies between the original design and the rendered animation. Check the [Lottie documentation](https://lottiefiles.com/supported-features) for a list of supported features.
- **Text rendering**: Lottie renders text as vector shapes. If you need DOM text for accessibility or CSS manipulation, consider using HTML/CSS animations instead.
- **SVG DOM manipulation**: Lottie renders animations on a canvas. If you need to manipulate individual elements of the animation using JavaScript or CSS, consider using SVG animations instead.
## Basic demo
To use the `Lottie` component, import it and provide the Lottie animation source. The height of the container defaults to `100lvh`, but you can adjust this to any valid CSS height value such as `1200px` or `200lvh` with the `height` prop.
**Use `lvh` or `svh` units instead of `vh`** as [these units](https://www.w3.org/TR/css-values-4/#large-viewport-size) are more reliable on mobile and other devices where elements such as the address bar appear and disappear and affect the height.
The component also provides a `width` prop to set the width of the Lottie container. While the `width` prop defaults to `fluid`, it allows any `ContainerWidth` value such as `narrower`, `narrow`, `normal`, `wide`, `wider`, `widest`, `fluid`, or a custom CSS width value like `600px` or `80vw`.
If importing the Lottie file directly into a Svelte component, make sure to append **?url** to the import statement (see example below). This ensures that the file is treated as a URL.
> 💡TIP: Set `showDebugInfo` prop to `true` to display information about the component state.
[Demo](?path=/story/components-graphics-scrollerlottie--demo)
```svelte
<script lang="ts">
import { Lottie } from '@reuters-graphics/graphics-components';
// Import Lottie file
import MyLottie from './lottie/my-lottie.zip?url'; // Append **?url** to the file path
</script>
<Lottie src={MyLottie} autoplay={true} showDebugInfo={true} />
```
## Using with ArchieML
If you are using `Lottie` with ArchieML, store your Lottie zip file in the `src/statics/lottie/` folder.
With the graphics kit, you'll likely get your text and prop values from an ArchieML doc...
```yaml
# ArchieML doc
[blocks]
type: lottie
# Lottie file stored in `src/statics/lottie/` folder
src: lottie/LottieFile.zip
autoplay: true
loop: true
showDebugInfo: true
[]
```
... which you'll parse out of a ArchieML block object before passing to the `Lottie` component.
```svelte
<script lang="ts">
import { Lottie } from '@reuters-graphics/graphics-components';
// Graphics kit only
import { assets } from '$app/paths'; // 👈 If using in the graphics kit...
import { truthy } from '$utils/propValidators'; // 👈 If using in the graphics kit...
</script>
{#each content.blocks as block}
<!-- Inside the content.blocks for loop... -->
{#if block.type == 'lottie'}
<Lottie
src={`${assets}/${block.src}`}
autoplay={truthy(block.autoplay)}
loop={truthy(block.loop)}
showDebugInfo={truthy(block.showDebugInfo)}
/>
{/if}
{/each}
```
## Playing a segment
The `Lottie` component can play a specific segment of the Lottie animation using the `segment` prop. The `segment` prop expects an array of two numbers representing the start and end frames of the segment.
[Demo](?path=/story/components-graphics-scrollerlottie--segment)
With the graphics kit, you'll likely get your text and prop values from an ArchieML doc...
```yaml
# ArchieML doc
[blocks]
type: lottie
showDebugInfo: true
loop: true
# Optionally, set playback speed
speed: 0.5
# Lottie file stored in `src/statics/lottie/` folder
src: lottie/LottieFile.zip
[.segment]
start: 0
end: 20
[]
[]
```
... which you'll parse out of a ArchieML block object before passing to the `Lottie` component.
```svelte
<script lang="ts">
import { Lottie } from '@reuters-graphics/graphics-components';
// Graphics kit only
import { assets } from '$app/paths'; // 👈 If using in the graphics kit...
import { truthy } from '$utils/propValidators'; // 👈 If using in the graphics kit...
</script>
{#each content.blocks as block}
<!-- Inside the content.blocks for loop... -->
{#if block.type == 'lottie'}
<Lottie
src={`${assets}/${block.src}`}
segment={[parseInt(block.segment.start), parseInt(block.segment.end)]}
showDebugInfo={truthy(block.showDebugInfo)}
loop={truthy(block.loop)}
speed={parseInt(block.speed)}
/>
{/if}
{/each}
```
## Markers
The `Lottie` component can also play a specific portion of the Lottie animation using markers set in [AfterEffects](https://helpx.adobe.com/in/after-effects/using/layer-markers-composition-markers.html).
The list of available markers, which can be passed into the `marker` prop, can be found in the debug info box that appears when `showDebugInfo` is set to `true`.
When setting markers in AfterEffects, ensure that the **Comment** section of the Composition Marker contains only the name of your marker:
<img src={CompositionMarkerImage} alt="Composition Marker Dialog" />
[Demo](?path=/story/components-graphics-scrollerlottie--marker)
```svelte
<script lang="ts">
import { Lottie } from '@reuters-graphics/graphics-components';
// Import Lottie file
import MyLottie from './lottie/my-lottie.zip?url'; // Append **?url** to the file path
</script>
<Lottie src={MyLottie} marker="myMarker" />
```
## Switching themes
[Lottie Creator](https://lottiefiles.com/theming) allows you to define multiple colour themes for your animation. You can switch between these themes using the `theme` prop.
Available themes can be found in the debug info when the `showDebugInfo` prop is set to `true`.
You can set multiple themes and switch between them dynamically -- for example, based on the `progress` of the animation.
[Demo](?path=/story/components-graphics-scrollerlottie--themes)
```svelte
<script lang="ts">
import { Lottie } from '@reuters-graphics/graphics-components';
import MyLottie from './lottie/my-lottie.zip?url';
// Set a bindable `progress` variable to pass into `<Lottie />`
let progress = $state(0);
</script>
<Lottie
src={MyLottie}
bind:progress
themeId={progress < 0.33 ? 'Water'
: progress < 0.66 ? 'air'
: 'earth'}
autoplay
showDebugInfo
/>
```
## Using with `ScrollerBase`
The `Lottie` component can be used with the `ScrollerBase` component to create a more complex scrolling experience. `ScrollerBase` provides a scrollable container sets the `Lottie` component as a background.
```svelte
<script lang="ts">
import { Lottie, ScrollerBase } from '@reuters-graphics/graphics-components';
import MyLottie from './lottie/my-lottie.zip?url';
// Pass `progress` as `videoPercentage` to Lottie
let progress = $state(0);
</script>
<ScrollerBase bind:progress query="div.step-foreground-container">
{#snippet backgroundSnippet()}
<!-- Pass bindable prop `progress` as `progress` -->
<Lottie src={MyLottie} {progress} height="100lvh" showDebugInfo />
{/snippet}
{#snippet foregroundSnippet()}
<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%;
margin: 0 auto;
h3 {
background-color: antiquewhite;
text-align: center;
}
}
</style>
```
## With foregrounds
The `Lottie` component can also be used with the `LottieForeground` component to display foreground elements at specific times in the animation.
[Demo](?path=/story/components-graphics-scrollerlottie--with-foregrounds).
```svelte
<script lang="ts">
import { Lottie, ScrollerBase } from '@reuters-graphics/graphics-components';
import MyLottie from './lottie/my-lottie.zip?url';
</script>
<Lottie src={MyLottie} autoplay>
<!-- Foreground 1: Headline component as foreground -->
<LottieForeground
startFrame={0}
endFrame={50}
position="center center"
backgroundColour="rgba(0, 0, 0)"
>
<div class="headline-container">
<Theme base="dark">
<Headline
hed="Headline"
dek="This is an example of using a Svelte component as the foreground."
authors={['Jane Doe', 'John Doe']}
/>
</Theme>
</div>
</LottieForeground>
<!-- Foreground 2: Text only -->
<LottieForeground
startFrame={50}
endFrame={100}
text="Foreground caption between frames 50 and 100."
position="bottom center"
backgroundColour="rgba(0, 0, 0)"
width="wide"
/>
</Lottie>
```
### Using with ArchieML
With the graphics kit, you'll likely get your text and prop values from an ArchieML doc...
```yaml
# ArchieML doc
[blocks]
type: lottie
# Lottie file stored in `src/statics/lottie/` folder
src: lottie/LottieFile.zip
# Array of foregrounds
[.foregrounds]
# Foreground 1: Headline component
startFrame: 0 # When in the animation to start showing the foreground
endFrame: 50 # When to stop showing the foreground
# Set foreground type
type: component
# Set props to pass into `LottieForeground`
{.foregroundProps}
componentName: Headline
hed: Headline
dek: Some deck text
[.authors]
* Jane Doe
* John Smith
[]
{}
# Foreground 2: Text only
startFrame: 50
endFrame: 100
# Set foreground type
type: text
# If the foreground type is `text`, set text prop here
{.foregroundProps}
text: Some text for the foreground
{}
[]
```
... which you'll parse out of a ArchieML block object before passing to the `Lottie` component.
```svelte
<script lang="ts">
import {
Lottie,
LottieForeground,
} from '@reuters-graphics/graphics-components';
import { assets } from '$app/paths';
// Make an object of components to use as foregrounds
const Components = $state({
Headline,
});
</script>
{#each content.blocks as block}
<!-- Inside the content.blocks for loop... -->
{#if block.type == 'lottie'}
<Lottie src={`${assets}/${block.src}`}>
{#each block.foregrounds as foreground}
{#if foreground.type == 'text'}
<LottieForeground
startFrame={parseInt(foreground.startFrame)}
endFrame={parseInt(foreground.endFrame)}
text={foreground.foregroundProps.text}
/>
{:else if foreground.type == 'component'}
{@const Component =
Components[foreground.foregroundProps.componentName]}
<LottieForeground
startFrame={parseInt(foreground.startFrame)}
endFrame={parseInt(foreground.endFrame)}
>
<Component {...foreground.foregroundProps} />
</LottieForeground>
{/if}
{/each}
</Lottie>
{/if}
{/each}
```

View file

@ -0,0 +1,150 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
// Components
import Lottie from './Lottie.svelte';
import LottieForeground from './LottieForeground.svelte';
import Headline from '../Headline/Headline.svelte';
import Theme from '../Theme/Theme.svelte';
// Denmo Lottie file
import DemoLottie from './lottie/demo.zip?url';
import MarkerSample from './lottie/markerSample.zip?url';
import ForegroundSample from './lottie/foregroundSample.zip?url';
import ThemesSample from './lottie/themesLottie.zip?url';
import WithScrollerBase from './demo/withScrollerBase.svelte';
const { Story } = defineMeta({
title: 'Components/Multimedia/Lottie',
component: Lottie,
argTypes: {
data: {
table: {
disable: true, // Hides from Docs table
},
control: false, // Removes from Controls panel
},
lottiePlayer: {
table: {
category: 'Bindable states',
},
},
progress: {
table: {
category: 'Bindable states',
},
},
},
});
let progress = $state(0);
</script>
<Story name="Demo" args={{ autoplay: true, showDebugInfo: true }}>
{#snippet children(args)}
<Lottie src={DemoLottie} {...args} />
{/snippet}
</Story>
<Story
name="Segment"
args={{
autoplay: true,
loop: true,
showDebugInfo: true,
segment: [0, 20],
speed: 0.5,
}}
>
{#snippet children(args)}
<Lottie src={DemoLottie} {...args} />
{/snippet}
</Story>
<Story
name="Marker"
args={{
autoplay: true,
loop: true,
showDebugInfo: true,
marker: 'ballerina',
mode: 'bounce',
}}
>
{#snippet children(args)}
<Lottie src={MarkerSample} {...args} />
{/snippet}
</Story>
<Story name="Themes" args={{ autoplay: true, showDebugInfo: true }}>
{#snippet children(args)}
<Lottie
src={ThemesSample}
bind:progress
themeId={progress < 0.33 ? 'Water'
: progress < 0.66 ? 'air'
: 'earth'}
{...args}
/>
{/snippet}
</Story>
<Story name="Using with ScrollerBase" exportName="ScrollerBase">
<WithScrollerBase />
</Story>
<Story name="With foregrounds">
<Lottie src={ForegroundSample} autoplay showDebugInfo>
<LottieForeground
startFrame={0}
endFrame={50}
position="center center"
backgroundColour="rgba(0, 0, 0)"
>
<div class="headline-container">
<Theme base="dark">
<Headline
hed="Headline"
dek="This is an example of using a Svelte component as the foreground."
authors={['Jane Doe', 'John Doe']}
/>
</Theme>
</div>
</LottieForeground>
<LottieForeground
startFrame={50}
endFrame={100}
text="Foreground caption between frames 50ms and 100ms."
position="bottom center"
backgroundColour="rgba(0, 0, 0)"
width="wide"
/>
</Lottie>
</Story>
<style lang="scss">
:global {
.lottie-foreground-container {
header {
padding: 2rem;
background-color: black;
}
.foreground-text {
p {
color: white !important;
}
}
}
}
.headline-container {
width: 100%;
height: 100%;
position: absolute;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View file

@ -0,0 +1,443 @@
<script lang="ts">
// Libraries & utils
import { onMount, setContext } from 'svelte';
// @ts-ignore library has no types
import { DotLottie } from '@lottiefiles/dotlottie-web';
import { createLottieState } from './ts/lottieState.svelte';
import { isEqual } from 'es-toolkit';
import {
syncLottieState,
getMarkerRange,
calculateTargetFrame,
isReverseMode,
createRenderConfig,
isNullish,
isContainerWidth,
} from './ts/utils';
import { Tween } from 'svelte/motion';
// Components
import Debug from './Debug.svelte';
// Types
import type { Props } from './ts/types';
let canvas: HTMLCanvasElement;
let canvasWidth: number = $state(1);
let canvasHeight: number = $state(1);
let prevSrc: undefined | string = void 0;
let prevData: undefined | unknown = void 0;
let progressTween = new Tween(0, { duration: 100 });
let start: number = $state(0);
let end: number = $state(0);
let {
autoplay = false,
loop = false,
mode = 'forward',
src,
speed = 1,
data = undefined,
backgroundColor = '#ffffff',
segment,
renderConfig,
dotLottieRefCallback = () => {},
useFrameInterpolation = true,
themeId = '',
themeData = '',
playOnHover = false,
marker,
layout = { fit: 'contain', align: [0.5, 0.5] },
animationId = '',
lottiePlayer = $bindable(undefined),
width = 'fluid',
height = 'auto',
showDebugInfo = false,
lottieState = createLottieState(),
progress = $bindable(0),
tweenDuration = 100,
easing = (t: number) => t,
onLoad = () => {},
onRender = () => {},
onComplete = () => {},
children,
}: Props = $props();
// pass on component state to child components
// this controls fade in and out of foregrounds
setContext('lottieState', lottieState);
function onLoadEvent() {
if (lottiePlayer) {
lottiePlayer.setLayout(layout);
lottieState.allMarkers = lottiePlayer.markers().map((x) => x.name);
if (lottiePlayer.manifest) {
lottieState.allThemes =
lottiePlayer?.manifest.themes ?
lottiePlayer.manifest.themes.map((t) => t.id)
: [];
}
if (isNullish(marker)) {
start = segment ? segment[0] : 0;
end = segment ? segment[1] : lottiePlayer.totalFrames - 1;
}
// set to frame 1 to trigger initial render
// helpful especially when themeId is set
lottiePlayer.setFrame(1);
onLoad(); // call user-defined onLoad function
}
}
function onCompleteEvent() {
onComplete();
}
function onRenderEvent() {
if (lottiePlayer && lottieState) {
syncLottieState(lottiePlayer, lottieState);
progress = (lottiePlayer.currentFrame + 1) / lottiePlayer.totalFrames;
lottieState.progress = progress;
onRender();
}
}
function handleMouseEnter() {
if (playOnHover && lottiePlayer?.isLoaded) {
lottiePlayer.play();
}
}
function handleMouseLeave() {
if (playOnHover && lottiePlayer?.isLoaded) {
lottiePlayer.pause();
}
}
function handleWindowResize() {
let resizing = false;
let timer = undefined;
if (!resizing && lottiePlayer?.isLoaded && lottiePlayer.isPlaying) {
lottiePlayer?.pause();
resizing = true;
}
clearTimeout(timer);
timer = setTimeout(() => {
resizing = false;
if (lottiePlayer?.isLoaded && lottiePlayer.isPaused) {
lottiePlayer?.play();
}
}, 1000);
}
onMount(() => {
const shouldAutoplay = autoplay && !playOnHover;
progressTween = new Tween(0, { duration: tweenDuration, easing: easing });
const _renderConfig = createRenderConfig();
lottiePlayer = new DotLottie({
canvas,
src,
autoplay: shouldAutoplay,
loop,
speed,
data,
renderConfig: _renderConfig,
segment,
useFrameInterpolation,
backgroundColor,
mode,
animationId,
themeId,
});
lottiePlayer.addEventListener('load', onLoadEvent);
lottiePlayer.addEventListener('frame', onRenderEvent);
lottiePlayer.addEventListener('complete', onCompleteEvent);
window.addEventListener('resize', handleWindowResize);
if (dotLottieRefCallback) {
dotLottieRefCallback(lottiePlayer);
}
return () => {
if (lottiePlayer) {
lottiePlayer.removeEventListener('render', onRender);
lottiePlayer.removeEventListener('load', onLoad);
lottiePlayer.destroy();
}
window.removeEventListener('resize', handleWindowResize);
};
});
// Handles progress change
$effect(() => {
if (lottieState.isLoaded && lottieState.progress !== progress) {
autoplay = false;
lottiePlayer?.pause();
loop = false;
if (progress >= 0 && progress <= 1) {
if (lottieState.isFrozen) {
lottiePlayer?.unfreeze();
lottieState.isFrozen = false;
}
const targetFrame = calculateTargetFrame(progress, mode, start, end);
progressTween.target = targetFrame;
// lottiePlayer.setFrame(targetFrame);
} else if ((progress < 0 || progress > 1) && !lottieState.isFrozen) {
if (isReverseMode(mode)) {
progressTween.target = progress < 0 ? end : start;
} else {
progressTween.target = progress < 0 ? start : end;
}
lottiePlayer?.freeze();
lottieState.isFrozen = true;
}
}
});
// Tweens to progress value
$effect(() => {
if (progressTween.current >= 0) {
lottiePlayer?.setFrame(progressTween.current);
}
});
// Handles layout change
$effect(() => {
if (
typeof layout === 'object' &&
lottiePlayer?.isLoaded &&
!isEqual(layout, lottiePlayer.layout)
) {
lottiePlayer.setLayout(layout);
}
});
// Handles marker change
$effect(() => {
if (lottieState.isLoaded && lottiePlayer?.marker !== marker) {
if (typeof marker === 'string' && lottiePlayer) {
lottiePlayer.setMarker(marker);
[start, end] = getMarkerRange(lottiePlayer, marker);
lottieState.marker = marker;
} else if (isNullish(marker)) {
lottiePlayer?.setMarker('');
} else {
console.warn('Invalid marker type:', marker);
}
}
});
// Handles speed change
$effect(() => {
if (
lottieState.isLoaded &&
typeof speed == 'number' &&
lottiePlayer?.speed !== speed
) {
lottiePlayer?.setSpeed(speed);
}
});
// Handles frame interpolation change
$effect(() => {
if (
lottieState.isLoaded &&
typeof useFrameInterpolation == 'boolean' &&
lottiePlayer?.useFrameInterpolation !== useFrameInterpolation
) {
lottiePlayer?.setUseFrameInterpolation(useFrameInterpolation);
}
});
// Handles segment change
$effect(() => {
if (lottieState.isLoaded && !isEqual(lottiePlayer?.segment, segment)) {
if (
Array.isArray(segment) &&
segment.length === 2 &&
typeof segment[0] === 'number' &&
typeof segment[1] === 'number'
) {
let [start, end] = segment;
lottiePlayer?.setSegment(start, end);
} else if (segment === null || segment === undefined) {
lottiePlayer?.setSegment(0, lottiePlayer?.totalFrames);
}
}
});
// Handles loop change
$effect(() => {
if (
lottieState.isLoaded &&
typeof loop == 'boolean' &&
lottiePlayer?.loop !== loop
) {
lottiePlayer?.setLoop(loop);
}
});
// Handles autoplay change
$effect(() => {
if (
lottieState.isLoaded &&
typeof autoplay == 'boolean' &&
lottieState.autoplay !== autoplay
) {
lottieState.autoplay = !autoplay;
}
});
// Handles background color change
$effect(() => {
if (
lottieState.isLoaded &&
lottiePlayer?.backgroundColor !== backgroundColor
) {
lottiePlayer?.setBackgroundColor(backgroundColor || '');
}
});
// Handles mode change
$effect(() => {
if (
lottieState.isLoaded &&
typeof mode == 'string' &&
lottiePlayer?.mode !== mode
) {
lottiePlayer?.setMode(mode);
}
});
// Handles src change
$effect(() => {
if (lottieState && src !== prevSrc) {
lottiePlayer?.load({
src,
autoplay,
loop,
speed,
data,
renderConfig,
segment,
useFrameInterpolation,
backgroundColor,
mode,
marker,
layout,
animationId,
themeId,
});
prevSrc = src;
}
});
// Generate new instance if data changes
$effect(() => {
if (lottiePlayer && data !== prevData) {
lottiePlayer.load({
src,
autoplay,
loop,
speed,
data,
renderConfig,
segment,
useFrameInterpolation,
backgroundColor,
mode,
marker,
layout,
animationId,
themeId,
});
prevData = data;
}
});
// Handles animationId change
$effect(() => {
if (
lottieState.isLoaded &&
lottiePlayer?.activeAnimationId !== animationId
) {
lottiePlayer?.loadAnimation(animationId);
}
});
// Handles themeId change
$effect(() => {
if (lottieState.isLoaded && lottiePlayer?.activeThemeId !== themeId) {
lottiePlayer?.setTheme(themeId);
}
});
// Handles themeData change
$effect(() => {
if (lottieState.isLoaded && lottiePlayer?.isLoaded) {
lottiePlayer?.setThemeData(themeData);
}
});
</script>
<div
class="lottie-block"
class:debug-border={showDebugInfo}
style="max-width: {isContainerWidth(width) ?
`var(--${width}-column-width)`
: width};"
>
{#if showDebugInfo && lottiePlayer}
<Debug componentState={lottieState} />
{/if}
<div class="lottie-container" style:height>
<canvas
bind:this={canvas}
bind:clientWidth={canvasWidth}
bind:clientHeight={canvasHeight}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
></canvas>
</div>
{#if children}
{@render children()}
{/if}
</div>
<style lang="scss">
:global(.lottie-block) {
position: relative;
height: 100%;
width: 100%;
margin: 0 auto;
.lottie-container {
width: 100%;
height: 100%;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
}
.debug-border {
border: 1px dashed lightgray;
}
</style>

View file

@ -0,0 +1,145 @@
<script lang="ts">
import Block from '../Block/Block.svelte';
import { fade } from 'svelte/transition';
import { getContext, onDestroy } from 'svelte';
import { Markdown } from '@reuters-graphics/svelte-markdown';
// Types
import type { Snippet } from 'svelte';
import type { LottieState } from './ts/lottieState.svelte';
import type {
ContainerWidth,
LottieForegroundPosition,
} from '../@types/global';
interface ForegroundProps {
id?: string;
class?: string;
startFrame?: number;
endFrame?: number;
children?: Snippet;
backgroundColour?: string;
width?: ContainerWidth;
position?: LottieForegroundPosition | string;
text?: string;
}
let {
id,
class: cls,
startFrame = 0,
endFrame = 10,
children,
backgroundColour = '#000',
width = 'normal',
position = 'center center',
text,
}: ForegroundProps = $props();
let componentState: LottieState | null = $state(getContext('lottieState'));
onDestroy(() => {
componentState = null;
});
</script>
<div class="lottie-foreground-container {cls}" {id}>
{#if componentState?.currentFrame && componentState.currentFrame >= startFrame && componentState.currentFrame <= endFrame}
<div
class="lottie-foreground"
in:fade={{ delay: 100, duration: 200 }}
out:fade={{ delay: 0, duration: 100 }}
>
<!-- Text blurb foreground -->
{#if text}
<Block class="lottie-foreground-block {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}
{@render children()}
{/if}
</div>
{/if}
</div>
<style lang="scss">
@use '../../scss/mixins' as mixins;
.lottie-foreground-container {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 2;
.lottie-foreground {
width: 100%;
height: 100%;
}
}
:global {
.lottie-foreground-block {
position: absolute;
width: 100%;
max-width: calc(mixins.$column-width-normal * 0.9);
height: 100%;
&.center {
left: 50%;
transform: translateX(-50%);
}
&.left {
left: 0;
}
&.right {
right: 0;
}
@media (max-width: 1200px) {
left: 50%;
transform: translateX(-50%);
}
}
.foreground-text {
position: absolute;
left: 50%;
width: 100%;
border-radius: 0.25rem;
@include mixins.fpy-5;
@include mixins.fpx-4;
@include mixins.fm-0;
* {
margin: 0;
padding: 0;
}
&.center {
top: 50%;
transform: translate(-50%, -50%);
}
&.top {
top: 0;
transform: translate(-50%, 50%);
}
&.bottom {
top: 100%;
transform: translate(-50%, -150%);
}
}
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -0,0 +1,64 @@
<script lang="ts">
import BodyText from '../../BodyText/BodyText.svelte';
import ScrollerBase from '../../ScrollerBase/ScrollerBase.svelte';
import Lottie from '../Lottie.svelte';
import LottieSample from '../lottie/themesLottie.zip?url';
// Optional: Bind your own variables to use them in your code.
let count = $state(1);
let index = $state(0);
let offset = $state(0);
let progress = $state(0);
let top = $state(0);
let threshold = $state(0.5);
let bottom = $state(1);
const dummyText = `Greetings, earthling. This placeholder text is running in debug mode. All bugs have been upgraded to features, and all features downgraded to TODOs. If you find yourself reading this, you are now the sysadmin of your own destiny.
Remember the network is down because someone tripped over the Ethernet cable. Keep calm and RTFM before summoning the wizard. In case of panic, type 'kill -9' and hope for the best. End of line. Insert witty comment here.`;
</script>
<BodyText text={dummyText} />
<ScrollerBase
{top}
{threshold}
{bottom}
bind:count
bind:index
bind:offset
bind:progress
query="div.step-foreground-container"
>
{#snippet backgroundSnippet()}
<Lottie src={LottieSample} {progress} height="100lvh" showDebugInfo />
{/snippet}
{#snippet foregroundSnippet()}
<div class="step-foreground-container"><h3>Step 1</h3></div>
<div class="step-foreground-container"><h3>Step 2</h3></div>
<div class="step-foreground-container"><h3>Step 3</h3></div>
<div class="step-foreground-container"><h3>Step 4</h3></div>
<div class="step-foreground-container"><h3>Step 5</h3></div>
{/snippet}
</ScrollerBase>
<BodyText text={dummyText} />
<style lang="scss">
@use '../../../scss/mixins' as mixins;
.step-foreground-container {
height: 100lvh;
width: 50%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
h3 {
width: 100%;
background-color: antiquewhite;
text-align: center;
}
}
</style>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,61 @@
import type { Layout } from '@lottiefiles/dotlottie-web';
export interface LottieState {
[key: string]:
| number
| boolean
| string
| null
| Array<string>
| Array<number>
| [number, number]
| Layout
| undefined;
progress: number;
currentFrame: number;
totalFrames: number;
duration: number;
loop: boolean;
speed: number;
loopCount: number;
mode: string;
isPaused: boolean;
isPlaying: boolean;
isStopped: boolean;
isLoaded: boolean;
isFrozen: boolean;
segment: null | [number, number];
autoplay: boolean;
layout: null | Layout;
allMarkers: Array<string>;
marker: undefined | string;
allThemes: Array<string>;
activeThemeId: null | string;
}
export function createLottieState(): LottieState {
const lottieState = $state<LottieState>({
progress: 0,
currentFrame: 0,
totalFrames: 0,
duration: 0,
loop: false,
speed: 1,
loopCount: 0,
mode: '',
isPaused: false,
isPlaying: false,
isStopped: false,
isLoaded: false,
isFrozen: false,
segment: null,
autoplay: false,
layout: null,
allMarkers: [],
marker: undefined,
allThemes: [],
activeThemeId: null,
});
return lottieState;
}

View file

@ -0,0 +1,45 @@
// Types
import type { Snippet } from 'svelte';
import {
type Config,
type DotLottie as DotLottieType,
} from '@lottiefiles/dotlottie-web';
import { type LottieState } from './lottieState.svelte';
import type { ContainerWidth } from '../../@types/global';
type DotlottieProps = {
autoplay?: Config['autoplay'];
backgroundColor?: Config['backgroundColor'];
data?: Config['data'];
loop?: Config['loop'];
mode?: Config['mode'];
renderConfig?: Config['renderConfig'];
segment?: Config['segment'];
speed?: Config['speed'];
src: Config['src'];
useFrameInterpolation?: Config['useFrameInterpolation'];
marker?: Config['marker'] | undefined;
layout?: Config['layout'];
animationId?: Config['animationId'];
themeId?: Config['themeId'];
playOnHover?: boolean;
themeData?: string;
dotLottieRefCallback?: (dotLottie: DotLottieType) => void;
onLoad?: () => void;
onRender?: () => void;
onComplete?: () => void;
};
export type Props = DotlottieProps & {
// Additional properties can be added here if needed
lottiePlayer?: DotLottieType | undefined;
showDebugInfo?: boolean;
width?: string | ContainerWidth;
height?: string;
lottieState?: LottieState;
progress?: number;
tweenDuration?: number;
easing?: (t: number) => number;
/** Children render function */
children?: Snippet;
};

View file

@ -0,0 +1,127 @@
import type { DotLottie } from '@lottiefiles/dotlottie-web';
import type { LottieState } from './lottieState.svelte';
import type { ContainerWidth } from '$lib/components/@types/global';
function constrain(n: number, low: number, high: number) {
return Math.max(Math.min(n, high), low);
}
export function map(
n: number,
start1: number,
stop1: number,
start2: number,
stop2: number,
withinBounds: boolean = true
) {
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);
}
}
/**
* Syncs the lottie player state with the component's lottie state
*/
export function syncLottieState(
lottiePlayer: DotLottie,
lottieState: LottieState
) {
lottieState.currentFrame = lottiePlayer.currentFrame;
lottieState.totalFrames = lottiePlayer.totalFrames;
lottieState.duration = lottiePlayer.duration;
lottieState.loop = lottiePlayer.loop;
lottieState.speed = lottiePlayer.speed;
lottieState.loopCount = lottiePlayer.loopCount;
lottieState.mode = lottiePlayer.mode;
lottieState.isPaused = lottiePlayer.isPaused;
lottieState.isPlaying = lottiePlayer.isPlaying;
lottieState.isStopped = lottiePlayer.isStopped;
lottieState.isLoaded = lottiePlayer.isLoaded;
lottieState.isFrozen = lottiePlayer.isFrozen;
lottieState.segment = lottiePlayer.segment ?? null;
lottieState.autoplay = lottiePlayer.autoplay ?? false;
lottieState.layout = lottiePlayer.layout ?? null;
lottieState.activeThemeId = lottiePlayer.activeThemeId ?? null;
lottieState.marker = lottiePlayer.marker ?? undefined;
}
/**
* Gets marker info by name
*/
export function getMarkerByName(lottiePlayer: DotLottie, markerName: string) {
return lottiePlayer.markers().find((m) => m.name === markerName);
}
/**
* Gets the start and end frames for a marker
*/
export function getMarkerRange(
lottiePlayer: DotLottie,
markerName: string
): [number, number] {
const marker = getMarkerByName(lottiePlayer, markerName);
const start = marker?.time ?? 0;
const end = start + (marker?.duration ?? 0);
return [start, end];
}
/**
* Calculates target frame based on progress and mode
*/
export function calculateTargetFrame(
progress: number,
mode: string,
start: number,
end: number
): number {
const adjustedProgress =
mode === 'reverse' || mode === 'reverse-bounce' ? 1 - progress : progress;
return map(adjustedProgress, 0, 1, start, end);
}
/**
* Determines if mode is reverse
*/
export function isReverseMode(mode: string): boolean {
return mode === 'reverse' || mode === 'reverse-bounce';
}
/**
* Creates render config with optimized defaults
*/
export function createRenderConfig() {
return {
autoResize: true,
devicePixelRatio:
window.devicePixelRatio > 1 ? window.devicePixelRatio * 0.75 : 1,
freezeOnOffscreen: true,
};
}
/**
* Checks if a value is null or undefined (empty marker check)
*/
export function isNullish(value: unknown): boolean {
return value === null || value === undefined || value === '';
}
/**
* Checks if a value is of type ContainerWidth
*/
export function isContainerWidth(string: string): string is ContainerWidth {
return (
string === 'narrower' ||
string === 'narrow' ||
string === 'normal' ||
string === 'wide' ||
string === 'wider' ||
string === 'widest' ||
string === 'fluid'
);
}

View file

@ -0,0 +1,177 @@
import { Meta, Canvas } from '@storybook/blocks';
import * as PhotoPackStories from './PhotoPack.stories.svelte';
<Meta of={PhotoPackStories} />
# PhotoPack
The `PhotoPack` component makes simple photo grids with custom layouts at various breakpoints.
`images` are defined with their src, alt text, captions and an optional `maxHeight`, which ensures that an image is no taller than that height in any layout.
```javascript
const images = [
{
src: 'https://...',
altText: 'Alt text',
caption: 'Lorem ipsum. REUTERS/Photog',
// Optional max-height of images across all layouts
maxHeight: 800,
},
// ...
];
```
`layouts` optionally define how images are laid out at different breakpoints. You can customise the layouts and group images into `rows` above a certain `breakpoint` by specifying the number of images that should go in that row. For example:
```javascript
const layouts = [
{
breakpoint: 450,
rows: [1, 2, 1],
},
];
```
... tells the component that when the `PhotoPack` container is 450 pixels or wider, it should group the 4 images in 3 rows: 1 in the first, 2 in the second and 1 in the last.
If you don't specify any layouts, the component will use a default responsive layout based on the number of images in your pack.
You can define as many layouts for as many images as you like.
```svelte
<script>
import { PhotoPack } from '@reuters-graphics/graphics-components';
/** Array of photo metadata */
const images = [
{
src: 'https://...',
altText: 'Alt text',
caption: 'Lorem ipsum. REUTERS/Photog',
// Optional max-height of images across all layouts
maxHeight: 800,
},
// ...
];
/** Set the number of photos in each row at various breakpoints */
const layouts = [
{
breakpoint: 450, // Applies to containers wider than 450px
rows: [1, 2, 1], // Number of photos in each row
},
// Another layout for containers wider than 750px
{ breakpoint: 750, rows: [1, 3] },
];
</script>
<PhotoPack {images} {layouts} />
```
<Canvas of={PhotoPackStories.Demo} />
## Using with ArchieML docs
With the graphics kit, you'll likely get your text value from an ArchieML doc...
```yaml
# ArchieML doc
[blocks]
type: photo-pack
id: my-photo-pack # Optional
class: mb-2 # Optional
width: wide # Optional
textWidth: normal # Optional
gap: 10 # Optional; must be a number.
# Array of image metadata
[.images]
src: images/my-img-1.jpg
altText: Alt text
caption: Lorem ipsum. REUTERS/Photog
src: images/my-img-2.jpg
altText: Alt text
caption: Lorem ipsum. REUTERS/Photog
...
[]
[]
```
... which you'll parse out of a ArchieML block object before passing to the `PhotoPack` component.
> **Important ❗**: The prop `gap` must be a number. ArchieML renders all values -- including numbers -- as strings, so convert the `prop` value to a number before passing it to `PhotoPack`.
```svelte
<!-- App.svelte -->
<script>
import { PhotoPack } from '@reuters-graphics/graphics-components';
import { assets } from '$app/paths'; // 👈 If using in the graphics kit...
import content from '$locales/en/content.json';
</script>
{#each content.blocks as block}
{#if block.type === 'photo-pack'}
<!-- Pass `assets` into the image source in graphics kit -->
<PhotoPack
id={block.id}
class={block.class}
width={block.width}
textWidth={block.textWidth}
gap={Number(block.gap)}
images={block.images.map((img) => ({
...img,
src: `${assets}/${img.src}`,
}))}
layouts={[
{ breakpoint: 750, rows: [2, 3] },
{ breakpoint: 450, rows: [1, 2, 2] },
]}
/>
{/if}
{/each}
```
<Canvas of={PhotoPackStories.ArchieML} />
## Smart default layouts
If you don't specify the `layouts` prop, `PhotoPack` will automatically generate responsive layouts based on the number of images and the container width.
**How it works:**
- **Desktop** (1024px+): Number of images per row depends on container width:
- `normal`: max 2 per row
- `wide` / `wider`: max 3 per row
- `widest` / `fluid`: max 4 per row
- **Tablet** (768px+): Always max 2 per row
- **Mobile** (below 768px): 1 per row
The smart defaults use a **bottom-heavy distribution**, meaning earlier rows have fewer images (making them larger and more prominent), while later rows have more images.
**Examples:**
- 5 images, `wide` container, desktop: `[2, 3]` (2 in first row, 3 in second)
- 7 images, `widest` container, desktop: `[3, 4]` (3 in first row, 4 in second)
- 4 images, any container, desktop: `[2, 2]` (evenly distributed)
```svelte
<script>
import { PhotoPack } from '@reuters-graphics/graphics-components';
const images = [
{ src: `${assets}/image1.jpg`, altText: 'Photo 1', caption: 'Caption 1' },
{ src: `${assets}/image2.jpg`, altText: 'Photo 2', caption: 'Caption 2' },
{ src: `${assets}/image3.jpg`, altText: 'Photo 3', caption: 'Caption 3' },
{ src: `${assets}/image4.jpg`, altText: 'Photo 4', caption: 'Caption 4' },
];
</script>
<!-- No layouts prop = smart defaults! -->
<PhotoPack {images} width="wide" />
```

View file

@ -0,0 +1,140 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import PhotoPack from './PhotoPack.svelte';
const { Story } = defineMeta({
title: 'Components/Multimedia/PhotoPack',
component: PhotoPack,
argTypes: {
width: {
control: 'select',
options: ['normal', 'wide', 'wider', 'widest', 'fluid'],
},
textWidth: {
control: 'select',
options: ['normal', 'wide', 'wider', 'widest', 'fluid'],
},
},
});
</script>
<script lang="ts">
import type { ComponentProps } from 'svelte';
type SmartDefaultsArgs = Omit<ComponentProps<typeof PhotoPack>, 'images'> & {
imageCount: number;
};
const defaultImages = [
{
src: 'https://graphics.thomsonreuters.com/cdn/django-tools/media/graphics-gallery/galleries/world-cup-2022/spain-germany-11-27/2022-11-27T194630Z_544493697_UP1E.jpeg',
caption:
"Spain's Sergio Busquets and Aymeric Laporte react before a Germany goal is disallowed following a VAR review.",
altText: 'alt text',
},
{
src: 'https://graphics.thomsonreuters.com/cdn/django-tools/media/graphics-gallery/galleries/world-cup-2022/spain-germany-11-27/2022-11-27T194619Z_2007900040_UP1.jpeg',
caption:
"Spain's Sergio Busquets fouls Germany's Jamal Musiala before being shown yellow card.",
altText: 'alt text',
},
{
src: 'https://graphics.thomsonreuters.com/cdn/django-tools/media/graphics-gallery/galleries/world-cup-2022/spain-germany-11-27/2022-11-27T194619Z_635809122_UP1E.jpeg',
caption:
"Spain's Sergio Busquets is shown a yellow card by referee Danny Desmond Makkelie.",
altText: 'alt text',
},
{
src: 'https://graphics.thomsonreuters.com/cdn/django-tools/media/graphics-gallery/galleries/world-cup-2022/spain-germany-11-27/2022-11-27T191015Z_1293757566_UP1.jpeg',
caption:
"Spain's Sergio Busquets in action with Germany's Thomas Muller.",
altText: 'alt text',
},
];
const defaultLayouts = [
{ breakpoint: 450, rows: [1, 2, 1] },
{ breakpoint: 750, rows: [1, 3] },
];
const allImages = [
{
src: 'https://graphics.thomsonreuters.com/cdn/django-tools/media/graphics-gallery/galleries/world-cup-2022/spain-germany-11-27/2022-11-27T194630Z_544493697_UP1E.jpeg',
caption:
"Spain's Sergio Busquets and Aymeric Laporte react before a Germany goal is disallowed following a VAR review.",
altText: 'alt text',
},
{
src: 'https://graphics.thomsonreuters.com/cdn/django-tools/media/graphics-gallery/galleries/world-cup-2022/spain-germany-11-27/2022-11-27T194619Z_2007900040_UP1.jpeg',
caption:
"Spain's Sergio Busquets fouls Germany's Jamal Musiala before being shown yellow card.",
altText: 'alt text',
},
{
src: 'https://graphics.thomsonreuters.com/cdn/django-tools/media/graphics-gallery/galleries/world-cup-2022/spain-germany-11-27/2022-11-27T194619Z_635809122_UP1E.jpeg',
caption:
"Spain's Sergio Busquets is shown a yellow card by referee Danny Desmond Makkelie.",
altText: 'alt text',
},
{
src: 'https://graphics.thomsonreuters.com/cdn/django-tools/media/graphics-gallery/galleries/world-cup-2022/spain-germany-11-27/2022-11-27T191015Z_1293757566_UP1.jpeg',
caption:
"Spain's Sergio Busquets in action with Germany's Thomas Muller.",
altText: 'alt text',
},
{
src: 'https://graphics.thomsonreuters.com/cdn/django-tools/media/graphics-gallery/galleries/world-cup-2022/spain-germany-11-27/2022-11-27T203612Z_1399473226_UP1.jpeg',
caption: "Spain's Alvaro Morata celebrates scoring their first goal.",
altText: 'alt text',
},
];
const archieMLBlock = {
id: 'my-photo-pack',
class: 'mb-2',
width: 'wide' as const,
textWidth: 'normal' as const,
gap: Number('15'),
images: allImages.slice(0, 5),
layouts: [
{ breakpoint: 750, rows: [2, 3] },
{ breakpoint: 450, rows: [1, 2, 2] },
],
};
</script>
<Story
name="Demo"
args={{
width: 'wide',
textWidth: 'normal',
images: defaultImages,
layouts: defaultLayouts,
}}
/>
<Story name="ArchieML" args={archieMLBlock} />
<Story
name="Smart layouts"
args={{
width: 'wide',
textWidth: 'normal',
// @ts-expect-error - imageCount is a custom arg for this story's template
imageCount: 4,
}}
argTypes={{
// @ts-expect-error - imageCount is a custom arg for this story's template
imageCount: {
control: { type: 'range', min: 2, max: 5, step: 1 },
description:
'Number of images to display (demonstrates smart default layouts)',
},
}}
>
{#snippet children(args)}
{@const { imageCount, ...photoPackProps } = args as SmartDefaultsArgs}
<PhotoPack
{...photoPackProps}
images={allImages.slice(0, imageCount || 4)}
/>
{/snippet}
</Story>

View file

@ -0,0 +1,151 @@
<!-- @component `PhotoPack` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-multimedia-photopack--docs) -->
<script lang="ts">
import Block from '../Block/Block.svelte';
import PaddingReset from '../PaddingReset/PaddingReset.svelte';
import { Markdown } from '@reuters-graphics/svelte-markdown';
// Utils
import { random4 } from '../../utils';
import { groupRows, generateDefaultLayouts } from './utils';
// Types
export interface Image {
src: string;
altText: string;
caption?: string;
maxHeight?: number;
}
export interface Layout {
breakpoint: number;
rows: number[];
}
type ContainerWidth = 'normal' | 'wide' | 'wider' | 'widest' | 'fluid';
interface Props {
/** Array of image objects */
images: Image[];
/** Array of layout objects */
layouts?: Layout[];
/** Gap between images. */
gap?: number;
/** Add an ID to target with SCSS. Should be unique from all other elements. */
id?: string;
/** Add a class to target with SCSS. */
class?: string;
/** Width of the component within the text well: 'normal' | 'wide' | 'wider' | 'widest' | 'fluid' */
width: ContainerWidth;
/** Set a different width for captions within the text well. For example, "normal" to keep captions inline with the rest of the text well.
*
* Can't ever be wider than `width`: 'normal' | 'wide' | 'wider' | 'widest' | 'fluid' */
textWidth: ContainerWidth;
}
let {
images,
layouts,
gap = 15,
id = 'photopack-' + random4() + random4(),
class: cls = '',
width = 'normal',
textWidth = 'normal',
}: Props = $props();
let containerWidth = $state(0); // or undefined?
/**
*
* Sort layouts by descending breakpoints.
*
* @NOTE - We can't use `sort` directly on the array because it mutates the original array; we can't update a state inside a derived expression: https://svelte.dev/docs/svelte/runtime-errors#Client-errors-state_unsafe_mutation
*
* We avoid `toSorted` because it's not supported on older iPhones. Instead, we create a shallow copy using the spread operator and then sort that copy.
*
* If no layouts are provided, we generate smart defaults based on the container width and number of images.
*/
let sortedLayouts = $derived(
layouts ?
[...layouts].sort((a, b) => (a.breakpoint < b.breakpoint ? 1 : -1))
: generateDefaultLayouts(images.length, width)
);
let layout = $derived(
sortedLayouts?.find(
(l) =>
// Must have valid rows schema, i.e., adds to the total number of images
l.rows.reduce((a, b) => a + b, 0) === images.length &&
// Breakpoint is higher than container width
(containerWidth || 0) >= l.breakpoint
)
);
let rows = $derived(groupRows(images, layout));
</script>
<Block {width} {id} class="photopack fmy-6 {cls}">
<div class="photopack-container w-full" bind:clientWidth={containerWidth}>
{#each rows as row, ri}
<div
class="photopack-row flex justify-between"
style:gap="0 {gap}px"
style:margin-block-end={ri < rows.length - 1 ? gap + 'px' : ''}
>
{#each row as img, i}
<figure
class="relative m-0 p-0 flex-1"
aria-labelledby="{id}-figure-{ri}-{i}"
>
<img
class="m-0 w-full h-full object-cover"
src={img.src}
alt={img.altText}
style:max-height={img.maxHeight ? img.maxHeight + 'px' : ''}
/>
{#if !img.altText}
<div class="alt-warning absolute text-xxs py-1 px-2">altText</div>
{/if}
</figure>
{/each}
</div>
{/each}
</div>
<PaddingReset containerIsFluid={width === 'fluid'}>
<div class="notes contents">
<Block width={textWidth} class="photopack-captions-container">
{#each rows as row, ri}
{#each row as img, i}
{#if img.caption}
<div id="{id}-figure-{ri}-{i}" class="caption">
<Markdown source={img.caption} />
</div>
{/if}
{/each}
{/each}
</Block>
</div>
</PaddingReset>
</Block>
<style lang="scss">
@use '../../scss/mixins' as mixins;
div.photopack-container {
div.photopack-row {
figure {
div.alt-warning {
background-color: red;
color: white;
top: 0;
right: 0;
}
}
}
}
.notes {
:global(.photopack-captions-container .caption p) {
@include mixins.body-caption;
}
}
</style>

View file

@ -0,0 +1,94 @@
import type { Image, Layout } from './PhotoPack.svelte';
// Breakpoint constants for smart default layouts
export const DESKTOP_BREAKPOINT = 1024;
export const TABLET_BREAKPOINT = 768;
/**
* Generates a smart layout for a given number of images with bottom-heavy distribution.
* Avoids single-image rows by redistributing when necessary.
*
* @param imageCount - Total number of images
* @param maxPerRow - Maximum images per row
* @param breakpoint - Breakpoint threshold for this layout
* @returns Layout object with rows array
*/
export const generateSmartLayout = (
imageCount: number,
maxPerRow: number,
breakpoint: number
): Layout => {
// Handle edge cases
if (imageCount === 0) return { breakpoint, rows: [] };
if (imageCount === 1) return { breakpoint, rows: [1] };
const fullRows = Math.floor(imageCount / maxPerRow);
const remainder = imageCount % maxPerRow;
let rows: number[] = [];
if (remainder === 0) {
// Perfect division: all rows have maxPerRow
rows = Array(fullRows).fill(maxPerRow);
} else {
// Bottom-heavy: smaller row at top, larger rows below
// This makes early images larger (fewer per row = bigger display size)
rows = [remainder, ...Array(fullRows).fill(maxPerRow)];
}
return {
breakpoint,
rows,
};
};
type ContainerWidth = 'normal' | 'wide' | 'wider' | 'widest' | 'fluid';
/**
* Generates smart default layouts for desktop and tablet breakpoints.
* Mobile (below TABLET_BREAKPOINT) automatically shows 1 image per row.
*
* Max images per row by container width:
* - normal: 2
* - wide/wider: 3
* - widest/fluid: 4
*
* @param imageCount - Total number of images
* @param width - Container width setting
* @returns Array of 2 layouts [desktop, tablet]
*/
export const generateDefaultLayouts = (
imageCount: number,
width: ContainerWidth
): Layout[] => {
// Map container width to max images per row for desktop
const desktopMaxPerRow =
width === 'normal' ? 2
: width === 'widest' || width === 'fluid' ? 4
: 3;
// Tablet always uses max 2 per row
const tabletMaxPerRow = 2;
return [
generateSmartLayout(imageCount, desktopMaxPerRow, DESKTOP_BREAKPOINT),
generateSmartLayout(imageCount, tabletMaxPerRow, TABLET_BREAKPOINT),
];
};
export const groupRows = (images: Image[], layout?: Layout) => {
// Default layout, one img per row
if (!layout) return images.map((img) => [img]);
// Otherwise, chunk into rows according to layout scheme
let i = 0;
const rows = [];
for (const rowLength of layout.rows) {
const row = [];
for (const imgI of [...Array(rowLength).keys()]) {
row.push(images[imgI + i]);
}
rows.push(row);
i += rowLength;
}
return rows;
};

View file

@ -0,0 +1,51 @@
<script lang="ts">
import type { ScrollerStep } from '../@types/global';
interface Props {
index: number;
steps: ScrollerStep[];
preload?: number;
stackBackground?: boolean;
}
let { index, steps, preload = 1, stackBackground = true }: Props = $props();
function showStep(i: number) {
if (preload === 0) return true;
if (stackBackground) return i >= 0;
return i >= index - preload && i <= index + preload;
}
function isVisible(i: number) {
if (stackBackground) return i <= index;
return i === index;
}
</script>
{#each steps as step, i}
{#if showStep(i)}
<div
class={`step-background step-${i + 1} w-full absolute`}
class:visible={isVisible(i)}
class:invisible={!isVisible(i)}
>
<step.background {...step.backgroundProps || {}}></step.background>
</div>
{/if}
{/each}
<style lang="scss">
.step-background {
opacity: 0;
will-change: opacity;
transition: 0.35s opacity ease;
&.visible {
opacity: 1;
}
&.invisible {
opacity: 0;
}
}
</style>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import type { ContainerWidth, ScrollerStep } from '../../@types/global';
import Block from '../../Block/Block.svelte';
interface Props {
step: ScrollerStep;
backgroundWidth: ContainerWidth;
index: number;
}
let { step, backgroundWidth, index }: Props = $props();
</script>
<Block width={backgroundWidth} class="background-container step-{index + 1}">
<div class="embedded-background step-{index + 1}" aria-hidden="true">
<step.background {...step.backgroundProps || {}}></step.background>
</div>
</Block>

Some files were not shown because too many files have changed in this diff Show more