heatmap
Grid intensity reveal for activity and distribution.
import {Scene, Video} from "odori";
import {Heatmap} from "../components/heatmap/heatmap";
<Video>
<Scene id="heatmap" duration="4s">
<Heatmap
rows={[
[
0.1,
0.3,
0.2,
0.6,
0.4,
0.1,
0.05
],
[
0.2,
0.5,
0.7,
0.8,
0.6,
0.2,
0.1
],
[
0.4,
0.7,
0.9,
1,
0.8,
0.3,
0.15
]
]}
title="Renders per day"
rowLabels={[
"week 1",
"week 2",
"week 3"
]}
/>
</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/heatmapnpx odori add @odori/heatmapbunx odori add @odori/heatmapSource is copied into videos/components/heatmap/ 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/heatmap/heatmap.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type HeatmapProps = {
/** Rows of intensity values, 0 to 1. */
rows: number[][];
title?: string;
rowLabels?: string[];
};
/**
* Grid intensity revealed cell by cell, in reading order. Intensity is carried
* by opacity of one accent color rather than a rainbow, so the scale stays
* legible at video distance.
*/
export const Heatmap = ({rows, title, rowLabels}: HeatmapProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const columns = Math.max(...rows.map((row) => row.length), 1);
let cellIndex = 0;
return (
<Fill style={{gap: 34 * scale, justifyContent: "center", padding: `${110 * scale}px ${130 * scale}px`}}>
{title ? (
<div style={{fontSize: 56 * scale, fontWeight: 560, letterSpacing: "-0.04em", opacity: interpolate(frame, [0, 16], [0, 1])}}>
{title}
</div>
) : null}
<div style={{display: "grid", gap: 12 * scale}}>
{rows.map((row, rowNumber) => (
<div key={rowNumber} style={{alignItems: "center", display: "flex", gap: 18 * scale}}>
{rowLabels ? (
<span style={{color: brand.colors.muted, fontSize: 26 * scale, width: 200 * scale}}>
{rowLabels[rowNumber]}
</span>
) : null}
<div style={{display: "grid", flex: 1, gap: 12 * scale, gridTemplateColumns: `repeat(${columns}, 1fr)`}}>
{row.map((intensity, columnNumber) => {
const delay = 10 + cellIndex * 1.5;
cellIndex += 1;
const enter = interpolate(frame, [delay, delay + 14], [0, 1], {easing: Easing.standard});
return (
<span
key={columnNumber}
style={{
background: `color-mix(in srgb, ${brand.colors.accent} ${Math.round(intensity * 100)}%, ${brand.colors.surface})`,
borderRadius: 10 * scale,
height: 62 * scale,
opacity: enter,
transform: `scale(${0.86 + enter * 0.14})`,
}}
/>
);
})}
</div>
</div>
))}
</div>
</Fill>
);
};
videos/components/heatmap/heatmap.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {Heatmap} from "./heatmap";
export default defineComponentPreview({
title: "Heatmap",
category: "Data",
description: "Grid intensity reveal for activity and distribution.",
component: Heatmap,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {title: {type: "text", defaultValue: "Renders per day"}},
examples: [
{
name: "Activity",
props: {
rowLabels: ["week 1", "week 2", "week 3"],
rows: [
[0.1, 0.3, 0.2, 0.6, 0.4, 0.1, 0.05],
[0.2, 0.5, 0.7, 0.8, 0.6, 0.2, 0.1],
[0.4, 0.7, 0.9, 1, 0.8, 0.3, 0.15],
],
},
},
],
});
Use it in a scene.
import {Scene, Video} from "odori";
import {Heatmap} from "../components/heatmap/heatmap";
<Video>
<Scene id="heatmap" duration="4s">
<Heatmap
rows={[
[
0.1,
0.3,
0.2,
0.6,
0.4,
0.1,
0.05
],
[
0.2,
0.5,
0.7,
0.8,
0.6,
0.2,
0.1
],
[
0.4,
0.7,
0.9,
1,
0.8,
0.3,
0.15
]
]}
title="Renders per day"
rowLabels={[
"week 1",
"week 2",
"week 3"
]}
/>
</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 · 9:16 · 1:1
- Recommended duration
- 120 frames · 4s
- Minimum duration
- 60 frames · 2s
- Entrance
- 50 frames
- Exit
- 0 frames
- Reduced motion
- cells appear together
- Requires
- sans font
- Content limits
- rows ≤ 6, columns ≤ 10
Props
| Prop | Type | Default | Required |
|---|---|---|---|
rowsRows of intensity values, 0 to 1. | number[][] | — | Yes |
title | string | — | — |
rowLabels | string[] | — | — |