Skip to content

Commit 455c4cd

Browse files
committed
feat(charts): add display: 'sparkline' mode for inline mini-charts
Adds a top-level `display` field on ChartSpec (`'full' | 'sparkline'`) that strips chrome, axes, legend, watermark, labels, and animation overhead so a minimal spec renders as a publication-ready sparkline at sizes down to ~30x20px. Works with line, area, bar, and point marks; explicit user fields at any level (including breakpoint overrides) continue to win over sparkline defaults. Sparkline-mode entrance animation uses a 1100ms expo-out curve for line/area so the reveal feels hand-drawn, with thinner 1.25px strokes to match the smaller scale. Includes a Ladle story demonstrating line/area/bar variants, KPI cards, and inline-with-text usage.
1 parent 7b80a7b commit 455c4cd

22 files changed

Lines changed: 1100 additions & 13 deletions

File tree

e2e/visual/stories.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const stories: Array<{ name: string; slug: string; note?: string }> = [
2727
},
2828
{ name: 'chart-with-annotations', slug: 'column-diverging--temperature-anomaly' },
2929
{ name: 'chart-with-watermark', slug: 'chrome--chrome-all-elements' },
30+
{ name: 'sparklines', slug: 'sparkline--sparklines' },
3031
];
3132

3233
/**

examples/src/sparkline.stories.tsx

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
/**
2+
* Sparklines: tiny inline charts for KPI cards, dashboards, and tight
3+
* editorial layouts. Same VizSpec grammar as a regular chart, just with
4+
* `display: 'sparkline'` to strip chrome, axes, legend, and watermark.
5+
*
6+
* One story shows all variants at realistic small sizes.
7+
*/
8+
9+
import type { Story } from '@ladle/react';
10+
import type { ChartSpec } from '@opendata-ai/openchart-core';
11+
import { Chart, useDarkMode, useVizDarkMode } from '@opendata-ai/openchart-react';
12+
13+
const SANS = '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif';
14+
15+
// Realistic-looking series. Each one uses a different mix of trend, noise, and
16+
// step changes so the sparklines don't all look like the same stock photo.
17+
18+
const revenueSeries = [
19+
9.8, 10.1, 9.9, 10.4, 10.7, 10.5, 11.0, 10.8, 11.2, 11.6, 11.4, 11.8, 12.1, 11.9, 12.3, 12.0,
20+
12.4, 12.6, 12.3, 12.4,
21+
];
22+
23+
const usersSeries = [
24+
142, 145, 151, 148, 155, 161, 158, 165, 168, 164, 172, 175, 171, 178, 174, 181, 178, 184, 180,
25+
184,
26+
];
27+
28+
const conversionSeries = [
29+
4.2, 4.1, 4.3, 4.0, 3.9, 4.0, 3.8, 3.9, 3.7, 3.8, 3.6, 3.7, 3.5, 3.6, 3.5, 3.6, 3.5, 3.4, 3.5,
30+
3.4,
31+
];
32+
33+
const aovSeries = [
34+
61.2, 62.8, 61.5, 63.1, 62.4, 64.0, 63.5, 64.8, 64.2, 65.5, 64.9, 66.1, 65.4, 66.7, 66.0, 66.9,
35+
66.3, 67.1, 66.7, 67.2,
36+
];
37+
38+
const churnSeries = [
39+
2.8, 2.9, 2.7, 2.8, 2.6, 2.7, 2.5, 2.6, 2.4, 2.5, 2.3, 2.4, 2.3, 2.2, 2.3, 2.2, 2.1, 2.2, 2.1,
40+
2.1,
41+
];
42+
43+
const npsSeries = [44, 46, 45, 48, 47, 49, 48, 50, 51, 49, 52, 50, 53, 51, 53, 52, 54, 52, 53, 52];
44+
45+
const pageViewsSeries = [
46+
82, 91, 78, 88, 102, 95, 87, 110, 98, 92, 115, 105, 99, 122, 108, 101, 128, 114, 106, 132,
47+
];
48+
49+
function toSeries(values: number[]): { date: string; value: number }[] {
50+
return values.map((value, i) => ({
51+
date: `2026-01-${String(i + 1).padStart(2, '0')}`,
52+
value,
53+
}));
54+
}
55+
56+
const revenueData = toSeries(revenueSeries);
57+
const usersData = toSeries(usersSeries);
58+
const conversionData = toSeries(conversionSeries);
59+
const aovData = toSeries(aovSeries);
60+
const churnData = toSeries(churnSeries);
61+
const npsData = toSeries(npsSeries);
62+
const pageViewsData = toSeries(pageViewsSeries);
63+
64+
// Default series color matches the OpenChart palette's first slot. Hardcoded
65+
// here so the area gradient stops can sample it without round-tripping through
66+
// the theme.
67+
const SPARKLINE_COLOR = '#1b7fa3';
68+
69+
function makeSpec(
70+
mark: 'line' | 'area' | 'bar',
71+
data: { date: string; value: number }[],
72+
): ChartSpec {
73+
// Area sparklines look much better with a fade-to-transparent gradient than a
74+
// flat 25% fill — gives them the dashboard polish DevExpress/Highcharts ship.
75+
const markDef =
76+
mark === 'area'
77+
? {
78+
type: 'area' as const,
79+
fill: {
80+
gradient: 'linear' as const,
81+
x1: 0,
82+
y1: 0,
83+
x2: 0,
84+
y2: 1,
85+
stops: [
86+
{ offset: 0, color: SPARKLINE_COLOR, opacity: 0.45 },
87+
{ offset: 1, color: SPARKLINE_COLOR, opacity: 0.02 },
88+
],
89+
},
90+
}
91+
: mark;
92+
93+
return {
94+
mark: markDef,
95+
data,
96+
encoding: {
97+
x: { field: 'date', type: mark === 'bar' ? 'ordinal' : 'temporal' },
98+
y: { field: 'value', type: 'quantitative' },
99+
},
100+
display: 'sparkline',
101+
animation: true,
102+
};
103+
}
104+
105+
const kpis: Array<{
106+
label: string;
107+
value: string;
108+
change: string;
109+
up: boolean;
110+
data: { date: string; value: number }[];
111+
mark: 'line' | 'area';
112+
}> = [
113+
{ label: 'Revenue', value: '$12.4M', change: '+8.2%', up: true, data: revenueData, mark: 'area' },
114+
{
115+
label: 'Active users',
116+
value: '184k',
117+
change: '+12.1%',
118+
up: true,
119+
data: usersData,
120+
mark: 'line',
121+
},
122+
{
123+
label: 'Conversion',
124+
value: '3.4%',
125+
change: '-0.6%',
126+
up: false,
127+
data: conversionData,
128+
mark: 'line',
129+
},
130+
{ label: 'AOV', value: '$67.20', change: '+2.4%', up: true, data: aovData, mark: 'area' },
131+
{ label: 'Churn', value: '2.1%', change: '-0.3%', up: true, data: churnData, mark: 'line' },
132+
{ label: 'NPS', value: '52', change: '+4', up: true, data: npsData, mark: 'line' },
133+
];
134+
135+
function Section({
136+
title,
137+
children,
138+
dark,
139+
}: {
140+
title: string;
141+
children: React.ReactNode;
142+
dark: boolean;
143+
}) {
144+
return (
145+
<div style={{ marginBottom: 24 }}>
146+
<div
147+
style={{
148+
fontSize: 11,
149+
fontWeight: 600,
150+
textTransform: 'uppercase',
151+
letterSpacing: '0.06em',
152+
color: dark ? '#94a3b8' : '#6b7280',
153+
marginBottom: 8,
154+
}}
155+
>
156+
{title}
157+
</div>
158+
{children}
159+
</div>
160+
);
161+
}
162+
163+
export const Sparklines: Story = () => {
164+
const contextDark = useVizDarkMode();
165+
const dark = useDarkMode(contextDark);
166+
167+
return (
168+
<div
169+
style={{
170+
padding: 24,
171+
maxWidth: 920,
172+
fontFamily: SANS,
173+
color: dark ? '#e5e7eb' : '#111827',
174+
}}
175+
>
176+
<Section title="Variants (200 x 40)" dark={dark}>
177+
<div style={{ display: 'flex', gap: 24, alignItems: 'center' }}>
178+
<div>
179+
<div style={{ fontSize: 12, marginBottom: 4 }}>Line</div>
180+
<div style={{ width: 200, height: 40 }}>
181+
<Chart spec={makeSpec('line', usersData)} darkMode={dark ? 'force' : 'off'} />
182+
</div>
183+
</div>
184+
<div>
185+
<div style={{ fontSize: 12, marginBottom: 4 }}>Area</div>
186+
<div style={{ width: 200, height: 40 }}>
187+
<Chart spec={makeSpec('area', revenueData)} darkMode={dark ? 'force' : 'off'} />
188+
</div>
189+
</div>
190+
<div>
191+
<div style={{ fontSize: 12, marginBottom: 4 }}>Bar</div>
192+
<div style={{ width: 200, height: 40 }}>
193+
<Chart spec={makeSpec('bar', pageViewsData)} darkMode={dark ? 'force' : 'off'} />
194+
</div>
195+
</div>
196+
</div>
197+
</Section>
198+
199+
<Section title="Tight (100 x 24)" dark={dark}>
200+
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
201+
<div style={{ width: 100, height: 24 }}>
202+
<Chart spec={makeSpec('line', usersData)} darkMode={dark ? 'force' : 'off'} />
203+
</div>
204+
<div style={{ width: 100, height: 24 }}>
205+
<Chart spec={makeSpec('area', revenueData)} darkMode={dark ? 'force' : 'off'} />
206+
</div>
207+
<div style={{ width: 100, height: 24 }}>
208+
<Chart spec={makeSpec('bar', pageViewsData)} darkMode={dark ? 'force' : 'off'} />
209+
</div>
210+
</div>
211+
</Section>
212+
213+
<Section title="KPI cards" dark={dark}>
214+
<div
215+
style={{
216+
display: 'grid',
217+
gridTemplateColumns: 'repeat(3, 1fr)',
218+
gap: 12,
219+
}}
220+
>
221+
{kpis.map((k) => (
222+
<div
223+
key={k.label}
224+
style={{
225+
padding: 14,
226+
border: dark ? '1px solid #1f2937' : '1px solid #e5e7eb',
227+
borderRadius: 8,
228+
background: dark ? '#0b1220' : '#fff',
229+
}}
230+
>
231+
<div
232+
style={{
233+
fontSize: 11,
234+
fontWeight: 500,
235+
textTransform: 'uppercase',
236+
letterSpacing: '0.04em',
237+
color: dark ? '#94a3b8' : '#6b7280',
238+
marginBottom: 4,
239+
}}
240+
>
241+
{k.label}
242+
</div>
243+
<div
244+
style={{
245+
display: 'flex',
246+
alignItems: 'baseline',
247+
gap: 8,
248+
marginBottom: 8,
249+
}}
250+
>
251+
<div style={{ fontSize: 22, fontWeight: 600, letterSpacing: '-0.01em' }}>
252+
{k.value}
253+
</div>
254+
<div
255+
style={{
256+
fontSize: 12,
257+
fontWeight: 500,
258+
color: k.up ? '#16a34a' : '#dc2626',
259+
}}
260+
>
261+
{k.change}
262+
</div>
263+
</div>
264+
<div style={{ height: 32 }}>
265+
<Chart spec={makeSpec(k.mark, k.data)} darkMode={dark ? 'force' : 'off'} />
266+
</div>
267+
</div>
268+
))}
269+
</div>
270+
</Section>
271+
272+
<Section title="Inline with text" dark={dark}>
273+
<div style={{ fontSize: 14, lineHeight: 1.6, maxWidth: 560 }}>
274+
Revenue is up{' '}
275+
<span
276+
style={{
277+
display: 'inline-block',
278+
width: 60,
279+
height: 16,
280+
verticalAlign: 'middle',
281+
margin: '0 4px',
282+
}}
283+
>
284+
<Chart spec={makeSpec('line', revenueData)} darkMode={dark ? 'force' : 'off'} />
285+
</span>{' '}
286+
versus last quarter, while churn{' '}
287+
<span
288+
style={{
289+
display: 'inline-block',
290+
width: 60,
291+
height: 16,
292+
verticalAlign: 'middle',
293+
margin: '0 4px',
294+
}}
295+
>
296+
<Chart spec={makeSpec('line', churnData)} darkMode={dark ? 'force' : 'off'} />
297+
</span>{' '}
298+
continued to fall.
299+
</div>
300+
</Section>
301+
</div>
302+
);
303+
};

