diff --git a/.changeset/flat-birds-sink.md b/.changeset/flat-birds-sink.md new file mode 100644 index 00000000..f8e36b12 --- /dev/null +++ b/.changeset/flat-birds-sink.md @@ -0,0 +1,5 @@ +--- +'@reuters-graphics/graphics-components': patch +--- + +Adds Lottie component diff --git a/package.json b/package.json index 372da22c..bfc49daa 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "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", "dayjs": "^1.11.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e42e9fd..99a6a95d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@fortawesome/free-solid-svg-icons': specifier: ^6.7.2 version: 6.7.2 + '@lottiefiles/dotlottie-web': + specifier: ^0.52.2 + version: 0.52.2 '@reuters-graphics/svelte-markdown': specifier: ^0.0.3 version: 0.0.3(svelte@5.28.1) @@ -569,6 +572,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@lottiefiles/dotlottie-web@0.52.2': + resolution: {integrity: sha512-aeXCMUB5RdusHrvZ3Py2KaMgQ0w7SdA8NFbPK+SpwqC1UW1CDFZl5vPLueZHju7vLhB1rpPvpQ5fQ0L/KEZt7w==} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -4641,6 +4647,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@lottiefiles/dotlottie-web@0.52.2': {} + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.27.0 diff --git a/src/components/@types/global.ts b/src/components/@types/global.ts index c5bb00e2..9aaee2ed 100644 --- a/src/components/@types/global.ts +++ b/src/components/@types/global.ts @@ -69,6 +69,17 @@ export type ScrollerVideoForegroundPosition = | '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 diff --git a/src/components/Lottie/Debug.svelte b/src/components/Lottie/Debug.svelte new file mode 100644 index 00000000..8fc6fafc --- /dev/null +++ b/src/components/Lottie/Debug.svelte @@ -0,0 +1,269 @@ + + + + +
+
+ + CONSOLE + +
+ +

Progress:

+
+

{componentState.progress}

+
+
+
+
+ +

Duration:

+

+ {componentState.duration}s +

