@opendata-ai/openchart-vanilla 7.11.0 → 8.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-EQ6DYFO7.js → chunk-5EOV5JQZ.js} +431 -538
- package/dist/chunk-5EOV5JQZ.js.map +1 -0
- package/dist/chunk-ENXSUXC5.js +6513 -0
- package/dist/chunk-ENXSUXC5.js.map +1 -0
- package/dist/chunk-W34KN7FD.js +298 -0
- package/dist/chunk-W34KN7FD.js.map +1 -0
- package/dist/index.d.ts +110 -116
- package/dist/index.js +189 -4408
- package/dist/index.js.map +1 -1
- package/dist/mount-CUKQLeqD.d.ts +207 -0
- package/dist/static.js +16 -11
- package/dist/static.js.map +1 -1
- package/dist/story/index.d.ts +277 -0
- package/dist/story/index.js +537 -0
- package/dist/story/index.js.map +1 -0
- package/dist/styles.css +2 -1
- package/dist/styles.css.map +1 -0
- package/package.json +9 -3
- package/src/__test-fixtures__/specs.ts +1 -1
- package/src/__tests__/annotations-render.test.ts +201 -0
- package/src/__tests__/continuous-legend.test.ts +91 -0
- package/src/__tests__/edit-events.test.ts +155 -0
- package/src/__tests__/map.test.ts +293 -0
- package/src/__tests__/mount.test.ts +26 -0
- package/src/__tests__/pattern-fills.test.ts +105 -0
- package/src/__tests__/series-search.test.ts +311 -0
- package/src/__tests__/table-mount.test.ts +34 -0
- package/src/__tests__/tooltip.test.ts +26 -0
- package/src/__tests__/transition.test.ts +196 -11
- package/src/__tests__/you-draw-it.test.ts +273 -0
- package/src/animation-vars.ts +42 -0
- package/src/barlist-renderer.ts +2 -2
- package/src/dom-helpers.ts +17 -0
- package/src/export.ts +33 -16
- package/src/index.ts +7 -0
- package/src/interactions/editing-drags.ts +9 -6
- package/src/interactions/legend-interaction.ts +9 -1
- package/src/interactions/selection.ts +12 -3
- package/src/map-camera.ts +88 -0
- package/src/map-mount.ts +701 -0
- package/src/map-renderer.ts +338 -0
- package/src/map-transition.ts +83 -0
- package/src/mount.ts +212 -26
- package/src/pattern-utils.ts +110 -0
- package/src/renderers/annotations.ts +162 -117
- package/src/renderers/axes.ts +13 -6
- package/src/renderers/brand.ts +7 -2
- package/src/renderers/chrome.ts +32 -10
- package/src/renderers/legend.ts +171 -2
- package/src/renderers/marks.ts +33 -5
- package/src/sankey-renderer.ts +7 -11
- package/src/series-search.ts +392 -0
- package/src/static.ts +13 -8
- package/src/story/__tests__/camera-math.test.ts +145 -0
- package/src/story/__tests__/create-chart-story.test.ts +317 -0
- package/src/story/__tests__/progress-math.test.ts +95 -0
- package/src/story/__tests__/scroll-driver.test.ts +130 -0
- package/src/story/__tests__/tween.test.ts +159 -0
- package/src/story/camera-math.ts +125 -0
- package/src/story/create-chart-story.ts +353 -0
- package/src/story/crossfade.ts +70 -0
- package/src/story/index.ts +39 -0
- package/src/story/progress-math.ts +85 -0
- package/src/story/resolve-camera-target.ts +119 -0
- package/src/story/scroll-driver.ts +206 -0
- package/src/story/story-camera.ts +49 -0
- package/src/story/tween.ts +112 -0
- package/src/story/types.ts +76 -0
- package/src/svg-renderer.ts +60 -29
- package/src/table-mount.ts +12 -3
- package/src/table-renderer.ts +9 -28
- package/src/text-edit-overlay.ts +1 -1
- package/src/theme-tokens.ts +4 -4
- package/src/tilemap-mount.ts +27 -6
- package/src/tilemap-renderer.ts +30 -88
- package/src/tooltip.ts +11 -0
- package/src/transition.ts +376 -33
- package/src/you-draw-it.ts +480 -0
- package/dist/chunk-EQ6DYFO7.js.map +0 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { ChartSpec, LayerSpec, GraphSpec, ElementRef, ChartLayout, ChartEventHandlers, ThemeConfig, DarkMode, DataRow } from '@opendata-ai/openchart-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Export utilities: serialize charts to SVG, PNG, JPG, or CSV.
|
|
5
|
+
*
|
|
6
|
+
* - SVG: serializes the rendered DOM element via XMLSerializer
|
|
7
|
+
* - SVG with fonts: async version that embeds @font-face data URIs
|
|
8
|
+
* - PNG: renders SVG to canvas, then extracts as Blob
|
|
9
|
+
* - JPG: same as PNG but with JPEG compression and background fill
|
|
10
|
+
* - CSV: converts a data array to comma-separated text
|
|
11
|
+
*/
|
|
12
|
+
interface SVGExportOptions {
|
|
13
|
+
/** Embed fonts as base64 data URIs in the SVG. Defaults to true. */
|
|
14
|
+
embedFonts?: boolean;
|
|
15
|
+
}
|
|
16
|
+
interface PNGExportOptions extends SVGExportOptions {
|
|
17
|
+
/** DPI scaling factor. Defaults to 2 for retina-quality output. */
|
|
18
|
+
dpi?: number;
|
|
19
|
+
}
|
|
20
|
+
interface JPGExportOptions extends PNGExportOptions {
|
|
21
|
+
/** JPEG quality from 0 to 1. Defaults to 0.92. */
|
|
22
|
+
quality?: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Serialize an SVG element to an XML string.
|
|
26
|
+
*
|
|
27
|
+
* @param svgElement - The rendered SVG element to serialize.
|
|
28
|
+
* @returns The SVG markup as a string.
|
|
29
|
+
*/
|
|
30
|
+
declare function exportSVG(svgElement: SVGElement): string;
|
|
31
|
+
/**
|
|
32
|
+
* Serialize an SVG element with embedded fonts to an XML string.
|
|
33
|
+
*
|
|
34
|
+
* Collects font-family declarations from the SVG's text elements,
|
|
35
|
+
* finds matching @font-face rules in the page's stylesheets, fetches
|
|
36
|
+
* the font files, and embeds them as base64 data URIs. The resulting
|
|
37
|
+
* SVG is self-contained and renders correctly without external fonts.
|
|
38
|
+
*
|
|
39
|
+
* @param svgElement - The rendered SVG element to serialize.
|
|
40
|
+
* @param options - Export options.
|
|
41
|
+
* @returns A Promise resolving to the SVG markup as a string.
|
|
42
|
+
*/
|
|
43
|
+
declare function exportSVGWithFonts(svgElement: SVGElement, options?: SVGExportOptions): Promise<string>;
|
|
44
|
+
/**
|
|
45
|
+
* Render an SVG element to a PNG Blob via a canvas.
|
|
46
|
+
*
|
|
47
|
+
* Embeds fonts by default so the exported image matches on-screen rendering.
|
|
48
|
+
* Set `embedFonts: false` to skip font embedding for faster exports.
|
|
49
|
+
*
|
|
50
|
+
* @param svgElement - The rendered SVG element.
|
|
51
|
+
* @param options - Optional DPI scaling and font embedding.
|
|
52
|
+
* @returns A Promise resolving to the PNG Blob.
|
|
53
|
+
*/
|
|
54
|
+
declare function exportPNG(svgElement: SVGElement, options?: PNGExportOptions): Promise<Blob>;
|
|
55
|
+
/**
|
|
56
|
+
* Render an SVG element to a JPEG Blob via a canvas.
|
|
57
|
+
*
|
|
58
|
+
* Same pipeline as exportPNG but outputs JPEG with configurable quality.
|
|
59
|
+
* The canvas is filled with the chart's background color before drawing
|
|
60
|
+
* to avoid transparent backgrounds rendering as black in JPEG format.
|
|
61
|
+
*
|
|
62
|
+
* @param svgElement - The rendered SVG element.
|
|
63
|
+
* @param options - Optional DPI scaling, JPEG quality, and font embedding.
|
|
64
|
+
* @returns A Promise resolving to the JPEG Blob.
|
|
65
|
+
*/
|
|
66
|
+
declare function exportJPG(svgElement: SVGElement, options?: JPGExportOptions): Promise<Blob>;
|
|
67
|
+
/**
|
|
68
|
+
* Convert an array of data objects to a CSV string.
|
|
69
|
+
*
|
|
70
|
+
* Uses the keys from the first row as column headers.
|
|
71
|
+
* Values are quoted if they contain commas, quotes, or newlines.
|
|
72
|
+
*
|
|
73
|
+
* @param data - Array of row objects.
|
|
74
|
+
* @returns CSV-formatted string.
|
|
75
|
+
*/
|
|
76
|
+
declare function exportCSV(data: Record<string, unknown>[]): string;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Pure math for the scrollytelling camera. `createChartStory` owns DOM
|
|
80
|
+
* writes; everything here is testable without a browser. Ported from
|
|
81
|
+
* opendata/shared/lib/scrolly/camera-math.ts.
|
|
82
|
+
*
|
|
83
|
+
* The camera is a center + zoom over a fixed viewBox, applied as a
|
|
84
|
+
* `transform` on a dedicated wrapper `<g>` around the marks group (NOT
|
|
85
|
+
* viewBox interpolation -- that scales text and gives labels no way to
|
|
86
|
+
* counter-scale). See .claude/rules/svg-animation.md: CSS transform
|
|
87
|
+
* replaces SVG transform attributes, so the camera transform must compose
|
|
88
|
+
* with, not replace, existing SVG transforms on marks.
|
|
89
|
+
*/
|
|
90
|
+
interface Camera {
|
|
91
|
+
cx: number;
|
|
92
|
+
cy: number;
|
|
93
|
+
/** Zoom factor. 1 = full viewBox visible. */
|
|
94
|
+
k: number;
|
|
95
|
+
}
|
|
96
|
+
interface CameraTarget {
|
|
97
|
+
x: number;
|
|
98
|
+
y: number;
|
|
99
|
+
width: number;
|
|
100
|
+
height: number;
|
|
101
|
+
/** ViewBox units added on all sides before fitting */
|
|
102
|
+
padding?: number;
|
|
103
|
+
}
|
|
104
|
+
interface ViewBoxSize {
|
|
105
|
+
width: number;
|
|
106
|
+
height: number;
|
|
107
|
+
}
|
|
108
|
+
declare const FULL_VIEW: (vb: ViewBoxSize) => CameraTarget;
|
|
109
|
+
/** Fit a target rect inside the viewBox, preserving aspect ratio. */
|
|
110
|
+
declare function fitTarget(target: CameraTarget, vb: ViewBoxSize): Camera;
|
|
111
|
+
/**
|
|
112
|
+
* Pan lerps linearly; zoom lerps in LOG space so the zoom rate reads
|
|
113
|
+
* constant (a 1->4 zoom passes through 2 at the midpoint, not 2.5).
|
|
114
|
+
*/
|
|
115
|
+
declare function interpolateCamera(a: Camera, b: Camera, t: number): Camera;
|
|
116
|
+
/** SVG transform string that frames the camera inside the viewBox. */
|
|
117
|
+
declare function cameraTransform(c: Camera, vb: ViewBoxSize): string;
|
|
118
|
+
/**
|
|
119
|
+
* Frame-rate-independent exponential damping: two 8ms steps land where one
|
|
120
|
+
* 16ms step does. `tauMs` is the time constant (~63% of the gap per tau).
|
|
121
|
+
*/
|
|
122
|
+
declare function damp(current: number, target: number, tauMs: number, dtMs: number): number;
|
|
123
|
+
declare function dampCamera(current: Camera, target: Camera, tauMs: number, dtMs: number): Camera;
|
|
124
|
+
declare function camerasClose(a: Camera, b: Camera, epsilon?: number): boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Where the scrub camera should point for a given frame.
|
|
127
|
+
*
|
|
128
|
+
* Hold-zone semantics: the camera DWELLS on the active step's target while
|
|
129
|
+
* the reader reads, and only transitions toward the next step's target in
|
|
130
|
+
* the tail of the span (`stepProgress > holdUntil`). Interpolating across
|
|
131
|
+
* the whole span would mean the camera departs the moment a step activates
|
|
132
|
+
* and never rests on what the text is describing.
|
|
133
|
+
*/
|
|
134
|
+
declare function scrubCamera(step: number, stepProgress: number, fitted: Camera[], holdUntil: number, ease: (t: number) => number): Camera;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Mount API: the main entry point for vanilla JS usage.
|
|
138
|
+
*
|
|
139
|
+
* createChart() takes a container, spec, and options, compiles the chart,
|
|
140
|
+
* renders it as SVG, sets up responsive resizing, tooltip interaction
|
|
141
|
+
* (mouse/touch/keyboard), keyboard navigation between data points,
|
|
142
|
+
* and returns a ChartInstance with update/resize/export/destroy methods.
|
|
143
|
+
*/
|
|
144
|
+
|
|
145
|
+
interface MountOptions extends ChartEventHandlers {
|
|
146
|
+
/** Theme overrides. */
|
|
147
|
+
theme?: ThemeConfig;
|
|
148
|
+
/** Dark mode setting: "auto" (system pref), "force", or "off". */
|
|
149
|
+
darkMode?: DarkMode;
|
|
150
|
+
/** Callback when a data point is clicked. @deprecated Use onMarkClick instead. */
|
|
151
|
+
onDataPointClick?: (data: Record<string, unknown>) => void;
|
|
152
|
+
/** Enable responsive resizing. Defaults to true. */
|
|
153
|
+
responsive?: boolean;
|
|
154
|
+
/** Show the tryOpenData.ai watermark. Defaults to true. */
|
|
155
|
+
watermark?: boolean;
|
|
156
|
+
/** Initial selected element. */
|
|
157
|
+
selectedElement?: ElementRef;
|
|
158
|
+
}
|
|
159
|
+
interface UpdateOptions {
|
|
160
|
+
/** Override the selected element after update. When omitted, preserves current selection. */
|
|
161
|
+
selectedElement?: ElementRef;
|
|
162
|
+
}
|
|
163
|
+
interface ExportOptions extends JPGExportOptions {
|
|
164
|
+
}
|
|
165
|
+
interface ChartInstance {
|
|
166
|
+
/** Re-compile and re-render with a new spec. */
|
|
167
|
+
update(spec: ChartSpec | LayerSpec | GraphSpec, options?: UpdateOptions): void;
|
|
168
|
+
/** Re-compile at current container dimensions. */
|
|
169
|
+
resize(): void;
|
|
170
|
+
/** Export the chart. */
|
|
171
|
+
export(format: 'svg'): string;
|
|
172
|
+
export(format: 'svg-with-fonts', options?: SVGExportOptions): Promise<string>;
|
|
173
|
+
export(format: 'png', options?: ExportOptions): Promise<Blob>;
|
|
174
|
+
export(format: 'jpg', options?: ExportOptions): Promise<Blob>;
|
|
175
|
+
export(format: 'csv'): string;
|
|
176
|
+
export(format: 'svg' | 'svg-with-fonts' | 'png' | 'jpg' | 'csv', options?: ExportOptions): string | Promise<Blob> | Promise<string>;
|
|
177
|
+
/** Remove all DOM elements and disconnect observers. */
|
|
178
|
+
destroy(): void;
|
|
179
|
+
/** The current compiled layout (for hooks / debugging). */
|
|
180
|
+
readonly layout: ChartLayout;
|
|
181
|
+
/** Get the currently selected element, or null if none. */
|
|
182
|
+
getSelectedElement(): ElementRef | null;
|
|
183
|
+
/** Programmatically select an element. Silent no-op if element not found. */
|
|
184
|
+
select(ref: ElementRef): void;
|
|
185
|
+
/** Deselect the current element. */
|
|
186
|
+
deselect(): void;
|
|
187
|
+
/** Whether inline text editing is active. */
|
|
188
|
+
readonly isEditing: boolean;
|
|
189
|
+
/** Set highlight values on the color encoding and re-render. Pass null to clear. */
|
|
190
|
+
setHighlight(values: string[] | null): void;
|
|
191
|
+
/**
|
|
192
|
+
* Reset a "you draw it" (`youDrawIt`) drawing: clears the reader's guess and
|
|
193
|
+
* returns to the drawing state. No-op when youDrawIt isn't enabled.
|
|
194
|
+
*/
|
|
195
|
+
resetDrawing(): void;
|
|
196
|
+
/**
|
|
197
|
+
* Reveal a "you draw it" drawing programmatically (equivalent to the
|
|
198
|
+
* skip-to-reveal button). No-op when youDrawIt isn't enabled.
|
|
199
|
+
*/
|
|
200
|
+
revealDrawing(): void;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Create a chart instance from a spec and mount it into a container.
|
|
204
|
+
*/
|
|
205
|
+
declare function createChart<TData extends DataRow = DataRow>(container: HTMLElement, spec: ChartSpec<TData> | LayerSpec<TData> | GraphSpec, options?: MountOptions): ChartInstance;
|
|
206
|
+
|
|
207
|
+
export { type Camera as C, type ExportOptions as E, FULL_VIEW as F, type JPGExportOptions as J, type MountOptions as M, type PNGExportOptions as P, type SVGExportOptions as S, type UpdateOptions as U, type ViewBoxSize as V, type ChartInstance as a, exportJPG as b, createChart as c, exportPNG as d, exportCSV as e, exportSVG as f, exportSVGWithFonts as g, type CameraTarget as h, cameraTransform as i, camerasClose as j, damp as k, dampCamera as l, fitTarget as m, interpolateCamera as n, scrubCamera as s };
|
package/dist/static.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
|
+
import {
|
|
2
|
+
renderTileMapSVG
|
|
3
|
+
} from "./chunk-W34KN7FD.js";
|
|
1
4
|
import {
|
|
2
5
|
SVG_NS,
|
|
3
6
|
renderChartSVG,
|
|
4
|
-
renderTileMapSVG,
|
|
5
7
|
resetSvgIdCounter
|
|
6
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-5EOV5JQZ.js";
|
|
7
9
|
|
|
8
10
|
// src/static.ts
|
|
9
11
|
import { createRequire } from "module";
|
|
10
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
adaptForLightLineStroke,
|
|
14
|
+
cssTokenDefault,
|
|
15
|
+
isLayerSpec,
|
|
16
|
+
isTileMapSpec
|
|
17
|
+
} from "@opendata-ai/openchart-core";
|
|
11
18
|
import { compileChart, compileLayer, compileTileMap } from "@opendata-ai/openchart-engine";
|
|
12
19
|
var esmRequire = createRequire(import.meta.url);
|
|
13
20
|
var cachedWindow;
|
|
@@ -28,15 +35,14 @@ function resolveStaticDarkMode(mode) {
|
|
|
28
35
|
return false;
|
|
29
36
|
}
|
|
30
37
|
function buildThemeStyleBlock(theme) {
|
|
31
|
-
const accent = theme.colors.categorical[0] ?? "
|
|
32
|
-
const bg = theme.colors.background === "transparent" ? theme.isDark ? "
|
|
38
|
+
const accent = theme.colors.categorical[0] ?? cssTokenDefault("--oc-accent", "light");
|
|
39
|
+
const bg = theme.colors.background === "transparent" ? theme.isDark ? cssTokenDefault("--oc-bg", "dark") : cssTokenDefault("--oc-bg", "light") : theme.colors.background;
|
|
33
40
|
const props = [
|
|
34
41
|
`--oc-font-family: ${theme.fonts.family}`,
|
|
35
42
|
`--oc-font-mono: ${theme.fonts.mono}`,
|
|
36
43
|
`--oc-title-size: ${theme.chrome.title.fontSize}px`,
|
|
37
44
|
`--oc-title-weight: ${theme.chrome.title.fontWeight}`,
|
|
38
|
-
`--oc-title-tracking: -
|
|
39
|
-
// sync with tokens.css
|
|
45
|
+
`--oc-title-tracking: ${cssTokenDefault("--oc-title-tracking", "light")}`,
|
|
40
46
|
`--oc-subtitle-size: ${theme.chrome.subtitle.fontSize}px`,
|
|
41
47
|
`--oc-subtitle-weight: ${theme.chrome.subtitle.fontWeight}`,
|
|
42
48
|
`--oc-source-size: ${theme.chrome.source.fontSize}px`,
|
|
@@ -44,12 +50,11 @@ function buildThemeStyleBlock(theme) {
|
|
|
44
50
|
`--oc-body-size: ${theme.fonts.sizes.body}px`,
|
|
45
51
|
`--oc-eyebrow-size: ${theme.chrome.eyebrow.fontSize}px`,
|
|
46
52
|
`--oc-eyebrow-weight: ${theme.chrome.eyebrow.fontWeight}`,
|
|
47
|
-
`--oc-eyebrow-tracking:
|
|
48
|
-
// sync with tokens.css
|
|
53
|
+
`--oc-eyebrow-tracking: ${cssTokenDefault("--oc-eyebrow-tracking", "light")}`,
|
|
49
54
|
`--oc-bg: ${bg}`,
|
|
50
55
|
`--oc-text: ${theme.colors.text}`,
|
|
51
56
|
`--oc-text-muted: ${theme.colors.axis}`,
|
|
52
|
-
`--oc-text-faint: ${theme.isDark ? "
|
|
57
|
+
`--oc-text-faint: ${theme.isDark ? cssTokenDefault("--oc-text-faint", "dark") : cssTokenDefault("--oc-text-faint", "light")}`,
|
|
53
58
|
`--oc-gridline: ${theme.colors.gridline}`,
|
|
54
59
|
`--oc-axis: ${theme.colors.axis}`,
|
|
55
60
|
`--oc-border-radius: ${theme.borderRadius}px`,
|
|
@@ -57,7 +62,7 @@ function buildThemeStyleBlock(theme) {
|
|
|
57
62
|
`--oc-accent-strong: ${adaptForLightLineStroke(accent)}`,
|
|
58
63
|
`--oc-positive: ${theme.colors.positive}`,
|
|
59
64
|
`--oc-negative: ${theme.colors.negative}`,
|
|
60
|
-
`--oc-legend-text: ${theme.isDark ? "
|
|
65
|
+
`--oc-legend-text: ${theme.isDark ? cssTokenDefault("--oc-legend-text", "dark") : cssTokenDefault("--oc-legend-text", "light")}`,
|
|
61
66
|
`--oc-space-2: ${theme.spacing.chromeGap * 2}px`,
|
|
62
67
|
`--oc-space-4: ${theme.spacing.padding}px`
|
|
63
68
|
];
|
package/dist/static.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/static.ts"],"sourcesContent":["// Node.js only — do not import from browser code.\nimport { createRequire } from 'node:module';\nimport type {\n ChartLayout,\n ChartSpec,\n CompileOptions,\n DarkMode,\n LayerSpec,\n ResolvedTheme,\n ThemeConfig,\n TileMapSpec,\n} from '@opendata-ai/openchart-core';\nimport { adaptForLightLineStroke, isLayerSpec, isTileMapSpec } from '@opendata-ai/openchart-core';\nimport { compileChart, compileLayer, compileTileMap } from '@opendata-ai/openchart-engine';\nimport { SVG_NS } from './renderers/svg-dom';\nimport { resetSvgIdCounter } from './svg-ids';\nimport { renderChartSVG } from './svg-renderer';\nimport { renderTileMapSVG } from './tilemap-renderer';\n\nconst esmRequire = createRequire(import.meta.url);\n\nlet cachedWindow: typeof import('happy-dom').Window | undefined;\nfunction getHappyDomWindow(): typeof import('happy-dom').Window {\n if (cachedWindow) return cachedWindow;\n try {\n ({ Window: cachedWindow } = esmRequire('happy-dom') as typeof import('happy-dom'));\n } catch {\n throw new Error(\n \"renderStaticSVG requires 'happy-dom' as a peer dependency. Install it with: npm add happy-dom\",\n );\n }\n return cachedWindow;\n}\n\nlet rendering = false;\n\nexport interface StaticRenderOptions {\n width?: number;\n height?: number;\n theme?: ThemeConfig;\n /**\n * Dark mode setting. In static rendering `'auto'` resolves to light mode\n * since there is no `matchMedia` to query. Use `'force'` for dark output.\n */\n darkMode?: DarkMode;\n watermark?: boolean;\n}\n\nfunction resolveStaticDarkMode(mode?: DarkMode): boolean {\n if (mode === 'force') return true;\n return false;\n}\n\nfunction buildThemeStyleBlock(theme: ResolvedTheme): string {\n const accent = theme.colors.categorical[0] ?? '#06b6d4';\n const bg =\n theme.colors.background === 'transparent'\n ? theme.isDark\n ? '#09090b'\n : '#ffffff'\n : theme.colors.background;\n\n const props = [\n `--oc-font-family: ${theme.fonts.family}`,\n `--oc-font-mono: ${theme.fonts.mono}`,\n `--oc-title-size: ${theme.chrome.title.fontSize}px`,\n `--oc-title-weight: ${theme.chrome.title.fontWeight}`,\n `--oc-title-tracking: -0.022em`, // sync with tokens.css\n `--oc-subtitle-size: ${theme.chrome.subtitle.fontSize}px`,\n `--oc-subtitle-weight: ${theme.chrome.subtitle.fontWeight}`,\n `--oc-source-size: ${theme.chrome.source.fontSize}px`,\n `--oc-source-weight: ${theme.chrome.source.fontWeight}`,\n `--oc-body-size: ${theme.fonts.sizes.body}px`,\n `--oc-eyebrow-size: ${theme.chrome.eyebrow.fontSize}px`,\n `--oc-eyebrow-weight: ${theme.chrome.eyebrow.fontWeight}`,\n `--oc-eyebrow-tracking: 0.08em`, // sync with tokens.css\n `--oc-bg: ${bg}`,\n `--oc-text: ${theme.colors.text}`,\n `--oc-text-muted: ${theme.colors.axis}`,\n `--oc-text-faint: ${theme.isDark ? '#52525b' : '#d4d4d8'}`,\n `--oc-gridline: ${theme.colors.gridline}`,\n `--oc-axis: ${theme.colors.axis}`,\n `--oc-border-radius: ${theme.borderRadius}px`,\n `--oc-accent: ${accent}`,\n `--oc-accent-strong: ${adaptForLightLineStroke(accent)}`,\n `--oc-positive: ${theme.colors.positive}`,\n `--oc-negative: ${theme.colors.negative}`,\n `--oc-legend-text: ${theme.isDark ? '#d0d6e0' : '#3f3f46'}`,\n `--oc-space-2: ${theme.spacing.chromeGap * 2}px`,\n `--oc-space-4: ${theme.spacing.padding}px`,\n ];\n\n const rules = [\n `svg.oc-chart { ${props.join('; ')}; }`,\n `.oc-chrome { font-family: var(--oc-font-family); }`,\n `.oc-eyebrow { font-size: var(--oc-eyebrow-size); font-weight: var(--oc-eyebrow-weight); letter-spacing: var(--oc-eyebrow-tracking); text-transform: uppercase; fill: var(--oc-accent); }`,\n `.oc-title { font-size: var(--oc-title-size); font-weight: var(--oc-title-weight); letter-spacing: var(--oc-title-tracking); fill: var(--oc-text); }`,\n `.oc-subtitle { font-size: var(--oc-subtitle-size); font-weight: var(--oc-subtitle-weight); fill: var(--oc-text-muted); }`,\n `.oc-source, .oc-byline, .oc-footer { font-size: var(--oc-source-size); font-weight: var(--oc-source-weight); fill: var(--oc-text-muted); }`,\n `.oc-brand { font-size: 11px; font-weight: 510; letter-spacing: 0.02em; fill: var(--oc-text-faint); }`,\n `.oc-brand-dot { fill: var(--oc-accent); }`,\n `.oc-eyebrow-dot { fill: var(--oc-accent); }`,\n `.oc-metrics { font-family: var(--oc-font-family); }`,\n `.oc-metric-label { font-size: 10px; font-weight: 510; letter-spacing: 0.08em; text-transform: uppercase; fill: var(--oc-text-muted); }`,\n `.oc-metric-value { font-size: 22px; font-weight: 510; letter-spacing: -0.01em; fill: var(--oc-text); font-variant-numeric: tabular-nums; }`,\n `.oc-metric-delta-up { fill: var(--oc-positive); font-size: 12px; font-weight: 510; }`,\n `.oc-metric-delta-down { fill: var(--oc-negative); font-size: 12px; font-weight: 510; }`,\n `.oc-axis-tick-inline { font-size: 11px; font-weight: 400; fill: var(--oc-text-muted); }`,\n `.oc-endpoint-labels { font-family: var(--oc-font-family); }`,\n `.oc-endpoint-label { fill: var(--oc-endpoint-label-color, var(--oc-text)); }`,\n `.oc-endpoint-value { fill: var(--oc-endpoint-value-color, var(--oc-text-muted)); }`,\n `.oc-endpoint-leader { stroke: var(--oc-endpoint-leader-color, currentColor); }`,\n `.oc-annotation-subtitle { fill: var(--oc-annotation-subtitle-color, var(--oc-text-muted)); }`,\n `.oc-metric-secondary { fill: var(--oc-positive); font-size: 12px; font-weight: 400; }`,\n `.oc-legend { font-family: var(--oc-font-family); font-size: var(--oc-body-size); }`,\n `.oc-legend-entry { cursor: default; }`,\n `.oc-legend text { fill: var(--oc-legend-text); }`,\n ];\n\n return rules.join('\\n');\n}\n\nfunction stripInteractiveElements(svg: Element): void {\n const selectors = ['[data-voronoi-overlay]', '[data-crosshair]', '[data-snap-dots]'];\n for (const selector of selectors) {\n const els = svg.querySelectorAll(selector);\n for (const el of els) {\n el.parentNode?.removeChild(el);\n }\n }\n}\n\n/**\n * Render a chart spec to a standalone SVG string without a browser DOM.\n *\n * Requires `happy-dom` as a peer dependency (`bun add happy-dom`).\n *\n * Not safe for concurrent invocation: the render pipeline relies on a global\n * SVG ID counter and a temporary global `document` swap, both of which are\n * single-threaded. In a server handling parallel requests, serialize calls\n * through a queue or mutex.\n *\n * The entire render pipeline is synchronous; the global swap is safe as long\n * as no code schedules microtasks that outlive the call.\n */\nexport function renderStaticSVG(\n spec: ChartSpec | LayerSpec | TileMapSpec,\n options?: StaticRenderOptions,\n): string {\n if (rendering) {\n throw new Error('renderStaticSVG is not reentrant — serialize calls through a queue or mutex');\n }\n rendering = true;\n\n const Window = getHappyDomWindow();\n const win = new Window({ url: 'about:blank' });\n\n const prevDocument = globalThis.document;\n const prevWindow = (globalThis as Record<string, unknown>).window;\n\n try {\n (globalThis as Record<string, unknown>).document = win.document;\n (globalThis as Record<string, unknown>).window = win;\n\n resetSvgIdCounter();\n\n const width = options?.width ?? 640;\n const height = options?.height ?? 420;\n const darkMode = resolveStaticDarkMode(options?.darkMode);\n\n const compileOpts: CompileOptions = {\n width,\n height,\n theme: options?.theme,\n darkMode,\n watermark: options?.watermark,\n };\n\n let svg: SVGElement;\n let themeForStyle: ResolvedTheme;\n\n if (isTileMapSpec(spec)) {\n const tileMapLayout = compileTileMap(spec, compileOpts);\n svg = renderTileMapSVG(tileMapLayout, { animate: false });\n themeForStyle = tileMapLayout.theme;\n } else {\n let layout: ChartLayout;\n if (isLayerSpec(spec)) {\n layout = compileLayer(spec, compileOpts);\n } else {\n layout = compileChart(spec, compileOpts);\n }\n\n const container = win.document.createElement('div');\n Object.defineProperty(container, 'getBoundingClientRect', {\n value: () => ({\n width,\n height,\n top: 0,\n left: 0,\n right: width,\n bottom: height,\n x: 0,\n y: 0,\n toJSON: () => ({}),\n }),\n });\n\n svg = renderChartSVG(layout, container as unknown as HTMLElement, {\n animate: false,\n crosshair: false,\n });\n\n stripInteractiveElements(svg);\n themeForStyle = layout.theme;\n }\n\n const doc = win.document as unknown as Document;\n let defs = svg.querySelector('defs');\n if (!defs) {\n defs = doc.createElementNS(SVG_NS, 'defs');\n svg.insertBefore(defs as unknown as Node, svg.firstChild);\n }\n const styleEl = doc.createElementNS(SVG_NS, 'style');\n styleEl.textContent = buildThemeStyleBlock(themeForStyle);\n defs.insertBefore(styleEl as unknown as Node, defs.firstChild);\n\n const serializer = new (\n win as unknown as { XMLSerializer: typeof XMLSerializer }\n ).XMLSerializer();\n return serializer.serializeToString(svg as unknown as Node);\n } finally {\n rendering = false;\n if (prevDocument !== undefined) {\n (globalThis as Record<string, unknown>).document = prevDocument;\n } else {\n delete (globalThis as Record<string, unknown>).document;\n }\n if (prevWindow !== undefined) {\n (globalThis as Record<string, unknown>).window = prevWindow;\n } else {\n delete (globalThis as Record<string, unknown>).window;\n }\n win.close();\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/static.ts"],"sourcesContent":["// Node.js only — do not import from browser code.\nimport { createRequire } from 'node:module';\nimport type {\n ChartLayout,\n ChartSpec,\n CompileOptions,\n DarkMode,\n LayerSpec,\n ResolvedTheme,\n ThemeConfig,\n TileMapSpec,\n} from '@opendata-ai/openchart-core';\nimport {\n adaptForLightLineStroke,\n cssTokenDefault,\n isLayerSpec,\n isTileMapSpec,\n} from '@opendata-ai/openchart-core';\nimport { compileChart, compileLayer, compileTileMap } from '@opendata-ai/openchart-engine';\nimport { SVG_NS } from './renderers/svg-dom';\nimport { resetSvgIdCounter } from './svg-ids';\nimport { renderChartSVG } from './svg-renderer';\nimport { renderTileMapSVG } from './tilemap-renderer';\n\nconst esmRequire = createRequire(import.meta.url);\n\nlet cachedWindow: typeof import('happy-dom').Window | undefined;\nfunction getHappyDomWindow(): typeof import('happy-dom').Window {\n if (cachedWindow) return cachedWindow;\n try {\n ({ Window: cachedWindow } = esmRequire('happy-dom') as typeof import('happy-dom'));\n } catch {\n throw new Error(\n \"renderStaticSVG requires 'happy-dom' as a peer dependency. Install it with: npm add happy-dom\",\n );\n }\n return cachedWindow;\n}\n\nlet rendering = false;\n\nexport interface StaticRenderOptions {\n width?: number;\n height?: number;\n theme?: ThemeConfig;\n /**\n * Dark mode setting. In static rendering `'auto'` resolves to light mode\n * since there is no `matchMedia` to query. Use `'force'` for dark output.\n */\n darkMode?: DarkMode;\n watermark?: boolean;\n}\n\nfunction resolveStaticDarkMode(mode?: DarkMode): boolean {\n if (mode === 'force') return true;\n return false;\n}\n\nfunction buildThemeStyleBlock(theme: ResolvedTheme): string {\n const accent = theme.colors.categorical[0] ?? cssTokenDefault('--oc-accent', 'light');\n const bg =\n theme.colors.background === 'transparent'\n ? theme.isDark\n ? cssTokenDefault('--oc-bg', 'dark')\n : cssTokenDefault('--oc-bg', 'light')\n : theme.colors.background;\n\n const props = [\n `--oc-font-family: ${theme.fonts.family}`,\n `--oc-font-mono: ${theme.fonts.mono}`,\n `--oc-title-size: ${theme.chrome.title.fontSize}px`,\n `--oc-title-weight: ${theme.chrome.title.fontWeight}`,\n `--oc-title-tracking: ${cssTokenDefault('--oc-title-tracking', 'light')}`,\n `--oc-subtitle-size: ${theme.chrome.subtitle.fontSize}px`,\n `--oc-subtitle-weight: ${theme.chrome.subtitle.fontWeight}`,\n `--oc-source-size: ${theme.chrome.source.fontSize}px`,\n `--oc-source-weight: ${theme.chrome.source.fontWeight}`,\n `--oc-body-size: ${theme.fonts.sizes.body}px`,\n `--oc-eyebrow-size: ${theme.chrome.eyebrow.fontSize}px`,\n `--oc-eyebrow-weight: ${theme.chrome.eyebrow.fontWeight}`,\n `--oc-eyebrow-tracking: ${cssTokenDefault('--oc-eyebrow-tracking', 'light')}`,\n `--oc-bg: ${bg}`,\n `--oc-text: ${theme.colors.text}`,\n `--oc-text-muted: ${theme.colors.axis}`,\n `--oc-text-faint: ${theme.isDark ? cssTokenDefault('--oc-text-faint', 'dark') : cssTokenDefault('--oc-text-faint', 'light')}`,\n `--oc-gridline: ${theme.colors.gridline}`,\n `--oc-axis: ${theme.colors.axis}`,\n `--oc-border-radius: ${theme.borderRadius}px`,\n `--oc-accent: ${accent}`,\n `--oc-accent-strong: ${adaptForLightLineStroke(accent)}`,\n `--oc-positive: ${theme.colors.positive}`,\n `--oc-negative: ${theme.colors.negative}`,\n `--oc-legend-text: ${theme.isDark ? cssTokenDefault('--oc-legend-text', 'dark') : cssTokenDefault('--oc-legend-text', 'light')}`,\n `--oc-space-2: ${theme.spacing.chromeGap * 2}px`,\n `--oc-space-4: ${theme.spacing.padding}px`,\n ];\n\n const rules = [\n `svg.oc-chart { ${props.join('; ')}; }`,\n `.oc-chrome { font-family: var(--oc-font-family); }`,\n `.oc-eyebrow { font-size: var(--oc-eyebrow-size); font-weight: var(--oc-eyebrow-weight); letter-spacing: var(--oc-eyebrow-tracking); text-transform: uppercase; fill: var(--oc-accent); }`,\n `.oc-title { font-size: var(--oc-title-size); font-weight: var(--oc-title-weight); letter-spacing: var(--oc-title-tracking); fill: var(--oc-text); }`,\n `.oc-subtitle { font-size: var(--oc-subtitle-size); font-weight: var(--oc-subtitle-weight); fill: var(--oc-text-muted); }`,\n `.oc-source, .oc-byline, .oc-footer { font-size: var(--oc-source-size); font-weight: var(--oc-source-weight); fill: var(--oc-text-muted); }`,\n `.oc-brand { font-size: 11px; font-weight: 510; letter-spacing: 0.02em; fill: var(--oc-text-faint); }`,\n `.oc-brand-dot { fill: var(--oc-accent); }`,\n `.oc-eyebrow-dot { fill: var(--oc-accent); }`,\n `.oc-metrics { font-family: var(--oc-font-family); }`,\n `.oc-metric-label { font-size: 10px; font-weight: 510; letter-spacing: 0.08em; text-transform: uppercase; fill: var(--oc-text-muted); }`,\n `.oc-metric-value { font-size: 22px; font-weight: 510; letter-spacing: -0.01em; fill: var(--oc-text); font-variant-numeric: tabular-nums; }`,\n `.oc-metric-delta-up { fill: var(--oc-positive); font-size: 12px; font-weight: 510; }`,\n `.oc-metric-delta-down { fill: var(--oc-negative); font-size: 12px; font-weight: 510; }`,\n `.oc-axis-tick-inline { font-size: 11px; font-weight: 400; fill: var(--oc-text-muted); }`,\n `.oc-endpoint-labels { font-family: var(--oc-font-family); }`,\n `.oc-endpoint-label { fill: var(--oc-endpoint-label-color, var(--oc-text)); }`,\n `.oc-endpoint-value { fill: var(--oc-endpoint-value-color, var(--oc-text-muted)); }`,\n `.oc-endpoint-leader { stroke: var(--oc-endpoint-leader-color, currentColor); }`,\n `.oc-annotation-subtitle { fill: var(--oc-annotation-subtitle-color, var(--oc-text-muted)); }`,\n `.oc-metric-secondary { fill: var(--oc-positive); font-size: 12px; font-weight: 400; }`,\n `.oc-legend { font-family: var(--oc-font-family); font-size: var(--oc-body-size); }`,\n `.oc-legend-entry { cursor: default; }`,\n `.oc-legend text { fill: var(--oc-legend-text); }`,\n ];\n\n return rules.join('\\n');\n}\n\nfunction stripInteractiveElements(svg: Element): void {\n const selectors = ['[data-voronoi-overlay]', '[data-crosshair]', '[data-snap-dots]'];\n for (const selector of selectors) {\n const els = svg.querySelectorAll(selector);\n for (const el of els) {\n el.parentNode?.removeChild(el);\n }\n }\n}\n\n/**\n * Render a chart spec to a standalone SVG string without a browser DOM.\n *\n * Requires `happy-dom` as a peer dependency (`bun add happy-dom`).\n *\n * Not safe for concurrent invocation: the render pipeline relies on a global\n * SVG ID counter and a temporary global `document` swap, both of which are\n * single-threaded. In a server handling parallel requests, serialize calls\n * through a queue or mutex.\n *\n * The entire render pipeline is synchronous; the global swap is safe as long\n * as no code schedules microtasks that outlive the call.\n */\nexport function renderStaticSVG(\n spec: ChartSpec | LayerSpec | TileMapSpec,\n options?: StaticRenderOptions,\n): string {\n if (rendering) {\n throw new Error('renderStaticSVG is not reentrant — serialize calls through a queue or mutex');\n }\n rendering = true;\n\n const Window = getHappyDomWindow();\n const win = new Window({ url: 'about:blank' });\n\n const prevDocument = globalThis.document;\n const prevWindow = (globalThis as Record<string, unknown>).window;\n\n try {\n (globalThis as Record<string, unknown>).document = win.document;\n (globalThis as Record<string, unknown>).window = win;\n\n resetSvgIdCounter();\n\n const width = options?.width ?? 640;\n const height = options?.height ?? 420;\n const darkMode = resolveStaticDarkMode(options?.darkMode);\n\n const compileOpts: CompileOptions = {\n width,\n height,\n theme: options?.theme,\n darkMode,\n watermark: options?.watermark,\n };\n\n let svg: SVGElement;\n let themeForStyle: ResolvedTheme;\n\n if (isTileMapSpec(spec)) {\n const tileMapLayout = compileTileMap(spec, compileOpts);\n svg = renderTileMapSVG(tileMapLayout, { animate: false });\n themeForStyle = tileMapLayout.theme;\n } else {\n let layout: ChartLayout;\n if (isLayerSpec(spec)) {\n layout = compileLayer(spec, compileOpts);\n } else {\n layout = compileChart(spec, compileOpts);\n }\n\n const container = win.document.createElement('div');\n Object.defineProperty(container, 'getBoundingClientRect', {\n value: () => ({\n width,\n height,\n top: 0,\n left: 0,\n right: width,\n bottom: height,\n x: 0,\n y: 0,\n toJSON: () => ({}),\n }),\n });\n\n svg = renderChartSVG(layout, container as unknown as HTMLElement, {\n animate: false,\n crosshair: false,\n });\n\n stripInteractiveElements(svg);\n themeForStyle = layout.theme;\n }\n\n const doc = win.document as unknown as Document;\n let defs = svg.querySelector('defs');\n if (!defs) {\n defs = doc.createElementNS(SVG_NS, 'defs');\n svg.insertBefore(defs as unknown as Node, svg.firstChild);\n }\n const styleEl = doc.createElementNS(SVG_NS, 'style');\n styleEl.textContent = buildThemeStyleBlock(themeForStyle);\n defs.insertBefore(styleEl as unknown as Node, defs.firstChild);\n\n const serializer = new (\n win as unknown as { XMLSerializer: typeof XMLSerializer }\n ).XMLSerializer();\n return serializer.serializeToString(svg as unknown as Node);\n } finally {\n rendering = false;\n if (prevDocument !== undefined) {\n (globalThis as Record<string, unknown>).document = prevDocument;\n } else {\n delete (globalThis as Record<string, unknown>).document;\n }\n if (prevWindow !== undefined) {\n (globalThis as Record<string, unknown>).window = prevWindow;\n } else {\n delete (globalThis as Record<string, unknown>).window;\n }\n win.close();\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { h as CameraTarget, M as MountOptions } from '../mount-CUKQLeqD.js';
|
|
2
|
+
export { C as Camera, F as FULL_VIEW, V as ViewBoxSize, i as cameraTransform, j as camerasClose, k as damp, l as dampCamera, m as fitTarget, n as interpolateCamera, s as scrubCamera } from '../mount-CUKQLeqD.js';
|
|
3
|
+
import { DataRow, ChartSpec, LayerSpec, GraphSpec, MapSpec, ChartLayout } from '@opendata-ai/openchart-core';
|
|
4
|
+
|
|
5
|
+
/** A spec a story can drive: same union `ChartInstance.update()` accepts. */
|
|
6
|
+
type StorySpec<TData extends DataRow = DataRow> = ChartSpec<TData> | LayerSpec<TData> | GraphSpec | MapSpec;
|
|
7
|
+
/** Deep-partial patch applied onto the accumulated spec at each step. */
|
|
8
|
+
type StorySpecPatch = Record<string, unknown>;
|
|
9
|
+
/**
|
|
10
|
+
* One step in a story. Patches are cumulative: step N's effective spec is
|
|
11
|
+
* the base spec with patches 0..N applied in order.
|
|
12
|
+
*/
|
|
13
|
+
interface StoryStep {
|
|
14
|
+
/** Deep-partial patch merged onto the running spec. Omit for a no-op step. */
|
|
15
|
+
spec?: StorySpecPatch;
|
|
16
|
+
/**
|
|
17
|
+
* Emphasis sugar: sets `encoding.color.highlight`. Equivalent to
|
|
18
|
+
* `spec: { encoding: { color: { highlight: [...] } } }` but doesn't
|
|
19
|
+
* require the caller to know the color field is nested under `encoding`.
|
|
20
|
+
* Pass `null` to clear a highlight set by an earlier step.
|
|
21
|
+
*/
|
|
22
|
+
highlight?: string[] | null;
|
|
23
|
+
/**
|
|
24
|
+
* Pan/zoom the camera to a data-coordinate region (resolved via the
|
|
25
|
+
* chart's compiled scales) or a raw viewBox-space `CameraTarget`. Pass
|
|
26
|
+
* `null`/omit to return to the full viewBox.
|
|
27
|
+
*/
|
|
28
|
+
camera?: StoryCameraStep | null;
|
|
29
|
+
}
|
|
30
|
+
/** Data-coordinate camera target: resolved to viewBox space via the chart's scales. */
|
|
31
|
+
interface StoryDataCameraTarget {
|
|
32
|
+
/** x-domain range to frame, in data units (or category names for ordinal scales). */
|
|
33
|
+
x?: [unknown, unknown];
|
|
34
|
+
/** y-domain range to frame, in data units (or category names for ordinal scales). */
|
|
35
|
+
y?: [unknown, unknown];
|
|
36
|
+
/** ViewBox units of padding added around the resolved rect. Default 24. */
|
|
37
|
+
padding?: number;
|
|
38
|
+
}
|
|
39
|
+
type StoryCameraStep = StoryDataCameraTarget | CameraTarget;
|
|
40
|
+
interface ChartStoryOptions<TData extends DataRow = DataRow> {
|
|
41
|
+
spec: StorySpec<TData>;
|
|
42
|
+
steps: StoryStep[];
|
|
43
|
+
/** Fraction of viewport height where the scroll trigger line sits. Default 0.4. */
|
|
44
|
+
triggerPosition?: number;
|
|
45
|
+
/** Camera driving mode. Step mode (default) eases on step change; scrub follows scroll continuously. */
|
|
46
|
+
cameraMode?: 'step' | 'scrub';
|
|
47
|
+
}
|
|
48
|
+
interface ChartStoryInstance {
|
|
49
|
+
/** Jump directly to a step index, applying patches 0..n and animating the diff. */
|
|
50
|
+
goTo(index: number): void;
|
|
51
|
+
/** Register a step element so the built-in scroll driver advances the story on scroll. */
|
|
52
|
+
registerStep(index: number, el: HTMLElement | null): (el: HTMLElement | null) => void;
|
|
53
|
+
/** Register the scrolling container that wraps all step elements. */
|
|
54
|
+
setContainer(el: HTMLElement | null): void;
|
|
55
|
+
/** Current step index. -1 before any step has activated. */
|
|
56
|
+
readonly currentStep: number;
|
|
57
|
+
/** Total number of steps. */
|
|
58
|
+
readonly totalSteps: number;
|
|
59
|
+
/** Tear down the scroll driver, camera, and underlying chart instance. */
|
|
60
|
+
destroy(): void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `createChartStory`: a base spec plus an ordered list of deep-partial
|
|
65
|
+
* patch steps, driven by scroll (built-in `ScrollDriver`) or directly via
|
|
66
|
+
* `goTo(n)`.
|
|
67
|
+
*
|
|
68
|
+
* Animation-clock ownership (decision baked 2026-07-11, see
|
|
69
|
+
* plans/design-evolution/11-scrollytelling.md): the data-update-transitions
|
|
70
|
+
* driver in `../transition.ts` owns mark morphing; the ported tween in
|
|
71
|
+
* `./tween.ts` owns camera and any scalar story-level animation. They never
|
|
72
|
+
* animate the same property. `goTo` calls `ChartInstance.update()`, which
|
|
73
|
+
* internally decides morph-vs-instant-swap via `canTransition`; this module
|
|
74
|
+
* predicts that decision ahead of time (`canTransitionSpecShape`) only to
|
|
75
|
+
* decide whether to arm the crossfade fallback, and separately drives the
|
|
76
|
+
* camera tween. It does not bridge the two clocks.
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create a scrollytelling story bound to a container. Mounts a chart with
|
|
81
|
+
* the base spec, then drives it through cumulative patch steps as the
|
|
82
|
+
* reader scrolls (or via `goTo`).
|
|
83
|
+
*
|
|
84
|
+
* `editMode` (any of `onEdit`/`onSelect`/`onDeselect`/`onTextEdit` on
|
|
85
|
+
* `mountOptions`) and an active story are mutually exclusive in v1: passing
|
|
86
|
+
* both warns once and disables the story's own drive loop, matching the
|
|
87
|
+
* seriesSearch/edit-mode precedent in `mount.ts`.
|
|
88
|
+
*/
|
|
89
|
+
declare function createChartStory<TData extends DataRow = DataRow>(container: HTMLElement, options: ChartStoryOptions<TData>, mountOptions?: MountOptions): ChartStoryInstance;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whole-chart crossfade fallback for story steps whose spec diff falls
|
|
93
|
+
* outside the data-update-transitions morph gate (`canTransition` in
|
|
94
|
+
* `../transition.ts`): re-encodes, type changes, and anything else that
|
|
95
|
+
* would otherwise instant-snap. No step in a story may visibly snap, so
|
|
96
|
+
* this ghosts the current rendered SVG over the container, lets
|
|
97
|
+
* `ChartInstance.update()` perform its normal instant swap underneath,
|
|
98
|
+
* then fades the ghost out to reveal the new state.
|
|
99
|
+
*
|
|
100
|
+
* This is deliberately NOT the mark-morphing transition driver: it never
|
|
101
|
+
* interpolates geometry, only opacity. See the animation-clock ownership
|
|
102
|
+
* note in the scrollytelling plan -- the transitions driver owns mark
|
|
103
|
+
* morphing, this owns the pixel-level fallback when that driver declines.
|
|
104
|
+
*/
|
|
105
|
+
interface CrossfadeOptions {
|
|
106
|
+
/** Fade duration in ms. Default `storyMotion.crossfade`. */
|
|
107
|
+
duration?: number;
|
|
108
|
+
/** Skip the fade and swap instantly (reduced motion). */
|
|
109
|
+
reducedMotion?: boolean;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Ghost the container's current SVG, run `applyUpdate` (which mutates the
|
|
113
|
+
* container to its next state), then fade the ghost out.
|
|
114
|
+
*/
|
|
115
|
+
declare function crossfadeUpdate(container: HTMLElement, applyUpdate: () => void, options?: CrossfadeOptions): void;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Pure math for continuous scrollytelling progress. The `ScrollDriver`
|
|
119
|
+
* measures DOM rects and feeds them here. Ported from
|
|
120
|
+
* opendata/shared/lib/scrolly/progress-math.ts.
|
|
121
|
+
*/
|
|
122
|
+
interface ScrollyFrame {
|
|
123
|
+
/** Geometric active step; -1 while the trigger line is above step 0 */
|
|
124
|
+
step: number;
|
|
125
|
+
/** 0..1 progress of the trigger line through the active step's span */
|
|
126
|
+
stepProgress: number;
|
|
127
|
+
/** 0..1 traversal from first step top to last step bottom */
|
|
128
|
+
progress: number;
|
|
129
|
+
direction: 'down' | 'up';
|
|
130
|
+
}
|
|
131
|
+
type ScrollyFrameGeometry = Omit<ScrollyFrame, 'direction'>;
|
|
132
|
+
/**
|
|
133
|
+
* Compute the geometric frame from viewport-relative step tops.
|
|
134
|
+
*
|
|
135
|
+
* @param tops viewport-relative step top offsets, ordered by step index
|
|
136
|
+
* @param lastBottom viewport-relative bottom of the final step
|
|
137
|
+
* @param triggerY the trigger line's viewport y (innerHeight * triggerPosition)
|
|
138
|
+
*/
|
|
139
|
+
declare function computeProgress(tops: number[], lastBottom: number, triggerY: number): ScrollyFrameGeometry;
|
|
140
|
+
/**
|
|
141
|
+
* Reduced-motion variant: no scrubbing. `stepProgress` pins to 0 and overall
|
|
142
|
+
* progress quantizes to the step's start fraction, so consumers see discrete
|
|
143
|
+
* snaps only.
|
|
144
|
+
*/
|
|
145
|
+
declare function quantizeFrame(frame: ScrollyFrameGeometry, totalSteps: number): ScrollyFrameGeometry;
|
|
146
|
+
declare function framesEqual(a: ScrollyFrame | null, b: ScrollyFrame): boolean;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolves data-coordinate camera targets (`{ x: [a, b], y: [c, d] }`) to
|
|
150
|
+
* viewBox-space `CameraTarget` rects using the compiled chart's axis ticks.
|
|
151
|
+
*
|
|
152
|
+
* There is no raw d3 scale on `ChartLayout` (by design -- the compiled
|
|
153
|
+
* layout is the public surface, not engine internals), so resolution works
|
|
154
|
+
* off `AxisTick[]`, which pairs each tick's raw data `value` with its pixel
|
|
155
|
+
* `position`:
|
|
156
|
+
* - Exact tick match (ordinal/categorical values, or a quantitative value
|
|
157
|
+
* that happens to land on a tick): use that tick's position directly.
|
|
158
|
+
* - Otherwise linear interpolation between the two nearest ticks by data
|
|
159
|
+
* value. Works for quantitative and temporal axes (temporal values are
|
|
160
|
+
* compared as timestamps). Ordinal axes with no exact match have no
|
|
161
|
+
* well-ordered "between" and fall back to the nearest tick.
|
|
162
|
+
*/
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Resolve a data-coordinate camera target to viewBox space. Falls back to
|
|
166
|
+
* the full chart area (padded) for axes that are missing or unresolvable,
|
|
167
|
+
* so a malformed target degrades to "camera does nothing" rather than
|
|
168
|
+
* throwing mid-scroll.
|
|
169
|
+
*/
|
|
170
|
+
declare function resolveCameraTarget(layout: ChartLayout, target: StoryDataCameraTarget): CameraTarget;
|
|
171
|
+
/** Type guard: a camera step given in data coordinates vs. raw viewBox `CameraTarget`. */
|
|
172
|
+
declare function isDataCameraTarget(value: StoryDataCameraTarget | CameraTarget): value is StoryDataCameraTarget;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Framework-agnostic scroll-step driver: element registry + rAF-throttled
|
|
176
|
+
* scroll handler + subscription store. Ported from the React
|
|
177
|
+
* `use-scroll-steps` hook in opendata/shared; the store design (60fps
|
|
178
|
+
* frames as subscriptions, never framework state) carries over unchanged
|
|
179
|
+
* because vanilla, React, Vue, and Svelte all want the same contract.
|
|
180
|
+
*
|
|
181
|
+
* Measurement is lazy: with zero subscribers the scroll handler does no
|
|
182
|
+
* rect reads.
|
|
183
|
+
*/
|
|
184
|
+
|
|
185
|
+
interface ScrollyProgressStore {
|
|
186
|
+
/** Latest frame; `{step: -1, ...}` sentinel before any measurement */
|
|
187
|
+
get(): ScrollyFrame;
|
|
188
|
+
/** Emits the current frame immediately on subscribe, then on every change */
|
|
189
|
+
subscribe(cb: (frame: ScrollyFrame) => void): () => void;
|
|
190
|
+
}
|
|
191
|
+
interface ScrollDriverOptions {
|
|
192
|
+
/** Fraction of viewport height where the trigger line sits. Default 0.4 */
|
|
193
|
+
triggerPosition?: number;
|
|
194
|
+
}
|
|
195
|
+
interface ScrollDriver {
|
|
196
|
+
/** Register the scrolling container that wraps all steps. */
|
|
197
|
+
setContainer(el: HTMLElement | null): void;
|
|
198
|
+
/** Register a step element by index. Pass `null` to unregister. */
|
|
199
|
+
registerStep(index: number, el: HTMLElement | null): void;
|
|
200
|
+
/** Continuous progress as a subscription store. */
|
|
201
|
+
progress: ScrollyProgressStore;
|
|
202
|
+
/** Scroll a step into view (center). Honors reduced motion. */
|
|
203
|
+
scrollToStep(index: number): void;
|
|
204
|
+
/** Force a re-measurement (e.g. after layout changes). */
|
|
205
|
+
measure(): void;
|
|
206
|
+
/** Tear down scroll/resize listeners and the reduced-motion media query. */
|
|
207
|
+
destroy(): void;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Create a scroll-step driver. Framework wrappers (React/Vue/Svelte hooks)
|
|
211
|
+
* are thin adapters over this: they own component lifecycle, this owns the
|
|
212
|
+
* measurement/subscription machinery.
|
|
213
|
+
*/
|
|
214
|
+
declare function createScrollDriver(options?: ScrollDriverOptions): ScrollDriver;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Numeric rAF tweening for scrollytelling story steps.
|
|
218
|
+
*
|
|
219
|
+
* Owns imperative numeric interpolation: the story driver and camera math
|
|
220
|
+
* mutate scalar/vector values per frame using these primitives instead of
|
|
221
|
+
* hand-rolling rAF loops. Ported from opendata/shared/lib/tween.ts.
|
|
222
|
+
*
|
|
223
|
+
* Everything here is SSR-safe: no `window`/`performance` access at module
|
|
224
|
+
* scope. Callers are expected to invoke these client-side.
|
|
225
|
+
*/
|
|
226
|
+
declare const clamp01: (t: number) => number;
|
|
227
|
+
declare const lerp: (a: number, b: number, t: number) => number;
|
|
228
|
+
type EasingFn = (t: number) => number;
|
|
229
|
+
declare const easingFns: {
|
|
230
|
+
readonly linear: (t: number) => number;
|
|
231
|
+
readonly easeOutCubic: (t: number) => number;
|
|
232
|
+
readonly easeInOutCubic: (t: number) => number;
|
|
233
|
+
readonly easeOutQuint: (t: number) => number;
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* Duration tokens (ms) for story state changes. Use these instead of
|
|
237
|
+
* arbitrary numbers so all stories share one timing scale.
|
|
238
|
+
*/
|
|
239
|
+
declare const storyMotion: {
|
|
240
|
+
/** Small opacity/emphasis shifts */
|
|
241
|
+
readonly fast: 300;
|
|
242
|
+
/** Step state transitions */
|
|
243
|
+
readonly base: 500;
|
|
244
|
+
/** Whole-chart crossfade fallback (steps outside the morph gate) */
|
|
245
|
+
readonly crossfade: 400;
|
|
246
|
+
/** Step-mode camera transitions */
|
|
247
|
+
readonly camera: 600;
|
|
248
|
+
/** Scrub-mode camera damping time constant */
|
|
249
|
+
readonly cameraTau: 100;
|
|
250
|
+
};
|
|
251
|
+
interface TweenConfig<T> {
|
|
252
|
+
initial: T;
|
|
253
|
+
lerp: (a: T, b: T, t: number) => T;
|
|
254
|
+
/** Default `storyMotion.base` */
|
|
255
|
+
duration?: number;
|
|
256
|
+
/** Default `easingFns.easeOutCubic` */
|
|
257
|
+
ease?: EasingFn;
|
|
258
|
+
onFrame: (value: T) => void;
|
|
259
|
+
}
|
|
260
|
+
interface Tween<T> {
|
|
261
|
+
/**
|
|
262
|
+
* Retarget the tween. If a tween is mid-flight, the new tween starts FROM
|
|
263
|
+
* THE LIVE INTERPOLATED VALUE, not the previous target -- fast retargeting
|
|
264
|
+
* never jumps. `snap: true` applies the target synchronously with no rAF.
|
|
265
|
+
*/
|
|
266
|
+
to: (target: T, opts?: {
|
|
267
|
+
snap?: boolean;
|
|
268
|
+
}) => void;
|
|
269
|
+
get: () => T;
|
|
270
|
+
cancel: () => void;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Retargetable tween over an arbitrary shape T.
|
|
274
|
+
*/
|
|
275
|
+
declare function createTween<T>(config: TweenConfig<T>): Tween<T>;
|
|
276
|
+
|
|
277
|
+
export { CameraTarget, type ChartStoryInstance, type ChartStoryOptions, type EasingFn, type ScrollDriver, type ScrollDriverOptions, type ScrollyFrame, type ScrollyFrameGeometry, type ScrollyProgressStore, type StoryCameraStep, type StoryDataCameraTarget, type StorySpec, type StorySpecPatch, type StoryStep, type Tween, type TweenConfig, clamp01, computeProgress, createChartStory, createScrollDriver, createTween, crossfadeUpdate, easingFns, framesEqual, isDataCameraTarget, lerp, quantizeFrame, resolveCameraTarget, storyMotion };
|