@opendata-ai/openchart-vanilla 8.6.4 → 8.8.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.
@@ -4,7 +4,7 @@ import {
4
4
  exportPNG,
5
5
  exportSVG,
6
6
  exportSVGWithFonts
7
- } from "./chunk-6B5OBGIO.js";
7
+ } from "./chunk-I73B3DJI.js";
8
8
  import {
9
9
  buildGradientDefs,
10
10
  buildPatternDefs,
@@ -17,12 +17,12 @@ import {
17
17
  resolveMarkFill,
18
18
  resolvePatternFill,
19
19
  stampAnimationVars
20
- } from "./chunk-MDK6AILK.js";
20
+ } from "./chunk-SXTHPXG6.js";
21
21
  import {
22
22
  SVG_NS,
23
23
  createSVGElement,
24
24
  setAttrs
25
- } from "./chunk-5RX6ZQGZ.js";
25
+ } from "./chunk-SDJHUELU.js";
26
26
  import {
27
27
  AnimationScheduler,
28
28
  SpatialIndex,
@@ -3122,21 +3122,40 @@ function applySecondaryOpacity(els, elapsed, enterDelay, enterDuration) {
3122
3122
  }
3123
3123
  }
3124
3124
 
3125
+ // src/dom-helpers.ts
3126
+ function applySrOnlyStyles(el) {
3127
+ el.style.position = "absolute";
3128
+ el.style.width = "1px";
3129
+ el.style.height = "1px";
3130
+ el.style.padding = "0";
3131
+ el.style.margin = "-1px";
3132
+ el.style.overflow = "clip";
3133
+ el.style.clipPath = "inset(50%)";
3134
+ el.style.whiteSpace = "nowrap";
3135
+ el.style.borderWidth = "0";
3136
+ }
3137
+
3125
3138
  // src/you-draw-it.ts
3126
3139
  var MIN_TOUCH_TARGET = 24;
3140
+ var TRAIL_RESOLUTION = 1e3;
3141
+ var START_SLOP_PX = 16;
3127
3142
  function buildLinearPath(points) {
3128
3143
  if (points.length === 0) return "";
3129
3144
  const sorted = [...points].sort((a, b) => a.x - b.x);
3130
3145
  return sorted.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ");
3131
3146
  }
