@opendata-ai/openchart-vanilla 6.25.4 → 6.27.0

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.
@@ -10,6 +10,25 @@ import {
10
10
  } from '@opendata-ai/openchart-core';
11
11
  import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
12
12
 
13
+ function appendCompoundLabel(
14
+ parent: SVGElement,
15
+ primaryText: string,
16
+ subtitle: string,
17
+ fontWeight: number,
18
+ ): void {
19
+ const primarySpan = createSVGElement('tspan');
20
+ primarySpan.setAttribute('font-weight', String(fontWeight));
21
+ primarySpan.textContent = primaryText;
22
+ parent.appendChild(primarySpan);
23
+
24
+ const subtitleSpan = createSVGElement('tspan');
25
+ subtitleSpan.setAttribute('dx', '0.5em');
26
+ subtitleSpan.textContent = subtitle;
27
+ subtitleSpan.setAttribute('font-weight', '400');
28
+ subtitleSpan.setAttribute('fill-opacity', '0.6');
29
+ parent.appendChild(subtitleSpan);
30
+ }
31
+
13
32
  function renderAxis(
14
33
  parent: SVGElement,
15
34
  axis: AxisLayout,
@@ -85,30 +104,72 @@ function renderAxis(
85
104
  const availableWidth = area.x - TICK_LABEL_OFFSET;
86
105
  const fontSize = axis.tickLabelStyle.fontSize;
87
106
  const fontWeight = axis.tickLabelStyle.fontWeight;
88
- const fullWidth = estimateTextWidth(tick.label, fontSize, fontWeight);
89
- if (fullWidth > availableWidth && availableWidth > 20) {
90
- const ellipsis = '…';
91
- const ellipsisWidth = estimateTextWidth(ellipsis, fontSize, fontWeight);
92
- let lo = 0;
93
- let hi = tick.label.length;
94
- while (lo < hi) {
95
- const mid = (lo + hi + 1) >>> 1;
96
- const candidate = tick.label.slice(0, mid);
97
- if (
98
- estimateTextWidth(candidate, fontSize, fontWeight) + ellipsisWidth <=
99
- availableWidth
100
- ) {
101
- lo = mid;
107
+
108
+ if (tick.subtitle) {
109
+ // Compound label: primary + gap + subtitle via tspan elements
110
+ const gapWidth = fontSize * 0.5;
111
+ const subtitleWidth = estimateTextWidth(tick.subtitle, fontSize, fontWeight);
112
+ const primaryWidth = estimateTextWidth(tick.label, fontSize, fontWeight);
113
+ const totalWidth = primaryWidth + gapWidth + subtitleWidth;
114
+
115
+ if (totalWidth > availableWidth && availableWidth > 20) {
116
+ const ellipsis = '…';
117
+ const ellipsisWidth = estimateTextWidth(ellipsis, fontSize, fontWeight);
118
+ const budgetForPrimary = availableWidth - gapWidth - subtitleWidth - ellipsisWidth;
119
+
120
+ let primaryText = tick.label;
121
+ if (budgetForPrimary > 0) {
122
+ let lo = 0;
123
+ let hi = tick.label.length;
124
+ while (lo < hi) {
125
+ const mid = (lo + hi + 1) >>> 1;
126
+ const candidate = tick.label.slice(0, mid);
127
+ if (estimateTextWidth(candidate, fontSize, fontWeight) <= budgetForPrimary) {
128
+ lo = mid;
129
+ } else {
130
+ hi = mid - 1;
131
+ }
132
+ }
133
+ primaryText = lo > 0 ? tick.label.slice(0, lo).trimEnd() + ellipsis : ellipsis;
102
134
  } else {
103
- hi = mid - 1;
135
+ primaryText = ellipsis;
104
136
  }
137
+
138
+ appendCompoundLabel(label, primaryText, tick.subtitle, fontWeight);
139
+
140
+ const titleEl = createSVGElement('title');
141
+ titleEl.textContent = `${tick.label} ${tick.subtitle}`;
142
+ label.appendChild(titleEl);
143
+ } else {
144
+ appendCompoundLabel(label, tick.label, tick.subtitle, fontWeight);
105
145
  }
106
- label.textContent = lo > 0 ? tick.label.slice(0, lo).trimEnd() + ellipsis : ellipsis;
107
- const titleEl = createSVGElement('title');
108
- titleEl.textContent = tick.label;
109
- label.appendChild(titleEl);
110
146
  } else {
111
- label.textContent = tick.label;
147
+ // Plain label (no subtitle)
148
+ const fullWidth = estimateTextWidth(tick.label, fontSize, fontWeight);
149
+ if (fullWidth > availableWidth && availableWidth > 20) {
150
+ const ellipsis = '…';
151
+ const ellipsisWidth = estimateTextWidth(ellipsis, fontSize, fontWeight);
152
+ let lo = 0;
153
+ let hi = tick.label.length;
154
+ while (lo < hi) {
155
+ const mid = (lo + hi + 1) >>> 1;
156
+ const candidate = tick.label.slice(0, mid);
157
+ if (
158
+ estimateTextWidth(candidate, fontSize, fontWeight) + ellipsisWidth <=
159
+ availableWidth
160
+ ) {
161
+ lo = mid;
162
+ } else {
163
+ hi = mid - 1;
164
+ }
165
+ }
166
+ label.textContent = lo > 0 ? tick.label.slice(0, lo).trimEnd() + ellipsis : ellipsis;
167
+ const titleEl = createSVGElement('title');
168
+ titleEl.textContent = tick.label;
169
+ label.appendChild(titleEl);
170
+ } else {
171
+ label.textContent = tick.label;
172
+ }
112
173
  }
113
174
  } else {
114
175
  label.textContent = tick.label;
@@ -2,12 +2,16 @@
2
2
  * Legend rendering: swatches + labels with wrap/overflow handling.
3
3
  */
4
4
 
5
- import type { LegendLayout } from '@opendata-ai/openchart-core';
5
+ import type { CategoricalLegendLayout, LegendLayout } from '@opendata-ai/openchart-core';
6
6
  import { estimateTextWidth } from '@opendata-ai/openchart-core';
7
7
  import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
8
8
 
9
+ function isCategorical(legend: LegendLayout): legend is CategoricalLegendLayout {
10
+ return !legend.type || legend.type === 'categorical';
11
+ }
12
+
9
13
  export function renderLegend(parent: SVGElement, legend: LegendLayout): void {
10
- if (legend.entries.length === 0) return;
14
+ if (!isCategorical(legend) || legend.entries.length === 0) return;
11
15
 
12
16
  const g = createSVGElement('g');
13
17
  g.setAttribute('class', 'oc-legend');
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type {
10
+ CategoricalLegendLayout,
10
11
  LegendLayout,
11
12
  MeasureTextFn,
12
13
  ResolvedAnimation,
@@ -230,8 +231,9 @@ function renderBrand(parent: SVGElement, layout: SankeyLayout): void {
230
231
  // Legend rendering
231
232
  // ---------------------------------------------------------------------------
232
233
 
233
- function renderLegend(parent: SVGElement, legend: LegendLayout): void {
234
- if (legend.entries.length === 0) return;
234
+ function renderLegend(parent: SVGElement, legendLayout: LegendLayout): void {
235
+ if (!('entries' in legendLayout) || legendLayout.entries.length === 0) return;
236
+ const legend = legendLayout as CategoricalLegendLayout;
235
237
 
236
238
  const g = createSVGElement('g');
237
239
  g.setAttribute('class', 'oc-legend');
@@ -41,7 +41,7 @@ const EASE_VAR_MAP: Record<string, string> = {
41
41
  export function renderChartSVG(
42
42
  layout: ChartLayout,
43
43
  container: HTMLElement,
44
- opts?: { animate?: boolean },
44
+ opts?: { animate?: boolean; crosshair?: boolean },
45
45
  ): SVGElement {
46
46
  const { width, height } = layout.dimensions;
47
47
  const animation = layout.animation;
@@ -64,6 +64,13 @@ export function renderChartSVG(
64
64
  svg.setAttribute('role', layout.a11y.role);
65
65
  svg.setAttribute('aria-label', layout.a11y.altText);
66
66
 
67
+ // Sparkline display mode: stamp a data attribute so consumers can target
68
+ // sparkline-specific styles. Only set the attribute in sparkline mode so
69
+ // regular charts keep an unchanged DOM signature.
70
+ if (layout.display === 'sparkline') {
71
+ svg.setAttribute('data-display', 'sparkline');
72
+ }
73
+
67
74
  // oc-animate must be set before the SVG enters the DOM to prevent a flash
68
75
  // of the final state. mount.ts passes animate: true only on genuine first render.
69
76
  const classes = opts?.animate ? 'oc-chart oc-animate' : 'oc-chart';
@@ -164,6 +171,26 @@ export function renderChartSVG(
164
171
  overlay.setAttribute('class', 'oc-voronoi-overlay');
165
172
  overlay.setAttribute('data-voronoi-overlay', 'true');
166
173
  clippedGroup.appendChild(overlay);
174
+
175
+ // Crosshair line: opt-in vertical line that tracks the nearest data point
176
+ if (opts?.crosshair) {
177
+ const crosshairLine = createSVGElement('line');
178
+ crosshairLine.setAttribute('data-crosshair', 'true');
179
+ crosshairLine.setAttribute('class', 'oc-crosshair');
180
+ setAttrs(crosshairLine, {
181
+ x1: 0,
182
+ y1: layout.area.y,
183
+ x2: 0,
184
+ y2: layout.area.y + layout.area.height,
185
+ stroke: layout.theme.colors.gridline,
186
+ 'stroke-opacity': '0.5',
187
+ 'stroke-dasharray': '4,3',
188
+ 'stroke-width': '1',
189
+ 'pointer-events': 'none',
190
+ });
191
+ crosshairLine.style.display = 'none';
192
+ clippedGroup.appendChild(crosshairLine);
193
+ }
167
194
  }
168
195
 
169
196
  svg.appendChild(clippedGroup);
@@ -0,0 +1,394 @@
1
+ /**
2
+ * TileMap mount API: the main entry point for vanilla JS tilemap usage.
3
+ *
4
+ * createTileMap() takes a container, TileMapSpec, and options, compiles the
5
+ * tilemap, renders it as SVG, sets up responsive resizing, tooltip interaction,
6
+ * and returns a TileMapInstance with update/resize/export/destroy.
7
+ */
8
+
9
+ import type {
10
+ CompileOptions,
11
+ DarkMode,
12
+ ThemeConfig,
13
+ TileMapLayout,
14
+ TileMapSpec,
15
+ } from '@opendata-ai/openchart-core';
16
+ import { compileTileMap } from '@opendata-ai/openchart-engine';
17
+ import { cancelAnimations, setupAnimationCleanup } from './animation';
18
+ import {
19
+ exportJPG,
20
+ exportPNG,
21
+ exportSVG,
22
+ exportSVGWithFonts,
23
+ type JPGExportOptions,
24
+ type SVGExportOptions,
25
+ } from './export';
26
+ import { createMeasureText } from './measure-text';
27
+ import { observeResize } from './resize-observer';
28
+ import { renderTileMapSVG } from './tilemap-renderer';
29
+ import { createTooltipManager, type TooltipManager } from './tooltip';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Types
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export interface TileMapMountOptions {
36
+ /** Theme overrides. */
37
+ theme?: ThemeConfig;
38
+ /** Dark mode setting: "auto" (system pref), "force", or "off". */
39
+ darkMode?: DarkMode;
40
+ /** Enable responsive resizing. Defaults to true. */
41
+ responsive?: boolean;
42
+ /** Show the tryOpenData.ai watermark. Defaults to true. */
43
+ watermark?: boolean;
44
+ /** Show tooltips on hover. Defaults to true. */
45
+ tooltip?: boolean;
46
+ /** Callback when a tile is clicked. */
47
+ onTileClick?: (tile: {
48
+ stateCode: string;
49
+ stateName: string;
50
+ value: number | null;
51
+ data: Record<string, unknown>;
52
+ }) => void;
53
+ /** Callback when a tile is hovered (null on mouse leave). */
54
+ onTileHover?: (
55
+ tile: {
56
+ stateCode: string;
57
+ stateName: string;
58
+ value: number | null;
59
+ data: Record<string, unknown>;
60
+ } | null,
61
+ ) => void;
62
+ }
63
+
64
+ export interface TileMapInstance {
65
+ /** Re-compile and re-render with a new spec. */
66
+ update(spec: TileMapSpec): void;
67
+ /** Re-compile at current container dimensions. */
68
+ resize(): void;
69
+ /** Export the tilemap. */
70
+ export(
71
+ format: 'svg' | 'svg-with-fonts' | 'png' | 'jpg',
72
+ options?: JPGExportOptions | SVGExportOptions,
73
+ ): string | Promise<Blob> | Promise<string>;
74
+ /** Remove all DOM elements and disconnect observers. */
75
+ destroy(): void;
76
+ /** The current compiled layout. */
77
+ readonly layout: TileMapLayout;
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Dark mode resolution
82
+ // ---------------------------------------------------------------------------
83
+
84
+ function resolveDarkMode(mode?: DarkMode): boolean {
85
+ if (mode === 'force') return true;
86
+ if (mode === 'off' || mode === undefined) return false;
87
+ if (typeof window !== 'undefined' && window.matchMedia) {
88
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
89
+ }
90
+ return false;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Main API
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Create a tilemap instance from a spec and mount it into a container.
99
+ */
100
+ export function createTileMap(
101
+ container: HTMLElement,
102
+ spec: TileMapSpec,
103
+ options?: TileMapMountOptions,
104
+ ): TileMapInstance {
105
+ let currentSpec = spec;
106
+ let currentLayout: TileMapLayout;
107
+ let destroyed = false;
108
+
109
+ // DOM
110
+ let svgElement: SVGSVGElement | null = null;
111
+
112
+ // Subsystems
113
+ let tooltipManager: TooltipManager | null = null;
114
+ let cleanupTooltipEvents: (() => void) | null = null;
115
+ let disconnectResize: (() => void) | null = null;
116
+
117
+ // Animation state
118
+ let animationCleanup: (() => void) | null = null;
119
+ let pendingResize = false;
120
+
121
+ const measureText = createMeasureText();
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Helpers
125
+ // ---------------------------------------------------------------------------
126
+
127
+ function getContainerDimensions(): { width: number; height: number } {
128
+ const rect = container.getBoundingClientRect();
129
+ return {
130
+ width: Math.max(rect.width || 600, 100),
131
+ height: Math.max(rect.height || 400, 100),
132
+ };
133
+ }
134
+
135
+ function compile(): TileMapLayout {
136
+ const { width, height } = getContainerDimensions();
137
+ const darkMode = resolveDarkMode(options?.darkMode);
138
+
139
+ const compileOpts: CompileOptions = {
140
+ width,
141
+ height,
142
+ theme: options?.theme,
143
+ darkMode,
144
+ watermark: options?.watermark,
145
+ measureText,
146
+ };
147
+
148
+ return compileTileMap(currentSpec, compileOpts);
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Tooltip and interaction wiring
153
+ // ---------------------------------------------------------------------------
154
+
155
+ function wireTooltipAndInteraction(svg: SVGSVGElement, layout: TileMapLayout): () => void {
156
+ const cleanups: Array<() => void> = [];
157
+
158
+ // Wire tooltip on tile elements
159
+ const tileElements = svg.querySelectorAll('.oc-tilemap-tile');
160
+ for (const el of tileElements) {
161
+ const stateCode = el.getAttribute('data-state');
162
+ if (!stateCode) continue;
163
+
164
+ const content = layout.tooltipDescriptors.get(stateCode);
165
+ const tile = layout.tiles.find((t) => t.stateCode === stateCode);
166
+
167
+ const handleMouseEnter = (e: Event) => {
168
+ const mouseEvent = e as MouseEvent;
169
+ if (content && tooltipManager && options?.tooltip !== false) {
170
+ const svgRect = svg.getBoundingClientRect();
171
+ const x = mouseEvent.clientX - svgRect.left;
172
+ const y = mouseEvent.clientY - svgRect.top;
173
+ tooltipManager.show(content, x, y);
174
+ }
175
+ if (tile) {
176
+ options?.onTileHover?.({
177
+ stateCode: tile.stateCode,
178
+ stateName: tile.data.stateName as string,
179
+ value: tile.value,
180
+ data: tile.data,
181
+ });
182
+ }
183
+ };
184
+
185
+ const handleMouseMove = (e: Event) => {
186
+ if (content && tooltipManager && options?.tooltip !== false) {
187
+ const mouseEvent = e as MouseEvent;
188
+ const svgRect = svg.getBoundingClientRect();
189
+ const x = mouseEvent.clientX - svgRect.left;
190
+ const y = mouseEvent.clientY - svgRect.top;
191
+ tooltipManager.show(content, x, y);
192
+ }
193
+ };
194
+
195
+ const handleMouseLeave = () => {
196
+ tooltipManager?.hide();
197
+ options?.onTileHover?.(null);
198
+ };
199
+
200
+ const handleClick = () => {
201
+ if (tile) {
202
+ options?.onTileClick?.({
203
+ stateCode: tile.stateCode,
204
+ stateName: tile.data.stateName as string,
205
+ value: tile.value,
206
+ data: tile.data,
207
+ });
208
+ }
209
+ };
210
+
211
+ el.addEventListener('mouseenter', handleMouseEnter);
212
+ el.addEventListener('mousemove', handleMouseMove);
213
+ el.addEventListener('mouseleave', handleMouseLeave);
214
+ el.addEventListener('click', handleClick);
215
+
216
+ cleanups.push(() => {
217
+ el.removeEventListener('mouseenter', handleMouseEnter);
218
+ el.removeEventListener('mousemove', handleMouseMove);
219
+ el.removeEventListener('mouseleave', handleMouseLeave);
220
+ el.removeEventListener('click', handleClick);
221
+ });
222
+ }
223
+
224
+ return () => {
225
+ for (const cleanup of cleanups) {
226
+ cleanup();
227
+ }
228
+ };
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Render + update
233
+ // ---------------------------------------------------------------------------
234
+
235
+ function render(animate = false): void {
236
+ // Cancel running animations
237
+ if (animationCleanup) {
238
+ animationCleanup();
239
+ animationCleanup = null;
240
+ }
241
+
242
+ // Remove old SVG
243
+ if (svgElement) {
244
+ if (cleanupTooltipEvents) {
245
+ cleanupTooltipEvents();
246
+ cleanupTooltipEvents = null;
247
+ }
248
+ svgElement.remove();
249
+ }
250
+
251
+ // Render new SVG
252
+ const newSvg = renderTileMapSVG(currentLayout, { animate });
253
+ container.appendChild(newSvg);
254
+ svgElement = newSvg;
255
+
256
+ // Wire interactions
257
+ cleanupTooltipEvents = wireTooltipAndInteraction(newSvg, currentLayout);
258
+
259
+ // Setup tooltips if enabled
260
+ if (options?.tooltip !== false) {
261
+ if (!tooltipManager) {
262
+ tooltipManager = createTooltipManager(container);
263
+ }
264
+ }
265
+
266
+ // Setup animation cleanup
267
+ if (currentLayout.animation?.enabled) {
268
+ animationCleanup = setupAnimationCleanup(newSvg, () => {
269
+ // On animation complete, check if resize was pending
270
+ if (pendingResize && !destroyed) {
271
+ pendingResize = false;
272
+ resize();
273
+ }
274
+ });
275
+ }
276
+ }
277
+
278
+ function update(newSpec: TileMapSpec): void {
279
+ currentSpec = newSpec;
280
+ currentLayout = compile();
281
+ render();
282
+ }
283
+
284
+ function resize(): void {
285
+ if (destroyed) return;
286
+
287
+ // If animation is running, queue the resize for after it completes
288
+ if (animationCleanup) {
289
+ pendingResize = true;
290
+ return;
291
+ }
292
+
293
+ currentLayout = compile();
294
+ render();
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Export
299
+ // ---------------------------------------------------------------------------
300
+
301
+ function exportChart(
302
+ format: 'svg' | 'svg-with-fonts' | 'png' | 'jpg',
303
+ options_?: JPGExportOptions | SVGExportOptions,
304
+ ): string | Promise<Blob> | Promise<string> {
305
+ if (!svgElement) return '';
306
+
307
+ switch (format) {
308
+ case 'svg':
309
+ return exportSVG(svgElement);
310
+ case 'svg-with-fonts':
311
+ return exportSVGWithFonts(svgElement);
312
+ case 'png':
313
+ return exportPNG(svgElement, options_ as JPGExportOptions);
314
+ case 'jpg':
315
+ return exportJPG(svgElement, options_ as JPGExportOptions);
316
+ default:
317
+ return '';
318
+ }
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Destroy
323
+ // ---------------------------------------------------------------------------
324
+
325
+ function destroy(): void {
326
+ if (destroyed) return;
327
+ destroyed = true;
328
+
329
+ // Cancel running animations
330
+ if (animationCleanup) {
331
+ cancelAnimations(svgElement);
332
+ animationCleanup();
333
+ animationCleanup = null;
334
+ }
335
+
336
+ // Clean up events
337
+ if (cleanupTooltipEvents) {
338
+ cleanupTooltipEvents();
339
+ cleanupTooltipEvents = null;
340
+ }
341
+
342
+ // Remove SVG
343
+ if (svgElement) {
344
+ svgElement.remove();
345
+ svgElement = null;
346
+ }
347
+
348
+ // Unmount tooltip manager
349
+ if (tooltipManager) {
350
+ tooltipManager.destroy();
351
+ tooltipManager = null;
352
+ }
353
+
354
+ // Disconnect resize observer
355
+ if (disconnectResize) {
356
+ disconnectResize();
357
+ disconnectResize = null;
358
+ }
359
+
360
+ // Remove root classes
361
+ container.classList.remove('oc-tilemap-root', 'oc-dark');
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Initialize
366
+ // ---------------------------------------------------------------------------
367
+
368
+ // Add root class for CSS custom properties (tokens, tooltip styles)
369
+ container.classList.add('oc-tilemap-root');
370
+ if (resolveDarkMode(options?.darkMode)) {
371
+ container.classList.add('oc-dark');
372
+ }
373
+
374
+ // Initial compile and render (animate on first mount)
375
+ currentLayout = compile();
376
+ render(true);
377
+
378
+ // Setup responsive resizing
379
+ if (options?.responsive !== false) {
380
+ disconnectResize = observeResize(container, () => {
381
+ resize();
382
+ });
383
+ }
384
+
385
+ return {
386
+ update,
387
+ resize,
388
+ export: exportChart,
389
+ destroy,
390
+ get layout(): TileMapLayout {
391
+ return currentLayout;
392
+ },
393
+ };
394
+ }