+ + {#if componentState.segment} +

Segment:

+

+ {componentState.segment[0]} -- {componentState.segment[1]} +

+ {/if} + +

Current frame:

+

+ {componentState.currentFrame}/{componentState.totalFrames} +

+ +

Speed:

+

+ {componentState.speed} +

+ +

Autoplay:

+

+ {componentState.autoplay} +

+ +

Loop:

+

+ {componentState.loop} + {componentState.loop ? `(Loop count: ${componentState.loopCount})` : ''} +

+ +

Mode:

+

+ {componentState.mode} +

+ +

Layout:

+

+ {JSON.stringify(componentState.layout)} +

+ + {#if Object.keys(componentState.allMarkers).length} +

All markers:

+

+ {componentState.allMarkers} +

+ {/if} + + {#if componentState.marker} +

Active marker:

+

+ {componentState.marker} +

+ {/if} + + {#if componentState.allThemes.length} +

All themes:

+

+ {componentState.allThemes.join(', ')} +

+ {/if} + {#if componentState.activeThemeId} +

Active theme ID:

+

+ {componentState.activeThemeId} +

+ {/if} + +

isPaused:

+

+ {componentState.isPaused} +

+ +

isPlaying:

+

+ {componentState.isPlaying} +

+ +

isStopped:

+

+ {componentState.isStopped} +

+ +

isLoaded:

+

+ {componentState.isLoaded} +

+ +

isFrozen:

+

+ {componentState.isFrozen} +

+
+
+
+ + diff --git a/src/components/Lottie/Lottie.mdx b/src/components/Lottie/Lottie.mdx new file mode 100644 index 00000000..6c0029bf --- /dev/null +++ b/src/components/Lottie/Lottie.mdx @@ -0,0 +1,386 @@ +import { Meta } from '@storybook/blocks'; + +import * as LottieStories from './Lottie.stories.svelte'; +import CompositionMarkerImage from './assets/marker.jpg?url'; + + + +# 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 + + + +``` + +## 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 + + +{#each content.blocks as block} + + {#if block.type == 'lottie'} + + {/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 + + +{#each content.blocks as block} + + {#if block.type == 'lottie'} + + {/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: + +Composition Marker Dialog + +[Demo](?path=/story/components-graphics-scrollerlottie--marker) + +```svelte + + + +``` + +## 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 + + + +``` + +## 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 + + + + {#snippet backgroundSnippet()} + + + {/snippet} + {#snippet foregroundSnippet()} +
+

Step 1

+
+
+

Step 2

+
+
+

Step 3

+
+ {/snippet} +
+ + +``` + +## 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 + + + + + +
+ + + +
+
+ + + +
+``` + +### 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 + + +{#each content.blocks as block} + + {#if block.type == 'lottie'} + + {#each block.foregrounds as foreground} + {#if foreground.type == 'text'} + + {:else if foreground.type == 'component'} + {@const Component = + Components[foreground.foregroundProps.componentName]} + + + + {/if} + {/each} + + {/if} +{/each} +``` diff --git a/src/components/Lottie/Lottie.stories.svelte b/src/components/Lottie/Lottie.stories.svelte new file mode 100644 index 00000000..8ecc7da2 --- /dev/null +++ b/src/components/Lottie/Lottie.stories.svelte @@ -0,0 +1,150 @@ + + + + {#snippet children(args)} + + {/snippet} + + + + {#snippet children(args)} + + {/snippet} + + + + {#snippet children(args)} + + {/snippet} + + + + {#snippet children(args)} + + {/snippet} + + + + + + + + + +
+ + + +
+
+ + +
+
+ + diff --git a/src/components/Lottie/Lottie.svelte b/src/components/Lottie/Lottie.svelte new file mode 100644 index 00000000..d7a64ec8 --- /dev/null +++ b/src/components/Lottie/Lottie.svelte @@ -0,0 +1,443 @@ + + +
+ {#if showDebugInfo && lottiePlayer} + + {/if} + +
+ +
+ + {#if children} + {@render children()} + {/if} +
+ + diff --git a/src/components/Lottie/LottieForeground.svelte b/src/components/Lottie/LottieForeground.svelte new file mode 100644 index 00000000..ed74a21e --- /dev/null +++ b/src/components/Lottie/LottieForeground.svelte @@ -0,0 +1,145 @@ + + +
+ {#if componentState?.currentFrame && componentState.currentFrame >= startFrame && componentState.currentFrame <= endFrame} +
+ + {#if text} + +
+ +
+
+ + {:else if children} + {@render children()} + {/if} +
+ {/if} +
+ + diff --git a/src/components/Lottie/assets/marker.jpg b/src/components/Lottie/assets/marker.jpg new file mode 100644 index 00000000..37f729e7 Binary files /dev/null and b/src/components/Lottie/assets/marker.jpg differ diff --git a/src/components/Lottie/demo/withScrollerBase.svelte b/src/components/Lottie/demo/withScrollerBase.svelte new file mode 100644 index 00000000..483626d1 --- /dev/null +++ b/src/components/Lottie/demo/withScrollerBase.svelte @@ -0,0 +1,64 @@ + + + + + + {#snippet backgroundSnippet()} + + {/snippet} + {#snippet foregroundSnippet()} +

Step 1

+

Step 2

+

Step 3

+

Step 4

+

Step 5

+ {/snippet} +
+ + + + diff --git a/src/components/Lottie/lottie/demo.zip b/src/components/Lottie/lottie/demo.zip new file mode 100644 index 00000000..2739af54 Binary files /dev/null and b/src/components/Lottie/lottie/demo.zip differ diff --git a/src/components/Lottie/lottie/foregroundSample.zip b/src/components/Lottie/lottie/foregroundSample.zip new file mode 100644 index 00000000..2b9e0b55 Binary files /dev/null and b/src/components/Lottie/lottie/foregroundSample.zip differ diff --git a/src/components/Lottie/lottie/markerSample.zip b/src/components/Lottie/lottie/markerSample.zip new file mode 100644 index 00000000..8064b2e7 Binary files /dev/null and b/src/components/Lottie/lottie/markerSample.zip differ diff --git a/src/components/Lottie/lottie/themesLottie.zip b/src/components/Lottie/lottie/themesLottie.zip new file mode 100644 index 00000000..eed4febc Binary files /dev/null and b/src/components/Lottie/lottie/themesLottie.zip differ diff --git a/src/components/Lottie/ts/lottieState.svelte.ts b/src/components/Lottie/ts/lottieState.svelte.ts new file mode 100644 index 00000000..2b8e9712 --- /dev/null +++ b/src/components/Lottie/ts/lottieState.svelte.ts @@ -0,0 +1,61 @@ +import type { Layout } from '@lottiefiles/dotlottie-web'; + +export interface LottieState { + [key: string]: + | number + | boolean + | string + | null + | Array + | Array + | [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; + marker: undefined | string; + allThemes: Array; + activeThemeId: null | string; +} + +export function createLottieState(): LottieState { + const lottieState = $state({ + 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; +} diff --git a/src/components/Lottie/ts/types.ts b/src/components/Lottie/ts/types.ts new file mode 100644 index 00000000..f4b088fb --- /dev/null +++ b/src/components/Lottie/ts/types.ts @@ -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; +}; diff --git a/src/components/Lottie/ts/utils.ts b/src/components/Lottie/ts/utils.ts new file mode 100644 index 00000000..dec2c2a3 --- /dev/null +++ b/src/components/Lottie/ts/utils.ts @@ -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' + ); +} diff --git a/src/components/ScrollerVideo/Debug.svelte b/src/components/ScrollerVideo/Debug.svelte index 547b3f04..b922f22a 100644 --- a/src/components/ScrollerVideo/Debug.svelte +++ b/src/components/ScrollerVideo/Debug.svelte @@ -120,8 +120,6 @@