packages/core/src/styles/animation.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,16 @@
142142
);
143143
}
144144
}
145+
146+
/* ---------------------------------------------------------------------------
147+
* Sparkline mode: pair the line/area reveal with a stronger ease-out so it
148+
* doesn't read as a uniform left-to-right swipe. cubic-bezier(0.16, 1, 0.3, 1)
149+
* is "expo-out" — fast initial draw that decelerates noticeably at the end,
150+
* giving the trend a hand-drawn feel. Duration is bumped via the engine
151+
* (compile.ts) so the cleanup timer stays in sync.
152+
* --------------------------------------------------------------------------- */
153+
154+
.oc-animate[data-display="sparkline"] .oc-mark-line,
155+
.oc-animate[data-display="sparkline"] .oc-mark-area {
156+
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
157+
}

packages/core/src/styles/index.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
@import "./table.css";
1818
@import "./table-animation.css";
1919
@import "./graph.css";
20+
@import "./sparkline.css";
2021
@import "./keyframes.css";
2122
@import "./animation.css";
2223
@import "./reduced-motion.css";
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Sparkline display mode.
3+
*
4+
* Stamped on the SVG root as data-display="sparkline" by the renderer when
5+
* the spec sets display: 'sparkline'. Strips any inherited padding/margin
6+
* so the mark renders truly edge-to-edge in tight container layouts (KPI
7+
* cards, table cells, dashboard tiles).
8+
*/
9+
10+
.oc-chart[data-display="sparkline"] {
11+
display: block;
12+
margin: 0;
13+
padding: 0;
14+
}

packages/core/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ export type {
115115
ConditionalValueDef,
116116
DarkMode,
117117
DataRow,
118+
Display,
118119
Encoding,
119120
EncodingChannel,
120121
FieldPredicate,

packages/core/src/types/layout.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,10 @@ export interface ChartLayout {
720720
animation?: ResolvedAnimation;
721721
/** Whether the tryOpenData.ai watermark is enabled. */
722722
watermark: boolean;
723+
/** Display mode controlling chrome/axes/legend stripping. `'sparkline'` produces an edge-to-edge mini chart. */
724+
display: import('./spec').Display;
725+
/** Whether the crosshair (vertical snap line) is enabled. */
726+
crosshair: boolean;
723727
/** Real text measurement function from the adapter (for accurate SVG text wrapping). */
724728
measureText?: MeasureTextFn;
725729
}

0 commit comments

Comments
 (0)