3147
+ function round2(n) {
3148
+ return Math.round(n * 100) / 100;
3149
+ }
3132
3150
  function createYouDrawIt(options) {
3133
3151
  const { container, onReveal } = options;
3134
3152
  let destroyed = false;
3135
3153
  let revealed = false;
3136
3154
  let config = null;
3137
3155
  let svgEl = null;
3138
- const guessByX = /* @__PURE__ */ new Map();
3156
+ const trail = /* @__PURE__ */ new Map();
3139
3157
  let cleanupPointerEvents = null;
3158
+ let activePointer = null;
3140
3159
  function buildHatchPattern(id, color) {
3141
3160
  const pattern = document.createElementNS(SVG_NS, "pattern");
3142
3161
  pattern.setAttribute("id", id);
@@ -3219,11 +3238,12 @@ function createYouDrawIt(options) {
3219
3238
  guessPath.setAttribute("pointer-events", "none");
3220
3239
  group.appendChild(guessPath);
3221
3240
  if (!revealed) {
3241
+ const overlayX = Math.max(area.x, fromX - START_SLOP_PX);
3222
3242
  const overlay = createSVGElement("rect");
3223
3243
  setAttrs(overlay, {
3224
- x: fromX,
3244
+ x: overlayX,
3225
3245
  y: area.y,
3226
- width: regionWidth,
3246
+ width: regionWidth + (fromX - overlayX),
3227
3247
  height: area.height,
3228
3248
  fill: "transparent"
3229
3249
  });
@@ -3240,97 +3260,146 @@ function createYouDrawIt(options) {
3240
3260
  }
3241
3261
  svg.appendChild(group);
3242
3262
  }
3263
+ function regionWidthOf(cfg) {
3264
+ return Math.max(0, cfg.area.x + cfg.area.width - cfg.fromX);
3265
+ }
3266
+ function pxToIndex(cfg, px) {
3267
+ const w = regionWidthOf(cfg);
3268
+ if (w === 0) return 0;
3269
+ return (px - cfg.fromX) / w * TRAIL_RESOLUTION;
3270
+ }
3271
+ function indexToPx(cfg, index) {
3272
+ return cfg.fromX + index / TRAIL_RESOLUTION * regionWidthOf(cfg);
3273
+ }
3243
3274
  function redrawGuessPath() {
3244
- if (!svgEl) return;
3275
+ if (!svgEl || !config) return;
3245
3276
  const path = svgEl.querySelector("[data-ydi-guess-path]");
3246
3277
  if (!path) return;
3247
- const points = Array.from(guessByX.entries()).map(([x, y]) => ({ x, y }));
3248
- path.setAttribute("d", buildLinearPath(points));
3249
- }
3250
- function snapToNearestSample(px) {
3251
- if (!config || config.samples.length === 0) return null;
3252
- let nearestPx = config.samples[0].px;
3253
- let best = Math.abs(nearestPx - px);
3254
- for (const s of config.samples) {
3255
- const d = Math.abs(s.px - px);
3256
- if (d < best) {
3257
- best = d;
3258
- nearestPx = s.px;
3259
- }
3260
- }
3261
- return nearestPx;
3278
+ const cfg = config;
3279
+ const keys = Array.from(trail.keys()).sort((a, b) => a - b);
3280
+ const d = keys.map((i, n) => {
3281
+ const cmd = n === 0 || i - keys[n - 1] > 1 ? "M" : "L";
3282
+ return `${cmd}${round2(indexToPx(cfg, i))},${round2(dataToPixelY(trail.get(i)))}`;
3283
+ }).join(" ");
3284
+ path.setAttribute("d", d);
3285
+ syncResetButton();
3286
+ }
3287
+ function clampX(px) {
3288
+ if (!config) return px;
3289
+ return Math.min(config.area.x + config.area.width, Math.max(config.fromX, px));
3262
3290
  }
3263
3291
  function clampY(py) {
3264
3292
  if (!config) return py;
3265
3293
  return Math.min(config.area.y + config.area.height, Math.max(config.area.y, py));
3266
3294
  }
3295
+ function paintSegment(x0, y0, x1, y1) {
3296
+ if (!config) return;
3297
+ const i0 = pxToIndex(config, x0);
3298
+ const i1 = pxToIndex(config, x1);
3299
+ const lo = Math.max(0, Math.ceil(Math.min(i0, i1)));
3300
+ const hi = Math.min(TRAIL_RESOLUTION, Math.floor(Math.max(i0, i1)));
3301
+ const span = i1 - i0;
3302
+ for (let i = lo; i <= hi; i++) {
3303
+ const t = span === 0 ? 1 : (i - i0) / span;
3304
+ trail.set(i, pixelYToData(y0 + t * (y1 - y0)));
3305
+ }
3306
+ const end = Math.min(TRAIL_RESOLUTION, Math.max(0, Math.round(i1)));
3307
+ trail.set(end, pixelYToData(y1));
3308
+ }
3267
3309
  function wirePointerEvents(svg) {
3268
3310
  const overlay = svg.querySelector("[data-ydi-overlay]");
3269
3311
  if (!overlay) return () => {
3270
3312
  };
3271
- let dragging = false;
3272
- const toSvgCoords = (clientX, clientY) => {
3313
+ let last = null;
3314
+ if (activePointer !== null) {
3315
+ try {
3316
+ overlay.setPointerCapture?.(activePointer);
3317
+ } catch {
3318
+ activePointer = null;
3319
+ }
3320
+ }
3321
+ const toSvgPoint = (clientX, clientY) => {
3273
3322
  const svgRect = svg.getBoundingClientRect();
3274
3323
  const viewBox = svg.viewBox?.baseVal;
3275
3324
  const scaleX = viewBox?.width && svgRect.width ? viewBox.width / svgRect.width : 1;
3276
3325
  const scaleY = viewBox?.height && svgRect.height ? viewBox.height / svgRect.height : 1;
3277
3326
  return {
3278
- svgX: (clientX - svgRect.left) * scaleX,
3279
- svgY: (clientY - svgRect.top) * scaleY
3327
+ x: clampX((clientX - svgRect.left) * scaleX),
3328
+ y: clampY((clientY - svgRect.top) * scaleY)
3280
3329
  };
3281
3330
  };
3282
- const paintAt = (clientX, clientY) => {
3283
- const { svgX, svgY } = toSvgCoords(clientX, clientY);
3284
- const snapped = snapToNearestSample(svgX);
3285
- if (snapped === null) return;
3286
- guessByX.set(snapped, clampY(svgY));
3287
- redrawGuessPath();
3331
+ const nearAnchor = (p) => {
3332
+ if (!config?.anchor) return false;
3333
+ if (p.x - config.fromX <= START_SLOP_PX) return true;
3334
+ return config.samples.length > 0 && p.x <= config.samples[0].px;
3288
3335
  };
3289
- const handleMouseDown = (e) => {
3290
- const me = e;
3291
- dragging = true;
3292
- paintAt(me.clientX, me.clientY);
3336
+ const endStroke = () => {
3337
+ activePointer = null;
3338
+ last = null;
3293
3339
  };
3294
- const handleMouseMove = (e) => {
3295
- if (!dragging) return;
3296
- const me = e;
3297
- paintAt(me.clientX, me.clientY);
3340
+ const handleDown = (e) => {
3341
+ const pe = e;
3342
+ if (pe.button !== 0) return;
3343
+ if (activePointer !== null && activePointer !== pe.pointerId && overlay.hasPointerCapture?.(activePointer)) {
3344
+ return;
3345
+ }
3346
+ if (pe.cancelable) pe.preventDefault();
3347
+ activePointer = pe.pointerId;
3348
+ overlay.setPointerCapture?.(pe.pointerId);
3349
+ live.textContent = "";
3350
+ const p = toSvgPoint(pe.clientX, pe.clientY);
3351
+ const anchor = config?.anchor;
3352
+ if (trail.size === 0 && anchor && nearAnchor(p)) {
3353
+ trail.set(0, pixelYToData(anchor.y));
3354
+ if (p.x > anchor.x) {
3355
+ paintSegment(anchor.x, anchor.y, p.x, p.y);
3356
+ last = p;
3357
+ } else {
3358
+ last = { x: anchor.x, y: anchor.y };
3359
+ }
3360
+ } else {
3361
+ paintSegment(p.x, p.y, p.x, p.y);
3362
+ last = p;
3363
+ }
3364
+ redrawGuessPath();
3298
3365
  };
3299
- const handleMouseUp = () => {
3300
- dragging = false;
3366
+ const handleMove = (e) => {
3367
+ const pe = e;
3368
+ if (activePointer !== pe.pointerId) return;
3369
+ if (pe.buttons === 0) {
3370
+ endStroke();
3371
+ return;
3372
+ }
3373
+ if (pe.cancelable) pe.preventDefault();
3374
+ const coalesced = pe.getCoalescedEvents?.() ?? [];
3375
+ const events = coalesced.length > 0 ? coalesced : [pe];
3376
+ for (const ev of events) {
3377
+ const p = toSvgPoint(ev.clientX, ev.clientY);
3378
+ const from = last ?? p;
3379
+ paintSegment(from.x, from.y, p.x, p.y);
3380
+ last = p;
3381
+ }
3382
+ redrawGuessPath();
3301
3383
  };
3302
- const handleTouchStart = (e) => {
3303
- const te = e;
3304
- if (te.touches.length === 0) return;
3305
- if (te.cancelable) te.preventDefault();
3306
- dragging = true;
3307
- paintAt(te.touches[0].clientX, te.touches[0].clientY);
3384
+ const handleUp = (e) => {
3385
+ const pe = e;
3386
+ if (activePointer !== pe.pointerId) return;
3387
+ endStroke();
3308
3388
  };
3309
- const handleTouchMove = (e) => {
3310
- if (!dragging) return;
3311
- const te = e;
3312
- if (te.touches.length === 0) return;
3313
- if (te.cancelable) te.preventDefault();
3314
- paintAt(te.touches[0].clientX, te.touches[0].clientY);
3389
+ const handleLostCapture = () => {
3390
+ endStroke();
3315
3391
  };
3316
- const handleTouchEnd = () => {
3317
- dragging = false;
3318
- };
3319
- overlay.addEventListener("mousedown", handleMouseDown);
3320
- document.addEventListener("mousemove", handleMouseMove);
3321
- document.addEventListener("mouseup", handleMouseUp);
3322
- overlay.addEventListener("touchstart", handleTouchStart, { passive: false });
3323
- overlay.addEventListener("touchmove", handleTouchMove, { passive: false });
3324
- overlay.addEventListener("touchend", handleTouchEnd);
3325
- overlay.addEventListener("touchcancel", handleTouchEnd);
3392
+ overlay.addEventListener("pointerdown", handleDown);
3393
+ overlay.addEventListener("pointermove", handleMove);
3394
+ overlay.addEventListener("pointerup", handleUp);
3395
+ overlay.addEventListener("pointercancel", handleUp);
3396
+ overlay.addEventListener("lostpointercapture", handleLostCapture);
3326
3397
  return () => {
3327
- overlay.removeEventListener("mousedown", handleMouseDown);
3328
- document.removeEventListener("mousemove", handleMouseMove);
3329
- document.removeEventListener("mouseup", handleMouseUp);
3330
- overlay.removeEventListener("touchstart", handleTouchStart);
3331
- overlay.removeEventListener("touchmove", handleTouchMove);
3332
- overlay.removeEventListener("touchend", handleTouchEnd);
3333
- overlay.removeEventListener("touchcancel", handleTouchEnd);
3398
+ overlay.removeEventListener("pointerdown", handleDown);
3399
+ overlay.removeEventListener("pointermove", handleMove);
3400
+ overlay.removeEventListener("pointerup", handleUp);
3401
+ overlay.removeEventListener("pointercancel", handleUp);
3402
+ overlay.removeEventListener("lostpointercapture", handleLostCapture);
3334
3403
  };
3335
3404
  }
3336
3405
  const root = document.createElement("div");
@@ -3339,10 +3408,21 @@ function createYouDrawIt(options) {
3339
3408
  root.style.position = "absolute";
3340
3409
  const prompt = document.createElement("div");
3341
3410
  prompt.className = "oc-ydi-prompt";
3411
+ const resetButton = document.createElement("button");
3412
+ resetButton.type = "button";
3413
+ resetButton.className = "oc-ydi-reset-button";
3414
+ resetButton.hidden = true;
3342
3415
  const revealButton = document.createElement("button");
3343
3416
  revealButton.type = "button";
3344
3417
  revealButton.className = "oc-ydi-reveal-button";
3345
- root.append(prompt, revealButton);
3418
+ const actions = document.createElement("div");
3419
+ actions.className = "oc-ydi-actions";
3420
+ actions.append(resetButton, revealButton);
3421
+ const live = document.createElement("span");
3422
+ live.className = "oc-sr-only";
3423
+ applySrOnlyStyles(live);
3424
+ live.setAttribute("aria-live", "polite");
3425
+ root.append(prompt, actions, live);
3346
3426
  container.style.position = container.style.position || "relative";
3347
3427
  container.appendChild(root);
3348
3428
  function reduceMotion() {
@@ -3360,14 +3440,48 @@ function createYouDrawIt(options) {
3360
3440
  const t = (py - inv.topPixel) / span;
3361
3441
  return inv.topData + t * (inv.bottomData - inv.topData);
3362
3442
  }
3443
+ function dataToPixelY(value) {
3444
+ if (!config) return value;
3445
+ const inv = config.yInvert;
3446
+ if (!inv) {
3447
+ const area = config.area;
3448
+ return area.y + (1 - value) * area.height;
3449
+ }
3450
+ const dataSpan = inv.bottomData - inv.topData;
3451
+ if (dataSpan === 0) return inv.topPixel;
3452
+ const t = (value - inv.topData) / dataSpan;
3453
+ return inv.topPixel + t * (inv.bottomPixel - inv.topPixel);
3454
+ }
3363
3455
  function getGuessData() {
3364
- if (!config) return [];
3365
- const valueByPx = new Map(config.samples.map((s) => [s.px, s.xValue]));
3366
- return Array.from(guessByX.entries()).sort((a, b) => a[0] - b[0]).map(([px, py]) => ({ x: valueByPx.get(px) ?? px, y: pixelYToData(py) }));
3456
+ if (!config || trail.size === 0) return [];
3457
+ const cfg = config;
3458
+ const keys = Array.from(trail.keys()).sort((a, b) => a - b);
3459
+ const first = keys[0];
3460
+ const lastKey = keys[keys.length - 1];
3461
+ const out = [];
3462
+ for (const sample of cfg.samples) {
3463
+ const i = pxToIndex(cfg, sample.px);
3464
+ if (i < first - 0.5 || i > lastKey + 0.5) continue;
3465
+ let hiPos = keys.findIndex((k) => k >= i);
3466
+ if (hiPos === -1) hiPos = keys.length - 1;
3467
+ const loPos = hiPos > 0 && keys[hiPos] > i ? hiPos - 1 : hiPos;
3468
+ const k0 = keys[loPos];
3469
+ const k1 = keys[hiPos];
3470
+ if (k1 - k0 > 1) continue;
3471
+ const v0 = trail.get(k0);
3472
+ const v1 = trail.get(k1);
3473
+ const t = k1 === k0 ? 0 : (i - k0) / (k1 - k0);
3474
+ out.push({ x: sample.xValue, y: v0 + t * (v1 - v0) });
3475
+ }
3476
+ return out;
3477
+ }
3478
+ function syncResetButton() {
3479
+ resetButton.hidden = revealed || trail.size === 0;
3367
3480
  }
3368
3481
  function doReveal() {
3369
3482
  if (destroyed || revealed || !svgEl || !config) return;
3370
3483
  revealed = true;
3484
+ activePointer = null;
3371
3485
  const clipRect = svgEl.querySelector("[data-ydi-clip-rect]");
3372
3486
  if (clipRect) {
3373
3487
  if (reduceMotion()) {
@@ -3386,9 +3500,30 @@ function createYouDrawIt(options) {
3386
3500
  root.classList.add("oc-ydi-revealed");
3387
3501
  prompt.textContent = "";
3388
3502
  revealButton.disabled = true;
3503
+ syncResetButton();
3389
3504
  onReveal?.(getGuessData());
3390
3505
  }
3506
+ function doReset() {
3507
+ if (destroyed) return;
3508
+ revealed = false;
3509
+ activePointer = null;
3510
+ trail.clear();
3511
+ revealButton.disabled = false;
3512
+ root.classList.remove("oc-ydi-revealed");
3513
+ syncResetButton();
3514
+ if (config && svgEl) {
3515
+ render(config, svgEl);
3516
+ prompt.textContent = config.prompt;
3517
+ cleanupPointerEvents?.();
3518
+ cleanupPointerEvents = wirePointerEvents(svgEl);
3519
+ }
3520
+ }
3391
3521
  revealButton.addEventListener("click", doReveal);
3522
+ resetButton.addEventListener("click", () => {
3523
+ doReset();
3524
+ revealButton.focus();
3525
+ live.textContent = "Drawing cleared";
3526
+ });
3392
3527
  return {
3393
3528
  update(cfg, svg) {
3394
3529
  if (destroyed) return;
@@ -3403,6 +3538,9 @@ function createYouDrawIt(options) {
3403
3538
  revealButton.setAttribute("aria-label", cfg.revealLabel);
3404
3539
  revealButton.style.minHeight = `${MIN_TOUCH_TARGET}px`;
3405
3540
  revealButton.disabled = revealed;
3541
+ resetButton.textContent = cfg.resetLabel;
3542
+ resetButton.style.minHeight = `${MIN_TOUCH_TARGET}px`;
3543
+ syncResetButton();
3406
3544
  if (revealed) root.classList.add("oc-ydi-revealed");
3407
3545
  else root.classList.remove("oc-ydi-revealed");
3408
3546
  const viewBox = svg.viewBox?.baseVal;
@@ -3421,6 +3559,7 @@ function createYouDrawIt(options) {
3421
3559
  }
3422
3560
  },
3423
3561
  hide() {
3562
+ activePointer = null;
3424
3563
  root.style.display = "none";
3425
3564
  svgEl?.querySelector("[data-you-draw-it]")?.remove();
3426
3565
  cleanupPointerEvents?.();
@@ -3430,17 +3569,7 @@ function createYouDrawIt(options) {
3430
3569
  doReveal();
3431
3570
  },
3432
3571
  reset() {
3433
- if (destroyed) return;
3434
- revealed = false;
3435
- guessByX.clear();
3436
- revealButton.disabled = false;
3437
- root.classList.remove("oc-ydi-revealed");
3438
- if (config && svgEl) {
3439
- render(config, svgEl);
3440
- prompt.textContent = config.prompt;
3441
- cleanupPointerEvents?.();
3442
- cleanupPointerEvents = wirePointerEvents(svgEl);
3443
- }
3572
+ doReset();
3444
3573
  },
3445
3574
  get isRevealed() {
3446
3575
  return revealed;
@@ -4140,7 +4269,7 @@ function wireAnnotationDrag(svg, specAnnotations, onAnnotationEdit, onEdit, setD
4140
4269
  };
4141
4270
  }
4142
4271
  function wireConnectorEndpointDrag(svg, specAnnotations, onEdit, setDragging) {
4143
- const SVG_NS4 = "http://www.w3.org/2000/svg";
4272
+ const SVG_NS5 = "http://www.w3.org/2000/svg";
4144
4273
  const cleanups = [];
4145
4274
  const annotationGroups = svg.querySelectorAll(".oc-annotation-text");
4146
4275
  for (const el of annotationGroups) {
@@ -4178,7 +4307,7 @@ function wireConnectorEndpointDrag(svg, specAnnotations, onEdit, setDragging) {
4178
4307
  const createdHandles = [];
4179
4308
  for (const ep of endpoints) {
4180
4309
  if (!Number.isFinite(ep.cx) || !Number.isFinite(ep.cy)) continue;
4181
- const handleEl = document.createElementNS(SVG_NS4, "circle");
4310
+ const handleEl = document.createElementNS(SVG_NS5, "circle");
4182
4311
  handleEl.setAttribute("class", "oc-connector-handle");
4183
4312
  handleEl.setAttribute("data-endpoint", ep.name);
4184
4313
  handleEl.setAttribute("cx", String(ep.cx));
@@ -4455,7 +4584,7 @@ function wireSeriesLabelDrag(svg, spec, onEdit, setDragging) {
4455
4584
  function wireAnchorDrag(svg, specAnnotations, layout, onEdit, setDragging) {
4456
4585
  if (!onEdit || !layout.xInvert || !layout.yInvert) return () => {
4457
4586
  };
4458
- const SVG_NS4 = "http://www.w3.org/2000/svg";
4587
+ const SVG_NS5 = "http://www.w3.org/2000/svg";
4459
4588
  const cleanups = [];
4460
4589
  const annotationGroups = svg.querySelectorAll(".oc-annotation-text");
4461
4590
  for (const el of annotationGroups) {
@@ -4470,7 +4599,7 @@ function wireAnchorDrag(svg, specAnnotations, layout, onEdit, setDragging) {
4470
4599
  if (!resolved?.dot) continue;
4471
4600
  const anchorX = resolved.dot.x;
4472
4601
  const anchorY = resolved.dot.y;
4473
- const handleEl = document.createElementNS(SVG_NS4, "circle");
4602
+ const handleEl = document.createElementNS(SVG_NS5, "circle");
4474
4603
  handleEl.setAttribute("class", "oc-anchor-handle");
4475
4604
  handleEl.setAttribute("cx", String(anchorX));
4476
4605
  handleEl.setAttribute("cy", String(anchorY));
@@ -4843,21 +4972,6 @@ function wireLegendInteraction(svg, _layout, toggleSeries, onLegendToggle, onEdi
4843
4972
 
4844
4973
  // src/interactions/selection.ts
4845
4974
  import { elementRef as elementRef2 } from "@opendata-ai/openchart-core";
4846
-
4847
- // src/dom-helpers.ts
4848
- function applySrOnlyStyles(el) {
4849
- el.style.position = "absolute";
4850
- el.style.width = "1px";
4851
- el.style.height = "1px";
4852
- el.style.padding = "0";
4853
- el.style.margin = "-1px";
4854
- el.style.overflow = "clip";
4855
- el.style.clipPath = "inset(50%)";
4856
- el.style.whiteSpace = "nowrap";
4857
- el.style.borderWidth = "0";
4858
- }
4859
-
4860
- // src/interactions/selection.ts
4861
4975
  function findElementByRef(svg, ref) {
4862
4976
  switch (ref.type) {
4863
4977
  case "annotation": {
@@ -6889,6 +7003,42 @@ function cameraForTarget(layout, target) {
6889
7003
  const cam = fitTarget(target, vb);
6890
7004
  return { ...cam, k: Math.max(1, Math.min(40, cam.k)) };
6891
7005
  }
7006
+ var DEFAULT_MAX_ZOOM = 12;
7007
+ function resolveMapZoom(zoom) {
7008
+ if (!zoom) return null;
7009
+ const cfg = zoom === true ? {} : zoom;
7010
+ const max = cfg.maxZoom;
7011
+ return {
7012
+ maxZoom: typeof max === "number" && Number.isFinite(max) ? Math.max(1, max) : DEFAULT_MAX_ZOOM,
7013
+ controls: cfg.controls !== false
7014
+ };
7015
+ }
7016
+ function clampMapCamera(camera, vb, maxZoom) {
7017
+ const k = Math.min(Math.max(camera.k, 1), Math.max(1, maxZoom));
7018
+ const halfW = vb.width / (2 * k);
7019
+ const halfH = vb.height / (2 * k);
7020
+ return {
7021
+ cx: Math.min(Math.max(camera.cx, halfW), vb.width - halfW),
7022
+ cy: Math.min(Math.max(camera.cy, halfH), vb.height - halfH),
7023
+ k
7024
+ };
7025
+ }
7026
+ function zoomCameraAt(camera, vb, k, pivotX, pivotY) {
7027
+ const ox = pivotX - vb.width / 2;
7028
+ const oy = pivotY - vb.height / 2;
7029
+ const px = camera.cx + ox / camera.k;
7030
+ const py = camera.cy + oy / camera.k;
7031
+ return { cx: px - ox / k, cy: py - oy / k, k };
7032
+ }
7033
+ function panCamera(camera, dx, dy) {
7034
+ return { cx: camera.cx - dx / camera.k, cy: camera.cy - dy / camera.k, k: camera.k };
7035
+ }
7036
+ function wheelZoomFactor(e) {
7037
+ const perUnit = e.deltaMode === 1 ? 0.05 : e.deltaMode === 2 ? 1 : 2e-3;
7038
+ let exp = -e.deltaY * perUnit;
7039
+ if (e.ctrlKey && e.deltaMode === 0 && Math.abs(e.deltaY) < 50) exp *= 5;
7040
+ return 2 ** Math.max(-1, Math.min(1, exp));
7041
+ }
6892
7042
  function applyMapCamera(svg, camera, layout) {
6893
7043
  const cameraGroup = svg.querySelector("[data-oc-map-camera]");
6894
7044
  if (!cameraGroup) return;
@@ -7410,6 +7560,468 @@ function runMapFillTransition(svg, prev, opts) {
7410
7560
  };
7411
7561
  }
7412
7562
 
7563
+ // src/map-zoom.ts
7564
+ var ZOOM_STEP = 2;
7565
+ var KEY_PAN_FRACTION = 0.1;
7566
+ var DRAG_THRESHOLD = 3;
7567
+ var HINT_MS = 1500;
7568
+ var EPS = 1e-3;
7569
+ var SVG_NS4 = "http://www.w3.org/2000/svg";
7570
+ var clipIdCounter = 0;
7571
+ var ICONS = {
7572
+ in: '<path d="M8 3v10M3 8h10"/>',
7573
+ out: '<path d="M3 8h10"/>',
7574
+ reset: '<path d="M3 8a5 5 0 1 0 1.6-3.7"/><path d="M3 2.5v2.8h2.8"/>'
7575
+ };
7576
+ function isMacLike() {
7577
+ if (typeof navigator === "undefined") return false;
7578
+ return /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent || "");
7579
+ }
7580
+ function createMapZoomController(host) {
7581
+ const { container } = host;
7582
+ const clipId = `oc-map-zoom-clip-${++clipIdCounter}`;
7583
+ let overlay = null;
7584
+ let buttons = null;
7585
+ let hint = null;
7586
+ let hintTimer = null;
7587
+ let drag = null;
7588
+ let suppressClick = false;
7589
+ let pinch = null;
7590
+ let touchPan = null;
7591
+ let gestureStartK = null;
7592
+ function vb() {
7593
+ return host.getLayout().mapSize;
7594
+ }
7595
+ function clamp(camera) {
7596
+ const cfg = host.getConfig();
7597
+ return clampMapCamera(camera, vb(), cfg?.maxZoom ?? 1);
7598
+ }
7599
+ function clampK(k) {
7600
+ const cfg = host.getConfig();
7601
+ return Math.min(Math.max(k, 1), cfg?.maxZoom ?? 1);
7602
+ }
7603
+ function svgRect() {
7604
+ const svg = host.getSvg();
7605
+ let rect = svg?.getBoundingClientRect();
7606
+ if (!rect || !rect.width || !rect.height) rect = container.getBoundingClientRect();
7607
+ if (!rect.width || !rect.height) return null;
7608
+ return rect;
7609
+ }
7610
+ function toMapFrame(clientX, clientY) {
7611
+ const rect = svgRect();
7612
+ if (!rect) return null;
7613
+ const layout = host.getLayout();
7614
+ const x = (clientX - rect.left) * (layout.width / rect.width) - layout.area.x;
7615
+ const y = (clientY - rect.top) * (layout.height / rect.height) - layout.area.y;
7616
+ const inside = x >= 0 && y >= 0 && x <= layout.area.width && y <= layout.area.height;
7617
+ return { x, y, inside };
7618
+ }
7619
+ function unitsPerPx() {
7620
+ const rect = svgRect();
7621
+ return rect ? host.getLayout().width / rect.width : 1;
7622
+ }
7623
+ function isZoomed() {
7624
+ return host.getCamera().k > 1 + EPS;
7625
+ }
7626
+ function fromControls(e) {
7627
+ const t = e.target;
7628
+ return !!t?.closest?.(".oc-map-zoom");
7629
+ }
7630
+ function zoomAround(factor, x, y, animate) {
7631
+ const cam = host.getCamera();
7632
+ const next = clamp(zoomCameraAt(cam, vb(), clampK(cam.k * factor), x, y));
7633
+ if (animate) host.animateCamera(next);
7634
+ else host.setCamera(next);
7635
+ }
7636
+ function zoomAtCenter(factor) {
7637
+ const { width, height } = vb();
7638
+ zoomAround(factor, width / 2, height / 2, true);
7639
+ }
7640
+ function showHint() {
7641
+ if (!hint) return;
7642
+ hint.setAttribute("data-visible", "");
7643
+ if (hintTimer) clearTimeout(hintTimer);
7644
+ hintTimer = setTimeout(() => {
7645
+ hint?.removeAttribute("data-visible");
7646
+ hintTimer = null;
7647
+ }, HINT_MS);
7648
+ }
7649
+ function onWheel(e) {
7650
+ if (!host.getConfig() || fromControls(e)) return;
7651
+ const p = toMapFrame(e.clientX, e.clientY);
7652
+ if (!p?.inside) return;
7653
+ if (!e.ctrlKey && !e.metaKey) {
7654
+ if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) showHint();
7655
+ return;
7656
+ }
7657
+ e.preventDefault();
7658
+ hint?.removeAttribute("data-visible");
7659
+ zoomAround(wheelZoomFactor(e), p.x, p.y, false);
7660
+ }
7661
+ function onGestureStart(e) {
7662
+ if (!host.getConfig()) return;
7663
+ e.preventDefault();
7664
+ gestureStartK = host.getCamera().k;
7665
+ }
7666
+ function onGestureChange(e) {
7667
+ if (!host.getConfig() || gestureStartK === null) return;
7668
+ e.preventDefault();
7669
+ if (pinch) return;
7670
+ const g = e;
7671
+ const p = toMapFrame(g.clientX, g.clientY);
7672
+ if (!p) return;
7673
+ const cam = host.getCamera();
7674
+ host.setCamera(clamp(zoomCameraAt(cam, vb(), clampK(gestureStartK * g.scale), p.x, p.y)));
7675
+ }
7676
+ function onGestureEnd(e) {
7677
+ if (gestureStartK === null) return;
7678
+ e.preventDefault();
7679
+ gestureStartK = null;
7680
+ }
7681
+ function onPointerDown(e) {
7682
+ if (e.pointerType === "touch" || e.button !== 0) return;
7683
+ if (!host.getConfig() || fromControls(e) || !isZoomed()) return;
7684
+ const p = toMapFrame(e.clientX, e.clientY);
7685
+ if (!p?.inside) return;
7686
+ drag = {
7687
+ id: e.pointerId,
7688
+ startX: e.clientX,
7689
+ startY: e.clientY,
7690
+ lastX: e.clientX,
7691
+ lastY: e.clientY,
7692
+ moved: false
7693
+ };
7694
+ }
7695
+ function onMouseDown(e) {
7696
+ if (e.button !== 0 || !host.getConfig() || fromControls(e)) return;
7697
+ if (!isZoomed() && e.detail < 2) return;
7698
+ const p = toMapFrame(e.clientX, e.clientY);
7699
+ if (!p?.inside) return;
7700
+ e.preventDefault();
7701
+ host.getSvg()?.focus({ preventScroll: true });
7702
+ }
7703
+ function onPointerMove(e) {
7704
+ if (!drag || e.pointerId !== drag.id) return;
7705
+ if (!drag.moved) {
7706
+ if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < DRAG_THRESHOLD) return;
7707
+ drag.moved = true;
7708
+ const svg = host.getSvg();
7709
+ try {
7710
+ svg?.setPointerCapture(e.pointerId);
7711
+ } catch {
7712
+ }
7713
+ svg?.classList.add("oc-map--panning");
7714
+ host.hideTooltip();
7715
+ }
7716
+ e.preventDefault();
7717
+ const s = unitsPerPx();
7718
+ const dx = (e.clientX - drag.lastX) * s;
7719
+ const dy = (e.clientY - drag.lastY) * s;
7720
+ drag.lastX = e.clientX;
7721
+ drag.lastY = e.clientY;
7722
+ host.setCamera(clamp(panCamera(host.getCamera(), dx, dy)));
7723
+ }
7724
+ function endDrag(e) {
7725
+ if (!drag || e.pointerId !== drag.id) return;
7726
+ const svg = host.getSvg();
7727
+ if (drag.moved) {
7728
+ suppressClick = true;
7729
+ setTimeout(() => {
7730
+ suppressClick = false;
7731
+ }, 0);
7732
+ }
7733
+ try {
7734
+ if (svg?.hasPointerCapture?.(e.pointerId)) svg.releasePointerCapture(e.pointerId);
7735
+ } catch {
7736
+ }
7737
+ svg?.classList.remove("oc-map--panning");
7738
+ drag = null;
7739
+ }
7740
+ function onClickCapture(e) {
7741
+ if (!suppressClick) return;
7742
+ suppressClick = false;
7743
+ e.stopPropagation();
7744
+ e.preventDefault();
7745
+ }
7746
+ function onDblClick(e) {
7747
+ if (!host.getConfig() || fromControls(e)) return;
7748
+ const p = toMapFrame(e.clientX, e.clientY);
7749
+ if (!p?.inside) return;
7750
+ e.preventDefault();
7751
+ zoomAround(e.shiftKey ? 1 / ZOOM_STEP : ZOOM_STEP, p.x, p.y, true);
7752
+ }
7753
+ function touchPoint(t) {
7754
+ return toMapFrame(t.clientX, t.clientY);
7755
+ }
7756
+ function startPinch(e) {
7757
+ const a = touchPoint(e.touches[0]);
7758
+ const b = touchPoint(e.touches[1]);
7759
+ if (!a || !b) return;
7760
+ pinch = {
7761
+ dist: Math.hypot(b.x - a.x, b.y - a.y) || 1,
7762
+ x: (a.x + b.x) / 2,
7763
+ y: (a.y + b.y) / 2
7764
+ };
7765
+ touchPan = null;
7766
+ }
7767
+ function startTouchPan(t) {
7768
+ touchPan = isZoomed() ? { x: t.clientX, y: t.clientY, moved: false } : null;
7769
+ }
7770
+ function onTouchStart(e) {
7771
+ if (!host.getConfig() || fromControls(e)) return;
7772
+ if (e.touches.length >= 2) {
7773
+ const a = touchPoint(e.touches[0]);
7774
+ if (!a?.inside) return;
7775
+ e.preventDefault();
7776
+ host.hideTooltip();
7777
+ startPinch(e);
7778
+ } else if (e.touches.length === 1) {
7779
+ const p = touchPoint(e.touches[0]);
7780
+ if (!p?.inside) return;
7781
+ startTouchPan(e.touches[0]);
7782
+ }
7783
+ }
7784
+ function onTouchMove(e) {
7785
+ if (!host.getConfig()) return;
7786
+ if (pinch && e.touches.length >= 2) {
7787
+ e.preventDefault();
7788
+ const a = touchPoint(e.touches[0]);
7789
+ const b = touchPoint(e.touches[1]);
7790
+ if (!a || !b) return;
7791
+ const dist = Math.hypot(b.x - a.x, b.y - a.y) || 1;
7792
+ const x = (a.x + b.x) / 2;
7793
+ const y = (a.y + b.y) / 2;
7794
+ let cam = host.getCamera();
7795
+ cam = zoomCameraAt(cam, vb(), clampK(cam.k * (dist / pinch.dist)), x, y);
7796
+ cam = panCamera(cam, x - pinch.x, y - pinch.y);
7797
+ host.setCamera(clamp(cam));
7798
+ pinch = { dist, x, y };
7799
+ return;
7800
+ }
7801
+ if (touchPan && e.touches.length === 1) {
7802
+ const t = e.touches[0];
7803
+ if (!touchPan.moved) {
7804
+ if (Math.hypot(t.clientX - touchPan.x, t.clientY - touchPan.y) < DRAG_THRESHOLD) return;
7805
+ touchPan.moved = true;
7806
+ host.hideTooltip();
7807
+ }
7808
+ if (e.cancelable) e.preventDefault();
7809
+ const s = unitsPerPx();
7810
+ const dx = (t.clientX - touchPan.x) * s;
7811
+ const dy = (t.clientY - touchPan.y) * s;
7812
+ touchPan.x = t.clientX;
7813
+ touchPan.y = t.clientY;
7814
+ host.setCamera(clamp(panCamera(host.getCamera(), dx, dy)));
7815
+ }
7816
+ }
7817
+ function onTouchEnd(e) {
7818
+ if (e.touches.length >= 2) {
7819
+ startPinch(e);
7820
+ return;
7821
+ }
7822
+ pinch = null;
7823
+ if (e.touches.length === 1) {
7824
+ startTouchPan(e.touches[0]);
7825
+ if (touchPan) touchPan.moved = true;
7826
+ } else {
7827
+ touchPan = null;
7828
+ }
7829
+ }
7830
+ function onKeyDown(e) {
7831
+ if (!host.getConfig() || e.target !== host.getSvg()) return;
7832
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
7833
+ const { width, height } = vb();
7834
+ switch (e.key) {
7835
+ case "+":
7836
+ case "=":
7837
+ zoomAtCenter(ZOOM_STEP);
7838
+ break;
7839
+ case "-":
7840
+ case "_":
7841
+ zoomAtCenter(1 / ZOOM_STEP);
7842
+ break;
7843
+ case "0":
7844
+ host.reset();
7845
+ break;
7846
+ case "ArrowLeft":
7847
+ case "ArrowRight":
7848
+ case "ArrowUp":
7849
+ case "ArrowDown": {
7850
+ if (!isZoomed()) return;
7851
+ const dx = e.key === "ArrowLeft" ? width * KEY_PAN_FRACTION : e.key === "ArrowRight" ? -width * KEY_PAN_FRACTION : 0;
7852
+ const dy = e.key === "ArrowUp" ? height * KEY_PAN_FRACTION : e.key === "ArrowDown" ? -height * KEY_PAN_FRACTION : 0;
7853
+ host.setCamera(clamp(panCamera(host.getCamera(), dx, dy)));
7854
+ break;
7855
+ }
7856
+ default:
7857
+ return;
7858
+ }
7859
+ e.preventDefault();
7860
+ }
7861
+ function makeButton(kind, label) {
7862
+ const btn = document.createElement("button");
7863
+ btn.type = "button";
7864
+ btn.className = "oc-map-zoom-btn";
7865
+ btn.setAttribute("data-zoom", kind);
7866
+ btn.setAttribute("aria-label", label);
7867
+ btn.title = label;
7868
+ btn.innerHTML = `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${ICONS[kind]}</svg>`;
7869
+ btn.addEventListener("click", (e) => {
7870
+ e.stopPropagation();
7871
+ if (kind === "in") zoomAtCenter(ZOOM_STEP);
7872
+ else if (kind === "out") zoomAtCenter(1 / ZOOM_STEP);
7873
+ else host.reset();
7874
+ });
7875
+ return btn;
7876
+ }
7877
+ function ensureOverlay(showControls) {
7878
+ if (!overlay) {
7879
+ overlay = document.createElement("div");
7880
+ overlay.className = "oc-map-zoom";
7881
+ hint = document.createElement("div");
7882
+ hint.className = "oc-map-zoom-hint";
7883
+ hint.setAttribute("aria-hidden", "true");
7884
+ hint.textContent = isMacLike() ? "Use \u2318 + scroll to zoom the map" : "Use Ctrl + scroll to zoom the map";
7885
+ overlay.appendChild(hint);
7886
+ if (getComputedStyle(container).position === "static") {
7887
+ container.style.position = "relative";
7888
+ }
7889
+ container.appendChild(overlay);
7890
+ }
7891
+ if (showControls && !buttons) {
7892
+ const group = document.createElement("div");
7893
+ group.className = "oc-map-zoom-controls";
7894
+ group.setAttribute("role", "group");
7895
+ group.setAttribute("aria-label", "Map zoom");
7896
+ buttons = {
7897
+ in: makeButton("in", "Zoom in"),
7898
+ out: makeButton("out", "Zoom out"),
7899
+ reset: makeButton("reset", "Reset zoom")
7900
+ };
7901
+ group.append(buttons.in, buttons.out, buttons.reset);
7902
+ overlay.appendChild(group);
7903
+ } else if (!showControls && buttons) {
7904
+ buttons.in.parentElement?.remove();
7905
+ buttons = null;
7906
+ }
7907
+ }
7908
+ function removeOverlay() {
7909
+ if (hintTimer) clearTimeout(hintTimer);
7910
+ hintTimer = null;
7911
+ overlay?.remove();
7912
+ overlay = null;
7913
+ buttons = null;
7914
+ hint = null;
7915
+ }
7916
+ function placeOverlay(layout) {
7917
+ if (!overlay || !layout.width || !layout.height) return;
7918
+ const { area, width, height } = layout;
7919
+ overlay.style.left = `${area.x / width * 100}%`;
7920
+ overlay.style.top = `${area.y / height * 100}%`;
7921
+ overlay.style.width = `${area.width / width * 100}%`;
7922
+ overlay.style.height = `${area.height / height * 100}%`;
7923
+ }
7924
+ function ensureClip(svg, layout) {
7925
+ if (svg.querySelector(`#${clipId}`)) return;
7926
+ let defs = svg.querySelector("defs");
7927
+ if (!defs) {
7928
+ defs = document.createElementNS(SVG_NS4, "defs");
7929
+ svg.insertBefore(defs, svg.firstChild);
7930
+ }
7931
+ const clip = document.createElementNS(SVG_NS4, "clipPath");
7932
+ clip.setAttribute("id", clipId);
7933
+ const rect = document.createElementNS(SVG_NS4, "rect");
7934
+ rect.setAttribute("width", String(layout.area.width));
7935
+ rect.setAttribute("height", String(layout.area.height));
7936
+ clip.appendChild(rect);
7937
+ defs.appendChild(clip);
7938
+ }
7939
+ function onRender(restoreFocus) {
7940
+ const cfg = host.getConfig();
7941
+ const svg = host.getSvg();
7942
+ if (!cfg || !svg) {
7943
+ removeOverlay();
7944
+ return;
7945
+ }
7946
+ const layout = host.getLayout();
7947
+ svg.setAttribute("tabindex", "0");
7948
+ svg.setAttribute("aria-keyshortcuts", "+ - 0 ArrowUp ArrowDown ArrowLeft ArrowRight");
7949
+ ensureClip(svg, layout);
7950
+ ensureOverlay(cfg.controls);
7951
+ placeOverlay(layout);
7952
+ if (restoreFocus) svg.focus({ preventScroll: true });
7953
+ onCamera();
7954
+ }
7955
+ function onCamera() {
7956
+ const cfg = host.getConfig();
7957
+ const svg = host.getSvg();
7958
+ if (!cfg || !svg) return;
7959
+ const cam = host.getCamera();
7960
+ const zoomed = cam.k > 1 + EPS;
7961
+ const { width, height } = vb();
7962
+ const atFullView = !zoomed && Math.abs(cam.cx - width / 2) < EPS && Math.abs(cam.cy - height / 2) < EPS;
7963
+ const group = svg.querySelector(".oc-map-group");
7964
+ if (zoomed) group?.setAttribute("clip-path", `url(#${clipId})`);
7965
+ else group?.removeAttribute("clip-path");
7966
+ svg.style.touchAction = zoomed ? "none" : "pan-x pan-y";
7967
+ if (zoomed) svg.setAttribute("data-oc-zoomed", "");
7968
+ else svg.removeAttribute("data-oc-zoomed");
7969
+ if (buttons) {
7970
+ setDisabled(buttons.in, cam.k >= cfg.maxZoom - EPS, svg);
7971
+ setDisabled(buttons.out, !zoomed, svg);
7972
+ setDisabled(buttons.reset, atFullView, svg);
7973
+ }
7974
+ }
7975
+ function setDisabled(btn, disabled, svg) {
7976
+ if (btn.disabled === disabled) return;
7977
+ const hadFocus = disabled && document.activeElement === btn;
7978
+ btn.disabled = disabled;
7979
+ if (hadFocus) svg.focus({ preventScroll: true });
7980
+ }
7981
+ const touchOpts = { passive: false };
7982
+ container.addEventListener("wheel", onWheel, { passive: false });
7983
+ container.addEventListener("pointerdown", onPointerDown);
7984
+ container.addEventListener("mousedown", onMouseDown);
7985
+ container.addEventListener("pointermove", onPointerMove);
7986
+ container.addEventListener("pointerup", endDrag);
7987
+ container.addEventListener("pointercancel", endDrag);
7988
+ container.addEventListener("click", onClickCapture, true);
7989
+ container.addEventListener("dblclick", onDblClick);
7990
+ container.addEventListener("touchstart", onTouchStart, touchOpts);
7991
+ container.addEventListener("touchmove", onTouchMove, touchOpts);
7992
+ container.addEventListener("touchend", onTouchEnd);
7993
+ container.addEventListener("touchcancel", onTouchEnd);
7994
+ container.addEventListener("gesturestart", onGestureStart);
7995
+ container.addEventListener("gesturechange", onGestureChange);
7996
+ container.addEventListener("gestureend", onGestureEnd);
7997
+ container.addEventListener("keydown", onKeyDown);
7998
+ return {
7999
+ onRender,
8000
+ onCamera,
8001
+ destroy() {
8002
+ container.removeEventListener("wheel", onWheel);
8003
+ container.removeEventListener("pointerdown", onPointerDown);
8004
+ container.removeEventListener("mousedown", onMouseDown);
8005
+ container.removeEventListener("pointermove", onPointerMove);
8006
+ container.removeEventListener("pointerup", endDrag);
8007
+ container.removeEventListener("pointercancel", endDrag);
8008
+ container.removeEventListener("click", onClickCapture, true);
8009
+ container.removeEventListener("dblclick", onDblClick);
8010
+ container.removeEventListener("touchstart", onTouchStart);
8011
+ container.removeEventListener("touchmove", onTouchMove);
8012
+ container.removeEventListener("touchend", onTouchEnd);
8013
+ container.removeEventListener("touchcancel", onTouchEnd);
8014
+ container.removeEventListener("gesturestart", onGestureStart);
8015
+ container.removeEventListener("gesturechange", onGestureChange);
8016
+ container.removeEventListener("gestureend", onGestureEnd);
8017
+ container.removeEventListener("keydown", onKeyDown);
8018
+ const svg = host.getSvg();
8019
+ svg?.removeAttribute("tabindex");
8020
+ removeOverlay();
8021
+ }
8022
+ };
8023
+ }
8024
+
7413
8025
  // src/map-mount.ts
7414
8026
  function focusSignature(focus) {
7415
8027
  if (!focus) return null;
@@ -7438,6 +8050,7 @@ function restoreFeatureOrder(el) {
7438
8050
  parent.appendChild(el);
7439
8051
  }
7440
8052
  }
8053
+ var USER_ZOOM_MS = 250;
7441
8054
  function prefersReducedMotion2() {
7442
8055
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
7443
8056
  try {
@@ -7460,6 +8073,7 @@ function createGeoMap(container, spec, options) {
7460
8073
  let currentCamera = { cx: 0, cy: 0, k: 1 };
7461
8074
  let currentFocusIds = null;
7462
8075
  let cameraTween = null;
8076
+ let zoomController = null;
7463
8077
  let isFirstRender = true;
7464
8078
  let fontsReloadPending = false;
7465
8079
  container.classList.add("oc-map-root");
@@ -7660,6 +8274,18 @@ function createGeoMap(container, spec, options) {
7660
8274
  }
7661
8275
  return cameraForTarget(currentLayout, null);
7662
8276
  }
8277
+ function commitCamera(cam) {
8278
+ currentCamera = cam;
8279
+ if (svgElement) applyMapCamera(svgElement, cam, currentLayout);
8280
+ zoomController?.onCamera();
8281
+ options?.onCameraChange?.({ ...cam });
8282
+ }
8283
+ function cancelCameraTween() {
8284
+ if (cameraTween) {
8285
+ cameraTween.cancel();
8286
+ cameraTween = null;
8287
+ }
8288
+ }
7663
8289
  function animateCamera(target, duration) {
7664
8290
  if (cameraTween) cameraTween.cancel();
7665
8291
  cameraTween = createTween2({
@@ -7668,20 +8294,64 @@ function createGeoMap(container, spec, options) {
7668
8294
  duration,
7669
8295
  ease: easingFns.easeInOutCubic,
7670
8296
  onFrame(cam) {
7671
- currentCamera = cam;
7672
- if (svgElement) applyMapCamera(svgElement, cam, currentLayout);
8297
+ commitCamera(cam);
7673
8298
  }
7674
8299
  });
7675
8300
  cameraTween.to(target);
7676
8301
  }
7677
8302
  function driveCamera(cam, duration) {
7678
8303
  if (duration === 0 || prefersReducedMotion2()) {
7679
- currentCamera = cam;
7680
- if (svgElement) applyMapCamera(svgElement, cam, currentLayout);
8304
+ cancelCameraTween();
8305
+ commitCamera(cam);
7681
8306
  } else {
7682
8307
  animateCamera(cam, duration);
7683
8308
  }
7684
8309
  }
8310
+ function resetCamera(duration) {
8311
+ currentFocusIds = null;
8312
+ const cam = cameraForTarget(currentLayout, null);
8313
+ driveCamera(cam, duration);
8314
+ applyFocusDim(null);
8315
+ updateA11yLive(null);
8316
+ }
8317
+ function rescaleFreeCamera(prev) {
8318
+ const next = currentLayout.mapSize;
8319
+ if (!prev.width || !prev.height || prev.width === next.width && prev.height === next.height) {
8320
+ return;
8321
+ }
8322
+ const scaled = {
8323
+ cx: currentCamera.cx * next.width / prev.width,
8324
+ cy: currentCamera.cy * next.height / prev.height,
8325
+ k: currentCamera.k
8326
+ };
8327
+ const zoom = resolveMapZoom(currentSpec.geo?.zoom);
8328
+ currentCamera = zoom ? clampMapCamera(scaled, next, zoom.maxZoom) : scaled;
8329
+ }
8330
+ function syncZoomController(restoreFocus) {
8331
+ const zoom = resolveMapZoom(currentSpec.geo?.zoom);
8332
+ if (!zoom && !zoomController) return;
8333
+ if (!zoomController) {
8334
+ zoomController = createMapZoomController({
8335
+ container,
8336
+ getSvg: () => svgElement,
8337
+ getLayout: () => currentLayout,
8338
+ getConfig: () => resolveMapZoom(currentSpec.geo?.zoom),
8339
+ getCamera: () => ({ ...currentCamera }),
8340
+ setCamera(cam) {
8341
+ cancelCameraTween();
8342
+ currentFocusIds = null;
8343
+ commitCamera(cam);
8344
+ },
8345
+ animateCamera(cam) {
8346
+ currentFocusIds = null;
8347
+ driveCamera(cam, USER_ZOOM_MS);
8348
+ },
8349
+ reset: () => resetCamera(USER_ZOOM_MS),
8350
+ hideTooltip: () => tooltipManager?.hide()
8351
+ });
8352
+ }
8353
+ zoomController.onRender(restoreFocus);
8354
+ }
7685
8355
  function updateA11yLive(ids) {
7686
8356
  let liveEl = container.querySelector(".oc-map-live");
7687
8357
  if (!liveEl) {
@@ -7728,6 +8398,7 @@ function createGeoMap(container, spec, options) {
7728
8398
  fillTransitionHandle = null;
7729
8399
  }
7730
8400
  const oldSvg = svgElement;
8401
+ const oldSvgHadFocus = !!oldSvg && document.activeElement === oldSvg;
7731
8402
  const newSvg = renderMapSVG(currentLayout, { animate });
7732
8403
  if (oldSvg) {
7733
8404
  if (cleanupTooltipEvents) {
@@ -7779,6 +8450,7 @@ function createGeoMap(container, spec, options) {
7779
8450
  }
7780
8451
  }
7781
8452
  isFirstRender = false;
8453
+ syncZoomController(oldSvgHadFocus);
7782
8454
  }
7783
8455
  function update(newSpec) {
7784
8456
  currentSpec = newSpec;
@@ -7791,7 +8463,9 @@ function createGeoMap(container, spec, options) {
7791
8463
  const prevPaint = canAnimate ? captureMapSnapshot(svgElement) : null;
7792
8464
  const prevFocusIds = currentFocusIds ? currentFocusIds.map(String).sort().join(",") : null;
7793
8465
  const prevFocusSig = focusSignature(currentLayout.focus);
8466
+ const prevMapSize = currentLayout.mapSize;
7794
8467
  currentLayout = compile();
8468
+ if (!currentFocusIds) rescaleFreeCamera(prevMapSize);
7795
8469
  render();
7796
8470
  if (canAnimate && prevPaint && (prevPaint.fills.size > 0 || prevPaint.points.size > 0) && svgElement && currentLayout.animation?.update) {
7797
8471
  fillTransitionHandle = runMapFillTransition(svgElement, prevPaint, {
@@ -7820,11 +8494,10 @@ function createGeoMap(container, spec, options) {
7820
8494
  pendingResize = true;
7821
8495
  return;
7822
8496
  }
7823
- if (cameraTween) {
7824
- cameraTween.cancel();
7825
- cameraTween = null;
7826
- }
8497
+ cancelCameraTween();
8498
+ const prevMapSize = currentLayout.mapSize;
7827
8499
  currentLayout = compile();
8500
+ if (!currentFocusIds) rescaleFreeCamera(prevMapSize);
7828
8501
  render();
7829
8502
  if (currentFocusIds && currentFocusIds.length > 0) {
7830
8503
  currentCamera = resolveCamera();
@@ -7835,6 +8508,7 @@ function createGeoMap(container, spec, options) {
7835
8508
  if (svgElement) applyMapCamera(svgElement, currentCamera, currentLayout);
7836
8509
  }
7837
8510
  applyFocusDim(currentFocusIds);
8511
+ zoomController?.onCamera();
7838
8512
  }
7839
8513
  function exportChart(format, options_) {
7840
8514
  if (!svgElement) return "";
@@ -7863,9 +8537,10 @@ function createGeoMap(container, spec, options) {
7863
8537
  fillTransitionHandle.cancel();
7864
8538
  fillTransitionHandle = null;
7865
8539
  }
7866
- if (cameraTween) {
7867
- cameraTween.cancel();
7868
- cameraTween = null;
8540
+ cancelCameraTween();
8541
+ if (zoomController) {
8542
+ zoomController.destroy();
8543
+ zoomController = null;
7869
8544
  }
7870
8545
  if (cleanupTooltipEvents) {
7871
8546
  cleanupTooltipEvents();
@@ -7948,19 +8623,15 @@ function createGeoMap(container, spec, options) {
7948
8623
  applyFocusDim(null);
7949
8624
  },
7950
8625
  resetView(opts) {
7951
- currentFocusIds = null;
7952
- const cam = cameraForTarget(currentLayout, null);
7953
- driveCamera(cam, opts?.duration ?? storyMotion.camera);
7954
- applyFocusDim(null);
7955
- updateA11yLive(null);
8626
+ resetCamera(opts?.duration ?? storyMotion.camera);
7956
8627
  },
7957
8628
  getCamera() {
7958
8629
  return { ...currentCamera };
7959
8630
  },
7960
8631
  setCamera(camera) {
7961
- currentCamera = camera;
8632
+ cancelCameraTween();
7962
8633
  currentFocusIds = null;
7963
- if (svgElement) applyMapCamera(svgElement, camera, currentLayout);
8634
+ commitCamera(camera);
7964
8635
  }
7965
8636
  };
7966
8637
  }
@@ -7994,4 +8665,4 @@ export {
7994
8665
  scrubCamera,
7995
8666
  createGeoMap
7996
8667
  };
7997
- //# sourceMappingURL=chunk-W3WPGHYV.js.map
8668
+ //# sourceMappingURL=chunk-QBTRAQAX.js.map