@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21
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-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
- package/dist/chunk-DCODCLWI.js +234 -0
- package/dist/chunk-DCODCLWI.js.map +1 -0
- package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
- package/dist/chunk-EKAUQAAX.js.map +1 -0
- package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
- package/dist/chunk-KNT5AZ2O.js.map +1 -0
- package/dist/chunk-RM5XS6NR.js +113 -0
- package/dist/chunk-RM5XS6NR.js.map +1 -0
- package/dist/chunk-T32UHMZA.js +282 -0
- package/dist/chunk-T32UHMZA.js.map +1 -0
- package/dist/export-gif.d.ts +113 -0
- package/dist/export-gif.js +21 -0
- package/dist/export-gif.js.map +1 -0
- package/dist/index.d.ts +283 -24
- package/dist/index.js +2012 -763
- package/dist/index.js.map +1 -1
- package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
- package/dist/simulation-worker.js +83 -2
- package/dist/static.js +7 -69
- package/dist/static.js.map +1 -1
- package/dist/story/index.d.ts +3 -2
- package/dist/story/index.js +5 -3
- package/dist/story/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +14 -6
- package/dist/chunk-5EOV5JQZ.js.map +0 -1
- package/dist/chunk-ENXSUXC5.js.map +0 -1
- package/src/__test-fixtures__/dom.ts +0 -70
- package/src/__test-fixtures__/specs.ts +0 -214
- package/src/__tests__/animation.test.ts +0 -358
- package/src/__tests__/annotations-render.test.ts +0 -201
- package/src/__tests__/compound-labels.test.ts +0 -122
- package/src/__tests__/continuous-legend.test.ts +0 -91
- package/src/__tests__/crosshair.test.ts +0 -130
- package/src/__tests__/edit-events.test.ts +0 -902
- package/src/__tests__/events.test.ts +0 -387
- package/src/__tests__/export.test.ts +0 -241
- package/src/__tests__/font-race.test.ts +0 -199
- package/src/__tests__/gradient-ids.test.ts +0 -159
- package/src/__tests__/gradient-utils.test.ts +0 -125
- package/src/__tests__/known-bugs-render.test.ts +0 -183
- package/src/__tests__/map.test.ts +0 -293
- package/src/__tests__/mount.test.ts +0 -357
- package/src/__tests__/pattern-fills.test.ts +0 -105
- package/src/__tests__/resize-timing.test.ts +0 -218
- package/src/__tests__/sankey.test.ts +0 -133
- package/src/__tests__/selection-events.test.ts +0 -869
- package/src/__tests__/series-search.test.ts +0 -311
- package/src/__tests__/static.test.ts +0 -141
- package/src/__tests__/svg-renderer.test.ts +0 -895
- package/src/__tests__/table-keyboard.test.ts +0 -361
- package/src/__tests__/table-mount.test.ts +0 -639
- package/src/__tests__/tilemap.test.ts +0 -158
- package/src/__tests__/tooltip.test.ts +0 -337
- package/src/__tests__/transition.test.ts +0 -1777
- package/src/__tests__/you-draw-it.test.ts +0 -273
- package/src/animation-vars.ts +0 -42
- package/src/animation.ts +0 -76
- package/src/barlist-mount.ts +0 -383
- package/src/barlist-renderer.ts +0 -267
- package/src/dom-helpers.ts +0 -17
- package/src/export.ts +0 -482
- package/src/gradient-utils.ts +0 -138
- package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
- package/src/graph/__tests__/graph-mount.test.ts +0 -213
- package/src/graph/__tests__/interaction.test.ts +0 -205
- package/src/graph/__tests__/keyboard.test.ts +0 -653
- package/src/graph/__tests__/search.test.ts +0 -88
- package/src/graph/__tests__/simulation.test.ts +0 -233
- package/src/graph/__tests__/spatial-index.test.ts +0 -142
- package/src/graph/__tests__/zoom.test.ts +0 -203
- package/src/graph/canvas-renderer.ts +0 -747
- package/src/graph/interaction.ts +0 -367
- package/src/graph/keyboard.ts +0 -208
- package/src/graph/search.ts +0 -50
- package/src/graph/simulation-worker-url.ts +0 -12
- package/src/graph/simulation-worker.ts +0 -278
- package/src/graph/simulation.ts +0 -422
- package/src/graph/spatial-index.ts +0 -121
- package/src/graph/types.ts +0 -47
- package/src/graph/worker-protocol.ts +0 -70
- package/src/graph/zoom.ts +0 -134
- package/src/graph-mount.ts +0 -888
- package/src/index.ts +0 -76
- package/src/interactions/chart-events.ts +0 -139
- package/src/interactions/crosshair.ts +0 -233
- package/src/interactions/drag-handler.ts +0 -175
- package/src/interactions/editing-drags.ts +0 -515
- package/src/interactions/index.ts +0 -25
- package/src/interactions/keyboard-nav.ts +0 -111
- package/src/interactions/legend-interaction.ts +0 -46
- package/src/interactions/selection.ts +0 -280
- package/src/interactions/tooltip-events.ts +0 -72
- package/src/map-camera.ts +0 -88
- package/src/map-mount.ts +0 -701
- package/src/map-renderer.ts +0 -338
- package/src/map-transition.ts +0 -83
- package/src/measure-text.ts +0 -134
- package/src/mount.ts +0 -1382
- package/src/pattern-utils.ts +0 -110
- package/src/renderers/annotations.ts +0 -388
- package/src/renderers/axes.ts +0 -351
- package/src/renderers/brand.ts +0 -87
- package/src/renderers/chrome.ts +0 -196
- package/src/renderers/endpoint-labels.ts +0 -150
- package/src/renderers/legend.ts +0 -311
- package/src/renderers/marks.ts +0 -561
- package/src/renderers/metrics.ts +0 -63
- package/src/renderers/svg-dom.ts +0 -40
- package/src/renderers/table-cells.ts +0 -446
- package/src/resize-observer.ts +0 -46
- package/src/sankey-mount.ts +0 -622
- package/src/sankey-renderer.ts +0 -614
- package/src/series-search.ts +0 -392
- package/src/static.ts +0 -251
- package/src/story/__tests__/camera-math.test.ts +0 -145
- package/src/story/__tests__/create-chart-story.test.ts +0 -317
- package/src/story/__tests__/progress-math.test.ts +0 -95
- package/src/story/__tests__/scroll-driver.test.ts +0 -130
- package/src/story/__tests__/tween.test.ts +0 -159
- package/src/story/camera-math.ts +0 -125
- package/src/story/create-chart-story.ts +0 -353
- package/src/story/crossfade.ts +0 -70
- package/src/story/index.ts +0 -39
- package/src/story/progress-math.ts +0 -85
- package/src/story/resolve-camera-target.ts +0 -119
- package/src/story/scroll-driver.ts +0 -206
- package/src/story/story-camera.ts +0 -49
- package/src/story/tween.ts +0 -112
- package/src/story/types.ts +0 -76
- package/src/svg-ids.ts +0 -23
- package/src/svg-renderer.ts +0 -383
- package/src/table-keyboard.ts +0 -266
- package/src/table-mount.ts +0 -567
- package/src/table-renderer.ts +0 -414
- package/src/text-edit-overlay.ts +0 -255
- package/src/theme-tokens.ts +0 -51
- package/src/tilemap-mount.ts +0 -511
- package/src/tilemap-renderer.ts +0 -396
- package/src/tooltip.ts +0 -177
- package/src/transition.ts +0 -2816
- package/src/you-draw-it.ts +0 -480
- /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
package/src/map-mount.ts
DELETED
|
@@ -1,701 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Map mount API: the main entry point for vanilla JS map usage.
|
|
3
|
-
*
|
|
4
|
-
* createMap() takes a container, MapSpec, and options, compiles the
|
|
5
|
-
* map, renders it as SVG, sets up responsive resizing, tooltip interaction,
|
|
6
|
-
* and returns a MapInstance with update/resize/export/destroy.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type {
|
|
10
|
-
CompileOptions,
|
|
11
|
-
DarkMode,
|
|
12
|
-
MapLayout,
|
|
13
|
-
MapSpec,
|
|
14
|
-
ThemeConfig,
|
|
15
|
-
} from '@opendata-ai/openchart-core';
|
|
16
|
-
import { compileMap } 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 {
|
|
27
|
-
applyMapCamera,
|
|
28
|
-
cameraForTarget,
|
|
29
|
-
focusTargetForFeatures,
|
|
30
|
-
type MapCameraOptions,
|
|
31
|
-
} from './map-camera';
|
|
32
|
-
import { renderMapSVG } from './map-renderer';
|
|
33
|
-
import { captureFeatureFills, runMapFillTransition } from './map-transition';
|
|
34
|
-
import { createMeasureText, resolveFontFamily, scheduleFontReload } from './measure-text';
|
|
35
|
-
import { observeResize } from './resize-observer';
|
|
36
|
-
import type { Camera } from './story/camera-math';
|
|
37
|
-
import { interpolateCamera } from './story/camera-math';
|
|
38
|
-
import type { Tween } from './story/tween';
|
|
39
|
-
import { createTween, easingFns, storyMotion } from './story/tween';
|
|
40
|
-
import { createTooltipManager, type TooltipManager } from './tooltip';
|
|
41
|
-
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
// Types
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
|
|
46
|
-
export interface MapMountOptions {
|
|
47
|
-
/** Theme overrides. */
|
|
48
|
-
theme?: ThemeConfig;
|
|
49
|
-
/** Dark mode setting: "auto" (system pref), "force", or "off". */
|
|
50
|
-
darkMode?: DarkMode;
|
|
51
|
-
/** Enable responsive resizing. Defaults to true. */
|
|
52
|
-
responsive?: boolean;
|
|
53
|
-
/** Show the tryOpenData.ai watermark. Defaults to true. */
|
|
54
|
-
watermark?: boolean;
|
|
55
|
-
/** Show tooltips on hover. Defaults to true. */
|
|
56
|
-
tooltip?: boolean;
|
|
57
|
-
/** Callback when a feature is clicked. */
|
|
58
|
-
onMarkClick?: (feature: {
|
|
59
|
-
id: string | number;
|
|
60
|
-
name?: string;
|
|
61
|
-
data: Record<string, unknown> | null;
|
|
62
|
-
}) => void;
|
|
63
|
-
/** Callback when a feature is hovered (null on mouse leave). */
|
|
64
|
-
onMarkHover?: (
|
|
65
|
-
feature: {
|
|
66
|
-
id: string | number;
|
|
67
|
-
name?: string;
|
|
68
|
-
data: Record<string, unknown> | null;
|
|
69
|
-
} | null,
|
|
70
|
-
) => void;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface MapInstance {
|
|
74
|
-
/** Re-compile and re-render with a new spec. */
|
|
75
|
-
update(spec: MapSpec): void;
|
|
76
|
-
/** Re-compile at current container dimensions. */
|
|
77
|
-
resize(): void;
|
|
78
|
-
/** Export the map. */
|
|
79
|
-
export(
|
|
80
|
-
format: 'svg' | 'svg-with-fonts' | 'png' | 'jpg',
|
|
81
|
-
options?: JPGExportOptions | SVGExportOptions,
|
|
82
|
-
): string | Promise<Blob> | Promise<string>;
|
|
83
|
-
/** Remove all DOM elements and disconnect observers. */
|
|
84
|
-
destroy(): void;
|
|
85
|
-
/** The current compiled layout. */
|
|
86
|
-
readonly layout: MapLayout;
|
|
87
|
-
/** Zoom to one or more features by ID. */
|
|
88
|
-
zoomTo(featureId: string | number | Array<string | number>, opts?: MapCameraOptions): void;
|
|
89
|
-
/** Pan to a feature keeping the current zoom level. */
|
|
90
|
-
panTo(featureId: string | number, opts?: MapCameraOptions): void;
|
|
91
|
-
/** Fit the camera to an arbitrary bounding box, or reset to full view if no target. */
|
|
92
|
-
fitBounds(
|
|
93
|
-
target?: { x: number; y: number; width: number; height: number; padding?: number },
|
|
94
|
-
opts?: MapCameraOptions,
|
|
95
|
-
): void;
|
|
96
|
-
/** Reset the camera to the full map view. */
|
|
97
|
-
resetView(opts?: MapCameraOptions): void;
|
|
98
|
-
/** Get a snapshot of the current camera state. */
|
|
99
|
-
getCamera(): Camera;
|
|
100
|
-
/** Set the camera to an exact state (no animation). */
|
|
101
|
-
setCamera(camera: Camera): void;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
// Dark mode resolution
|
|
106
|
-
// ---------------------------------------------------------------------------
|
|
107
|
-
|
|
108
|
-
function prefersReducedMotion(): boolean {
|
|
109
|
-
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
|
110
|
-
try {
|
|
111
|
-
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
112
|
-
} catch {
|
|
113
|
-
return false;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function resolveDarkMode(mode?: DarkMode): boolean {
|
|
118
|
-
if (mode === 'force') return true;
|
|
119
|
-
if (mode === 'off' || mode === undefined) return false;
|
|
120
|
-
if (typeof window !== 'undefined' && window.matchMedia) {
|
|
121
|
-
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
122
|
-
}
|
|
123
|
-
return false;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// ---------------------------------------------------------------------------
|
|
127
|
-
// Main API
|
|
128
|
-
// ---------------------------------------------------------------------------
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Create a map instance from a spec and mount it into a container.
|
|
132
|
-
*/
|
|
133
|
-
export function createMap(
|
|
134
|
-
container: HTMLElement,
|
|
135
|
-
spec: MapSpec,
|
|
136
|
-
options?: MapMountOptions,
|
|
137
|
-
): MapInstance {
|
|
138
|
-
let currentSpec = spec;
|
|
139
|
-
let currentLayout: MapLayout;
|
|
140
|
-
let destroyed = false;
|
|
141
|
-
|
|
142
|
-
// DOM
|
|
143
|
-
let svgElement: SVGSVGElement | null = null;
|
|
144
|
-
|
|
145
|
-
// Subsystems
|
|
146
|
-
let tooltipManager: TooltipManager | null = null;
|
|
147
|
-
let cleanupTooltipEvents: (() => void) | null = null;
|
|
148
|
-
let disconnectResize: (() => void) | null = null;
|
|
149
|
-
|
|
150
|
-
// Animation state
|
|
151
|
-
let animationCleanup: (() => void) | null = null;
|
|
152
|
-
let fillTransitionHandle: { cancel: () => void } | null = null;
|
|
153
|
-
let pendingResize = false;
|
|
154
|
-
|
|
155
|
-
// Camera state
|
|
156
|
-
let currentCamera: Camera = { cx: 0, cy: 0, k: 1 };
|
|
157
|
-
let currentFocusIds: Array<string | number> | null = null;
|
|
158
|
-
let cameraTween: Tween<Camera> | null = null;
|
|
159
|
-
|
|
160
|
-
// Track whether this is the first render (for snap-to-focus).
|
|
161
|
-
let isFirstRender = true;
|
|
162
|
-
|
|
163
|
-
// Set when webfonts have loaded and a recompile is owed.
|
|
164
|
-
let fontsReloadPending = false;
|
|
165
|
-
|
|
166
|
-
// Apply the root class up front so getComputedStyle sees --oc-font-family
|
|
167
|
-
container.classList.add('oc-map-root');
|
|
168
|
-
|
|
169
|
-
// Resolve the effective font the same way compile() will.
|
|
170
|
-
function resolveEffectiveFont(): string {
|
|
171
|
-
return (
|
|
172
|
-
options?.theme?.fonts?.family ??
|
|
173
|
-
currentSpec.theme?.fonts?.family ??
|
|
174
|
-
resolveFontFamily(container)
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
let fontFamily = resolveEffectiveFont();
|
|
178
|
-
let measureText = createMeasureText(fontFamily);
|
|
179
|
-
let renderGen = 0;
|
|
180
|
-
|
|
181
|
-
// ---------------------------------------------------------------------------
|
|
182
|
-
// Helpers
|
|
183
|
-
// ---------------------------------------------------------------------------
|
|
184
|
-
|
|
185
|
-
let isAutoHeight: boolean | null = null;
|
|
186
|
-
|
|
187
|
-
function getContainerDimensions(): { width: number; height: number } {
|
|
188
|
-
const rect = container.getBoundingClientRect();
|
|
189
|
-
const width = Math.max(rect.width || 600, 100);
|
|
190
|
-
|
|
191
|
-
if (isAutoHeight === null && (rect.width > 0 || rect.height > 0)) {
|
|
192
|
-
isAutoHeight = rect.height === 0 && rect.width > 0;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Explicit host height is a hard budget: return it as-is so the
|
|
196
|
-
// compiler keeps the SVG inside its box.
|
|
197
|
-
if (isAutoHeight === false && rect.height > 0) {
|
|
198
|
-
return { width, height: Math.max(rect.height, 100) };
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Auto-height host: pick our own budget.
|
|
202
|
-
return { width, height: Math.round(width * 0.625) };
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function compile(): MapLayout {
|
|
206
|
-
const { width, height } = getContainerDimensions();
|
|
207
|
-
const darkMode = resolveDarkMode(options?.darkMode);
|
|
208
|
-
|
|
209
|
-
const compileOpts: CompileOptions = {
|
|
210
|
-
width,
|
|
211
|
-
height,
|
|
212
|
-
theme: options?.theme,
|
|
213
|
-
darkMode,
|
|
214
|
-
watermark: options?.watermark,
|
|
215
|
-
measureText,
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
return compileMap(currentSpec, compileOpts);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// ---------------------------------------------------------------------------
|
|
222
|
-
// Tooltip and interaction wiring
|
|
223
|
-
// ---------------------------------------------------------------------------
|
|
224
|
-
|
|
225
|
-
function wireTooltipAndInteraction(svg: SVGSVGElement, layout: MapLayout): () => void {
|
|
226
|
-
const cleanups: Array<() => void> = [];
|
|
227
|
-
|
|
228
|
-
const featureElements = svg.querySelectorAll('.oc-map-feature');
|
|
229
|
-
for (const el of featureElements) {
|
|
230
|
-
const featureId = el.getAttribute('data-feature-id');
|
|
231
|
-
if (!featureId) continue;
|
|
232
|
-
|
|
233
|
-
const content = layout.tooltipDescriptors.get(featureId);
|
|
234
|
-
const feature = layout.features.find((f) => String(f.id) === featureId);
|
|
235
|
-
|
|
236
|
-
const handleMouseEnter = (e: Event) => {
|
|
237
|
-
const mouseEvent = e as MouseEvent;
|
|
238
|
-
(el as SVGElement & ElementCSSInlineStyle).style.setProperty('cursor', 'pointer');
|
|
239
|
-
(el as SVGElement & ElementCSSInlineStyle).style.setProperty('filter', 'brightness(1.1)');
|
|
240
|
-
if (content && tooltipManager && options?.tooltip !== false) {
|
|
241
|
-
const svgRect = svg.getBoundingClientRect();
|
|
242
|
-
const x = mouseEvent.clientX - svgRect.left;
|
|
243
|
-
const y = mouseEvent.clientY - svgRect.top;
|
|
244
|
-
tooltipManager.show(content, x, y);
|
|
245
|
-
}
|
|
246
|
-
if (feature) {
|
|
247
|
-
options?.onMarkHover?.({
|
|
248
|
-
id: feature.id,
|
|
249
|
-
name: feature.name,
|
|
250
|
-
data: feature.data,
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
const handleMouseMove = (e: Event) => {
|
|
256
|
-
if (content && tooltipManager && options?.tooltip !== false) {
|
|
257
|
-
const mouseEvent = e as MouseEvent;
|
|
258
|
-
const svgRect = svg.getBoundingClientRect();
|
|
259
|
-
const x = mouseEvent.clientX - svgRect.left;
|
|
260
|
-
const y = mouseEvent.clientY - svgRect.top;
|
|
261
|
-
tooltipManager.show(content, x, y);
|
|
262
|
-
}
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
const handleMouseLeave = () => {
|
|
266
|
-
(el as SVGElement & ElementCSSInlineStyle).style.removeProperty('cursor');
|
|
267
|
-
(el as SVGElement & ElementCSSInlineStyle).style.removeProperty('filter');
|
|
268
|
-
tooltipManager?.hide();
|
|
269
|
-
options?.onMarkHover?.(null);
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
const handleClick = () => {
|
|
273
|
-
if (feature) {
|
|
274
|
-
options?.onMarkClick?.({
|
|
275
|
-
id: feature.id,
|
|
276
|
-
name: feature.name,
|
|
277
|
-
data: feature.data,
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
};
|
|
281
|
-
|
|
282
|
-
el.addEventListener('mouseenter', handleMouseEnter);
|
|
283
|
-
el.addEventListener('mousemove', handleMouseMove);
|
|
284
|
-
el.addEventListener('mouseleave', handleMouseLeave);
|
|
285
|
-
el.addEventListener('click', handleClick);
|
|
286
|
-
|
|
287
|
-
cleanups.push(() => {
|
|
288
|
-
el.removeEventListener('mouseenter', handleMouseEnter);
|
|
289
|
-
el.removeEventListener('mousemove', handleMouseMove);
|
|
290
|
-
el.removeEventListener('mouseleave', handleMouseLeave);
|
|
291
|
-
el.removeEventListener('click', handleClick);
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
return () => {
|
|
296
|
-
for (const cleanup of cleanups) {
|
|
297
|
-
cleanup();
|
|
298
|
-
}
|
|
299
|
-
};
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// ---------------------------------------------------------------------------
|
|
303
|
-
// Camera helpers
|
|
304
|
-
// ---------------------------------------------------------------------------
|
|
305
|
-
|
|
306
|
-
function resolveCamera(): Camera {
|
|
307
|
-
if (currentFocusIds && currentFocusIds.length > 0) {
|
|
308
|
-
const target = focusTargetForFeatures(currentLayout, currentFocusIds);
|
|
309
|
-
return cameraForTarget(currentLayout, target);
|
|
310
|
-
}
|
|
311
|
-
return cameraForTarget(currentLayout, null);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function animateCamera(target: Camera, duration: number): void {
|
|
315
|
-
if (cameraTween) cameraTween.cancel();
|
|
316
|
-
cameraTween = createTween<Camera>({
|
|
317
|
-
initial: currentCamera,
|
|
318
|
-
lerp: interpolateCamera,
|
|
319
|
-
duration,
|
|
320
|
-
ease: easingFns.easeInOutCubic,
|
|
321
|
-
onFrame(cam) {
|
|
322
|
-
currentCamera = cam;
|
|
323
|
-
if (svgElement) applyMapCamera(svgElement, cam, currentLayout);
|
|
324
|
-
},
|
|
325
|
-
});
|
|
326
|
-
cameraTween.to(target);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function driveCamera(cam: Camera, duration: number): void {
|
|
330
|
-
if (duration === 0 || prefersReducedMotion()) {
|
|
331
|
-
currentCamera = cam;
|
|
332
|
-
if (svgElement) applyMapCamera(svgElement, cam, currentLayout);
|
|
333
|
-
} else {
|
|
334
|
-
animateCamera(cam, duration);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function updateA11yLive(ids: Array<string | number> | null): void {
|
|
339
|
-
let liveEl = container.querySelector('.oc-map-live') as HTMLElement | null;
|
|
340
|
-
if (!liveEl) {
|
|
341
|
-
liveEl = document.createElement('div');
|
|
342
|
-
liveEl.className = 'oc-map-live oc-sr-only';
|
|
343
|
-
liveEl.setAttribute('aria-live', 'polite');
|
|
344
|
-
container.appendChild(liveEl);
|
|
345
|
-
}
|
|
346
|
-
if (!ids || ids.length === 0) {
|
|
347
|
-
liveEl.textContent = 'Showing full map';
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
const names = ids.map((id) => {
|
|
351
|
-
const f = currentLayout.features.find((feat) => String(feat.id) === String(id));
|
|
352
|
-
return f?.name ?? String(id);
|
|
353
|
-
});
|
|
354
|
-
liveEl.textContent = `Zoomed to ${names.join(', ')}`;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function applyFocusDim(ids: Array<string | number> | null): void {
|
|
358
|
-
if (!svgElement) return;
|
|
359
|
-
const features = svgElement.querySelectorAll('.oc-map-feature');
|
|
360
|
-
if (!ids || ids.length === 0) {
|
|
361
|
-
for (const el of features) {
|
|
362
|
-
(el as SVGElement & ElementCSSInlineStyle).style.removeProperty('opacity');
|
|
363
|
-
}
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
const focusSet = new Set(ids.map(String));
|
|
367
|
-
for (const el of features) {
|
|
368
|
-
const fid = el.getAttribute('data-feature-id');
|
|
369
|
-
(el as SVGElement & ElementCSSInlineStyle).style.setProperty(
|
|
370
|
-
'opacity',
|
|
371
|
-
fid && focusSet.has(fid) ? '1' : '0.25',
|
|
372
|
-
);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// ---------------------------------------------------------------------------
|
|
377
|
-
// Render + update
|
|
378
|
-
// ---------------------------------------------------------------------------
|
|
379
|
-
|
|
380
|
-
function render(animate = false): void {
|
|
381
|
-
// Cancel running animations
|
|
382
|
-
if (animationCleanup) {
|
|
383
|
-
animationCleanup();
|
|
384
|
-
animationCleanup = null;
|
|
385
|
-
}
|
|
386
|
-
if (fillTransitionHandle) {
|
|
387
|
-
fillTransitionHandle.cancel();
|
|
388
|
-
fillTransitionHandle = null;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Remove old SVG
|
|
392
|
-
if (svgElement) {
|
|
393
|
-
if (cleanupTooltipEvents) {
|
|
394
|
-
cleanupTooltipEvents();
|
|
395
|
-
cleanupTooltipEvents = null;
|
|
396
|
-
}
|
|
397
|
-
svgElement.remove();
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Render new SVG
|
|
401
|
-
const newSvg = renderMapSVG(currentLayout, { animate });
|
|
402
|
-
container.appendChild(newSvg);
|
|
403
|
-
svgElement = newSvg;
|
|
404
|
-
|
|
405
|
-
// Wire interactions
|
|
406
|
-
cleanupTooltipEvents = wireTooltipAndInteraction(newSvg, currentLayout);
|
|
407
|
-
|
|
408
|
-
// Setup tooltips if enabled
|
|
409
|
-
if (options?.tooltip !== false) {
|
|
410
|
-
if (!tooltipManager) {
|
|
411
|
-
tooltipManager = createTooltipManager(container);
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// Setup animation cleanup only when actually animating
|
|
416
|
-
const isAnimating = animate && !!currentLayout.animation?.enter;
|
|
417
|
-
if (isAnimating) {
|
|
418
|
-
animationCleanup = setupAnimationCleanup(newSvg, () => {
|
|
419
|
-
if (pendingResize && !destroyed) {
|
|
420
|
-
pendingResize = false;
|
|
421
|
-
resize();
|
|
422
|
-
}
|
|
423
|
-
// Apply deferred focus dim after animation completes
|
|
424
|
-
if (currentFocusIds && currentFocusIds.length > 0) {
|
|
425
|
-
applyFocusDim(currentFocusIds);
|
|
426
|
-
}
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// Re-apply camera transform after render if zoomed or focused
|
|
431
|
-
if (currentCamera.k > 1 + 1e-3 || (currentFocusIds && currentFocusIds.length > 0)) {
|
|
432
|
-
applyMapCamera(newSvg, currentCamera, currentLayout);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
renderGen += 1;
|
|
436
|
-
container.dataset.ocRenderGen = String(renderGen);
|
|
437
|
-
|
|
438
|
-
if (fontsReloadPending) {
|
|
439
|
-
fontsReloadPending = false;
|
|
440
|
-
container.dataset.ocFontsState = 'ready';
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// On first render, snap to declarative focus if present
|
|
444
|
-
if (isFirstRender && currentLayout.focus) {
|
|
445
|
-
const target = currentLayout.focus.target;
|
|
446
|
-
currentFocusIds = [...currentLayout.focus.ids];
|
|
447
|
-
const cam = cameraForTarget(currentLayout, target);
|
|
448
|
-
currentCamera = cam;
|
|
449
|
-
if (svgElement) applyMapCamera(svgElement, cam, currentLayout);
|
|
450
|
-
if (!isAnimating) applyFocusDim(currentFocusIds);
|
|
451
|
-
}
|
|
452
|
-
isFirstRender = false;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function update(newSpec: MapSpec): void {
|
|
456
|
-
currentSpec = newSpec;
|
|
457
|
-
const nextFont = resolveEffectiveFont();
|
|
458
|
-
if (nextFont !== fontFamily) {
|
|
459
|
-
fontFamily = nextFont;
|
|
460
|
-
measureText = createMeasureText(fontFamily);
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// Capture fills optimistically; gate the tween on the *new* layout's animation config after compile
|
|
464
|
-
const canAnimate = !animationCleanup && !prefersReducedMotion();
|
|
465
|
-
const prevFills = canAnimate ? captureFeatureFills(svgElement) : null;
|
|
466
|
-
|
|
467
|
-
// Remember previous focus to detect changes
|
|
468
|
-
const prevFocusIds = currentFocusIds ? currentFocusIds.map(String).sort().join(',') : null;
|
|
469
|
-
const prevFocusPadding = currentLayout.focus?.target.padding ?? null;
|
|
470
|
-
|
|
471
|
-
currentLayout = compile();
|
|
472
|
-
render();
|
|
473
|
-
|
|
474
|
-
// Run fill tween if the *new* layout enables update animation
|
|
475
|
-
if (
|
|
476
|
-
canAnimate &&
|
|
477
|
-
prevFills &&
|
|
478
|
-
prevFills.size > 0 &&
|
|
479
|
-
svgElement &&
|
|
480
|
-
currentLayout.animation?.update
|
|
481
|
-
) {
|
|
482
|
-
fillTransitionHandle = runMapFillTransition(svgElement, prevFills, {
|
|
483
|
-
duration: currentLayout.animation.update.duration,
|
|
484
|
-
});
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// Declarative focus from spec: tween camera if focus changed
|
|
488
|
-
const nextFocus = currentLayout.focus;
|
|
489
|
-
const nextFocusIds = nextFocus ? nextFocus.ids.map(String).sort().join(',') : null;
|
|
490
|
-
const nextFocusPadding = nextFocus?.target.padding ?? null;
|
|
491
|
-
|
|
492
|
-
if (nextFocusIds !== prevFocusIds || nextFocusPadding !== prevFocusPadding) {
|
|
493
|
-
if (nextFocus) {
|
|
494
|
-
currentFocusIds = [...nextFocus.ids];
|
|
495
|
-
const cam = cameraForTarget(currentLayout, nextFocus.target);
|
|
496
|
-
driveCamera(cam, prefersReducedMotion() ? 0 : storyMotion.camera);
|
|
497
|
-
applyFocusDim(currentFocusIds);
|
|
498
|
-
} else if (prevFocusIds !== null) {
|
|
499
|
-
currentFocusIds = null;
|
|
500
|
-
const cam = cameraForTarget(currentLayout, null);
|
|
501
|
-
driveCamera(cam, prefersReducedMotion() ? 0 : storyMotion.camera);
|
|
502
|
-
applyFocusDim(null);
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
function resize(): void {
|
|
508
|
-
if (destroyed) return;
|
|
509
|
-
|
|
510
|
-
// If animation is running, queue the resize for after it completes
|
|
511
|
-
if (animationCleanup) {
|
|
512
|
-
pendingResize = true;
|
|
513
|
-
return;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
currentLayout = compile();
|
|
517
|
-
render();
|
|
518
|
-
|
|
519
|
-
// Re-resolve camera against new layout dimensions.
|
|
520
|
-
// Imperative focus (set via zoomTo/panTo) takes precedence over declarative.
|
|
521
|
-
if (currentFocusIds && currentFocusIds.length > 0) {
|
|
522
|
-
currentCamera = resolveCamera();
|
|
523
|
-
if (svgElement) applyMapCamera(svgElement, currentCamera, currentLayout);
|
|
524
|
-
} else if (currentLayout.focus) {
|
|
525
|
-
currentFocusIds = [...currentLayout.focus.ids];
|
|
526
|
-
currentCamera = cameraForTarget(currentLayout, currentLayout.focus.target);
|
|
527
|
-
if (svgElement) applyMapCamera(svgElement, currentCamera, currentLayout);
|
|
528
|
-
}
|
|
529
|
-
applyFocusDim(currentFocusIds);
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// ---------------------------------------------------------------------------
|
|
533
|
-
// Export
|
|
534
|
-
// ---------------------------------------------------------------------------
|
|
535
|
-
|
|
536
|
-
function exportChart(
|
|
537
|
-
format: 'svg' | 'svg-with-fonts' | 'png' | 'jpg',
|
|
538
|
-
options_?: JPGExportOptions | SVGExportOptions,
|
|
539
|
-
): string | Promise<Blob> | Promise<string> {
|
|
540
|
-
if (!svgElement) return '';
|
|
541
|
-
|
|
542
|
-
switch (format) {
|
|
543
|
-
case 'svg':
|
|
544
|
-
return exportSVG(svgElement);
|
|
545
|
-
case 'svg-with-fonts':
|
|
546
|
-
return exportSVGWithFonts(svgElement);
|
|
547
|
-
case 'png':
|
|
548
|
-
return exportPNG(svgElement, options_ as JPGExportOptions);
|
|
549
|
-
case 'jpg':
|
|
550
|
-
return exportJPG(svgElement, options_ as JPGExportOptions);
|
|
551
|
-
default:
|
|
552
|
-
return '';
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// ---------------------------------------------------------------------------
|
|
557
|
-
// Destroy
|
|
558
|
-
// ---------------------------------------------------------------------------
|
|
559
|
-
|
|
560
|
-
function destroy(): void {
|
|
561
|
-
if (destroyed) return;
|
|
562
|
-
destroyed = true;
|
|
563
|
-
|
|
564
|
-
// Cancel running animations
|
|
565
|
-
if (animationCleanup) {
|
|
566
|
-
cancelAnimations(svgElement);
|
|
567
|
-
animationCleanup();
|
|
568
|
-
animationCleanup = null;
|
|
569
|
-
}
|
|
570
|
-
if (fillTransitionHandle) {
|
|
571
|
-
fillTransitionHandle.cancel();
|
|
572
|
-
fillTransitionHandle = null;
|
|
573
|
-
}
|
|
574
|
-
if (cameraTween) {
|
|
575
|
-
cameraTween.cancel();
|
|
576
|
-
cameraTween = null;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// Clean up events
|
|
580
|
-
if (cleanupTooltipEvents) {
|
|
581
|
-
cleanupTooltipEvents();
|
|
582
|
-
cleanupTooltipEvents = null;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
// Remove SVG
|
|
586
|
-
if (svgElement) {
|
|
587
|
-
svgElement.remove();
|
|
588
|
-
svgElement = null;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// Remove a11y live region
|
|
592
|
-
const liveEl = container.querySelector('.oc-map-live');
|
|
593
|
-
if (liveEl) liveEl.remove();
|
|
594
|
-
|
|
595
|
-
// Unmount tooltip manager
|
|
596
|
-
if (tooltipManager) {
|
|
597
|
-
tooltipManager.destroy();
|
|
598
|
-
tooltipManager = null;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// Disconnect resize observer
|
|
602
|
-
if (disconnectResize) {
|
|
603
|
-
disconnectResize();
|
|
604
|
-
disconnectResize = null;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// Remove root classes
|
|
608
|
-
container.classList.remove('oc-map-root', 'oc-dark');
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// ---------------------------------------------------------------------------
|
|
612
|
-
// Initialize
|
|
613
|
-
// ---------------------------------------------------------------------------
|
|
614
|
-
|
|
615
|
-
if (resolveDarkMode(options?.darkMode)) {
|
|
616
|
-
container.classList.add('oc-dark');
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
// Initial compile and render (animate on first mount)
|
|
620
|
-
currentLayout = compile();
|
|
621
|
-
render(true);
|
|
622
|
-
|
|
623
|
-
// Recompile once after webfonts load
|
|
624
|
-
const fontsPending = scheduleFontReload(
|
|
625
|
-
fontFamily,
|
|
626
|
-
() => !destroyed,
|
|
627
|
-
() => {
|
|
628
|
-
fontsReloadPending = true;
|
|
629
|
-
resize();
|
|
630
|
-
},
|
|
631
|
-
);
|
|
632
|
-
container.dataset.ocFontsState = fontsPending ? 'pending' : 'ready';
|
|
633
|
-
|
|
634
|
-
// Setup responsive resizing
|
|
635
|
-
if (options?.responsive !== false) {
|
|
636
|
-
disconnectResize = observeResize(container, () => {
|
|
637
|
-
resize();
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
return {
|
|
642
|
-
update,
|
|
643
|
-
resize,
|
|
644
|
-
export: exportChart,
|
|
645
|
-
destroy,
|
|
646
|
-
get layout(): MapLayout {
|
|
647
|
-
return currentLayout;
|
|
648
|
-
},
|
|
649
|
-
zoomTo(featureId: string | number | Array<string | number>, opts?: MapCameraOptions): void {
|
|
650
|
-
const ids = Array.isArray(featureId) ? featureId : [featureId];
|
|
651
|
-
const target = focusTargetForFeatures(currentLayout, ids, opts?.padding ?? 16);
|
|
652
|
-
if (!target) {
|
|
653
|
-
console.warn(`[openchart] zoomTo: no features found for ids: ${ids.join(', ')}`);
|
|
654
|
-
return;
|
|
655
|
-
}
|
|
656
|
-
currentFocusIds = ids;
|
|
657
|
-
const cam = cameraForTarget(currentLayout, target);
|
|
658
|
-
driveCamera(cam, opts?.duration ?? storyMotion.camera);
|
|
659
|
-
applyFocusDim(ids);
|
|
660
|
-
updateA11yLive(ids);
|
|
661
|
-
},
|
|
662
|
-
panTo(featureId: string | number, opts?: MapCameraOptions): void {
|
|
663
|
-
const target = focusTargetForFeatures(currentLayout, [featureId], opts?.padding ?? 16);
|
|
664
|
-
if (!target) {
|
|
665
|
-
console.warn(`[openchart] panTo: no feature found for id: ${featureId}`);
|
|
666
|
-
return;
|
|
667
|
-
}
|
|
668
|
-
const cam: Camera = {
|
|
669
|
-
cx: target.x + target.width / 2,
|
|
670
|
-
cy: target.y + target.height / 2,
|
|
671
|
-
k: currentCamera.k,
|
|
672
|
-
};
|
|
673
|
-
driveCamera(cam, opts?.duration ?? storyMotion.camera);
|
|
674
|
-
updateA11yLive([featureId]);
|
|
675
|
-
},
|
|
676
|
-
fitBounds(
|
|
677
|
-
target?: { x: number; y: number; width: number; height: number; padding?: number },
|
|
678
|
-
opts?: MapCameraOptions,
|
|
679
|
-
): void {
|
|
680
|
-
currentFocusIds = null;
|
|
681
|
-
const cam = cameraForTarget(currentLayout, target ?? null);
|
|
682
|
-
driveCamera(cam, opts?.duration ?? storyMotion.camera);
|
|
683
|
-
applyFocusDim(null);
|
|
684
|
-
},
|
|
685
|
-
resetView(opts?: MapCameraOptions): void {
|
|
686
|
-
currentFocusIds = null;
|
|
687
|
-
const cam = cameraForTarget(currentLayout, null);
|
|
688
|
-
driveCamera(cam, opts?.duration ?? storyMotion.camera);
|
|
689
|
-
applyFocusDim(null);
|
|
690
|
-
updateA11yLive(null);
|
|
691
|
-
},
|
|
692
|
-
getCamera(): Camera {
|
|
693
|
-
return { ...currentCamera };
|
|
694
|
-
},
|
|
695
|
-
setCamera(camera: Camera): void {
|
|
696
|
-
currentCamera = camera;
|
|
697
|
-
currentFocusIds = null;
|
|
698
|
-
if (svgElement) applyMapCamera(svgElement, camera, currentLayout);
|
|
699
|
-
},
|
|
700
|
-
};
|
|
701
|
-
}
|