line-chart
Time-series path draw with optional focal annotation.
import {Scene, Video} from "odori";
import {LineChart} from "../components/line-chart/line-chart";
<Video>
<Scene id="line-chart" duration="4s">
<LineChart
values={[
3,
4,
6,
5,
9,
12,
18,
21
]}
title="Videos shipped per week"
annotateIndex={6}
annotation="The week the registry landed."
/>
</Scene>
</Video>The same runtime that renders the export. Scrub to any frame, switch format, and edit the fixture props.
Installation
pnpm odori add @odori/line-chartnpx odori add @odori/line-chartbunx odori add @odori/line-chartSource is copied into videos/components/line-chart/ and belongs to your repository. odori diff compares it with upstream later; odori update applies the change you accept. It has no registry dependencies.
Copy and paste the following code into your project.
videos/components/line-chart/line-chart.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type LineChartProps = {
/** One value per step, drawn left to right. */
values: number[];
title?: string;
/** Index to annotate once the path finishes drawing. */
annotateIndex?: number;
annotation?: string;
};
/**
* A time series drawn as one continuous path. The line reveals by clipping
* from the left rather than by animating point positions, so the shape of the
* data never lies during the reveal.
*/
export const LineChart = ({values, title, annotateIndex, annotation}: LineChartProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const max = Math.max(...values, 1);
const min = Math.min(...values, 0);
const draw = interpolate(frame, [12, 60], [0, 1], {easing: Easing.standard});
const point = (value: number, index: number) => ({
x: (index / Math.max(1, values.length - 1)) * 100,
y: 100 - ((value - min) / Math.max(1, max - min)) * 100,
});
const path = values
.map((value, index) => {
const {x, y} = point(value, index);
return `${index === 0 ? "M" : "L"} ${x} ${y}`;
})
.join(" ");
const marked = annotateIndex !== undefined ? point(values[annotateIndex] ?? 0, annotateIndex) : null;
const markOpacity = interpolate(frame, [58, 76], [0, 1], {easing: Easing.standard});
return (
<Fill style={{gap: 34 * scale, justifyContent: "center", padding: `${110 * scale}px ${130 * scale}px`}}>
{title ? (
<div style={{fontSize: 58 * scale, fontWeight: 560, letterSpacing: "-0.04em", opacity: interpolate(frame, [0, 16], [0, 1])}}>
{title}
</div>
) : null}
<div style={{height: 460 * scale, position: "relative", width: "100%"}}>
<svg preserveAspectRatio="none" style={{height: "100%", width: "100%"}} viewBox="0 0 100 100">
<path
d={path}
fill="none"
stroke={brand.colors.foreground}
strokeLinecap="round"
strokeWidth={9 * scale}
style={{clipPath: `inset(0 ${(1 - draw) * 100}% 0 0)`}}
vectorEffect="non-scaling-stroke"
/>
</svg>
{marked ? (
<div
style={{
left: `${marked.x}%`,
opacity: markOpacity,
position: "absolute",
top: `${marked.y}%`,
transform: "translate(-50%, -50%)",
}}
>
<span
style={{
background: brand.colors.accent,
borderRadius: 999,
display: "block",
height: 18 * scale,
width: 18 * scale,
}}
/>
</div>
) : null}
</div>
{annotation ? (
<div style={{color: brand.colors.muted, fontSize: 32 * scale, opacity: markOpacity}}>{annotation}</div>
) : null}
</Fill>
);
};
videos/components/line-chart/line-chart.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {LineChart} from "./line-chart";
export default defineComponentPreview({
title: "Line chart",
category: "Data",
description: "Time-series path draw with optional focal annotation.",
component: LineChart,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
title: {type: "text", defaultValue: "Videos shipped per week"},
annotation: {type: "text", defaultValue: "The week the registry landed."},
annotateIndex: {type: "number", defaultValue: 6, min: 0, max: 11},
},
examples: [
{name: "Growth", props: {values: [3, 4, 6, 5, 9, 12, 18, 21], annotateIndex: 6}},
],
});
Use it in a scene.
import {Scene, Video} from "odori";
import {LineChart} from "../components/line-chart/line-chart";
<Video>
<Scene id="line-chart" duration="4s">
<LineChart
values={[
3,
4,
6,
5,
9,
12,
18,
21
]}
title="Videos shipped per week"
annotateIndex={6}
annotation="The week the registry landed."
/>
</Scene>
</Video>Nothing else to install.
The component imports only from odori, so there are no dependencies to add and no import paths to rewrite.
Timing contract
Every registry component declares how it behaves in time.odori test fails a cut that violates these.
- Family
- Data
- Aspect ratios
- 16:9 · 1:1
- Recommended duration
- 120 frames · 4s
- Minimum duration
- 78 frames · 2.6s
- Entrance
- 64 frames
- Exit
- 0 frames
- Reduced motion
- the full path is drawn immediately
- Requires
- sans font
- Content limits
- points ≤ 16
Props
| Prop | Type | Default | Required |
|---|---|---|---|
valuesOne value per step, drawn left to right. | number[] | — | Yes |
title | string | — | — |
annotateIndexIndex to annotate once the path finishes drawing. | number | — | — |
annotation | string | — | — |