Merge branch 'main' of git.hypnagaga.com:wires/hypnagaga

This commit is contained in:
wires 2026-03-29 20:37:26 -04:00
commit cd11d1525b
28 changed files with 4005 additions and 810 deletions

View file

@ -98,5 +98,6 @@
"graphic": {},
"preview": ""
},
"homepage": ""
"homepage": "",
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
}

View file

@ -192,3 +192,4 @@ dist
# End of https://www.toptal.com/developers/gitignore/api/node,macos,linux
*storybook.log
storybook-static/

View file

@ -1,7 +1,7 @@
import { create } from '@storybook/theming';
export default create({
base: 'light',
base: 'dark',
brandTitle: 'Reuters Graphics components',
brandUrl: 'https://reuters-graphics.github.io/graphics-components/',
brandImage: './logo.svg',

View file

@ -1,5 +1,23 @@
# @reuters-graphics/graphics-components
## 3.3.2
### Patch Changes
- d3c501d: Switch casting as GeoJSON to data property
## 3.3.1
### Patch Changes
- b924478: Updates to blog component styles
## 3.3.0
### Minor Changes
- 8abe183: Adds graphics blog components
## 3.2.1
### Patch Changes

View file

@ -1,6 +1,6 @@
{
"name": "@reuters-graphics/graphics-components",
"version": "3.2.1",
"version": "3.3.2",
"type": "module",
"private": false,
"homepage": "https://reuters-graphics.github.io/graphics-components",
@ -47,84 +47,84 @@
"svelte": "^5.0.0"
},
"devDependencies": {
"@changesets/cli": "^2.29.2",
"@chromatic-com/storybook": "^3.2.6",
"@changesets/cli": "^2.30.0",
"@chromatic-com/storybook": "^3.2.7",
"@reuters-graphics/yaks-eslint": "^0.1.1",
"@reuters-graphics/yaks-prettier": "^0.1.1",
"@storybook/addon-a11y": "^8.6.12",
"@storybook/addon-essentials": "^8.6.12",
"@storybook/addon-interactions": "^8.6.12",
"@storybook/addon-a11y": "^8.6.18",
"@storybook/addon-essentials": "^8.6.18",
"@storybook/addon-interactions": "^8.6.18",
"@storybook/addon-svelte-csf": "5.0.11",
"@storybook/blocks": "^8.6.12",
"@storybook/components": "^8.6.12",
"@storybook/manager-api": "^8.6.12",
"@storybook/svelte": "^8.6.12",
"@storybook/sveltekit": "^8.6.12",
"@storybook/test": "^8.6.12",
"@storybook/theming": "^8.6.12",
"@sveltejs/package": "^2.3.11",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@storybook/blocks": "^8.6.18",
"@storybook/components": "^8.6.18",
"@storybook/manager-api": "^8.6.18",
"@storybook/svelte": "^8.6.18",
"@storybook/sveltekit": "^8.6.18",
"@storybook/test": "^8.6.18",
"@storybook/theming": "^8.6.18",
"@sveltejs/package": "^2.5.7",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@types/css": "^0.0.37",
"@types/eslint": "^9.6.1",
"@types/fs-extra": "^11.0.4",
"@types/google-publisher-tag": "^1.20250210.0",
"@types/google-publisher-tag": "^1.20260309.1",
"@types/gtag.js": "^0.0.12",
"@types/lodash-es": "^4.17.12",
"@types/mdx": "^2.0.13",
"@types/node": "^22.14.1",
"@types/node": "^22.19.15",
"@types/prompts": "^2.4.9",
"@types/proper-url-join": "^2.1.5",
"@types/pym.js": "^1.3.2",
"@types/react": "^18.3.20",
"@types/react": "^18.3.28",
"@types/react-syntax-highlighter": "^15.5.13",
"chromatic": "^11.28.2",
"chromatic": "^11.29.0",
"css": "^3.0.0",
"css-color-converter": "^2.0.0",
"deep-object-diff": "^1.1.9",
"eslint": "^9.25.0",
"eslint-plugin-mdx": "^3.4.0",
"eslint": "^9.39.4",
"eslint-plugin-mdx": "^3.7.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-storybook": "^0.12.0",
"knip": "^5.50.5",
"mermaid": "^10.9.3",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3",
"knip": "^5.88.1",
"mermaid": "^10.9.5",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.1",
"prism-themes": "^1.9.0",
"prop-types": "^15.8.1",
"publint": "^0.3.12",
"publint": "^0.3.18",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
"react-syntax-highlighter": "^15.6.1",
"rimraf": "^6.0.1",
"sass": "^1.86.3",
"storybook": "^8.6.12",
"svelte": "^5.28.1",
"svelte-check": "^4.1.6",
"typescript": "^5.8.3",
"vite": "^6.3.2"
"react-syntax-highlighter": "^15.6.6",
"rimraf": "^6.1.3",
"sass": "^1.98.0",
"storybook": "^8.6.18",
"svelte": "^5.55.0",
"svelte-check": "^4.4.5",
"typescript": "^5.9.3",
"vite": "^6.4.1"
},
"dependencies": {
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@lottiefiles/dotlottie-web": "^0.52.2",
"@reuters-graphics/svelte-markdown": "^0.0.3",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/kit": "^2.55.0",
"@types/geojson": "^7946.0.16",
"dayjs": "^1.11.13",
"es-toolkit": "^1.35.0",
"dayjs": "^1.11.20",
"es-toolkit": "^1.45.1",
"journalize": "^2.6.0",
"maplibre-gl": "^5.15.0",
"maplibre-gl": "^5.21.1",
"mp4box": "^0.5.4",
"pmtiles": "^4.3.2",
"pmtiles": "^4.4.0",
"proper-url-join": "^2.1.2",
"pym.js": "^1.3.2",
"slugify": "^1.6.6",
"slugify": "^1.6.8",
"storybook-addon-rtl": "^1.1.0",
"svelte-fa": "^4.0.4",
"svelte-intersection-observer": "^1.0.0",
"ua-parser-js": "^2.0.3",
"svelte-intersection-observer": "^1.1.1",
"ua-parser-js": "^2.0.9",
"vitest": "^3.2.4"
},
"exports": {

View file

@ -0,0 +1,31 @@
import { Meta, Canvas } from '@storybook/blocks';
import * as BlogPostStories from './BlogPost.stories.svelte';
<Meta of={BlogPostStories} />
# BlogPost
The `BlogPost` component renders a single entry in a graphics blog page, including a headline, dateline, authors and a divider. Body content is passed as children.
```svelte
<script>
import { BlogPost } from '@reuters-graphics/graphics-components';
import { base } from '$app/paths';
</script>
<BlogPost
title="Iran fires ballistic missiles at Israel in major escalation"
slugTitle="Iran fires ballistic missiles at Israel in major escalation"
authors={['John Smith', 'Jane Doe']}
publishTime="2024-10-01T18:30:00Z"
updateTime="2024-10-01T21:45:00Z"
{base}
>
<!-- Post body content goes here -->
</BlogPost>
```
> **Important:** Do not change `slugTitle` after publishing. It is used to generate the post's anchor link — changing it will break any published links to the post.
<Canvas of={BlogPostStories.Demo} />

View file

@ -0,0 +1,28 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import BlogPost from './BlogPost.svelte';
import BodyText from '../BodyText/BodyText.svelte';
const { Story } = defineMeta({
title: 'Components/Blog/BlogPost',
component: BlogPost,
});
</script>
<Story name="Demo">
<BlogPost
title="Iran fires ballistic missiles at Israel in major escalation"
slugTitle="Iran fires ballistic missiles at Israel in major escalation"
authors={['John Smith', 'Jane Doe']}
publishTime="2024-10-01T18:30:00Z"
updateTime="2024-10-01T21:45:00Z"
base=""
>
<BodyText
text="Iran launched a barrage of ballistic missiles at Israel on Tuesday in its first direct attack on Israeli territory, marking a significant escalation in the conflict gripping the Middle East."
/>
<BodyText
text="The attack, which Iran said was in retaliation for Israeli strikes that killed senior Hezbollah and Hamas leaders, prompted Israel and the United States to vow a response."
/>
</BlogPost>
</Story>

View file

@ -0,0 +1,93 @@
<script lang="ts">
interface Props {
/** Title of the blog post */
title: string;
/**
* A sluggable title of the blog post.
*
* **Important:** Do not change this title after publishing the post. Changes will break
* published links to the post.
*/
slugTitle: string;
/**
* Base path prepended to the copied URL, e.g. "/graphics", usually gotten from base store in SvelteKit.
*/
base: 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;
/** Add an id to target post headline with custom CSS. */
id?: string;
/** Add extra classes to target post headline with custom CSS. */
cls?: string;
/**
* If the post is the last on the page, remove the dividing rule used to separate posts.
*/
isLastPost?: boolean;
children?: import('svelte').Snippet;
}
let {
title = 'Reuters Graphics blog post',
slugTitle = 'Reuters Graphics blog post',
base = '',
authors = [],
publishTime = '',
updateTime = '',
id = '',
cls = '',
isLastPost = false,
children,
}: Props = $props();
import PostHeadline from './PostHeadline.svelte';
import { slugify } from '../../utils';
import { getShortDate } from './utils';
let shortPubDate = $derived(getShortDate(publishTime));
</script>
<article id={slugify(slugTitle)} class="post-anchor">
<PostHeadline
hed={title}
sluggableHed={slugTitle}
{base}
{authors}
{publishTime}
{updateTime}
{id}
{cls}
/>
<a href="{base}/{shortPubDate}/{slugify(slugTitle)}/" hidden>
{title}
</a>
{@render children?.()}
</article>
{#if !isLastPost}
<div class="divider"></div>
{/if}
<style lang="scss">
@use '../../scss/mixins' as mixins;
.divider {
height: 1px;
max-width: calc(mixins.$column-width-normal * 0.9);
width: calc(100% - 40px);
margin: 4rem auto;
border-top: solid 1px var(--tr-muted-grey);
}
.post-anchor {
scroll-margin-top: 125px;
@media (min-width: 768px) {
scroll-margin-top: 150px;
}
@media (min-width: 1200px) {
scroll-margin-top: 175px;
}
}
</style>

View file

@ -0,0 +1,128 @@
<script lang="ts">
import Fa from 'svelte-fa';
import { slugify } from '../../utils';
import { getShortDate } from './utils';
import { faLink } from '@fortawesome/free-solid-svg-icons';
interface Props {
/** The published date of the post. */
publishedDate?: string;
/** The heading of the post. */
hed?: string;
/** Base path prepended to the copied URL, e.g. "/graphics". */
base: string;
/** The ARIA label for the copy link button. */
ariaLabel?: string;
/** The message to display before copying the link. */
copyMessageBefore?: string;
/** The message to display after copying the link. */
copyMessageAfter?: string;
}
let {
publishedDate = '',
hed = '',
base = '',
ariaLabel = 'Copy link to this post',
copyMessageBefore = 'Copy link',
copyMessageAfter = 'Copied',
}: Props = $props();
let hovering = $state(false);
let clicked = $state(false);
let publishDate = $derived(getShortDate(publishedDate));
</script>
<div class="link-container pl-0.5">
<div class="mask"></div>
<button
class:clicked
aria-label={ariaLabel}
role="link"
onclick={(e) => {
e.preventDefault();
clicked = true;
navigator.clipboard.writeText(
`${window.location.origin}${base}/${publishDate}/${slugify(hed)}/`
);
setTimeout(() => {
clicked = false;
}, 2000);
}}
onmouseenter={() => {
hovering = true;
}}
onmouseleave={() => {
hovering = false;
}}
>
<Fa icon={faLink} />
</button>
{#if !clicked}
<div class="message" class:active={hovering}>{copyMessageBefore}</div>
{:else}
<div class="message" class:active={clicked}>{copyMessageAfter}</div>
{/if}
</div>
<style lang="scss">
@use '../../scss/mixins' as mixins;
.link-container {
display: inline-flex;
justify-items: center;
align-items: center;
overflow-y: hidden;
position: relative;
.message {
background-color: var(--theme-colour-text-primary);
color: var(--theme-colour-background);
@include mixins.text-xxs;
font-weight: 500;
@include mixins.fpl-3;
@include mixins.fpr-2;
@include mixins.leading-loose;
display: inline-flex;
border-top-right-radius: 20px;
border-bottom-right-radius: 20px;
transform: translateX(-100px);
transition: all 0.15s ease;
opacity: 0;
&.active {
transform: translateX(-10px);
opacity: 1;
}
}
}
.mask {
background-color: var(--theme-colour-background);
display: inline-block;
height: 30px;
width: 20px;
position: absolute;
left: 0;
top: 0;
z-index: 1;
}
button {
z-index: 1;
background-color: var(--theme-colour-background);
border-radius: 50%;
@include mixins.text-sm;
aspect-ratio: 1;
display: inline-flex;
justify-items: center;
align-items: center;
border: 1px solid var(--theme-colour-background);
color: var(--tr-light-grey);
cursor: pointer;
&:hover,
&.clicked {
color: var(--tr-dark-grey);
border: 1px solid var(--tr-dark-grey);
}
}
</style>

View file

@ -0,0 +1,173 @@
<script lang="ts">
import { apdate } from 'journalize';
import CopyLink from './CopyLink.svelte';
import { slugify } from '../../utils';
import Kinesis from '../KinesisLogo/KinesisLogo.svelte';
import Block from '../Block/Block.svelte';
import Byline from '../Byline/Byline.svelte';
interface Props {
/** Headline of the blog post */
hed: string;
/**
* A sluggable headline of the blog post.
*
* **Important:** Do not change this headline after publishing the post. Changes will break
* published links to the post.
*/
sluggableHed: string;
/** Base path prepended to the copied URL, e.g. "/graphics". */
base: 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;
/** Add an id to target with custom CSS. */
id?: string;
/** Add extra classes to target with custom CSS. */
cls?: string;
}
let {
hed = 'Reuters Graphics blog post',
sluggableHed = 'Reuters Graphics blog post',
base = '',
authors = [],
publishTime = '',
updateTime = '',
id = '',
cls = '',
}: Props = $props();
const isValidDate = (datetime: string) => {
if (!datetime) return false;
if (!Date.parse(datetime)) return false;
return true;
};
const formatTime = (datetime: string) =>
new Date(datetime).toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short',
});
const areSameDay = (first: Date, second: Date) =>
first.getFullYear() === second.getFullYear() &&
first.getMonth() === second.getMonth() &&
first.getDate() === second.getDate();
let hedMain = $derived(hed.split(' ').slice(0, -1).join(' '));
let hedWidow = $derived(hed.split(' ').slice(-1).join(' '));
</script>
<Block {id} class="headline-container post-headline fmt-7 {cls}" width="normal">
<header class="headline">
<div class="kinesis">
<Kinesis width="22px" colour="#fff" />
</div>
<div class="dateline-container">
{#if isValidDate(publishTime)}
<div class="published-time font-bold whitespace-nowrap inline-block">
<time class="uppercase" datetime={publishTime}>
{#if isValidDate(updateTime)}
{apdate(new Date(publishTime))}
{:else}
{apdate(new Date(publishTime))}&nbsp;&nbsp;{formatTime(
publishTime
)}
{/if}
</time>
</div>
{/if}
{#if isValidDate(publishTime) && isValidDate(updateTime)}
<div class="updated-time font-bold whitespace-nowrap block">
<span>Updated</span>
<time class="uppercase" datetime={updateTime}>
{#if areSameDay(new Date(publishTime), new Date(updateTime))}
{formatTime(updateTime)}
{:else}
{apdate(new Date(updateTime))}&nbsp;&nbsp;{formatTime(updateTime)}
{/if}
</time>
</div>
{/if}
</div>
<div class="title">
{#if hed}
<h2>
<a href="#{slugify(sluggableHed)}">
{hedMain}
<span
>{hedWidow}
<CopyLink
hed={sluggableHed}
publishedDate={publishTime}
{base}
/></span
>
</a>
</h2>
{/if}
</div>
<Byline {authors} />
</header>
</Block>
<style lang="scss">
@use '../../scss/mixins' as mixins;
.headline {
position: relative;
.kinesis {
position: absolute;
top: -7px;
left: -39px;
background-color: #d64000;
border-radius: 50%;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
@media (max-width: 820px) {
display: none;
}
}
}
.dateline-container {
@include mixins.body-caption;
@include mixins.fmy-0;
text-align: left;
color: var(--theme-colour-accent);
@media (min-width: mixins.$column-width-narrow) {
.updated-time {
display: inline-block;
&:before {
content: '·';
margin: 0 5px 0 3px;
}
}
}
}
h2 {
@include mixins.fmy-1;
@include mixins.font-black;
a {
color: inherit;
text-decoration: none;
}
span {
white-space: nowrap;
display: inline-flex;
align-items: baseline;
}
}
</style>

View file

@ -0,0 +1,9 @@
/**
* Converts a date string to a short date format.
* @param d - The date string to be converted.
* @returns The short date format string, or empty string if missing or invalid (avoids throwing on `new Date('').toISOString()`).
*/
export const getShortDate = (d: string) => {
if (!d || Number.isNaN(Date.parse(d))) return '';
return new Date(d).toISOString().split('T')[0];
};

View file

@ -0,0 +1,42 @@
import { Meta, Canvas } from '@storybook/blocks';
import * as BlogTOCStories from './BlogTOC.stories.svelte';
<Meta of={BlogTOCStories} />
# BlogTOC
The `BlogTOC` component renders a collapsible table of contents for a graphics blog page, listing all posts sorted chronologically. It only renders when there are two or more posts.
```svelte
<script>
import { BlogTOC } from '@reuters-graphics/graphics-components';
import { base } from '$app/paths';
</script>
<BlogTOC
posts={[
{
title: 'Iran fires ballistic missiles at Israel',
slugTitle: 'Iran fires ballistic missiles at Israel',
publishTime: '2024-10-01T18:30:00Z',
},
{
title: 'Israel vows response',
slugTitle: 'Israel vows response',
publishTime: '2024-10-02T09:15:00Z',
},
]}
{base}
/>
```
The `base` prop should be set to your SvelteKit `base` path (e.g. `"/graphics"`) so that post links resolve correctly.
Each post in the `posts` array must have:
- `title` — the display title
- `slugTitle` — used to generate the anchor link; **do not change after publishing**
- `publishTime` — an ISO datetime string used for sorting and the dateline
<Canvas of={BlogTOCStories.Demo} />

View file

@ -0,0 +1,89 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import BlogTOC from './BlogTOC.svelte';
import BlogPost from '../BlogPost/BlogPost.svelte';
import BodyText from '../BodyText/BodyText.svelte';
import Headline from '../Headline/Headline.svelte';
import ClockWall from '../ClockWall/ClockWall.svelte';
const { Story } = defineMeta({
title: 'Components/Blog/BlogTOC',
component: BlogTOC,
});
const posts = [
{
title: 'Iran fires ballistic missiles at Israel in major escalation',
slugTitle: 'Iran fires ballistic missiles at Israel in major escalation',
publishTime: '2024-10-01T18:30:00Z',
},
{
title: 'Israel vows response as world leaders call for restraint',
slugTitle: 'Israel vows response as world leaders call for restraint',
publishTime: '2024-10-02T09:15:00Z',
},
{
title: 'Oil prices surge amid fears of wider Middle East conflict',
slugTitle: 'Oil prices surge amid fears of wider Middle East conflict',
publishTime: '2024-10-02T14:00:00Z',
},
{
title:
'UN Security Council holds emergency session on Iran-Israel crisis',
slugTitle:
'UN Security Council holds emergency session on Iran-Israel crisis',
publishTime: '2024-10-03T11:00:00Z',
},
{
title: 'Iran says missile attack achieved its objectives',
slugTitle: 'Iran says missile attack achieved its objectives',
publishTime: '2024-10-03T16:30:00Z',
},
{
title: 'Israel launches airstrikes on Hezbollah targets in Lebanon',
slugTitle: 'Israel launches airstrikes on Hezbollah targets in Lebanon',
publishTime: '2024-10-04T08:00:00Z',
},
{
title: 'U.S. sends additional warships to Middle East region',
slugTitle: 'U.S. sends additional warships to Middle East region',
publishTime: '2024-10-04T13:45:00Z',
},
];
</script>
<Story name="Demo" args={{ posts, base: '' }}>
<Headline
section="Graphics"
hed="Maps of the Iran crisis"
hedSize="big"
width="normal"
class="mb-2"
/>
<ClockWall
cities={[
{ name: 'Tehran', tzIdentifier: 'Asia/Tehran' },
{ name: 'Tel Aviv', tzIdentifier: 'Asia/Tel_Aviv' },
{ name: 'Washington D.C.', tzIdentifier: 'America/New_York' },
]}
/>
<BlogTOC {posts} base="" />
<BlogPost
title="Iran fires ballistic missiles at Israel in major escalation"
slugTitle="Iran fires ballistic missiles at Israel in major escalation"
authors={['John Smith', 'Jane Doe']}
publishTime="2024-10-01T18:30:00Z"
updateTime="2024-10-01T21:45:00Z"
base=""
>
<BodyText
text="Iran launched a barrage of ballistic missiles at Israel on Tuesday in its first direct attack on Israeli territory, marking a significant escalation in the conflict gripping the Middle East."
/>
<BodyText
text="The attack, which Iran said was in retaliation for Israeli strikes that killed senior Hezbollah and Hamas leaders, prompted Israel and the United States to vow a response."
/>
</BlogPost>
</Story>

View file

@ -0,0 +1,275 @@
<script lang="ts">
import Block from '../Block/Block.svelte';
import TOCList from './TOCList.svelte';
import { apmonth } from 'journalize';
import { slugify } from '../../utils';
import { slide } from 'svelte/transition';
import Fa from 'svelte-fa';
import {
faCaretDown,
faAngleDoubleUp,
faAngleDoubleDown,
} from '@fortawesome/free-solid-svg-icons';
interface Post {
title: string;
slugTitle: string;
publishTime: string;
}
interface Props {
posts: Post[];
/** Base path prepended to post links, e.g. "/graphics". */
base: string;
/** The label for the table of contents toggle button. */
label?: string;
/** The maximum height of the table of contents list in pixels. */
maxHeight?: number;
}
let {
posts,
base = '',
label = 'Show all articles',
maxHeight = 300,
}: Props = $props();
let showContents = $state(false);
let scrollPos = $state(0);
let listHeight = $state(0);
const contents = $derived(
[...posts]
.sort(
(a, b) =>
new Date(a.publishTime).getTime() - new Date(b.publishTime).getTime()
)
.reduce(
(acc, post) => {
const d = new Date(post.publishTime);
const dateKey = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const existing = acc.find((entry) => entry.dateKey === dateKey);
const event = {
title: post.title,
titleLink: `${base}/#${slugify(post.slugTitle)}`,
};
if (existing) {
existing.events.push(event);
} else {
acc.push({
dateKey,
date: `${apmonth(d)} ${d.getDate()}`,
events: [event],
});
}
return acc;
},
[] as {
dateKey: string;
date: string;
events: { title: string; titleLink: string }[];
}[]
)
.map(({ dateKey: _dateKey, ...rest }) => rest)
);
</script>
{#if contents.length > 1}
<Block width="fluid" class="mt-4 mb-0">
<div class="toc-gutter">
<div class="table-of-contents" style="--mh: {maxHeight}px;">
<div class="flex w-full">
<button
onclick={() => {
showContents = !showContents;
scrollPos = 0;
}}
>
<div class="icon" class:expanded={showContents}>
<Fa icon={faCaretDown} size="lg" />
</div>
<div class="label text-xs leading-loose tracking-wide py-0.5">
{label}
</div>
</button>
</div>
<Block
width="normal"
class="my-0 ml-2 relative {showContents ? 'fpb-6' : ''}"
>
<div>
{#if showContents}
<div
class="content-container fmt-3"
transition:slide={{ axis: 'y', duration: 350 }}
onscroll={(e) => {
scrollPos = e.currentTarget.scrollTop;
}}
>
<TOCList dates={contents} bind:listHeight />
{#if scrollPos > 10 && listHeight > maxHeight}
<div class="scroll-icon up">
<Fa icon={faAngleDoubleUp} />
</div>
{/if}
{#if listHeight > maxHeight && scrollPos < 0.95 * (listHeight - maxHeight)}
<div class="scroll-icon down">
<Fa icon={faAngleDoubleDown} />
</div>
{/if}
</div>
{/if}
</div></Block
>
</div>
</div>
</Block>
{/if}
<style lang="scss">
@use '../../scss/mixins' as mixins;
.toc-gutter {
// background-color: #fafafa;
// border-top: 1px solid #dedede;
// border-bottom: 1px solid #dedede;
padding: 5px 0 0;
margin-bottom: -40px;
}
.table-of-contents {
overflow: hidden;
max-width: var(--normal-column-width);
margin-left: auto;
margin-right: auto;
& > div {
display: flex;
justify-content: center;
}
}
.content-container {
max-height: var(--mh);
overflow-y: auto;
scroll-snap-type: y mandatory;
}
.scroll-icon {
position: absolute;
margin-left: -4px;
color: var(--theme-colour-text-secondary);
background-color: var(--theme-colour-background);
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
justify-content: center;
align-items: center;
&.up {
top: 0px;
animation: fade_scroll_up 1.5s ease-in-out infinite;
}
&.down {
bottom: 30px;
animation: fade_scroll_down 1.5s ease-in-out infinite;
}
}
@keyframes fade_scroll_up {
0% {
transform: translate(0, 5px);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: translate(0, -10px);
opacity: 0;
}
}
@keyframes fade_scroll_down {
0% {
transform: translate(0, -10px);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: translate(0, 5px);
opacity: 0;
}
}
button {
border: 0;
background-color: transparent;
display: inline-flex;
font-family: var(--theme-font-family-hed);
font-weight: normal;
padding: 0 5px;
color: var(--theme-colour-accent);
@include mixins.text-sm;
align-items: center;
cursor: pointer;
border: 1px solid var(--tr-muted-grey);
border-radius: 50px;
&:hover {
border: 1px solid var(--theme-colour-text-secondary);
background-color: #efefef;
div.label {
color: var(--theme-colour-text-primary);
}
div.icon {
color: var(--theme-colour-text-secondary);
}
}
div.icon {
z-index: 1;
@include mixins.text-sm;
@include mixins.leading-loose;
width: 18px;
height: 24px;
margin-left: 6px;
display: inline-flex;
justify-content: center;
align-items: center;
color: var(--tr-light-grey);
border-radius: 50%;
transition: transform 0.3s ease;
&.expanded {
transform: rotate(180deg);
}
}
div.label {
color: var(--theme-colour-text-secondary);
display: inline-flex;
font-weight: 500;
@include mixins.fpl-4;
@include mixins.fpr-2;
margin-left: -15px;
position: relative;
border-top-right-radius: 20px;
border-bottom-right-radius: 20px;
font-style: italic;
&:after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.2;
border-top-right-radius: 20px;
border-bottom-right-radius: 20px;
// background-color: var(--theme-colour-accent);
}
}
}
</style>

View file

@ -0,0 +1,121 @@
<script lang="ts">
import Block from '../Block/Block.svelte';
interface DateEvent {
title: string;
titleLink: string;
}
interface DateEntry {
date: string;
events: DateEvent[];
}
interface Props {
dates: DateEntry[];
/** Colour for the timeline bullet symbols and line. */
symbolColour?: string;
/** Colour for the date headings. */
dateColour?: string;
/** The height of the list, bindable for the parent to read. */
listHeight?: number;
id?: string;
class?: string;
}
let {
dates,
symbolColour = 'var(--theme-colour-brand-rules)',
dateColour = 'var(--theme-colour-accent, red)',
listHeight = $bindable(0),
id = '',
class: cls = '',
}: Props = $props();
</script>
<Block width="normal" {id} class="simple-timeline-container {cls}">
<div
bind:clientHeight={listHeight}
class="timeline"
style="--symbol-colour:{symbolColour};"
>
{#each dates as date}
<div class="date">
<svg class="absolute bg" height="25" width="20">
<circle
cx="10"
cy="12"
r="5"
stroke={symbolColour}
stroke-width="2"
fill="transparent"
></circle>
</svg>
<div class="timeline-date" style:color={dateColour}>
{date.date}
</div>
{#each date.events as event}
<div class="event">
<a href={event.titleLink}>
<div class="title">{event.title}</div>
</a>
</div>
{/each}
</div>
{/each}
</div>
</Block>
<style lang="scss">
@use '../../scss/mixins' as mixins;
.timeline {
padding-left: 0.5rem;
padding-right: 0.875rem;
.date {
position: relative;
padding-top: 0.125rem;
padding-left: 1.25rem;
padding-bottom: 1rem;
scroll-snap-align: start;
border-left: 1px solid var(--symbol-colour);
&:last-child {
border-left: 1px solid mixins.$theme-colour-background;
@include mixins.fpb-0;
}
}
.timeline-date {
@include mixins.font-note;
@include mixins.text-xxs;
text-transform: uppercase;
@include mixins.font-semibold;
@include mixins.tracking-wide;
@include mixins.fmb-0;
}
svg {
top: -1px;
left: -10.5px;
}
div.title {
@include mixins.h3;
@include mixins.text-base;
@include mixins.fmy-1;
@include mixins.font-medium;
}
div.event {
margin-bottom: 0.75rem;
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:global(p) {
@include mixins.body-note;
@include mixins.font-light;
}
}
}
</style>

View file

@ -13,7 +13,7 @@
/**
* Publish time as a datetime string.
*/
publishTime: string;
publishTime?: string;
/**
* Update time as a datetime string.
*/
@ -52,7 +52,7 @@
let {
authors = [],
publishTime,
publishTime = '',
updateTime,
align = 'auto',
id = '',

View file

@ -0,0 +1,264 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
const CLOCK_WEIGHT = { Light: 1, Normal: 2, Bold: 4 } as const;
type ClockWeight = keyof typeof CLOCK_WEIGHT;
const CLOCK_SIZE = { XS: 48, MD: 80, LG: 120, XL: 160 } as const;
type ClockSize = keyof typeof CLOCK_SIZE;
interface Props {
/**
* The name of the clock (to be displayed), e.g. "New York"
*/
name: string;
/**
* The UTC time to display, defaults to current time
*/
UTCTime?: Date;
/**
* The timezone identifier, e.g. "America/New_York"
*/
tzIdentifier: string;
/**
* Whether to show the clock, defaults to true
*/
showClock?: boolean;
/**
* The weight of the clock, either "normal" or "bold"
*/
clockWeight?: ClockWeight;
/**
* The size of the clock, either "XS", "MD", "LG", or "XL"
*/
clockSize?: ClockSize;
}
const {
name,
UTCTime = new Date(new Date().toUTCString()),
tzIdentifier,
showClock = true,
clockWeight = 'Normal',
clockSize = 'MD',
}: Props = $props();
/**
* Converts a UTC date to a specified timezone and formats it to a.m./p.m. style.
*
* @param utcDate - The UTC date to convert.
* @param timezone - The timezone identifier.
* @returns The formatted time string.
*
*/
function convertUTCToTimezone(utcDate: Date, timezone: string) {
const time = new Date(utcDate).toLocaleString('en-US', {
timeZone: timezone,
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
// Convert AM/PM to a.m./p.m. format
return time.replace('AM', 'a.m.').replace('PM', 'p.m.');
}
let clockInterval: ReturnType<typeof setInterval> | null = null;
let time: string = $state(convertUTCToTimezone(UTCTime, tzIdentifier));
onMount(() => {
clockInterval = setInterval(() => {
time = convertUTCToTimezone(
new Date(new Date().toUTCString()),
tzIdentifier
);
}, 1000 * 10); // Update every 10 seconds
});
let minute: number = $derived(
parseFloat(time?.split(' ')[0].split(':')[1]) || 0
);
let hour: number = $derived(
parseFloat(time?.split(' ')[0].split(':')[0]) || 0
);
onDestroy(() => {
if (clockInterval) {
clearInterval(clockInterval);
clockInterval = null;
}
});
</script>
<div class="clock-container" style="--clock-size: {CLOCK_SIZE[clockSize]}px;">
{#if showClock}
<svg class="clock-svg" width="100%" height="100%" viewBox="0 0 120 120">
<defs>
<filter id="inset-shadow">
<!-- Shadow offset -->
<feOffset dx="0" dy="4" />
<!-- Shadow blur -->
<feGaussianBlur stdDeviation="8" result="offset-blur" />
<!-- Invert drop shadow to make an inset shadow-->
<feComposite
operator="out"
in="SourceGraphic"
in2="offset-blur"
result="inverse"
/>
<!-- Cut colour inside shadow -->
<feFlood flood-color="black" flood-opacity=".2" result="color" />
<feComposite operator="in" in="color" in2="inverse" result="shadow" />
<!-- Placing shadow over element -->
<feComposite operator="over" in="shadow" in2="SourceGraphic" />
</filter>
</defs>
<circle
class="clock-outer-border"
cx="50%"
cy="50%"
r="58"
fill="transparent"
stroke="#cccccc"
stroke-width="2"
></circle>
<circle
class="clock-inner-shadow"
cx="50%"
cy="50%"
r="54"
fill="#ffffff"
filter="url(#inset-shadow)"
></circle>
<g id="clock-ticks" style="mix-blend-mode: multiply;">
{#each Array(12) as _, i (i)}
<line
class="clock-hour-mark"
x1="50%"
y1="56"
x2="50%"
y2="64"
stroke="var(--tr-light-grey)"
stroke-width="2"
transform-origin="50% 50%"
transform="rotate({i * 30}) translate(0, -46)"
></line>
{/each}
</g>
<g
id="clock-hand-minute"
transform-origin="50% 50%"
transform="rotate({(minute / 60) * 360})"
>
<circle
cx="50%"
cy="50%"
r="4"
fill="transparent"
stroke="var(--tr-light-grey)"
stroke-width={CLOCK_WEIGHT[clockWeight]}
></circle>
<line
x1="50%"
y1={60 - 4 - 36}
x2="50%"
y2={60 - 4}
stroke="var(--tr-light-grey)"
stroke-width={CLOCK_WEIGHT[clockWeight]}
transform-origin="50% 50%"
></line>
<line
x1="50%"
y1={60 + 4}
x2="50%"
y2={60 + 4 + 4}
stroke="var(--tr-light-grey)"
stroke-width={CLOCK_WEIGHT[clockWeight]}
transform-origin="50% 50%"
></line>
</g>
<g
id="clock-hand-hour"
transform-origin="50% 50%"
transform="rotate({(hour / 12) * 360 + (360 / 12) * (minute / 60)})"
>
<circle
cx="50%"
cy="50%"
r="4"
fill="transparent"
stroke="var(--tr-dark-grey)"
stroke-width={CLOCK_WEIGHT[clockWeight]}
></circle>
<line
x1="50%"
y1={60 - 4 - 24}
x2="50%"
y2={60 - 4}
stroke="var(--tr-dark-grey)"
stroke-width={CLOCK_WEIGHT[clockWeight]}
transform-origin="50% 50%"
></line>
<line
x1="50%"
y1={60 + 4}
x2="50%"
y2={60 + 4 + 4}
stroke="var(--tr-dark-grey)"
stroke-width={CLOCK_WEIGHT[clockWeight]}
transform-origin="50% 50%"
></line>
</g>
<circle
class="clock-origin"
cx="50%"
cy="50%"
r="2"
fill="var(--tr-dark-grey)"
></circle>
</svg>
{/if}
<div class="clock-info">
<p class="m-0 p-0 font-sans font-medium leading-none text-sm">
{name}
</p>
<p
class="m-0 p-0 font-sans text-xs leading-none"
style="color: var(--tr-medium-grey);"
>
{time}
</p>
</div>
</div>
<style lang="scss">
@use '../../scss/mixins' as mixins;
.clock-container {
height: var(--clock-size);
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
flex: 1 1 0px;
@media (max-width: 659px) {
height: 48px; // XS size
}
.clock-info {
display: flex;
flex-direction: column;
gap: 2px;
p {
text-wrap: nowrap;
}
}
svg {
aspect-ratio: 1 / 1;
width: auto;
height: 100%;
}
}
</style>

View file

@ -0,0 +1,27 @@
import { Meta, Canvas } from '@storybook/blocks';
import * as ClockWallStories from './ClockWall.stories.svelte';
<Meta of={ClockWallStories} />
# ClockWall
The `ClockWall` component displays a row of analog clocks for different cities and timezones. Use it paired with the overall headline of a graphics blog page to show the time of multiple cities involved in a breaking news event.
Use the [IANA tz database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) to find valid `tzIdentifier` strings.
```svelte
<script>
import { ClockWall } from '@reuters-graphics/graphics-components';
</script>
<ClockWall
cities={[
{ name: 'Tehran', tzIdentifier: 'Asia/Tehran' },
{ name: 'Tel Aviv', tzIdentifier: 'Asia/Tel_Aviv' },
{ name: 'Washington D.C.', tzIdentifier: 'America/New_York' },
]}
/>
```
<Canvas of={ClockWallStories.Demo} />

View file

@ -0,0 +1,21 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import ClockWall from './ClockWall.svelte';
const { Story } = defineMeta({
title: 'Components/Blog/ClockWall',
component: ClockWall,
argTypes: {
clockSize: {
control: 'select',
options: ['XS', 'MD', 'LG', 'XL'],
},
clockWeight: {
control: 'select',
options: ['Light', 'Normal', 'Bold'],
},
},
});
</script>
<Story name="Demo" />

View file

@ -0,0 +1,61 @@
<script lang="ts">
import type { ComponentProps } from 'svelte';
import type { ContainerWidth } from '../@types/global';
import Block from '../Block/Block.svelte';
import Clock from './Clock.svelte';
type ClockProps = ComponentProps<typeof Clock>;
interface City {
name: string;
tzIdentifier: string;
}
interface Props {
cities?: City[];
width?: ContainerWidth;
clockSize?: ClockProps['clockSize'];
clockWeight?: ClockProps['clockWeight'];
}
let {
cities = [
{ name: 'Tehran', tzIdentifier: 'Asia/Tehran' },
{ name: 'Tel Aviv', tzIdentifier: 'Asia/Tel_Aviv' },
{ name: 'Washington D.C.', tzIdentifier: 'America/New_York' },
],
width = 'normal',
clockSize = 'XS',
clockWeight = 'Bold',
}: Props = $props();
</script>
<Block {width} class="my-6">
<div id="clock-group">
{#each cities as city (city.tzIdentifier)}
<Clock
name={city.name}
tzIdentifier={city.tzIdentifier}
{clockSize}
{clockWeight}
/>
{/each}
</div>
</Block>
<style lang="scss">
@use '../../scss/mixins' as mixins;
div#clock-group {
width: 100%;
margin: 0px auto;
display: flex;
flex-wrap: wrap;
gap: 10px 1rem;
justify-content: space-around;
@media (max-width: 659px) {
justify-content: center;
}
}
</style>

View file

@ -0,0 +1,19 @@
import { Meta, Canvas } from '@storybook/blocks';
import * as KinesisLogoStories from './KinesisLogo.stories.svelte';
<Meta of={KinesisLogoStories} />
# KinesisLogo
The `KinesisLogo` component contains the official Kinesis logo.
```svelte
<script>
import { KinesisLogo } from '@reuters-graphics/graphics-components';
</script>
<KinesisLogo />
```
<Canvas of={KinesisLogoStories.Demo} />

View file

@ -0,0 +1,14 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import KinesisLogo from './KinesisLogo.svelte';
const { Story } = defineMeta({
title: 'Components/Logos/KinesisLogo',
component: KinesisLogo,
argTypes: {
colour: { control: 'color' },
},
});
</script>
<Story name="Demo" />

View file

@ -0,0 +1,140 @@
<!-- @component `ReutersLogo` [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-logos-reuterslogo--docs) -->
<script lang="ts">
interface Props {
/** "Kinesis" colour */
colour?: string;
/** CSS width value */
width?: string;
}
let { colour = '#D64000', width = '100%' }: Props = $props();
</script>
<svg
aria-hidden="true"
focusable="false"
viewBox="0 0 558.7 558.7"
style="width: {width}; --logoColour: {colour};"
>
<g id="Primary_Logo" data-name="Primary Logo">
<path
class="cls-1"
d="M483,75c7.1,7.1,11,16.6,11,26.7s-3.9,19.6-11,26.7c-14.7,14.7-38.6,14.7-53.3,0-7.1-7.1-11-16.6-11-26.7s3.9-19.6,11-26.7c7.4-7.4,17-11.1,26.7-11.1s19.3,3.7,26.7,11.1Z"
></path>
<path
class="cls-1"
d="M75.1,483.6c-14.7-14.7-14.7-38.7,0-53.4,7.1-7.1,16.6-11.1,26.7-11.1s19.6,3.9,26.7,11.1c14.7,14.7,14.7,38.7,0,53.4-7.1,7.1-16.6,11.1-26.7,11.1s-19.6-3.9-26.7-11.1Z"
></path>
<path
class="cls-1"
d="M362.6,77.9h-.3c-8-3.5-14.3-9.8-17.6-17.9-3.4-8.1-3.4-17.1,0-25.3,3.4-8.1,9.7-14.5,17.9-17.9,4.1-1.7,8.4-2.5,12.6-2.5s8.6.9,12.6,2.5c16.8,7,24.8,26.3,17.9,43.2-7,16.8-26.3,24.8-43.1,17.9h0Z"
></path>
<path
class="cls-1"
d="M183.1,478.3c4.3,0,8.5.9,12.6,2.5,16.8,7,24.8,26.3,17.9,43.2-3.4,8.2-9.7,14.5-17.9,17.9-8.1,3.4-17.1,3.4-25.3,0-16.8-7-24.8-26.3-17.9-43.2,3.4-8.1,9.7-14.5,17.9-17.9,4.1-1.7,8.4-2.5,12.6-2.5h0s0,0,0,0Z"
></path>
<path
class="cls-1"
d="M307.4,28.3c0,15.2-12,27.6-27,28.3h-1.3c-15.6,0-28.3-12.7-28.3-28.3S263.5,0,279.1,0s28.3,12.8,28.3,28.4h0s0,0,0,0Z"
></path>
<path
class="cls-1"
d="M250.8,530.4c0-15.6,12.7-28.3,28.3-28.3s28.3,12.7,28.3,28.3-12.7,28.3-28.3,28.3-28.3-12.7-28.3-28.3Z"
></path>
<path
class="cls-1"
d="M192.2,69.2c-5.8,2.4-12.2,2.4-18,0-5.9-2.4-10.4-7-12.8-12.8-5-12.1.7-25.9,12.8-30.9,2.9-1.2,6-1.8,9-1.8,9.3,0,18.1,5.5,21.8,14.6,2.4,5.9,2.4,12.3,0,18.1-2.4,5.9-6.9,10.4-12.8,12.8h0Z"
></path>
<path
class="cls-1"
d="M365.9,489.5c3-1.2,6-1.8,9.1-1.8s6.1.6,9,1.8c5.9,2.4,10.4,7,12.8,12.8,2.4,5.9,2.4,12.3,0,18.1-2.4,5.9-6.9,10.4-12.8,12.8-5.9,2.4-12.2,2.4-18.1,0-5.9-2.4-10.4-6.9-12.8-12.8-2.4-5.9-2.4-12.2,0-18.1,2.4-5.8,6.9-10.4,12.8-12.8h0Z"
></path>
<path
class="cls-1"
d="M115.1,88.6c7.2,7.3,7.4,18.9.4,26.4l-.4.4c-7.4,7.4-19.4,7.4-26.7,0-7.4-7.4-7.4-19.4,0-26.8,3.7-3.7,8.5-5.5,13.4-5.5s9.7,1.8,13.4,5.5h0,0Z"
></path>
<path
class="cls-1"
d="M442.8,443.5c3.6-3.7,8.5-5.5,13.3-5.5s9.7,1.8,13.4,5.5c3.6,3.5,5.5,8.3,5.5,13.4s-2,9.8-5.5,13.4c-7.4,7.4-19.3,7.4-26.7,0-3.6-3.6-5.5-8.4-5.5-13.4s2-9.8,5.5-13.4h0Z"
></path>
<path
class="cls-1"
d="M38.4,205.1c-12-5-17.8-18.8-12.8-30.9,2.4-5.9,6.9-10.4,12.8-12.8,3-1.2,6-1.8,9.1-1.8s6.1.6,9,1.8c12,5,17.7,18.8,12.8,30.9-2.4,5.9-6.9,10.4-12.8,12.8-5.9,2.4-12.3,2.4-18,0h0Z"
></path>
<path
class="cls-1"
d="M519.8,353.7c5.8,2.4,10.4,6.9,12.8,12.8,2.4,5.9,2.4,12.2,0,18.1-5,12-18.8,17.8-30.8,12.8-5.9-2.4-10.4-6.9-12.8-12.8-2.4-5.9-2.4-12.2,0-18.1,3.8-9.1,12.6-14.6,21.8-14.6s6,.6,9,1.8h0Z"
></path>
<path
class="cls-1"
d="M0,279.5c0-15.6,12.7-28.3,28.3-28.3s28.3,12.7,28.3,28.3-12.7,28.3-28.3,28.3S0,295.1,0,279.5Z"
></path>
<path
class="cls-1"
d="M501.6,279.5c0-15.6,12.7-28.3,28.3-28.3s28.3,12.7,28.3,28.3-12.7,28.3-28.3,28.3-28.3-12.7-28.3-28.3Z"
></path>
<path
class="cls-1"
d="M16.8,388.1c-7-16.9,1-36.2,17.9-43.2,8.1-3.4,17.1-3.4,25.3,0,8.1,3.4,14.5,9.7,17.9,17.9s3.4,17.1,0,25.3c-3.4,8.1-9.7,14.5-17.9,17.9s-17.1,3.4-25.3,0c-8.1-3.4-14.5-9.7-17.9-17.9Z"
></path>
<path
class="cls-1"
d="M480.1,195.9c-3.4-8.1-3.4-17.1,0-25.2,3.4-8.1,9.7-14.5,17.9-17.9,4.1-1.7,8.4-2.5,12.6-2.5,13,0,25.3,7.7,30.5,20.4,7,16.9-1,36.2-17.9,43.2-8.1,3.4-17.1,3.4-25.3,0-8.1-3.4-14.5-9.7-17.9-17.9h0,0Z"
></path>
<path
class="cls-1"
d="M359.3,359.7c-10.4,10.3-10.4,27.2,0,37.5,10.4,10.4,27.1,10.4,37.5,0,5-5,7.8-11.6,7.8-18.7s-2.8-13.8-7.8-18.7c-5.2-5.2-11.9-7.7-18.7-7.7s-13.6,2.6-18.8,7.7h0Z"
></path>
<path
class="cls-1"
d="M138.3,263.3c-11.2-3-17.8-14.5-14.8-25.7,1.5-5.4,4.9-9.9,9.8-12.8,3.2-1.9,6.8-2.8,10.4-2.8s3.6.2,5.4.7c5.4,1.5,9.9,4.9,12.7,9.8s3.5,10.5,2.1,16c-3,11.2-14.5,17.9-25.7,14.9h0,0Z"
></path>
<path
class="cls-1"
d="M408.9,335.9c-5.4-1.5-9.9-4.9-12.7-9.8s-3.5-10.5-2.1-16c1.5-5.4,4.9-10,9.8-12.8,3.2-1.9,6.9-2.8,10.5-2.8s3,.1,4.5.5l1,.3c11.2,3,17.8,14.6,14.8,25.7-1.5,5.4-4.9,10-9.8,12.8-4.9,2.8-10.5,3.6-16,2.1h0s0,0,0,0Z"
></path>
<path
class="cls-1"
d="M127.6,320c-2.3-8.7,2.5-17.5,10.9-20.4l1-.3c8.9-2.4,18.2,3,20.6,11.9,1.2,4.4.6,8.9-1.7,12.8-2.2,3.9-5.9,6.7-10.2,7.9-4.4,1.2-8.8.6-12.8-1.7-3.9-2.2-6.7-5.9-7.8-10.2h0s0,0,0,0Z"
></path>
<path
class="cls-1"
d="M405.9,257.8l-.7-.4c-3.5-2.3-6-5.7-7.1-9.8-2.4-9,2.9-18.2,11.9-20.6,8.9-2.4,18.2,2.9,20.6,11.9,2.4,9-2.9,18.2-11.9,20.6-4.3,1.2-8.8.5-12.8-1.7h0Z"
></path>
<path
class="cls-1"
d="M189.4,386.7l-.5.5c-4.9,4.9-13,4.9-17.9,0-4.9-4.9-4.9-12.9,0-17.9s13-4.9,17.9,0c2.4,2.4,3.7,5.6,3.7,9s-1.1,6.1-3.2,8.4Z"
></path>
<path
class="cls-1"
d="M386.9,171.2c2.4,2.4,3.7,5.6,3.7,9s-1.3,6.6-3.7,9c-4.9,4.9-13,4.9-17.9,0-2.4-2.4-3.7-5.6-3.7-9s1.3-6.6,3.7-9c2.4-2.4,5.6-3.7,9-3.7s6.6,1.3,9,3.7h0s0,0,0,0Z"
></path>
<path
class="cls-1"
d="M257.3,406.3c2.2,3.9,2.8,8.4,1.7,12.8-1.2,4.4-3.9,8-7.8,10.2s-8.4,2.8-12.8,1.7c-8.9-2.4-14.3-11.6-11.9-20.6,1.2-4.4,3.9-8,7.8-10.2,3.9-2.2,8.4-2.8,12.8-1.7,4.4,1.2,7.9,4,10.2,7.9h0s0,0,0,0Z"
></path>
<path
class="cls-1"
d="M300.6,152.5c-2.2-3.9-2.8-8.4-1.7-12.8,1.2-4.4,3.9-8,7.8-10.2,2.6-1.5,5.5-2.2,8.4-2.2s2.9.2,4.4.6c4.4,1.2,8,3.9,10.2,7.9,2.2,3.9,2.8,8.4,1.7,12.8-2.4,9-11.6,14.3-20.6,11.9-4.4-1.2-8-3.9-10.2-7.9h0Z"
></path>
<path
class="cls-1"
d="M325.9,396.6l.9.5c4.4,2.8,7.5,7.1,8.9,12.2,3,11.2-3.6,22.8-14.8,25.7-5.5,1.5-11.1.7-16-2.1-4.9-2.8-8.3-7.3-9.8-12.8-1.5-5.4-.7-11.1,2.1-16,2.8-4.9,7.3-8.3,12.7-9.8,1.8-.5,3.7-.7,5.5-.7,3.6,0,7.2,1,10.5,2.8h0Z"
></path>
<path
class="cls-1"
d="M248.2,164.3c-11.2,3-22.7-3.7-25.7-14.9-3-11.2,3.6-22.8,14.8-25.7,1.8-.5,3.6-.7,5.4-.7,9.3,0,17.8,6.2,20.3,15.6,3,11.2-3.6,22.8-14.8,25.7Z"
></path>
<path
class="cls-1"
d="M180,206.5c14.6,0,26.4-11.9,26.4-26.5s-11.8-26.5-26.4-26.5-26.4,11.9-26.4,26.5,11.8,26.5,26.4,26.5Z"
></path>
</g>
</svg>
<style>
.cls-1 {
fill: var(--logoColour);
stroke-width: 0px;
}
</style>

View file

@ -53,13 +53,16 @@ Disable interaction for static maps:
Use the `TileMapLayer` component to add GeoJSON data to your map. You can pass GeoJSON data directly or fetch it from a URL. Layer rendering order will directly correspond to the order in which you add the layers in the code.
> **Note for TypeScript users:** When passing GeoJSON data objects, you'll need to type cast them using `as GeoJSON` to ensure TypeScript recognizes the correct type. This provides better type safety and error messages. See examples below.
<Canvas of={TileMapStories.WithGeoJSONLayers} />
### Basic example with local data
```svelte
<script>
<script lang="ts">
import { TileMap, TileMapLayer } from '@reuters-graphics/graphics-components';
import type { GeoJSON } from 'geojson';
const parkData = {
type: 'FeatureCollection',
@ -88,7 +91,7 @@ Use the `TileMapLayer` component to add GeoJSON data to your map. You can pass G
<!-- Polygon fill -->
<TileMapLayer
id="park-fill"
data={parkData}
data={parkData as GeoJSON}
type="fill"
paint={{
'fill-color': '#179639',
@ -99,7 +102,7 @@ Use the `TileMapLayer` component to add GeoJSON data to your map. You can pass G
<!-- Polygon outline -->
<TileMapLayer
id="park-outline"
data={parkData}
data={parkData as GeoJSON}
type="line"
paint={{
'line-color': '#228b22',
@ -130,7 +133,9 @@ You can also pass a URL string to fetch GeoJSON data:
### Adding point markers
```svelte
<script>
<script lang="ts">
import type { GeoJSON } from 'geojson';
const officeLocation = {
type: 'FeatureCollection',
features: [
@ -150,7 +155,7 @@ You can also pass a URL string to fetch GeoJSON data:
<!-- Point marker -->
<TileMapLayer
id="office-point"
data={officeLocation}
data={officeLocation as GeoJSON}
type="circle"
paint={{
'circle-radius': 6,
@ -163,7 +168,7 @@ You can also pass a URL string to fetch GeoJSON data:
<!-- Text label -->
<TileMapLayer
id="office-label"
data={officeLocation}
data={officeLocation as GeoJSON}
type="symbol"
layout={{
'text-field': 'Office',

View file

@ -11,9 +11,12 @@ export {
registerPageview,
} from './components/Analytics/Analytics.svelte';
export { default as Article } from './components/Article/Article.svelte';
export { default as BlogPost } from './components/BlogPost/BlogPost.svelte';
export { default as BlogTOC } from './components/BlogTOC/BlogTOC.svelte';
export { default as AdScripts } from './components/AdSlot/AdScripts.svelte';
export { default as BeforeAfter } from './components/BeforeAfter/BeforeAfter.svelte';
export { default as Block } from './components/Block/Block.svelte';
export { default as ClockWall } from './components/ClockWall/ClockWall.svelte';
export { default as BodyText } from './components/BodyText/BodyText.svelte';
export { default as Byline } from './components/Byline/Byline.svelte';
export { default as DatawrapperChart } from './components/DatawrapperChart/DatawrapperChart.svelte';
@ -29,6 +32,7 @@ export { default as HorizontalScroller } from './components/HorizontalScroller/H
export { default as EndNotes } from './components/EndNotes/EndNotes.svelte';
export { default as InfoBox } from './components/InfoBox/InfoBox.svelte';
export { default as InlineAd } from './components/AdSlot/InlineAd.svelte';
export { default as KinesisLogo } from './components/KinesisLogo/KinesisLogo.svelte';
export { default as LeaderboardAd } from './components/AdSlot/LeaderboardAd.svelte';
export { default as TileMap } from './components/TileMap/TileMap.svelte';
export { default as TileMapLayer } from './components/TileMap/TileMapLayer.svelte';

View file

@ -16,4 +16,13 @@ declare module 'journalize' {
* @returns The converted value
*/
export function intcomma(val: number | string): string;
/**
* Returns an AP-formatted month string that corresponds with the supplied
* Date. If an `input` is not passed, it will use the result of `new Date();`.
*
* @param date - The supplied Date
* @returns The converted date as a string
*/
export function apmonth(date?: Date): string;
}

View file

@ -1,4 +1,4 @@
import slugify from 'slugify';
import slug from 'slugify';
/** Helper function to generate a random 4-character string */
export const random4 = () =>
@ -10,7 +10,7 @@ export const random4 = () =>
* Custom function that returns an author page URL.
*/
export const getAuthorPageUrl = (author: string): string => {
const authorSlug = slugify(author.trim(), { lower: true });
const authorSlug = slug(author.trim(), { lower: true });
return `https://www.reuters.com/authors/${authorSlug}/`;
};
@ -93,3 +93,12 @@ const prettifyAmPm = (text: string) => {
}
);
};
/**
* Converts a string into a URL-friendly slug.
*
* @param str The string to be slugified.
* @returns The slugified string.
*/
export const slugify = (str: string) =>
slug(str, { lower: true, strict: true });

File diff suppressed because it is too large Load diff