Squashy
24
.gitignore
vendored
Normal 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
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
47
README.md
|
|
@ -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
|
|
@ -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
|
|
@ -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
3
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 655 B |
9
public/favicon.svg
Normal 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
|
After Width: | Height: | Size: 16 MiB |
BIN
src/assets/Photo-3471.jpg
Executable file
|
After Width: | Height: | Size: 12 MiB |
BIN
src/assets/Photo-3515.jpg
Executable file
|
After Width: | Height: | Size: 16 MiB |
BIN
src/assets/Photo-3585.jpg
Executable file
|
After Width: | Height: | Size: 18 MiB |
128
src/components/@types/global.ts
Normal 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;
|
||||
}
|
||||
117
src/components/Article/Article.mdx
Normal 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} />
|
||||
117
src/components/Article/Article.stories.svelte
Normal 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>
|
||||
70
src/components/Article/Article.svelte
Normal 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>
|
||||
99
src/components/Block/Block.mdx
Normal 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>
|
||||
```
|
||||
181
src/components/Block/Block.stories.svelte
Normal 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>
|
||||
82
src/components/Block/Block.svelte
Normal 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>
|
||||
115
src/components/BodyText/BodyText.mdx
Normal 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
102
src/components/BodyText/BodyText.stories.svelte
Normal 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>
|
||||
19
src/components/BodyText/BodyText.svelte
Normal 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>
|
||||
50
src/components/Framer/Dropdown/index.svelte
Normal 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>
|
||||
19
src/components/Framer/Framer.mdx
Normal 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} />
|
||||
```
|
||||
19
src/components/Framer/Framer.stories.svelte
Normal 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/',
|
||||
],
|
||||
}}
|
||||
/>
|
||||
225
src/components/Framer/Framer.svelte
Normal 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>
|
||||
243
src/components/Framer/Resizer/index.svelte
Normal 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>
|
||||
101
src/components/Framer/Typeahead/Search.svelte
Normal 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>
|
||||
108
src/components/Framer/Typeahead/fuzzy.ts
Normal 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;
|
||||
375
src/components/Framer/Typeahead/index.svelte
Normal 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>
|
||||
3
src/components/Framer/stores.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export const width = writable(660);
|
||||
54
src/components/Framer/uniqNames.ts
Normal 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('/');
|
||||
});
|
||||
};
|
||||
27
src/components/Geocoder/Geocoder.mdx
Normal 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} />
|
||||
23
src/components/Geocoder/Geocoder.stories.svelte
Normal 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>
|
||||
303
src/components/Geocoder/Geocoder.svelte
Normal 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>
|
||||
94
src/components/Geocoder/geocode.ts
Normal 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 (1–10). 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 ?? [];
|
||||
}
|
||||
215
src/components/GraphicBlock/GraphicBlock.mdx
Normal 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} />
|
||||
134
src/components/GraphicBlock/GraphicBlock.stories.svelte
Normal 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>
|
||||
148
src/components/GraphicBlock/GraphicBlock.svelte
Normal 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>
|
||||
22
src/components/GraphicBlock/components/AriaHidden.svelte
Normal 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}
|
||||
23
src/components/GraphicBlock/components/TextBlock.svelte
Normal 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}
|
||||
630
src/components/GraphicBlock/demo/ai2svelte/ai-chart.svelte
Normal 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>
|
||||
BIN
src/components/GraphicBlock/demo/imgs/ai-chart-md.png
Normal file
|
After Width: | Height: | Size: 618 KiB |
BIN
src/components/GraphicBlock/demo/imgs/ai-chart-sm.png
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
src/components/GraphicBlock/demo/imgs/ai-chart-xs.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
src/components/GraphicBlock/demo/placeholder.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
150
src/components/Headline/Headline.mdx
Normal 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 Russia’s 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} />
|
||||
111
src/components/Headline/Headline.stories.svelte
Normal 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 Russia’s 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>
|
||||
161
src/components/Headline/Headline.svelte
Normal 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>
|
||||
BIN
src/components/Headline/demo/crown.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src/components/Headline/demo/graphic-lg.png
Normal file
|
After Width: | Height: | Size: 363 KiB |
BIN
src/components/Headline/demo/graphic-md.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
src/components/Headline/demo/graphic-sm.png
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
src/components/Headline/demo/graphic-xl.png
Normal file
|
After Width: | Height: | Size: 519 KiB |
BIN
src/components/Headline/demo/graphic-xs.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
962
src/components/Headline/demo/graphic.svelte
Normal 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>
|
||||
355
src/components/HorizontalScroller/Debug.svelte
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
<script lang="ts">
|
||||
import { map } from './utils';
|
||||
|
||||
const { componentState } = $props();
|
||||
|
||||
let isMoving = $state(false);
|
||||
let preventDetails = $state(false);
|
||||
let position = $state({ x: 8, y: 8 });
|
||||
|
||||
const fmt = new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
isMoving = true;
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (isMoving) {
|
||||
position = {
|
||||
x: position.x + e.movementX,
|
||||
y: position.y + e.movementY,
|
||||
};
|
||||
preventDetails = true;
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onMouseUp(e: MouseEvent) {
|
||||
if (isMoving) {
|
||||
isMoving = false;
|
||||
setTimeout(() => {
|
||||
preventDetails = false;
|
||||
}, 5);
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
if (preventDetails) {
|
||||
e.preventDefault();
|
||||
}
|
||||
isMoving = false;
|
||||
}
|
||||
|
||||
let normalisedScrollProgress = $derived(
|
||||
map(
|
||||
componentState.mappedProgress,
|
||||
componentState.mappedStart ?? 0,
|
||||
componentState.mappedEnd ?? 1,
|
||||
0,
|
||||
1
|
||||
)
|
||||
);
|
||||
|
||||
let normalisedProgress = $derived(
|
||||
map(
|
||||
componentState.easedProgress,
|
||||
componentState.mappedStart ?? 0,
|
||||
componentState.mappedEnd ?? 1,
|
||||
0,
|
||||
1
|
||||
)
|
||||
);
|
||||
|
||||
function mappedStop(stop: number): number {
|
||||
return map(
|
||||
stop,
|
||||
componentState.mappedStart ?? 0,
|
||||
componentState.mappedEnd ?? 1,
|
||||
0,
|
||||
1
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onmousemove={onMouseMove} />
|
||||
|
||||
{#snippet triggerPoints()}
|
||||
{#if componentState.triggerStops.length > 0}
|
||||
{#if componentState.scrubbed}
|
||||
{@const totalStops = componentState.triggerStops.length}
|
||||
{#each Array(totalStops) as _, index}
|
||||
<span
|
||||
class="stops"
|
||||
style={`left: ${((index + 1) / (totalStops + 1)) * 100}%;`}>|</span
|
||||
>
|
||||
{/each}
|
||||
{:else}
|
||||
{@const stops = componentState.triggerStops.map((x: number) =>
|
||||
mappedStop(x)
|
||||
)}
|
||||
{#each stops as stop, index}
|
||||
{#if index < stops.length - 1}
|
||||
<span
|
||||
class="stops"
|
||||
style={`left: ${(stop + (stops[index + 1] ?? stops[stops.length - 1])) * 0.5 * 100}%;`}
|
||||
>|</span
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div
|
||||
style="position: absolute; top: {position.y}px; left: {position.x}px; z-index: 5; user-select: none;"
|
||||
role="region"
|
||||
>
|
||||
<details class="debug-info" open>
|
||||
<summary
|
||||
class="text-xxs font-sans font-bold title"
|
||||
style="grid-column: span 2;"
|
||||
onmousedown={onMouseDown}
|
||||
onmouseup={onMouseUp}
|
||||
onclick={onClick}
|
||||
>
|
||||
CONSOLE
|
||||
</summary>
|
||||
<div class="state-debug">
|
||||
<!-- -->
|
||||
<p>Progress:</p>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<p class="state-value">
|
||||
{componentState.progress}
|
||||
</p>
|
||||
</div>
|
||||
<!-- -->
|
||||
<p>Mapped progress:</p>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<p class="state-value progress-value">
|
||||
{@render triggerPoints()}
|
||||
<span
|
||||
class="progress-stop"
|
||||
style={`left: ${normalisedScrollProgress * 100}%; transform: translateX(-50%);`}
|
||||
>{fmt.format(componentState.mappedProgress)}</span
|
||||
>
|
||||
|
||||
</p>
|
||||
<div id="video-progress-bar">
|
||||
<div
|
||||
style="width: {normalisedScrollProgress * 100}%; height: 100%;"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- -->
|
||||
<p>Eased Progress:</p>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<p class="state-value progress-value">
|
||||
{#if componentState.stops.length > 0}
|
||||
{#each componentState.stops as stop}
|
||||
<span class="stops" style={`left: ${mappedStop(stop) * 100}%;`}
|
||||
>{stop}</span
|
||||
>
|
||||
{/each}
|
||||
{/if}
|
||||
<span
|
||||
class="progress-stop"
|
||||
style={`left: ${normalisedProgress * 100}%; transform: translateX(-50%);`}
|
||||
>{fmt.format(componentState.easedProgress)}</span
|
||||
>
|
||||
|
||||
</p>
|
||||
<div id="video-progress-bar">
|
||||
<div style="width: {normalisedProgress * 100}%; height: 100%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- -->
|
||||
<p>Direction:</p>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<p class="state-value">
|
||||
<span class="tag">{componentState.direction}</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- -->
|
||||
{#if componentState.stops.length > 0}
|
||||
<p>Stops:</p>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<p
|
||||
class="state-value"
|
||||
style="display: flex; gap: 4px; flex-wrap: wrap;"
|
||||
>
|
||||
{#each componentState.stops as stop}
|
||||
<span class="tag">{stop}</span>
|
||||
{/each}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- -->
|
||||
<p>Handle scroll:</p>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<p class="state-value">
|
||||
<span class="tag">{componentState.handleScroll}</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- -->
|
||||
<p>Scrubbed:</p>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<p class="state-value">
|
||||
<span class="tag">{componentState.scrubbed}</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- -->
|
||||
<p>Easing:</p>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<p class="state-value">
|
||||
{componentState.easing}
|
||||
</p>
|
||||
</div>
|
||||
<!-- -->
|
||||
<p>
|
||||
Duration:
|
||||
{#if componentState.scrubbed}
|
||||
<span class="tag not-applicable">NA</span>
|
||||
{/if}
|
||||
</p>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<p class="state-value">
|
||||
<span class="tag">{componentState.duration}</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- -->
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
* {
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.debug-info {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: rgba(0, 0, 0, 1);
|
||||
z-index: 3;
|
||||
margin: 0;
|
||||
width: 50vmin;
|
||||
min-width: 50vmin;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
resize: horizontal;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.3s ease;
|
||||
filter: drop-shadow(0 0 16px rgba(0, 0, 0, 0.5));
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
interpolate-size: allow-keywords;
|
||||
}
|
||||
|
||||
&::details-content {
|
||||
opacity: 0;
|
||||
block-size: 0;
|
||||
overflow-y: clip;
|
||||
transition:
|
||||
content-visibility 0.4s allow-discrete,
|
||||
opacity 0.4s,
|
||||
block-size 0.4s cubic-bezier(0.87, 0, 0.13, 1);
|
||||
}
|
||||
|
||||
&[open]::details-content {
|
||||
opacity: 1;
|
||||
block-size: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
width: 100%;
|
||||
color: white;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.debug-info[open] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
div.state-debug {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
padding: 8px 8px 16px 8px;
|
||||
grid-template-columns: 20vmin 1fr;
|
||||
align-items: center;
|
||||
gap: 0.75rem 0.25rem;
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--theme-font-size-xxs);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 100%;
|
||||
font-variant: tabular-nums;
|
||||
}
|
||||
|
||||
.state-value {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stops {
|
||||
position: absolute;
|
||||
opacity: 0.5;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.progress-stop {
|
||||
position: absolute;
|
||||
opacity: 1;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
#video-progress-bar {
|
||||
width: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
height: 2px;
|
||||
border-radius: 50px;
|
||||
// margin: auto;
|
||||
|
||||
div {
|
||||
background-color: white;
|
||||
border-radius: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 0.1rem 0.2rem;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.not-applicable {
|
||||
background-color: #4a0000;
|
||||
color: #ff8a80;
|
||||
}
|
||||
</style>
|
||||
447
src/components/HorizontalScroller/HorizontalScroller.mdx
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
import { Meta } from '@storybook/blocks';
|
||||
|
||||
import * as HorizontalScrollerStories from './HorizontalScroller.stories.svelte';
|
||||
|
||||
import IllustratorScreenshot from './assets/illustrator.png';
|
||||
|
||||
<Meta of={HorizontalScrollerStories} />
|
||||
|
||||
# HorizontalScroller
|
||||
|
||||
The `HorizontalScroller` component creates a horizontal scrolling section that scrolls through any child content wider than `100vw`.
|
||||
|
||||
To use `HorizontalScroller`, wrap it around the content that you want to horizontally scroll through. The scroll length is controlled by the height of the `HorizontalScroller` container, which is set by the prop `height`. `height` defaults to `200lvh`, but you can adjust this to any valid CSS height value such as `1200px` or `400lvh`.
|
||||
|
||||
The child content inside the `HorizontalScroller` must be wider than `100vw` so that there is overflow to horizontal scroll through. By default, only the top `100lvh` of the child content is visible. You can use CSS `transform: translate()` on the child content to adjust its vertical positioning within the visible area.
|
||||
|
||||
> 💡TIP: Use `lvh` or `svh` units instead of `vh` unit for the height, as [these units](https://www.w3.org/TR/css-values-4/#large-viewport-size) are more reliable on mobile or other devices where elements such as the address bar toggle between being shown and hidden.
|
||||
|
||||
> 💡TIP: Set the `showDebugInfo` prop to `true` to visualise the scroll progress and other useful information.
|
||||
|
||||
See the full list of available props under the `Controls` tab in the [demo](?path=/story/components-graphics-horizontalscroller--demo).
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import {
|
||||
HorizontalScroller,
|
||||
Block,
|
||||
} from '@reuters-graphics/graphics-components';
|
||||
</script>
|
||||
|
||||
<!-- Wrap `HorizontalScroller` in a fluid container for a full bleed experience -->
|
||||
<Block width="fluid">
|
||||
<!-- Optionally set `height` prop to adjust scroll length. Defaults to `200lvh` -->
|
||||
<HorizontalScroller>
|
||||
<!-- Child content wider than 100vw. Only the top 100lvh is visible. -->
|
||||
<div style="width: 400vw; height: 100lvh;">
|
||||
<img
|
||||
src="my-wide-image.jpg"
|
||||
alt="alt text"
|
||||
style="width: 100%; height: 100%; object-fit: cover; padding: 0; margin: 0;"
|
||||
/>
|
||||
</div>
|
||||
</HorizontalScroller>
|
||||
</Block>
|
||||
```
|
||||
|
||||
## Controlling scroll behaviour with stops and easing
|
||||
|
||||
The `HorizontalScroller` allows you to control the horizontal scroll behaviour and pacing with various props.
|
||||
|
||||
**`stops`:**
|
||||
|
||||
`stops` is an optional prop that accepts an array of numbers between `0` and `1`. At these points, which corresponds to the scroll `progress` values, the scrolling stops or slows down. This is useful for adding custom pauses based on progress.
|
||||
|
||||
For example, as shown in the demo below, if you define `stops` as `[0.2, 0.5, 0.9]`, the scrolling will pause or slow down at these `progress` values as the user scrolls through the `HorizontalScroller` section.
|
||||
|
||||
**`scrubbed`:**
|
||||
|
||||
The `scrubbed` prop controls whether the scrolling is tied exactly to the scroll position (`scrubbed: true`) or is smoothed out (`scrubbed: false`). This prop defaults to `true`.
|
||||
|
||||
If `scrubbed` is set to `false` and `stops` are defined, the scrolling transitions smoothly between the stop values.
|
||||
|
||||
**`easing`** and **`duration`**:
|
||||
|
||||
`easing` accepts any easing function from `svelte/easing` or a custom easing function, while `duration` sets the time, in milliseconds, for each transition between stops.
|
||||
|
||||
So, if the stops are at irregular intervals — for example, `[0.2, 0.9]` — the scroll to the first stop will be much quicker than the scroll to the second stop since the distance to travel is different but the duration of the transition is the same.
|
||||
|
||||
By default, `duration` is set to `400` milliseconds.
|
||||
|
||||
[Demo](?path=/story/components-graphics-horizontalscroller--with-stops)
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import {
|
||||
HorizontalScroller,
|
||||
Block,
|
||||
} from '@reuters-graphics/graphics-components';
|
||||
import { quartInOut } from 'svelte/easing';
|
||||
</script>
|
||||
|
||||
<!-- Wrap `HorizontalScroller` in a fluid container for a full bleed experience -->
|
||||
<Block width="fluid">
|
||||
<HorizontalScroller
|
||||
stops={[0.2, 0.5, 0.9]}
|
||||
duration={400}
|
||||
scrubbed={false}
|
||||
easing={quartInOut}
|
||||
showDebugInfo={true}
|
||||
>
|
||||
<!-- Child content wider than 100vw. Only the top 100lvh is visible. -->
|
||||
<div style="width: 200vw; height: 100lvh;">
|
||||
<img
|
||||
src="my-wide-image.jpg"
|
||||
alt="alt text"
|
||||
style="width: 100%; height: 100%; object-fit: cover; padding: 0; margin: 0;"
|
||||
/>
|
||||
</div>
|
||||
</HorizontalScroller>
|
||||
</Block>
|
||||
```
|
||||
|
||||
## Extended boundaries
|
||||
|
||||
`HorizontalScroller` has `mappedStart` and `mappedEnd` props, which extend the horizontal scroll boundaries beyond the default 0 to 1 range. This is useful when you want to create an overscroll effect or have more control over the horizontal scroll range. By default, these values are set to 0 and 1 respectively.
|
||||
|
||||
If using custom `mappedStart` and `mappedEnd` values, you must also set `stops` values that are within the mapped range.
|
||||
|
||||
> 💡TIP: In the debugging info box, `Progress` indicates the raw scroll progress value between `0` and `1`. `Mapped Progress` indicates the vertical progress mapped to `mappedStart` and `mappedEnd`. If they are not set, `Mapped Progress` is bound between 0 and 1 and matches `Progress`. `Eased Progress` indicates the scroll progress with any stops and easing applied. `Eased Progress` is what reflects the actual transition of the horizontal scroll position.
|
||||
|
||||
[Demo](?path=/story/components-graphics-horizontalscroller--extended-boundaries)
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import {
|
||||
HorizontalScroller,
|
||||
Block,
|
||||
} from '@reuters-graphics/graphics-components';
|
||||
import { quartInOut } from 'svelte/easing';
|
||||
</script>
|
||||
|
||||
<!-- Wrap `HorizontalScroller` in a fluid container for a full bleed experience -->
|
||||
<Block width="fluid">
|
||||
<HorizontalScroller
|
||||
mappedStart={-0.5}
|
||||
mappedEnd={1.5}
|
||||
stops={[0, 1]}
|
||||
showDebugInfo={true}
|
||||
easing={quartInOut}
|
||||
>
|
||||
<!-- Child content wider than 100vw. Only the top 100lvh is visible. -->
|
||||
<div style="width: 200vw; height: 100lvh;">
|
||||
<img
|
||||
src="my-wide-image.jpg"
|
||||
alt="alt text"
|
||||
style="width: 100%; height: 100%; object-fit: cover; padding: 0; margin: 0;"
|
||||
/>
|
||||
</div>
|
||||
</HorizontalScroller>
|
||||
</Block>
|
||||
```
|
||||
|
||||
## With ai2svelte components
|
||||
|
||||
With [ai2svelte](https://reuters-graphics.github.io/ai2svelte/) v1.0.3 onwards, you can export your ai2svelte graphic with a wider-than-viewport layout and use it directly inside `HorizontalScroller` to create horizontally scrolling graphics.
|
||||
|
||||
To do that, follow these steps:
|
||||
|
||||
1. In Illustrator, rename your artboard with the breakpoint at which you want that artboard to be visible on the page. For example, to make the XL artboard visible on viewports wider than 1200px, rename it to `xl:1200`. You can have multiple artboards with different breakpoints.
|
||||
2. Add these properties to the ai2svelte settings and run the script to export the component.
|
||||
|
||||
```yaml
|
||||
include_resizer_css: false
|
||||
respect_height: true
|
||||
allow_overflow: true
|
||||
```
|
||||
|
||||
<img
|
||||
src={IllustratorScreenshot}
|
||||
alt="Screenshot showing Illustrator document with artboard panel"
|
||||
/>
|
||||
|
||||
[Demo](?path=/story/components-graphics-horizontalscroller--scrollable-ai-2-svelte)
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import {
|
||||
HorizontalScroller,
|
||||
Block,
|
||||
} from '@reuters-graphics/graphics-components';
|
||||
import AiGraphic from './ai2svelte/ai-graphic.svelte';
|
||||
|
||||
// If using with the graphics kit
|
||||
import { assets } from '$app/paths';
|
||||
|
||||
// Optional easing function
|
||||
import { sineInOut } from 'svelte/easing';
|
||||
</script>
|
||||
|
||||
<!-- Wrap `HorizontalScroller` in a fluid container for a full bleed experience -->
|
||||
<Block width="fluid">
|
||||
<HorizontalScroller height="800lvh" easing={sineInOut} showDebugInfo>
|
||||
<AiGraphic assetsPath={assets} />
|
||||
</HorizontalScroller>
|
||||
</Block>
|
||||
```
|
||||
|
||||
## With ai2svelte components: advanced
|
||||
|
||||
You can use the bound prop `progress` to create advanced interactivity with an ai2svelte graphic.
|
||||
|
||||
The demo below has 2 advanced interactions: fade in/out of caption boxes based on scroll position and parallax movement of a `png` layer.
|
||||
|
||||
### Captions fading in/out
|
||||
|
||||
Caption boxes are exported as `htext` [tagged layers](https://reuters-graphics.github.io/ai2svelte/users/tagged-layers/) in ai2svelte. In this example, we use the `handleScroll()` function to check the position of each caption box relative to the viewport width and set its opacity to `1` (visible) or `0` (hidden) based on whether the caption box is within the `threshold` of the viewport. In Adobe Illustrator, set `override_text: true` in the ai2svelte export settings to allow custom HTML content in tagged text layers.
|
||||
|
||||
### Parallax effect with png layer
|
||||
|
||||
This demo has a tagged `png` [layer](https://reuters-graphics.github.io/ai2svelte/users/tagged-layers/), which contains the foreground overlay image. The `handleScroll()` function uses the bound `progress` value to calculate a horizontal translation for the `png` layer, creating a parallax effect as the user scrolls through the `HorizontalScroller`.
|
||||
|
||||
[Demo](?path=/story/components-graphics-horizontalscroller--scrollable-ai-2-svelte-advanced)
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import {
|
||||
HorizontalScroller,
|
||||
Block,
|
||||
} from '@reuters-graphics/graphics-components';
|
||||
import AiGraphic from './ai2svelte/ai-graphic.svelte';
|
||||
import { sineInOut } from 'svelte/easing';
|
||||
|
||||
// If using with the graphics kit
|
||||
import { assets } from '$app/paths';
|
||||
|
||||
// bind progress for advanced interactivity
|
||||
let progress: number = $state(0);
|
||||
let pngLayer: HTMLElement | null;
|
||||
let captions: HTMLElement[] | null;
|
||||
let threshold = 0.8;
|
||||
let screenWidth: number = $state(0);
|
||||
|
||||
function handleScroll() {
|
||||
// Create a parallax movement for the foreground png layer
|
||||
if (pngLayer) {
|
||||
pngLayer.style.transform = `scale(1.5) translateX(${map(progress, 0, 1, -15, 85)}%)`;
|
||||
}
|
||||
|
||||
// For each caption, checks if position of the caption is below the threshold.
|
||||
// If it is, show it. If not, hide it
|
||||
if (captions?.length) {
|
||||
captions.forEach((caption) => {
|
||||
let captionWidth = caption.getBoundingClientRect().width;
|
||||
let captionMidpoint =
|
||||
caption.getBoundingClientRect().left + captionWidth / 2;
|
||||
|
||||
if (
|
||||
captionMidpoint < screenWidth * threshold &&
|
||||
caption.style.opacity !== '1'
|
||||
) {
|
||||
caption.style.opacity = '1';
|
||||
} else if (
|
||||
captionMidpoint > screenWidth * threshold &&
|
||||
caption.style.opacity !== '0'
|
||||
) {
|
||||
caption.style.opacity = '0';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Refetch new captions and png image every time the artboard changes
|
||||
function onArtboardChange(artboard: HTMLElement) {
|
||||
pngLayer = artboard.querySelector('.g-png-layer-overlay');
|
||||
captions = Array.from(artboard.querySelectorAll('.g-captions'));
|
||||
|
||||
if (pngLayer) {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.addEventListener('scroll', handleScroll, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
// to translate overlay layer on initial load
|
||||
handleScroll();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Wrap `HorizontalScroller` in a fluid container for a full bleed experience -->
|
||||
<Block width="fluid">
|
||||
<HorizontalScroller
|
||||
height="800lvh"
|
||||
bind:progress
|
||||
easing={sineInOut}
|
||||
showDebugInfo={true}
|
||||
>
|
||||
<AiGraphic
|
||||
assetsPath={assets}
|
||||
{onArtboardChange}
|
||||
taggedText={{
|
||||
htext: {
|
||||
captions: {
|
||||
caption1:
|
||||
'<div class="scroller-caption"><strong>Caption 1!</strong><br/>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>',
|
||||
caption2:
|
||||
'<div class="scroller-caption"><strong>Caption 2!</strong><br/>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>',
|
||||
caption3:
|
||||
'<div class="scroller-caption"><strong>Caption 3!</strong><br/>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>',
|
||||
caption4:
|
||||
'<div class="scroller-caption"><strong>Caption 4!</strong><br/>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</HorizontalScroller>
|
||||
</Block>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.scroller-caption) {
|
||||
padding: 1rem;
|
||||
margin: 0;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
filter: drop-shadow(0px 2px 16px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## With custom child components
|
||||
|
||||
You can create a custom horizontal layout with any component and pass it as a child to the `HorizontalScroller`. Here's an example with `DatawrapperChart`, `Headline` and ai2svelte components laid out in a horizontal scroll.
|
||||
|
||||
[Demo](?path=/story/components-graphics-horizontalscroller--custom-children)
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import {
|
||||
Block,
|
||||
DatawrapperChart,
|
||||
Headline,
|
||||
HorizontalScroller,
|
||||
} from '@reuters-graphics/graphics-components';
|
||||
import AiChart from './ai2svelte/ai-chart.svelte';
|
||||
</script>
|
||||
|
||||
<!-- Wrap `HorizontalScroller` in a fluid container for a full bleed experience -->
|
||||
<Block width="fluid">
|
||||
<HorizontalScroller>
|
||||
<div id="horizontal-stack">
|
||||
<div style="width: 100vw;">
|
||||
<DatawrapperChart
|
||||
title="Global abortion access"
|
||||
ariaLabel="map"
|
||||
id="abortion-rights-map"
|
||||
src="https://graphics.reuters.com/USA-ABORTION/lgpdwggnwvo/media-embed.html"
|
||||
frameTitle=""
|
||||
scrolling="no"
|
||||
textWidth="normal"
|
||||
width="wider"
|
||||
/>
|
||||
</div>
|
||||
<div style="width: 100vw;">
|
||||
<Headline
|
||||
hed="Reuters Graphics Interactive"
|
||||
dek="The beginning of a beautiful page"
|
||||
section="World News"
|
||||
/>
|
||||
</div>
|
||||
<div style="width: 100vw;">
|
||||
<Block width="normal">
|
||||
<AiChart />
|
||||
</Block>
|
||||
</div>
|
||||
</div>
|
||||
</HorizontalScroller>
|
||||
</Block>
|
||||
|
||||
<style lang="scss">
|
||||
#horizontal-stack {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
gap: 10vw;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## With ScrollerBase
|
||||
|
||||
You can also integrate HorizontalScroller with `ScrollerBase` for a horizontal scroll with vertical captions.
|
||||
|
||||
When using `HorizontalScroller` with `ScrollerBase` or other scrollers, you must:
|
||||
|
||||
- Create a `progress` state variable and bind it to both `ScrollerBase` and `HorizontalScroller`
|
||||
- Set `HorizontalScroller`'s `height` to `100lvh`
|
||||
- Set `handleScroll` to `false`
|
||||
|
||||
> **⚠️ Warning:** It is not recommended to use HorizontalScroller with vertical ScrollerBase. This example is only to serve the purpose of demonstrating how to control the HorizontalScroller with an external progress value (ScrollerBase's progress in this case).
|
||||
|
||||
[Demo](?path=/story/components-graphics-horizontalscroller--with-scroller-base)
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import {
|
||||
HorizontalScroller,
|
||||
ScrollerBase,
|
||||
Block,
|
||||
} from '@reuters-graphics/graphics-components';
|
||||
import AiGraphic from './ai2svelte/ai-graphic.svelte';
|
||||
import { circInOut } from 'svelte/easing';
|
||||
|
||||
// Optional: Bind your own variables to use them in your code.
|
||||
let progress = $state(0);
|
||||
</script>
|
||||
|
||||
<ScrollerBase bind:progress query="div.step-foreground-container">
|
||||
{#snippet backgroundSnippet()}
|
||||
<!-- Wrap `HorizontalScroller` in a fluid container for a full bleed experience -->
|
||||
<Block width="fluid">
|
||||
<HorizontalScroller
|
||||
bind:progress
|
||||
height="100lvh"
|
||||
handleScroll={false}
|
||||
showDebugInfo={true}
|
||||
>
|
||||
<AiGraphic />
|
||||
</HorizontalScroller>
|
||||
</Block>
|
||||
{/snippet}
|
||||
{#snippet foregroundSnippet()}
|
||||
<!-- Add custom foreground HTML or component -->
|
||||
<div class="step-foreground-container"><p>Step 1</p></div>
|
||||
<div class="step-foreground-container"><p>Step 2</p></div>
|
||||
<div class="step-foreground-container"><p>Step 3</p></div>
|
||||
<div class="step-foreground-container"><p>Step 4</p></div>
|
||||
<div class="step-foreground-container"><p>Step 5</p></div>
|
||||
{/snippet}
|
||||
</ScrollerBase>
|
||||
|
||||
<style lang="scss">
|
||||
.step-foreground-container {
|
||||
height: 100vh;
|
||||
width: 50%;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
padding: 1em;
|
||||
margin: 0 auto 10px 0;
|
||||
position: relative;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
|
||||
p {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import { quartInOut } from 'svelte/easing';
|
||||
|
||||
import HorizontalScroller from './HorizontalScroller.svelte';
|
||||
import DemoComponent from './demo/Demo.svelte';
|
||||
import DemoSnippetBlock from './demo/DemoSnippet.svelte';
|
||||
import CustomChildrenBlock from './demo/CustomChildrenSnippet.svelte';
|
||||
import ScrollableGraphic from './demo/ScrollableGraphic.svelte';
|
||||
import AdvancedScrollableGraphic from './demo/AdvancedScrollableGraphic.svelte';
|
||||
import WithScrollerBaseComponent from './demo/withScrollerBase.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/Graphics/HorizontalScroller',
|
||||
component: HorizontalScroller,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
chromatic: {
|
||||
disableSnapshot: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let width: number = $state(0);
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth={width} />
|
||||
|
||||
<Story name="Demo">
|
||||
<DemoComponent>
|
||||
<DemoSnippetBlock />
|
||||
</DemoComponent>
|
||||
</Story>
|
||||
|
||||
<Story name="With stops and easing" exportName="WithStops">
|
||||
<DemoComponent
|
||||
stops={[0.2, 0.5, 0.9]}
|
||||
duration={400}
|
||||
toggleScrub={true}
|
||||
easing={quartInOut}
|
||||
>
|
||||
<DemoSnippetBlock />
|
||||
</DemoComponent>
|
||||
</Story>
|
||||
|
||||
<Story name="Extended boundaries">
|
||||
<DemoComponent
|
||||
mappedStart={-0.5}
|
||||
mappedEnd={1.5}
|
||||
easing={quartInOut}
|
||||
stops={[0, 1]}
|
||||
>
|
||||
<DemoSnippetBlock />
|
||||
</DemoComponent>
|
||||
</Story>
|
||||
|
||||
<Story name="Custom children">
|
||||
<DemoComponent>
|
||||
<CustomChildrenBlock />
|
||||
</DemoComponent>
|
||||
</Story>
|
||||
|
||||
<Story name="Scrollable ai2svelte">
|
||||
<ScrollableGraphic />
|
||||
</Story>
|
||||
|
||||
<Story name="Scrollable ai2svelte (advanced)">
|
||||
<AdvancedScrollableGraphic />
|
||||
</Story>
|
||||
|
||||
<Story name="With ScrollerBase">
|
||||
<WithScrollerBaseComponent />
|
||||
</Story>
|
||||
254
src/components/HorizontalScroller/HorizontalScroller.svelte
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
<script lang="ts">
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import { Tween } from 'svelte/motion';
|
||||
import { clamp, map } from './utils/index';
|
||||
import type { Action } from 'svelte/action';
|
||||
|
||||
import Debug from './Debug.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Optional id for the scroller container */
|
||||
id?: string;
|
||||
/** Optional additional classes for the scroller container */
|
||||
class?: string;
|
||||
/** Height of the scroller container in CSS `vh` units. Set it to `100lvh` when using inside ScrollerBase. */
|
||||
height?: string;
|
||||
/** Bindable progress value. Ideal range: `[0-1]`. Bind ScrollerBase's progress to this prop. */
|
||||
progress?: number;
|
||||
/** Direction of movement*/
|
||||
direction?: 'left' | 'right';
|
||||
/** Content to scroll*/
|
||||
children?: Snippet;
|
||||
/** Array of numbers desired as stops for the scroller */
|
||||
stops?: number[];
|
||||
/** Should the component handle scroll events? Set it to `false` when using inside ScrollerBase. */
|
||||
handleScroll?: boolean;
|
||||
/** Whether the stops should be scrubbed */
|
||||
scrubbed?: boolean;
|
||||
/** Easing function for the progress/stops */
|
||||
easing?: (t: number) => number;
|
||||
/** Duration of the easing animation in milliseconds. Effective only when scrubbed is false. */
|
||||
duration?: number;
|
||||
/** Whether to show debug info */
|
||||
showDebugInfo?: boolean;
|
||||
/** Modified starting scale. Default is 0 */
|
||||
mappedStart?: number;
|
||||
/** Modified ending scale. Default is 1 */
|
||||
mappedEnd?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
id = '',
|
||||
class: cls = '',
|
||||
height = '200lvh',
|
||||
direction = 'right',
|
||||
progress = $bindable(0),
|
||||
mappedStart = 0,
|
||||
mappedEnd = 1,
|
||||
children,
|
||||
stops = [],
|
||||
handleScroll = true,
|
||||
scrubbed = true,
|
||||
easing: ease = (t) => t,
|
||||
duration = 400,
|
||||
showDebugInfo = false,
|
||||
}: Props = $props();
|
||||
|
||||
let easedProgress: Tween<number> = $state(
|
||||
new Tween(mappedStart, { duration, easing: ease })
|
||||
);
|
||||
let container: HTMLDivElement | undefined = $state(undefined);
|
||||
let containerHeight: number = $state(0);
|
||||
let containerWidth: number = $state(0);
|
||||
let content: HTMLDivElement | undefined = $state(undefined);
|
||||
let contentWidth: number = $state(0);
|
||||
let screenHeight: number = $state(0);
|
||||
let divisions: number[] = $derived(
|
||||
[...stops, mappedStart, mappedEnd].sort((a, b) => a - b)
|
||||
);
|
||||
let divisionsCount: number = $derived.by(() => divisions.length - 1);
|
||||
|
||||
let mappedProgress: number = $state(0);
|
||||
|
||||
// handles horizontal translation of the content
|
||||
let translateX: number = $derived.by(() => {
|
||||
let processedProgress = clamp(
|
||||
easedProgress.current,
|
||||
mappedStart,
|
||||
mappedEnd
|
||||
);
|
||||
let normalisedProgress = processedProgress;
|
||||
|
||||
normalisedProgress =
|
||||
direction === 'right' ? processedProgress : mappedEnd - processedProgress;
|
||||
|
||||
const translate = -(contentWidth - containerWidth) * normalisedProgress;
|
||||
|
||||
return translate;
|
||||
});
|
||||
|
||||
let componentState = $derived.by(() => ({
|
||||
progress,
|
||||
mappedProgress,
|
||||
easedProgress: easedProgress.current,
|
||||
direction,
|
||||
mappedStart,
|
||||
mappedEnd,
|
||||
triggerStops: scrubbed ? stops : divisions,
|
||||
stops: stops,
|
||||
handleScroll,
|
||||
scrubbed,
|
||||
easing: ease,
|
||||
duration,
|
||||
}));
|
||||
|
||||
onMount(() => {
|
||||
// Initialize mappedProgress to mappedStart on mount
|
||||
mappedProgress = mappedStart;
|
||||
});
|
||||
|
||||
const scrollListener: Action = () => {
|
||||
if (handleScroll) {
|
||||
window.addEventListener('scroll', handleScrollFunction, {
|
||||
passive: true,
|
||||
});
|
||||
} else {
|
||||
window.addEventListener('scroll', () => handleStops(progress), {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// calculates distance scrolled inside the container
|
||||
function handleScrollFunction() {
|
||||
if (!container) return;
|
||||
|
||||
progress =
|
||||
(-container?.offsetTop + window?.scrollY) /
|
||||
(containerHeight - screenHeight);
|
||||
|
||||
handleStops(progress);
|
||||
}
|
||||
|
||||
// updates easedProgress based on stops and scrubbed settings
|
||||
function handleStops(rawProgress: number) {
|
||||
mappedProgress = map(rawProgress, 0, 1, mappedStart, mappedEnd);
|
||||
|
||||
if (!stops || stops.length === 0) {
|
||||
easedProgress.set(ease(map(rawProgress, 0, 1, mappedStart, mappedEnd)), {
|
||||
duration: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scrubbed) {
|
||||
for (let i = 0; i < divisions.length; i++) {
|
||||
if (
|
||||
mappedProgress > divisions[i] &&
|
||||
mappedProgress <=
|
||||
(divisions[i + 1] ?? divisions[divisions.length - 1])
|
||||
) {
|
||||
const midPoint =
|
||||
divisions[i] +
|
||||
((divisions[i + 1] ?? divisions[divisions.length - 1]) -
|
||||
divisions[i]) *
|
||||
0.5;
|
||||
if (
|
||||
mappedProgress >= midPoint &&
|
||||
easedProgress.target !==
|
||||
(divisions[i + 1] ?? divisions[divisions.length - 1])
|
||||
) {
|
||||
easedProgress.set(
|
||||
divisions[i + 1] ?? divisions[divisions.length - 1]
|
||||
);
|
||||
return;
|
||||
} else if (
|
||||
mappedProgress < midPoint &&
|
||||
easedProgress.target !== divisions[i]
|
||||
) {
|
||||
easedProgress.set(divisions[i]);
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
mappedProgress <
|
||||
divisions[0] + (divisions[1] ?? mappedStart) * 0.5
|
||||
) {
|
||||
if (easedProgress.target !== divisions[0]) {
|
||||
easedProgress.set(divisions[0]);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < divisions.length; i++) {
|
||||
let oneByDivCount = 1 / divisionsCount;
|
||||
|
||||
let normalStart = i == 0 ? mappedStart : oneByDivCount * i;
|
||||
let normalEnd =
|
||||
i == divisionsCount - 1 ? mappedEnd : oneByDivCount * (i + 1);
|
||||
|
||||
if (mappedProgress >= normalStart && mappedProgress < normalEnd) {
|
||||
let stopStart = divisions[i];
|
||||
let stopEnd = divisions[i + 1] ?? mappedEnd;
|
||||
let newProgressVal =
|
||||
stopStart +
|
||||
ease(map(mappedProgress, normalStart, normalEnd, 0, 1)) *
|
||||
(stopEnd - stopStart);
|
||||
|
||||
easedProgress.set(newProgressVal, { duration: 0 });
|
||||
return;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerHeight={screenHeight} />
|
||||
|
||||
<div
|
||||
{id}
|
||||
class="horizontal-scroller-container {cls}"
|
||||
style="height: {height};"
|
||||
bind:this={container}
|
||||
bind:clientHeight={containerHeight}
|
||||
bind:clientWidth={containerWidth}
|
||||
>
|
||||
<div
|
||||
class="horizontal-scroller-content"
|
||||
bind:this={content}
|
||||
bind:clientWidth={contentWidth}
|
||||
style="transform: translateX({translateX}px);"
|
||||
use:scrollListener
|
||||
>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
{#if showDebugInfo}
|
||||
<div
|
||||
class="debug-info"
|
||||
style={`position: absolute; left: ${-translateX}px; top: 0px;`}
|
||||
>
|
||||
<Debug {componentState} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.horizontal-scroller-container {
|
||||
width: 100%;
|
||||
contain: paint;
|
||||
}
|
||||
|
||||
.horizontal-scroller-content {
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
max-height: 100lvh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
</style>
|
||||
BIN
src/components/HorizontalScroller/assets/illustrator.png
Normal file
|
After Width: | Height: | Size: 574 KiB |
|
|
@ -0,0 +1,103 @@
|
|||
<script lang="ts">
|
||||
import Demo from './graphic/ai2svelte/demo.svelte';
|
||||
import BodyText from '../../BodyText/BodyText.svelte';
|
||||
import Block from '../../Block/Block.svelte';
|
||||
|
||||
import HorizontalScroller from '../HorizontalScroller.svelte';
|
||||
import { map } from '../utils/index';
|
||||
import { sineInOut } from 'svelte/easing';
|
||||
|
||||
const foobarText: string =
|
||||
'In the mystical land of Foobaristan, the legendary hero Foo set out on an epic quest to find his missing semicolon, only to discover that Bar had accidentally used it as a bookmark inside a JSON file. Naturally, the entire kingdom crashed immediately. As the villagers panicked, Foo and Bar tried to fix the situation by turning everything off and on again, but all that did was anger the ancient deity known as “The Build System,” which now demanded three sacrifices: a clean cache, a fresh node_modules folder, and someone’s weekend. And thus began the saga nobody asked for, yet every developer somehow relates to.';
|
||||
|
||||
let progress: number = $state(0);
|
||||
let pngLayer: HTMLElement | null;
|
||||
let captions: HTMLElement[] | null;
|
||||
let threshold = 0.8;
|
||||
let screenWidth: number = $state(0);
|
||||
|
||||
function handleScroll() {
|
||||
if (pngLayer) {
|
||||
pngLayer.style.transform = `scale(1.5) translateX(${map(progress, 0, 1, -15, 85)}%)`;
|
||||
}
|
||||
|
||||
if (captions?.length) {
|
||||
captions.forEach((caption) => {
|
||||
let captionWidth = caption.getBoundingClientRect().width;
|
||||
let captionMidpoint =
|
||||
caption.getBoundingClientRect().left + captionWidth / 2;
|
||||
|
||||
if (
|
||||
captionMidpoint < screenWidth * threshold &&
|
||||
caption.style.opacity !== '1'
|
||||
) {
|
||||
caption.style.opacity = '1';
|
||||
} else if (
|
||||
captionMidpoint > screenWidth * threshold &&
|
||||
caption.style.opacity !== '0'
|
||||
) {
|
||||
caption.style.opacity = '0';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onArtboardChange(artboard: HTMLElement) {
|
||||
pngLayer = artboard.querySelector('.g-png-layer-overlay');
|
||||
captions = Array.from(artboard.querySelectorAll('.g-captions'));
|
||||
|
||||
if (pngLayer) {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.addEventListener('scroll', handleScroll, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
// to translate overlay layer on initial load
|
||||
handleScroll();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth={screenWidth} />
|
||||
|
||||
<BodyText text={foobarText} />
|
||||
|
||||
<Block width="fluid">
|
||||
<HorizontalScroller
|
||||
height="800lvh"
|
||||
bind:progress
|
||||
easing={sineInOut}
|
||||
showDebugInfo
|
||||
>
|
||||
<Demo
|
||||
{onArtboardChange}
|
||||
taggedText={{
|
||||
htext: {
|
||||
captions: {
|
||||
caption1:
|
||||
'<div class="scroller-caption"><strong>Caption 1!</strong><br/>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>',
|
||||
caption2:
|
||||
'<div class="scroller-caption"><strong>Caption 2!</strong><br/>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>',
|
||||
caption3:
|
||||
'<div class="scroller-caption"><strong>Caption 3!</strong><br/>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>',
|
||||
caption4:
|
||||
'<div class="scroller-caption"><strong>Caption 4!</strong><br/>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</div>',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</HorizontalScroller>
|
||||
</Block>
|
||||
|
||||
<BodyText text={foobarText} />
|
||||
|
||||
<style lang="scss">
|
||||
:global(.scroller-caption) {
|
||||
padding: 1rem;
|
||||
margin: 0;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
filter: drop-shadow(0px 2px 16px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<script lang="ts">
|
||||
import DatawrapperChart from '../../DatawrapperChart/DatawrapperChart.svelte';
|
||||
import Headline from '../../Headline/Headline.svelte';
|
||||
import AiChart from './graphic/ai2svelte/ai-chart.svelte';
|
||||
import Block from '../../Block/Block.svelte';
|
||||
</script>
|
||||
|
||||
<div id="horizontal-stack">
|
||||
<div style="width: 100vw;">
|
||||
<DatawrapperChart
|
||||
title="Global abortion access"
|
||||
ariaLabel="map"
|
||||
id="abortion-rights-map"
|
||||
src="https://graphics.reuters.com/USA-ABORTION/lgpdwggnwvo/media-embed.html"
|
||||
frameTitle=""
|
||||
scrolling="no"
|
||||
textWidth="normal"
|
||||
width="wider"
|
||||
/>
|
||||
</div>
|
||||
<div style="width: 100vw;">
|
||||
<Headline
|
||||
hed="Reuters Graphics Interactive"
|
||||
dek="The beginning of a beautiful page"
|
||||
section="World News"
|
||||
/>
|
||||
</div>
|
||||
<div style="width: 100vw;">
|
||||
<Block width="normal">
|
||||
<AiChart />
|
||||
</Block>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
#horizontal-stack {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
gap: 10vw;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
42
src/components/HorizontalScroller/demo/Demo.svelte
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<script lang="ts">
|
||||
import HorizontalScroller from '../HorizontalScroller.svelte';
|
||||
import BodyText from '../../BodyText/BodyText.svelte';
|
||||
import Block from '../../Block/Block.svelte';
|
||||
|
||||
let { ...args } = $props();
|
||||
|
||||
const foobarText: string =
|
||||
'In the mystical land of Foobaristan, the legendary hero Foo set out on an epic quest to find his missing semicolon, only to discover that Bar had accidentally used it as a bookmark inside a JSON file. Naturally, the entire kingdom crashed immediately. As the villagers panicked, Foo and Bar tried to fix the situation by turning everything off and on again, but all that did was anger the ancient deity known as “The Build System,” which now demanded three sacrifices: a clean cache, a fresh node_modules folder, and someone’s weekend. And thus began the saga nobody asked for, yet every developer somehow relates to.';
|
||||
|
||||
// For the `scrubbed` demo
|
||||
let scrubbed: boolean = $state(true);
|
||||
</script>
|
||||
|
||||
<BodyText text={foobarText} />
|
||||
|
||||
{#if args.toggleScrub}
|
||||
<Block>
|
||||
<button onclick={() => (scrubbed = !scrubbed)}>
|
||||
Toggle scrubbed: {scrubbed}
|
||||
</button>
|
||||
</Block>
|
||||
{/if}
|
||||
|
||||
<Block width="fluid">
|
||||
<HorizontalScroller showDebugInfo={true} {...args} {scrubbed} />
|
||||
</Block>
|
||||
|
||||
<BodyText text={foobarText} />
|
||||
|
||||
<style lang="scss">
|
||||
button {
|
||||
cursor: pointer;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #f0f0f0;
|
||||
border: 2px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<div style="width: 400vw; height: 100lvh;">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1533282960533-51328aa49826?q=80&w=3642&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
|
||||
alt="An ultra wide scenic view of cityscape"
|
||||
style="width: 100%; height: 100%; object-fit: cover; padding: 0; margin: 0; background-color: #ccc;"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts">
|
||||
import Demo from './graphic/ai2svelte/demo.svelte';
|
||||
import BodyText from '../../BodyText/BodyText.svelte';
|
||||
import Block from '../../Block/Block.svelte';
|
||||
import HorizontalScroller from '../HorizontalScroller.svelte';
|
||||
import { sineInOut } from 'svelte/easing';
|
||||
|
||||
const foobarText: string =
|
||||
'In the mystical land of Foobaristan, the legendary hero Foo set out on an epic quest to find his missing semicolon, only to discover that Bar had accidentally used it as a bookmark inside a JSON file. Naturally, the entire kingdom crashed immediately. As the villagers panicked, Foo and Bar tried to fix the situation by turning everything off and on again, but all that did was anger the ancient deity known as “The Build System,” which now demanded three sacrifices: a clean cache, a fresh node_modules folder, and someone’s weekend. And thus began the saga nobody asked for, yet every developer somehow relates to.';
|
||||
</script>
|
||||
|
||||
<BodyText text={foobarText} />
|
||||
|
||||
<Block width="fluid">
|
||||
<HorizontalScroller height="800lvh" easing={sineInOut}>
|
||||
<Demo />
|
||||
</HorizontalScroller>
|
||||
</Block>
|
||||
<BodyText text={foobarText} />
|
||||
|
||||
<style lang="scss">
|
||||
:global(.scroller-caption) {
|
||||
padding: 1rem;
|
||||
margin: 0;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 8px;
|
||||
filter: drop-shadow(0px 2px 16px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,630 @@
|
|||
<script lang="ts">
|
||||
// For demo purposes only, hard-wiring img paths from Vite
|
||||
// @ts-ignore img
|
||||
import chartXs from '../imgs/ai-chart-xs.png';
|
||||
// @ts-ignore img
|
||||
import chartSm from '../imgs/ai-chart-sm.png';
|
||||
// @ts-ignore img
|
||||
import chartMd from '../imgs/ai-chart-md.png';
|
||||
|
||||
let width = $state<number>();
|
||||
</script>
|
||||
|
||||
<!-- Generated by ai2html v0.100.0 - 2021-09-29 12:37 -->
|
||||
|
||||
<div id="g-_ai-chart-box" bind:clientWidth={width}>
|
||||
<!-- Artboard: xs -->
|
||||
{#if width && width >= 0 && width < 510}
|
||||
<div id="g-_ai-chart-xs" class="g-artboard" style="">
|
||||
<div style="padding: 0 0 91.7004% 0;"></div>
|
||||
<div
|
||||
id="g-_ai-chart-xs-img"
|
||||
class="g-aiImg"
|
||||
style={`background-image: url(${chartXs});`}
|
||||
></div>
|
||||
<div
|
||||
id="g-ai0-1"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:3.216%;margin-top:-7.7px;left:0.5952%;width:99px;"
|
||||
>
|
||||
<p class="g-pstyle0">Shake intensity</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai0-2"
|
||||
class="g-legend g-aiAbs g-aiPointText"
|
||||
style="top:9.8251%;margin-top:-7.7px;left:4.9821%;width:47px;"
|
||||
>
|
||||
<p class="g-pstyle0">Light</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai0-3"
|
||||
class="g-legend g-aiAbs g-aiPointText"
|
||||
style="top:15.7733%;margin-top:-7.7px;left:4.9821%;width:69px;"
|
||||
>
|
||||
<p class="g-pstyle0">Moderate</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai0-4"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:16.4343%;margin-top:-7.7px;left:79.0675%;width:82px;"
|
||||
>
|
||||
<p class="g-pstyle0">Cap-Haitien</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai0-5"
|
||||
class="g-legend g-aiAbs g-aiPointText"
|
||||
style="top:21.7216%;margin-top:-7.7px;left:4.9821%;width:55px;"
|
||||
>
|
||||
<p class="g-pstyle0">Strong</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai0-6"
|
||||
class="g-legend g-aiAbs g-aiPointText"
|
||||
style="top:28.0002%;margin-top:-7.7px;left:4.9821%;width:78px;"
|
||||
>
|
||||
<p class="g-pstyle0">Very strong</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai0-7"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:28.9916%;margin-top:-7.7px;left:62.2348%;width:68px;"
|
||||
>
|
||||
<p class="g-pstyle0">Gonaïves</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai0-8"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:39.9449%;margin-top:-14.9px;left:28.714%;margin-left:-36.5px;width:73px;"
|
||||
>
|
||||
<p class="g-pstyle1">Caribbean</p>
|
||||
<p class="g-pstyle1">Sea</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai0-9"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:42.6579%;margin-top:-10.1px;left:68.5061%;width:77px;"
|
||||
>
|
||||
<p class="g-pstyle2">HAITI</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai0-10"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:59.0632%;margin-top:-7.7px;left:11.2526%;width:63px;"
|
||||
>
|
||||
<p class="g-pstyle0">Jeremie</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai0-11"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:61.1155%;margin-top:-8.9px;left:70.5455%;width:106px;"
|
||||
>
|
||||
<p class="g-pstyle3">Port-au-Prince</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai0-12"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:62.1069%;margin-top:-8.9px;left:32.6015%;width:77px;"
|
||||
>
|
||||
<p class="g-pstyle3">Epicenter</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai0-13"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:78.8906%;margin-top:-7.7px;left:63.9138%;width:58px;"
|
||||
>
|
||||
<p class="g-pstyle0">Jacmel</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai0-14"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:80.2124%;margin-top:-7.7px;left:22.5649%;width:71px;"
|
||||
>
|
||||
<p class="g-pstyle0">Les Cayes</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai0-15"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:87.8129%;margin-top:-7.7px;left:0.6179%;width:49px;"
|
||||
>
|
||||
<p class="g-pstyle0">50 mi</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai0-16"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:91.0202%;margin-top:-11.4px;right:10.4418%;width:70px;"
|
||||
>
|
||||
<p class="g-pstyle4">Dominican</p>
|
||||
<p class="g-pstyle4">Republic</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai0-17"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:93.7611%;margin-top:-7.7px;left:0.6179%;width:52px;"
|
||||
>
|
||||
<p class="g-pstyle0">50 km</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Artboard: sm -->
|
||||
{#if width && width >= 510 && width < 660}
|
||||
<div id="g-_ai-chart-sm" class="g-artboard" style="">
|
||||
<div style="padding: 0 0 82.703% 0;"></div>
|
||||
<div
|
||||
id="g-_ai-chart-sm-img"
|
||||
class="g-aiImg"
|
||||
style={`background-image: url(${chartSm});`}
|
||||
></div>
|
||||
<div
|
||||
id="g-ai1-1"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:3.8773%;margin-top:-9.4px;left:0.3278%;width:111px;"
|
||||
>
|
||||
<p class="g-pstyle0">Shake intensity</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai1-2"
|
||||
class="g-legend g-aiAbs g-aiPointText"
|
||||
style="top:9.0933%;margin-top:-9.4px;left:3.0258%;width:52px;"
|
||||
>
|
||||
<p class="g-pstyle0">Light</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai1-3"
|
||||
class="g-legend g-aiAbs g-aiPointText"
|
||||
style="top:13.5979%;margin-top:-9.4px;left:3.0259%;width:77px;"
|
||||
>
|
||||
<p class="g-pstyle0">Moderate</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai1-4"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:16.6801%;margin-top:-9.4px;left:70.3255%;width:92px;"
|
||||
>
|
||||
<p class="g-pstyle0">Cap-Haitien</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai1-5"
|
||||
class="g-legend g-aiAbs g-aiPointText"
|
||||
style="top:18.3397%;margin-top:-9.4px;left:3.0258%;width:61px;"
|
||||
>
|
||||
<p class="g-pstyle0">Strong</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai1-6"
|
||||
class="g-legend g-aiAbs g-aiPointText"
|
||||
style="top:22.6073%;margin-top:-9.4px;left:3.0258%;width:88px;"
|
||||
>
|
||||
<p class="g-pstyle0">Very strong</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai1-7"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:28.5344%;margin-top:-9.4px;left:55.9181%;width:76px;"
|
||||
>
|
||||
<p class="g-pstyle0">Gonaïves</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai1-8"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:38.8091%;margin-top:-17.7px;left:27.2818%;margin-left:-41px;width:82px;"
|
||||
>
|
||||
<p class="g-pstyle1">Caribbean</p>
|
||||
<p class="g-pstyle1">Sea</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai1-9"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:39.9724%;margin-top:-8.6px;left:61.2858%;width:67px;"
|
||||
>
|
||||
<p class="g-pstyle2">HAITI</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai1-10"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:56.985%;margin-top:-9.4px;left:12.2815%;width:69px;"
|
||||
>
|
||||
<p class="g-pstyle0">Jeremie</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai1-11"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:59.1569%;margin-top:-9.5px;left:63.0314%;width:112px;"
|
||||
>
|
||||
<p class="g-pstyle3">Port-au-Prince</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai1-12"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:60.1053%;margin-top:-9.5px;left:30.5543%;width:81px;"
|
||||
>
|
||||
<p class="g-pstyle3">Epicenter</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai1-13"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:62.7194%;margin-top:-16.5px;left:91.2282%;margin-left:-57px;width:114px;"
|
||||
>
|
||||
<p class="g-pstyle4">Dominican</p>
|
||||
<p class="g-pstyle4">Republic</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai1-14"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:75.4778%;margin-top:-9.4px;left:57.3552%;width:64px;"
|
||||
>
|
||||
<p class="g-pstyle0">Jacmel</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai1-15"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:76.6632%;margin-top:-9.4px;left:21.9639%;width:79px;"
|
||||
>
|
||||
<p class="g-pstyle0">Les Cayes</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai1-16"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:85.5251%;margin-top:-7.7px;left:0.1344%;width:49px;"
|
||||
>
|
||||
<p class="g-pstyle5">50 mi</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai1-17"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:90.0297%;margin-top:-7.7px;left:0.1344%;width:52px;"
|
||||
>
|
||||
<p class="g-pstyle5">50 km</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Artboard: md -->
|
||||
{#if width && width >= 660}
|
||||
<div id="g-_ai-chart-md" class="g-artboard" style="">
|
||||
<div style="padding: 0 0 79.6009% 0;"></div>
|
||||
<div
|
||||
id="g-_ai-chart-md-img"
|
||||
class="g-aiImg"
|
||||
style={`background-image: url(${chartMd});`}
|
||||
></div>
|
||||
<div
|
||||
id="g-ai2-1"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:2.3515%;margin-top:-9.4px;left:0.3608%;width:111px;"
|
||||
>
|
||||
<p class="g-pstyle0">Shake intensity</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai2-2"
|
||||
class="g-legend g-aiAbs g-aiPointText"
|
||||
style="top:7.6811%;margin-top:-9.4px;left:2.6603%;width:52px;"
|
||||
>
|
||||
<p class="g-pstyle0">Light</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai2-3"
|
||||
class="g-legend g-aiAbs g-aiPointText"
|
||||
style="top:12.2494%;margin-top:-9.4px;left:2.6604%;width:77px;"
|
||||
>
|
||||
<p class="g-pstyle0">Moderate</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai2-4"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:15.4852%;margin-top:-9.4px;left:70.3606%;width:92px;"
|
||||
>
|
||||
<p class="g-pstyle0">Cap-Haitien</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai2-5"
|
||||
class="g-legend g-aiAbs g-aiPointText"
|
||||
style="top:17.1983%;margin-top:-9.4px;left:2.6603%;width:61px;"
|
||||
>
|
||||
<p class="g-pstyle0">Strong</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai2-6"
|
||||
class="g-legend g-aiAbs g-aiPointText"
|
||||
style="top:21.7666%;margin-top:-9.4px;left:2.6603%;width:88px;"
|
||||
>
|
||||
<p class="g-pstyle0">Very strong</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai2-7"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:27.6672%;margin-top:-9.4px;left:55.993%;width:76px;"
|
||||
>
|
||||
<p class="g-pstyle0">Gonaïves</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai2-8"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:38.0099%;margin-top:-17.7px;left:27.2388%;margin-left:-41px;width:82px;"
|
||||
>
|
||||
<p class="g-pstyle1">Caribbean</p>
|
||||
<p class="g-pstyle1">Sea</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai2-9"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:42.7626%;margin-top:-10.7px;left:62.8914%;width:80px;"
|
||||
>
|
||||
<p class="g-pstyle2">HAITI</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai2-10"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:50.0029%;margin-top:-17.7px;left:92.295%;margin-left:-60.5px;width:121px;"
|
||||
>
|
||||
<p class="g-pstyle3">Dominican</p>
|
||||
<p class="g-pstyle3">Republic</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai2-11"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:57.3608%;margin-top:-9.4px;left:12.2815%;width:69px;"
|
||||
>
|
||||
<p class="g-pstyle0">Jeremie</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai2-12"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:60.2742%;margin-top:-10.7px;left:30.6995%;width:89px;"
|
||||
>
|
||||
<p class="g-pstyle4">Epicenter</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai2-13"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:62.5583%;margin-top:-10.7px;left:66.3403%;width:125px;"
|
||||
>
|
||||
<p class="g-pstyle4">Port-au-Prince</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai2-14"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:75.6338%;margin-top:-9.4px;left:57.8174%;width:64px;"
|
||||
>
|
||||
<p class="g-pstyle0">Jacmel</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai2-15"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:77.3469%;margin-top:-9.4px;left:22.5239%;width:79px;"
|
||||
>
|
||||
<p class="g-pstyle0">Les Cayes</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai2-16"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:86.936%;margin-top:-7.7px;left:0.1678%;width:49px;"
|
||||
>
|
||||
<p class="g-pstyle5">50 mi</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-ai2-17"
|
||||
class="g-map-labels g-aiAbs g-aiPointText"
|
||||
style="top:91.5043%;margin-top:-7.7px;left:0.1678%;width:52px;"
|
||||
>
|
||||
<p class="g-pstyle5">50 km</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- End ai2html - 2021-09-29 12:37 -->
|
||||
|
||||
<!-- ai file: _ai-chart.ai -->
|
||||
<style lang="scss">
|
||||
#g-_ai-chart-box,
|
||||
#g-_ai-chart-box .g-artboard {
|
||||
margin: 0 auto;
|
||||
}
|
||||
#g-_ai-chart-box p {
|
||||
margin: 0;
|
||||
}
|
||||
#g-_ai-chart-box .g-aiAbs {
|
||||
position: absolute;
|
||||
}
|
||||
#g-_ai-chart-box .g-aiImg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
#g-_ai-chart-box .g-aiPointText p {
|
||||
white-space: nowrap;
|
||||
}
|
||||
#g-_ai-chart-xs {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
#g-_ai-chart-xs p {
|
||||
font-family:
|
||||
'Source Sans Pro',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-weight: 400;
|
||||
line-height: 14px;
|
||||
height: auto;
|
||||
opacity: 1;
|
||||
letter-spacing: 0em;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
color: rgb(51, 51, 51);
|
||||
text-transform: none;
|
||||
padding-bottom: 0;
|
||||
padding-top: 0;
|
||||
mix-blend-mode: normal;
|
||||
font-style: normal;
|
||||
position: static;
|
||||
}
|
||||
#g-_ai-chart-xs .g-pstyle0 {
|
||||
height: 14px;
|
||||
}
|
||||
#g-_ai-chart-xs .g-pstyle1 {
|
||||
font-style: italic;
|
||||
height: 14px;
|
||||
text-align: center;
|
||||
color: rgb(113, 115, 117);
|
||||
}
|
||||
#g-_ai-chart-xs .g-pstyle2 {
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
height: 18px;
|
||||
letter-spacing: 0.25em;
|
||||
font-size: 15px;
|
||||
}
|
||||
#g-_ai-chart-xs .g-pstyle3 {
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
height: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
#g-_ai-chart-xs .g-pstyle4 {
|
||||
line-height: 11px;
|
||||
height: 11px;
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
#g-_ai-chart-sm {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
#g-_ai-chart-sm p {
|
||||
font-family:
|
||||
'Source Sans Pro',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-weight: 400;
|
||||
line-height: 17px;
|
||||
height: auto;
|
||||
opacity: 1;
|
||||
letter-spacing: 0em;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
color: rgb(51, 51, 51);
|
||||
text-transform: none;
|
||||
padding-bottom: 0;
|
||||
padding-top: 0;
|
||||
mix-blend-mode: normal;
|
||||
font-style: normal;
|
||||
position: static;
|
||||
}
|
||||
#g-_ai-chart-sm .g-pstyle0 {
|
||||
height: 17px;
|
||||
}
|
||||
#g-_ai-chart-sm .g-pstyle1 {
|
||||
font-style: italic;
|
||||
height: 17px;
|
||||
text-align: center;
|
||||
color: rgb(113, 115, 117);
|
||||
}
|
||||
#g-_ai-chart-sm .g-pstyle2 {
|
||||
font-weight: 700;
|
||||
line-height: 15px;
|
||||
height: 15px;
|
||||
letter-spacing: 0.25em;
|
||||
font-size: 12px;
|
||||
}
|
||||
#g-_ai-chart-sm .g-pstyle3 {
|
||||
font-weight: 700;
|
||||
height: 17px;
|
||||
}
|
||||
#g-_ai-chart-sm .g-pstyle4 {
|
||||
font-weight: 300;
|
||||
line-height: 16px;
|
||||
height: 16px;
|
||||
letter-spacing: 0.25em;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
color: rgb(134, 136, 139);
|
||||
}
|
||||
#g-_ai-chart-sm .g-pstyle5 {
|
||||
line-height: 14px;
|
||||
height: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
#g-_ai-chart-md {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
#g-_ai-chart-md p {
|
||||
font-family:
|
||||
'Source Sans Pro',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-weight: 400;
|
||||
line-height: 17px;
|
||||
height: auto;
|
||||
opacity: 1;
|
||||
letter-spacing: 0em;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
color: rgb(51, 51, 51);
|
||||
text-transform: none;
|
||||
padding-bottom: 0;
|
||||
padding-top: 0;
|
||||
mix-blend-mode: normal;
|
||||
font-style: normal;
|
||||
position: static;
|
||||
}
|
||||
#g-_ai-chart-md .g-pstyle0 {
|
||||
height: 17px;
|
||||
}
|
||||
#g-_ai-chart-md .g-pstyle1 {
|
||||
font-style: italic;
|
||||
height: 17px;
|
||||
text-align: center;
|
||||
color: rgb(113, 115, 117);
|
||||
}
|
||||
#g-_ai-chart-md .g-pstyle2 {
|
||||
font-weight: 700;
|
||||
line-height: 19px;
|
||||
height: 19px;
|
||||
letter-spacing: 0.25em;
|
||||
font-size: 16px;
|
||||
}
|
||||
#g-_ai-chart-md .g-pstyle3 {
|
||||
font-weight: 300;
|
||||
height: 17px;
|
||||
letter-spacing: 0.25em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
color: rgb(134, 136, 139);
|
||||
}
|
||||
#g-_ai-chart-md .g-pstyle4 {
|
||||
font-weight: 700;
|
||||
line-height: 19px;
|
||||
height: 19px;
|
||||
font-size: 16px;
|
||||
}
|
||||
#g-_ai-chart-md .g-pstyle5 {
|
||||
line-height: 14px;
|
||||
height: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Custom CSS */
|
||||
</style>
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
<script lang="ts">
|
||||
// For demo purposes only, hard-wiring img paths from Vite
|
||||
// @ts-ignore img
|
||||
import imageXl from '../imgs/demo-xl.jpg';
|
||||
// @ts-ignore img
|
||||
import imageLg from '../imgs/demo-lg.jpg';
|
||||
// @ts-ignore img
|
||||
import imagePngOverlayXl from '../imgs/layer-overlay-xl.png';
|
||||
// @ts-ignore img
|
||||
import imagePngOverlayLg from '../imgs/layer-overlay-lg.png';
|
||||
|
||||
let {
|
||||
onAiMounted = () => {},
|
||||
onArtboardChange = () => {},
|
||||
taggedText = { text: {}, htext: {} },
|
||||
debugTaggedText = false,
|
||||
artboardWidth = $bindable(undefined),
|
||||
} = $props();
|
||||
import { onMount, untrack } from 'svelte';
|
||||
let aiBox: HTMLElement | undefined;
|
||||
let screenWidth = $state(0);
|
||||
let aiBoxWidth = $derived(artboardWidth ?? screenWidth);
|
||||
let activeArtboard: HTMLElement | undefined = $state(undefined);
|
||||
onMount(() => {
|
||||
onAiMounted();
|
||||
});
|
||||
$effect(() => {
|
||||
if (aiBoxWidth) {
|
||||
const currentArtboard = (aiBox as HTMLElement).querySelectorAll(
|
||||
'.g-artboard'
|
||||
)[0] as HTMLElement;
|
||||
if (currentArtboard?.id !== activeArtboard?.id) {
|
||||
activeArtboard = untrack(() => currentArtboard);
|
||||
onArtboardChange(activeArtboard);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth={screenWidth} />
|
||||
|
||||
<div
|
||||
id="g-demo-box"
|
||||
class="ai2svelte"
|
||||
bind:this={aiBox}
|
||||
style:--debug-tagged-text={debugTaggedText ? 'visible' : 'hidden'}
|
||||
style:--debug-stroke={debugTaggedText ? '2px' : '0px'}
|
||||
>
|
||||
<!-- Artboard: lg -->
|
||||
{#if aiBoxWidth && aiBoxWidth >= 0 && aiBoxWidth < 1200}
|
||||
<div
|
||||
id="g-demo-lg"
|
||||
class="g-artboard"
|
||||
style="max-width: 1199px;aspect-ratio: 2.75483870967742;"
|
||||
data-aspect-ratio="2.755"
|
||||
data-min-width="0"
|
||||
data-max-width="1199"
|
||||
>
|
||||
<div
|
||||
id="g-demo-lg-img"
|
||||
class="g-demo-lg-img g-aiImg"
|
||||
style="background-image: url({imageLg});"
|
||||
></div>
|
||||
<div
|
||||
id="g-png-layer-overlay-lg"
|
||||
class="g-png-layer-overlay g-aiImg"
|
||||
style="opacity:1;;background-image: url({imagePngOverlayLg});"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Artboard: xl -->
|
||||
{#if aiBoxWidth && aiBoxWidth >= 1200}
|
||||
<div
|
||||
id="g-demo-xl"
|
||||
class="g-artboard"
|
||||
style="min-width: 1200px;aspect-ratio: 4.80806451612903;"
|
||||
data-aspect-ratio="4.808"
|
||||
data-min-width="1200"
|
||||
>
|
||||
<div
|
||||
id="g-demo-xl-img"
|
||||
class="g-demo-xl-img g-aiImg"
|
||||
style="background-image: url({imageXl});"
|
||||
></div>
|
||||
<div
|
||||
id="g-png-layer-overlay-xl"
|
||||
class="g-png-layer-overlay g-aiImg"
|
||||
style="opacity:1;;background-image: url({imagePngOverlayXl});"
|
||||
></div>
|
||||
<div
|
||||
id="g-caption2"
|
||||
class="g-captions g-aiAbs"
|
||||
style="top:14.3548%;left:34.8126%;width:7.2794%;"
|
||||
>
|
||||
<p
|
||||
class="g-pstyle0 g-taggedText g-htext"
|
||||
data-tagged-type="HTEXT"
|
||||
data-tagged-prop="captions.caption2"
|
||||
>
|
||||
{@html taggedText?.htext?.captions?.caption2 || ''}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-caption3"
|
||||
class="g-captions g-aiAbs"
|
||||
style="top:14.3548%;left:60.6764%;width:7.2794%;"
|
||||
>
|
||||
<p
|
||||
class="g-pstyle0 g-taggedText g-htext"
|
||||
data-tagged-type="HTEXT"
|
||||
data-tagged-prop="captions.caption3"
|
||||
>
|
||||
{@html taggedText?.htext?.captions?.caption3 || ''}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-caption4"
|
||||
class="g-captions g-aiAbs"
|
||||
style="top:14.3548%;left:84.0914%;width:7.2794%;"
|
||||
>
|
||||
<p
|
||||
class="g-pstyle0 g-taggedText g-htext"
|
||||
data-tagged-type="HTEXT"
|
||||
data-tagged-prop="captions.caption4"
|
||||
>
|
||||
{@html taggedText?.htext?.captions?.caption4 || ''}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id="g-caption1"
|
||||
class="g-captions g-aiAbs"
|
||||
style="top:14.3548%;left:4.1182%;width:4.8306%;"
|
||||
>
|
||||
<p
|
||||
class="g-pstyle0 g-taggedText g-htext"
|
||||
data-tagged-type="HTEXT"
|
||||
data-tagged-prop="captions.caption1"
|
||||
>
|
||||
{@html taggedText?.htext?.captions?.caption1 || ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!--
|
||||
|
||||
TAGGED TEXT PROPS
|
||||
|
||||
taggedText={
|
||||
{
|
||||
"text": {
|
||||
|
||||
},
|
||||
"htext": {
|
||||
"captions": {
|
||||
"caption1": "",
|
||||
"caption2": "",
|
||||
"caption3": "",
|
||||
"caption4": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-->
|
||||
<!-- End ai2svelte - 2026-01-13 11:12 -->
|
||||
|
||||
<!-- Generated by ai2svelte v1.0.3 - 2026-01-13 11:12 -->
|
||||
<!-- ai file: demo.ai -->
|
||||
<style lang="scss">
|
||||
#g-demo-box,
|
||||
#g-demo-box .g-artboard {
|
||||
margin: 0 auto;
|
||||
}
|
||||
#g-demo-box .g-artboard {
|
||||
margin: 0 auto;
|
||||
min-height: 100%;
|
||||
min-width: unset !important;
|
||||
max-width: unset !important;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
#g-demo-box p {
|
||||
margin: 0;
|
||||
}
|
||||
#g-demo-box .g-aiAbs {
|
||||
position: absolute;
|
||||
}
|
||||
#g-demo-box .g-aiImg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
#g-demo-box {
|
||||
height: 100%;
|
||||
}
|
||||
#g-demo-lg {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
#g-demo-xl {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
#g-demo-xl p {
|
||||
font-family: var(--theme-font-family-sans-serif), Knowledge, sans-serif;
|
||||
font-weight: regular;
|
||||
line-height: 22px;
|
||||
opacity: 1;
|
||||
letter-spacing: 0em;
|
||||
font-size: 18px;
|
||||
text-align: left;
|
||||
color: rgb(0, 0, 0);
|
||||
text-transform: none;
|
||||
padding-bottom: 0;
|
||||
padding-top: 0;
|
||||
mix-blend-mode: normal;
|
||||
font-style: normal;
|
||||
height: auto;
|
||||
position: static;
|
||||
}
|
||||
#g-demo-xl .g-pstyle0 {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Custom CSS */
|
||||
|
||||
.g-taggedText:empty::before {
|
||||
content: attr(data-tagged-type);
|
||||
padding: 0px 4px;
|
||||
font-size: 0.6rem;
|
||||
font-style: normal;
|
||||
color: #fff;
|
||||
background-color: #ee0000;
|
||||
display: block;
|
||||
font-weight: 800;
|
||||
visibility: var(--debug-tagged-text, hidden);
|
||||
}
|
||||
|
||||
.g-taggedText:empty::after {
|
||||
content: attr(data-tagged-prop);
|
||||
padding: 0px 4px;
|
||||
font-size: 0.8rem;
|
||||
font-style: normal;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
visibility: var(--debug-tagged-text, hidden);
|
||||
}
|
||||
|
||||
.g-taggedText:empty {
|
||||
background-color: #440000;
|
||||
outline: 2px solid #ee0000;
|
||||
visibility: var(--debug-tagged-text, hidden);
|
||||
}
|
||||
|
||||
.g-taggedText:not(:empty)::before {
|
||||
content: attr(data-tagged-type);
|
||||
position: absolute;
|
||||
width: calc(100% + 4px);
|
||||
transform: translateY(-100%) translateX(-2px);
|
||||
padding: 0px 4px;
|
||||
font-size: 0.6rem;
|
||||
font-style: normal;
|
||||
color: #fff;
|
||||
background-color: black;
|
||||
display: block;
|
||||
font-weight: 800;
|
||||
visibility: var(--debug-tagged-text, hidden);
|
||||
}
|
||||
|
||||
.g-taggedText {
|
||||
outline: var(--debug-stroke, 0px) solid black;
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 618 KiB |
|
After Width: | Height: | Size: 388 KiB |
|
After Width: | Height: | Size: 226 KiB |
BIN
src/components/HorizontalScroller/demo/graphic/imgs/demo-lg.jpg
Normal file
|
After Width: | Height: | Size: 881 KiB |
BIN
src/components/HorizontalScroller/demo/graphic/imgs/demo-xl.jpg
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
BIN
src/components/HorizontalScroller/demo/graphic/placeholder.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
|
|
@ -0,0 +1,63 @@
|
|||
<script lang="ts">
|
||||
import Demo from './graphic/ai2svelte/demo.svelte';
|
||||
import BodyText from '../../BodyText/BodyText.svelte';
|
||||
import Block from '../../Block/Block.svelte';
|
||||
import HorizontalScroller from '../HorizontalScroller.svelte';
|
||||
import ScrollerBase from '../../ScrollerBase/ScrollerBase.svelte';
|
||||
|
||||
const foobarText: string =
|
||||
'In the mystical land of Foobaristan, the legendary hero Foo set out on an epic quest to find his missing semicolon, only to discover that Bar had accidentally used it as a bookmark inside a JSON file. Naturally, the entire kingdom crashed immediately. As the villagers panicked, Foo and Bar tried to fix the situation by turning everything off and on again, but all that did was anger the ancient deity known as “The Build System,” which now demanded three sacrifices: a clean cache, a fresh node_modules folder, and someone’s weekend. And thus began the saga nobody asked for, yet every developer somehow relates to.';
|
||||
|
||||
// Optional: Bind your own variables to use them in your code.
|
||||
let progress = $state(0);
|
||||
</script>
|
||||
|
||||
<BodyText text={foobarText} />
|
||||
|
||||
<ScrollerBase bind:progress query="div.step-foreground-container">
|
||||
{#snippet backgroundSnippet()}
|
||||
<Block width="fluid">
|
||||
<HorizontalScroller
|
||||
bind:progress
|
||||
height="100lvh"
|
||||
handleScroll={false}
|
||||
showDebugInfo
|
||||
>
|
||||
<Demo />
|
||||
</HorizontalScroller>
|
||||
</Block>
|
||||
{/snippet}
|
||||
{#snippet foregroundSnippet()}
|
||||
<!-- Add custom foreground HTML or component -->
|
||||
<div class="step-foreground-container"><p>Step 1</p></div>
|
||||
<div class="step-foreground-container"><p>Step 2</p></div>
|
||||
<div class="step-foreground-container"><p>Step 3</p></div>
|
||||
<div class="step-foreground-container"><p>Step 4</p></div>
|
||||
<div class="step-foreground-container"><p>Step 5</p></div>
|
||||
{/snippet}
|
||||
</ScrollerBase>
|
||||
|
||||
<style lang="scss">
|
||||
.step-foreground-container {
|
||||
height: 100vh;
|
||||
width: 50%;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
padding: 1em;
|
||||
margin: 0 auto 10px 0;
|
||||
position: relative;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
|
||||
p {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
40
src/components/HorizontalScroller/utils/index.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Clamp a number `n` to the inclusive range [low, high].
|
||||
*/
|
||||
export function clamp(n: number, low: number, high: number): number {
|
||||
// Ensure low <= high even if caller swaps them
|
||||
const min = Math.min(low, high);
|
||||
const max = Math.max(low, high);
|
||||
return Math.max(min, Math.min(n, max));
|
||||
}
|
||||
|
||||
/**
|
||||
* Linearly maps a value `n` from range [inStart, inEnd] to [outStart, outEnd].
|
||||
*
|
||||
* @param {number} n - The input value to map.
|
||||
* @param {number} inStart - Input range start.
|
||||
* @param {number} inEnd - Input range end.
|
||||
* @param {number} outStart - Output range start.
|
||||
* @param {number} outEnd - Output range end.
|
||||
* @param {boolean} withinBounds - If true, clamp the mapped value to [outStart, outEnd].
|
||||
* @returns {number} - Mapped (and optionally clamped) value.
|
||||
*/
|
||||
export function map(
|
||||
n: number,
|
||||
inStart: number,
|
||||
inEnd: number,
|
||||
outStart: number,
|
||||
outEnd: number,
|
||||
withinBounds: boolean = true
|
||||
): number {
|
||||
// Avoid division by zero: when input range is degenerate, return outStart
|
||||
const inSpan = inEnd - inStart;
|
||||
if (inSpan === 0) {
|
||||
return withinBounds ? clamp(outStart, outStart, outEnd) : outStart;
|
||||
}
|
||||
|
||||
const t = (n - inStart) / inSpan; // normalized 0..1 in input space (or beyond)
|
||||
const out = t * (outEnd - outStart) + outStart;
|
||||
|
||||
return withinBounds ? clamp(out, outStart, outEnd) : out;
|
||||
}
|
||||
269
src/components/Lottie/Debug.svelte
Normal 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>
|
||||
386
src/components/Lottie/Lottie.mdx
Normal 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}
|
||||
```
|
||||
150
src/components/Lottie/Lottie.stories.svelte
Normal 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>
|
||||
443
src/components/Lottie/Lottie.svelte
Normal 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>
|
||||
145
src/components/Lottie/LottieForeground.svelte
Normal 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>
|
||||
BIN
src/components/Lottie/assets/marker.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
64
src/components/Lottie/demo/withScrollerBase.svelte
Normal 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>
|
||||
BIN
src/components/Lottie/lottie/demo.zip
Normal file
BIN
src/components/Lottie/lottie/foregroundSample.zip
Normal file
BIN
src/components/Lottie/lottie/markerSample.zip
Normal file
BIN
src/components/Lottie/lottie/themesLottie.zip
Normal file
61
src/components/Lottie/ts/lottieState.svelte.ts
Normal 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;
|
||||
}
|
||||
45
src/components/Lottie/ts/types.ts
Normal 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;
|
||||
};
|
||||
127
src/components/Lottie/ts/utils.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
177
src/components/PhotoPack/PhotoPack.mdx
Normal 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" />
|
||||
```
|
||||
140
src/components/PhotoPack/PhotoPack.stories.svelte
Normal 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>
|
||||
151
src/components/PhotoPack/PhotoPack.svelte
Normal 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>
|
||||
94
src/components/PhotoPack/utils.ts
Normal 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;
|
||||
};
|
||||
51
src/components/Scroller/Background.svelte
Normal 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>
|
||||
18
src/components/Scroller/Embedded/Background.svelte
Normal 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>
|
||||