@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.
- package/dist/index.d.ts +54 -2
- package/dist/index.js +661 -29
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +3 -3
- package/src/__tests__/compound-labels.test.ts +122 -0
- package/src/__tests__/crosshair.test.ts +121 -0
- package/src/__tests__/mount.test.ts +19 -0
- package/src/__tests__/tilemap.test.ts +158 -0
- package/src/graph-mount.ts +1 -1
- package/src/index.ts +3 -0
- package/src/mount.ts +45 -2
- package/src/renderers/axes.ts +81 -20
- package/src/renderers/legend.ts +6 -2
- package/src/sankey-renderer.ts +4 -2
- package/src/svg-renderer.ts +28 -1
- package/src/tilemap-mount.ts +394 -0
- package/src/tilemap-renderer.ts +425 -0
package/src/renderers/axes.ts
CHANGED
|
@@ -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
|
-
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
package/src/renderers/legend.ts
CHANGED
|
@@ -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');
|
package/src/sankey-renderer.ts
CHANGED
|
@@ -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,
|
|
234
|
-
if (
|
|
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');
|
package/src/svg-renderer.ts
CHANGED
|
@@ -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
|
+
}
|