@simplex-viz/svg 0.2.0 → 0.3.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.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { rectToConvexPolygon, computeConvexHull, regularPolygon, starConvexHull, getHullBoundingRadius, SimplexGraphEngine } from '@simplex-viz/core';
2
- export { computeConvexHull, rectToConvexPolygon, regularPolygon, solveSimplexSnapshot, starConvexHull } from '@simplex-viz/core';
2
+ export { computeConvexHull, createAdaptiveHexMeshStrategy, createFixedLatticeMeshStrategy, createIncrementalAnchorClusteringStrategy, createJaccardAnnealingClusteringStrategy, createLocalAdaptiveLayoutStrategy, createOklabContrastColorStrategy, createPaletteColorStrategy, createPbdUniformLayoutStrategy, createThemeAdaptiveOklabColorStrategy, createWarmStartPbdLayoutStrategy, rectToConvexPolygon, regularPolygon, solveSimplexSnapshot, starConvexHull } from '@simplex-viz/core';
3
3
 
4
4
  // src/hullMeasure.ts
5
5
  var SVG_NS = "http://www.w3.org/2000/svg";
@@ -98,6 +98,11 @@ var BadgeClusterSvgView = class {
98
98
  while (groupEl.firstChild) {
99
99
  groupEl.removeChild(groupEl.firstChild);
100
100
  }
101
+ const isLight = visual.theme === "light";
102
+ const cardFill = isLight ? "#ffffff" : "#0d1320";
103
+ const hoverStroke = isLight ? "#0f172a" : "#e2e8f0";
104
+ const titleFill = isLight ? "#0f172a" : "#f1f5f9";
105
+ const metaFill = isLight ? "#475569" : "#94a3b8";
101
106
  const titleLen = visual.title.length;
102
107
  const metaLen = visual.metaLine.length;
103
108
  const estWidth = Math.max(122, Math.max(titleLen * 6.8 + 44, metaLen * 5.9 + 24));
@@ -109,11 +114,11 @@ var BadgeClusterSvgView = class {
109
114
  bg.setAttribute("width", String(estWidth));
110
115
  bg.setAttribute("height", String(height));
111
116
  bg.setAttribute("rx", "8");
112
- bg.setAttribute("fill", "#0d1320");
113
- bg.setAttribute("fill-opacity", "0.94");
117
+ bg.setAttribute("fill", cardFill);
118
+ bg.setAttribute("fill-opacity", isLight ? "0.97" : "0.94");
114
119
  bg.setAttribute(
115
120
  "stroke",
116
- state.interaction.isPinned ? "#f59e0b" : state.interaction.isHovered || state.interaction.isHighlighted ? "#e2e8f0" : visual.colorHex
121
+ state.interaction.isPinned ? "#f59e0b" : state.interaction.isHovered || state.interaction.isHighlighted ? hoverStroke : visual.colorHex
117
122
  );
118
123
  bg.setAttribute(
119
124
  "stroke-width",
@@ -129,7 +134,7 @@ var BadgeClusterSvgView = class {
129
134
  const codeText = doc.createElementNS(SVG_NS, "text");
130
135
  codeText.setAttribute("x", String(-halfW + 22));
131
136
  codeText.setAttribute("y", String(-3));
132
- codeText.setAttribute("fill", visual.colorHex);
137
+ codeText.setAttribute("fill", isLight ? "#0f172a" : visual.colorHex);
133
138
  codeText.setAttribute("font-size", "10");
134
139
  codeText.setAttribute("font-weight", "600");
135
140
  codeText.setAttribute("font-family", "IBM Plex Mono, monospace");
@@ -138,7 +143,7 @@ var BadgeClusterSvgView = class {
138
143
  const titleText = doc.createElementNS(SVG_NS, "text");
139
144
  titleText.setAttribute("x", String(-halfW + 44));
140
145
  titleText.setAttribute("y", String(-3));
141
- titleText.setAttribute("fill", "#f1f5f9");
146
+ titleText.setAttribute("fill", titleFill);
142
147
  titleText.setAttribute("font-size", "11");
143
148
  titleText.setAttribute("font-weight", "600");
144
149
  titleText.setAttribute("font-family", "Plus Jakarta Sans, sans-serif");
@@ -148,7 +153,7 @@ var BadgeClusterSvgView = class {
148
153
  subText.setAttribute("x", "0");
149
154
  subText.setAttribute("y", "13");
150
155
  subText.setAttribute("text-anchor", "middle");
151
- subText.setAttribute("fill", "#94a3b8");
156
+ subText.setAttribute("fill", metaFill);
152
157
  subText.setAttribute("font-size", "9.5");
153
158
  subText.setAttribute("font-family", "IBM Plex Mono, monospace");
154
159
  subText.textContent = visual.metaLine;
@@ -166,16 +171,20 @@ var ClusterAnchorSvgView = class {
166
171
  while (groupEl.firstChild) {
167
172
  groupEl.removeChild(groupEl.firstChild);
168
173
  }
174
+ const isLight = visual.theme === "light";
175
+ const outerFill = isLight ? "#ffffff" : "#07090E";
176
+ const hoverStroke = isLight ? "#0f172a" : "#f8fafc";
177
+ const codeFill = isLight ? "#0f172a" : "#f8fafc";
169
178
  const r = visual.radius;
170
179
  const outer = doc.createElementNS(SVG_NS, "circle");
171
180
  outer.setAttribute("cx", "0");
172
181
  outer.setAttribute("cy", "0");
173
182
  outer.setAttribute("r", String(r + 3));
174
- outer.setAttribute("fill", "#07090E");
175
- outer.setAttribute("fill-opacity", "0.88");
183
+ outer.setAttribute("fill", outerFill);
184
+ outer.setAttribute("fill-opacity", isLight ? "0.96" : "0.88");
176
185
  outer.setAttribute(
177
186
  "stroke",
178
- state.interaction.isHovered || state.interaction.isHighlighted ? "#f8fafc" : visual.colorHex
187
+ state.interaction.isHovered || state.interaction.isHighlighted ? hoverStroke : visual.colorHex
179
188
  );
180
189
  outer.setAttribute("stroke-width", state.interaction.isHovered ? "2.4" : "1.6");
181
190
  outer.setAttribute("stroke-dasharray", "4 2.5");
@@ -185,7 +194,7 @@ var ClusterAnchorSvgView = class {
185
194
  inner.setAttribute("cy", "0");
186
195
  inner.setAttribute("r", String(r - 2));
187
196
  inner.setAttribute("fill", visual.colorHex);
188
- inner.setAttribute("fill-opacity", "0.18");
197
+ inner.setAttribute("fill-opacity", isLight ? "0.14" : "0.18");
189
198
  inner.setAttribute("stroke", visual.colorHex);
190
199
  inner.setAttribute("stroke-width", "1.4");
191
200
  groupEl.appendChild(inner);
@@ -193,7 +202,7 @@ var ClusterAnchorSvgView = class {
193
202
  codeEl.setAttribute("x", "0");
194
203
  codeEl.setAttribute("y", "-2");
195
204
  codeEl.setAttribute("text-anchor", "middle");
196
- codeEl.setAttribute("fill", "#f8fafc");
205
+ codeEl.setAttribute("fill", codeFill);
197
206
  codeEl.setAttribute("font-size", "11");
198
207
  codeEl.setAttribute("font-weight", "600");
199
208
  codeEl.setAttribute("font-family", "IBM Plex Mono, monospace");
@@ -203,8 +212,9 @@ var ClusterAnchorSvgView = class {
203
212
  countEl.setAttribute("x", "0");
204
213
  countEl.setAttribute("y", "11");
205
214
  countEl.setAttribute("text-anchor", "middle");
206
- countEl.setAttribute("fill", visual.colorHex);
215
+ countEl.setAttribute("fill", isLight ? "#334155" : visual.colorHex);
207
216
  countEl.setAttribute("font-size", "9");
217
+ countEl.setAttribute("font-weight", isLight ? "600" : "400");
208
218
  countEl.setAttribute("font-family", "IBM Plex Mono, monospace");
209
219
  countEl.textContent = `${visual.memberCount}n`;
210
220
  groupEl.appendChild(countEl);
@@ -342,11 +352,15 @@ var GeometricShapeSvgView = class {
342
352
  const { pathEl, ringEl } = this.ensureElements(groupEl);
343
353
  const pts = sampleShapePerimeter(visual.shape, visual.radius);
344
354
  const d = pointsToSvgPath(pts);
355
+ const isLight = visual.theme === "light";
356
+ const defaultStroke = isLight ? "rgba(15, 23, 42, 0.25)" : "rgba(15, 23, 42, 0.8)";
357
+ const hoverStroke = isLight ? "#0f172a" : "#f8fafc";
358
+ const accentRing = isLight ? "#0891b2" : "#22d3ee";
345
359
  pathEl.setAttribute("d", d);
346
360
  pathEl.setAttribute("fill", visual.fillHex);
347
361
  pathEl.setAttribute(
348
362
  "stroke",
349
- state.interaction.isPinned ? "#f59e0b" : state.interaction.isHovered ? "#f8fafc" : visual.strokeHex ?? "rgba(15, 23, 42, 0.8)"
363
+ state.interaction.isPinned ? "#f59e0b" : state.interaction.isHovered ? hoverStroke : visual.strokeHex ?? defaultStroke
350
364
  );
351
365
  pathEl.setAttribute(
352
366
  "stroke-width",
@@ -355,7 +369,7 @@ var GeometricShapeSvgView = class {
355
369
  if (state.interaction.isHovered || state.interaction.isPinned || state.interaction.isHighlighted) {
356
370
  const outerPts = sampleShapePerimeter(visual.shape, visual.radius + 4.5);
357
371
  ringEl.setAttribute("d", pointsToSvgPath(outerPts));
358
- ringEl.setAttribute("stroke", state.interaction.isPinned ? "#f59e0b" : "#22d3ee");
372
+ ringEl.setAttribute("stroke", state.interaction.isPinned ? "#f59e0b" : accentRing);
359
373
  ringEl.setAttribute("stroke-width", "1.4");
360
374
  ringEl.setAttribute("stroke-opacity", "0.9");
361
375
  } else {
@@ -375,11 +389,12 @@ var GeometricShapeSvgView = class {
375
389
  y: a.y + (b.y - a.y) * progress
376
390
  });
377
391
  }
392
+ const isLight = to.theme === "light";
378
393
  pathEl.setAttribute("d", pointsToSvgPath(morphed));
379
394
  pathEl.setAttribute("fill", lerpHex(from.fillHex, to.fillHex, progress));
380
395
  pathEl.setAttribute(
381
396
  "stroke",
382
- state.interaction.isHovered ? "#f8fafc" : to.strokeHex ?? "rgba(15, 23, 42, 0.8)"
397
+ state.interaction.isHovered ? isLight ? "#0f172a" : "#f8fafc" : to.strokeHex ?? (isLight ? "rgba(15, 23, 42, 0.25)" : "rgba(15, 23, 42, 0.8)")
383
398
  );
384
399
  pathEl.setAttribute("stroke-width", String(to.strokeWidth ?? 1.4));
385
400
  ringEl.setAttribute("d", "");
@@ -427,6 +442,10 @@ var MultiSectorGlyphSvgView = class {
427
442
  while (groupEl.firstChild) {
428
443
  groupEl.removeChild(groupEl.firstChild);
429
444
  }
445
+ const isLight = visual.theme === "light";
446
+ const dividerStroke = isLight ? "#ffffff" : "#07090E";
447
+ const hoverStroke = isLight ? "#0f172a" : "#f8fafc";
448
+ const defaultAccent = isLight ? "#0891b2" : "#22d3ee";
430
449
  const totalWeight = visual.sectors.reduce((s, sec) => s + Math.max(0, sec.weight), 0) || 1;
431
450
  let angle = -Math.PI / 2;
432
451
  for (const sec of visual.sectors) {
@@ -439,7 +458,7 @@ var MultiSectorGlyphSvgView = class {
439
458
  describeDonutSectorPath(visual.innerRadius, visual.outerRadius, angle, nextAngle)
440
459
  );
441
460
  path.setAttribute("fill", sec.colorHex);
442
- path.setAttribute("stroke", "#07090E");
461
+ path.setAttribute("stroke", dividerStroke);
443
462
  path.setAttribute("stroke-width", visual.sectors.length > 1 ? "1.1" : "0.8");
444
463
  groupEl.appendChild(path);
445
464
  angle = nextAngle;
@@ -458,7 +477,7 @@ var MultiSectorGlyphSvgView = class {
458
477
  ring.setAttribute("fill", "none");
459
478
  ring.setAttribute(
460
479
  "stroke",
461
- state.interaction.isPinned ? "#f59e0b" : state.interaction.isHovered ? "#f8fafc" : visual.accentRingHex ?? "#22d3ee"
480
+ state.interaction.isPinned ? "#f59e0b" : state.interaction.isHovered ? hoverStroke : visual.accentRingHex ?? defaultAccent
462
481
  );
463
482
  ring.setAttribute("stroke-width", state.interaction.isHovered ? "2" : "1.2");
464
483
  groupEl.appendChild(ring);
@@ -493,7 +512,7 @@ function createPresetViews(preset) {
493
512
  const clusterView = preset === "badges" ? new BadgeClusterSvgView() : new ClusterAnchorSvgView();
494
513
  return { contentView, clusterView };
495
514
  }
496
- function createPresetVisualResolver(preset, getLabel) {
515
+ function createPresetVisualResolver(preset, getLabel, theme = "dark") {
497
516
  return {
498
517
  resolveContentVisual: (item, ctx) => {
499
518
  if (preset === "glyphs") {
@@ -504,7 +523,8 @@ function createPresetVisualResolver(preset, getLabel) {
504
523
  colorHex: uc.cluster.colorHex,
505
524
  weight: uc.weight
506
525
  })),
507
- centerColorHex: ctx.mixedColorHex
526
+ centerColorHex: ctx.mixedColorHex,
527
+ theme
508
528
  };
509
529
  }
510
530
  const rank = ctx.unitClusters.length;
@@ -512,9 +532,10 @@ function createPresetVisualResolver(preset, getLabel) {
512
532
  shape: rank >= 3 ? "star" : rank === 2 ? "diamond" : "circle",
513
533
  radius: 9.5,
514
534
  fillHex: ctx.mixedColorHex,
515
- strokeHex: "#07090E",
535
+ strokeHex: theme === "light" ? "#ffffff" : "#07090E",
516
536
  strokeWidth: 1.4,
517
- label: extractDefaultItemLabel(item, ctx.id, getLabel)
537
+ label: extractDefaultItemLabel(item, ctx.id, getLabel),
538
+ theme
518
539
  };
519
540
  },
520
541
  resolveClusterVisual: (cluster) => {
@@ -524,7 +545,8 @@ function createPresetVisualResolver(preset, getLabel) {
524
545
  code: cluster.code,
525
546
  title: cluster.defaultLabel,
526
547
  metaLine: `${count} ${count === 1 ? "item" : "items"}`,
527
- colorHex: cluster.colorHex
548
+ colorHex: cluster.colorHex,
549
+ theme
528
550
  };
529
551
  }
530
552
  return {
@@ -532,21 +554,23 @@ function createPresetVisualResolver(preset, getLabel) {
532
554
  label: cluster.defaultLabel,
533
555
  memberCount: cluster.members.length,
534
556
  colorHex: cluster.colorHex,
535
- radius: 22
557
+ radius: 22,
558
+ theme
536
559
  };
537
560
  },
538
561
  getVisualCacheKey: (visual) => {
562
+ const t = visual.theme ?? "dark";
539
563
  if ("sectors" in visual) {
540
564
  const secKey = visual.sectors.map((s) => `${s.colorHex}:${s.weight.toFixed(2)}`).join("|");
541
- return `glyph:${visual.outerRadius}:${visual.centerColorHex}:${secKey}`;
565
+ return `glyph:${t}:${visual.outerRadius}:${visual.centerColorHex}:${secKey}`;
542
566
  }
543
567
  if ("shape" in visual) {
544
- return `geom:${visual.shape}:${visual.radius}:${visual.fillHex}`;
568
+ return `geom:${t}:${visual.shape}:${visual.radius}:${visual.fillHex}`;
545
569
  }
546
570
  if ("metaLine" in visual) {
547
- return `badge:${visual.code}:${visual.title}:${visual.metaLine}:${visual.colorHex}`;
571
+ return `badge:${t}:${visual.code}:${visual.title}:${visual.metaLine}:${visual.colorHex}`;
548
572
  }
549
- return `anchor:${visual.code}:${visual.memberCount}:${visual.colorHex}`;
573
+ return `anchor:${t}:${visual.code}:${visual.memberCount}:${visual.colorHex}`;
550
574
  }
551
575
  };
552
576
  }
@@ -672,7 +696,7 @@ var SvgRendererAdapter = class {
672
696
  }
673
697
  while (this.edgeLines.length < snapshot.edges.length) {
674
698
  const line = this.doc.createElementNS(SVG_NS, "line");
675
- line.setAttribute("stroke", "rgba(148, 163, 184, 0.16)");
699
+ line.setAttribute("stroke", "rgba(100, 116, 139, 0.26)");
676
700
  line.setAttribute("stroke-width", "1");
677
701
  line.setAttribute("stroke-dasharray", "3 4");
678
702
  this.edgesGroup.appendChild(line);
@@ -935,16 +959,23 @@ var SimplexSvgController = class _SimplexSvgController {
935
959
  tooltipOption;
936
960
  tooltipEl = null;
937
961
  focusedClusterUidState = null;
962
+ currentPreset;
963
+ currentTheme;
964
+ usesCustomVisualResolver;
965
+ cameraProxy;
938
966
  rafId = null;
939
967
  lastFrameTs = 0;
940
968
  fpsSamples = 0;
941
969
  fpsAccumMs = 0;
942
970
  destroyed = false;
943
- // Centralized drag-to-pan state on root <svg>
971
+ // Centralized drag-to-pan and multi-touch pinch-zoom state on root <svg>
944
972
  isDraggingPan = false;
945
973
  dragStartX = 0;
946
974
  dragStartY = 0;
947
975
  dragMoved = false;
976
+ activePointers = /* @__PURE__ */ new Map();
977
+ lastPinchDist = null;
978
+ lastPinchMid = null;
948
979
  constructor(containerEl, options) {
949
980
  this.containerEl = resolveContainerElement(containerEl);
950
981
  this.onFpsUpdate = options.onFpsUpdate;
@@ -952,13 +983,16 @@ var SimplexSvgController = class _SimplexSvgController {
952
983
  this.getLabelExtractor = options.getLabel;
953
984
  this.tooltipOption = options.tooltip ?? false;
954
985
  this.getIdExtractor = options.getId ?? ((item) => typeof item === "object" && item !== null && "id" in item ? String(item.id) : "");
955
- const activePreset = options.preset ?? "geometric";
956
- const presetViews = createPresetViews(activePreset);
986
+ this.currentPreset = options.preset ?? "geometric";
987
+ this.currentTheme = options.theme ?? "dark";
988
+ this.usesCustomVisualResolver = options.visualResolver !== void 0;
989
+ const presetViews = createPresetViews(this.currentPreset);
957
990
  const contentView = options.contentView ?? presetViews.contentView;
958
991
  const clusterView = options.clusterView ?? presetViews.clusterView;
959
992
  const visualResolver = options.visualResolver ?? createPresetVisualResolver(
960
- activePreset,
961
- this.getLabelExtractor
993
+ this.currentPreset,
994
+ this.getLabelExtractor,
995
+ this.currentTheme
962
996
  );
963
997
  const doc = this.containerEl.ownerDocument;
964
998
  this.svgEl = doc.createElementNS(SVG_NS, "svg");
@@ -988,11 +1022,42 @@ var SimplexSvgController = class _SimplexSvgController {
988
1022
  resolveTagMeta: options.resolveTagMeta,
989
1023
  meshOptions: options.meshOptions,
990
1024
  layoutOptions: options.layoutOptions,
1025
+ meshStrategy: options.meshStrategy,
1026
+ clusteringStrategy: options.clusteringStrategy,
1027
+ colorStrategy: options.colorStrategy,
1028
+ layoutStrategy: options.layoutStrategy,
1029
+ theme: this.currentTheme,
991
1030
  showHulls: options.showHulls,
992
1031
  interactions: this.wrapInteractions(options.interactions),
993
1032
  onMetricsChange: options.onMetricsChange,
994
1033
  onClustersChange: options.onClustersChange
995
1034
  });
1035
+ const baseCamera = this.engine.camera;
1036
+ const syncCameraRender = () => {
1037
+ if (this.destroyed) return;
1038
+ this.engine.renderNow();
1039
+ };
1040
+ this.cameraProxy = {
1041
+ getState: () => baseCamera.getState(),
1042
+ setState: (next) => {
1043
+ baseCamera.setState(next);
1044
+ syncCameraRender();
1045
+ },
1046
+ zoomToCursor: (screenX, screenY, zoomFactor) => {
1047
+ baseCamera.zoomToCursor(screenX, screenY, zoomFactor);
1048
+ syncCameraRender();
1049
+ },
1050
+ panBy: (dx, dy) => {
1051
+ baseCamera.panBy(dx, dy);
1052
+ syncCameraRender();
1053
+ },
1054
+ reset: () => {
1055
+ baseCamera.reset();
1056
+ syncCameraRender();
1057
+ },
1058
+ screenToWorld: (screenX, screenY) => baseCamera.screenToWorld(screenX, screenY),
1059
+ worldToScreen: (worldX, worldY) => baseCamera.worldToScreen(worldX, worldY)
1060
+ };
996
1061
  this.attachSvgListeners();
997
1062
  if (typeof ResizeObserver !== "undefined") {
998
1063
  this.resizeObserver = new ResizeObserver((entries) => {
@@ -1076,6 +1141,13 @@ ${tagList}` : label;
1076
1141
  return `${ev.cluster.code} \xB7 ${ev.cluster.defaultLabel}
1077
1142
  ${count} ${count === 1 ? "item" : "items"}`;
1078
1143
  }
1144
+ applyTooltipThemeStyles(el) {
1145
+ const isLight = this.currentTheme === "light";
1146
+ el.style.background = isLight ? "rgba(255, 255, 255, 0.96)" : "rgba(9, 13, 22, 0.94)";
1147
+ el.style.border = isLight ? "1px solid rgba(203, 213, 225, 0.95)" : "1px solid rgba(51, 65, 85, 0.85)";
1148
+ el.style.color = isLight ? "#0f172a" : "#f1f5f9";
1149
+ el.style.boxShadow = isLight ? "0 10px 24px rgba(15, 23, 42, 0.12)" : "0 10px 24px rgba(0, 0, 0, 0.45)";
1150
+ }
1079
1151
  ensureTooltipElement() {
1080
1152
  if (!this.tooltipOption) return null;
1081
1153
  if (this.tooltipEl) return this.tooltipEl;
@@ -1093,14 +1165,11 @@ ${count} ${count === 1 ? "item" : "items"}`;
1093
1165
  el.style.maxWidth = "240px";
1094
1166
  el.style.padding = "7px 10px";
1095
1167
  el.style.borderRadius = "8px";
1096
- el.style.background = "rgba(9, 13, 22, 0.94)";
1097
- el.style.border = "1px solid rgba(51, 65, 85, 0.85)";
1098
- el.style.color = "#f1f5f9";
1099
1168
  el.style.fontFamily = "Plus Jakarta Sans, ui-sans-serif, system-ui, sans-serif";
1100
1169
  el.style.fontSize = "12px";
1101
1170
  el.style.lineHeight = "1.4";
1102
1171
  el.style.whiteSpace = "pre-line";
1103
- el.style.boxShadow = "0 10px 24px rgba(0, 0, 0, 0.45)";
1172
+ this.applyTooltipThemeStyles(el);
1104
1173
  this.containerEl.appendChild(el);
1105
1174
  this.tooltipEl = el;
1106
1175
  return el;
@@ -1121,7 +1190,7 @@ ${count} ${count === 1 ? "item" : "items"}`;
1121
1190
  }
1122
1191
  }
1123
1192
  get camera() {
1124
- return this.engine.camera;
1193
+ return this.cameraProxy;
1125
1194
  }
1126
1195
  getSvgElement() {
1127
1196
  return this.svgEl;
@@ -1152,13 +1221,15 @@ ${count} ${count === 1 ? "item" : "items"}`;
1152
1221
  * morph/crossfade transitions.
1153
1222
  */
1154
1223
  setPreset(preset, getLabel) {
1224
+ this.currentPreset = preset;
1155
1225
  if (getLabel !== void 0) {
1156
1226
  this.getLabelExtractor = getLabel;
1157
1227
  }
1158
1228
  const views = createPresetViews(preset);
1159
1229
  const resolver = createPresetVisualResolver(
1160
1230
  preset,
1161
- this.getLabelExtractor
1231
+ this.getLabelExtractor,
1232
+ this.currentTheme
1162
1233
  );
1163
1234
  this.adapter.setViews({
1164
1235
  contentView: views.contentView,
@@ -1169,6 +1240,64 @@ ${count} ${count === 1 ? "item" : "items"}`;
1169
1240
  );
1170
1241
  this.ensureLoopRunning();
1171
1242
  }
1243
+ /**
1244
+ * Switches built-in preset & tooltip theme between `'dark'` and `'light'`.
1245
+ */
1246
+ setTheme(theme) {
1247
+ if (this.currentTheme === theme) return;
1248
+ this.currentTheme = theme;
1249
+ if (this.tooltipEl) {
1250
+ this.applyTooltipThemeStyles(this.tooltipEl);
1251
+ }
1252
+ this.engine.setTheme(theme);
1253
+ if (!this.usesCustomVisualResolver) {
1254
+ const resolver = createPresetVisualResolver(
1255
+ this.currentPreset,
1256
+ this.getLabelExtractor,
1257
+ theme
1258
+ );
1259
+ this.engine.setVisualResolver(
1260
+ resolver
1261
+ );
1262
+ }
1263
+ this.ensureLoopRunning();
1264
+ }
1265
+ setMeshStrategy(strategy) {
1266
+ this.engine.setMeshStrategy(strategy);
1267
+ this.ensureLoopRunning();
1268
+ }
1269
+ setClusteringStrategy(strategy) {
1270
+ this.engine.setClusteringStrategy(strategy);
1271
+ this.ensureLoopRunning();
1272
+ }
1273
+ setColorStrategy(strategy) {
1274
+ this.engine.setColorStrategy(strategy);
1275
+ this.ensureLoopRunning();
1276
+ }
1277
+ setLayoutStrategy(strategy) {
1278
+ this.engine.setLayoutStrategy(strategy);
1279
+ this.ensureLoopRunning();
1280
+ }
1281
+ getMeshStrategy() {
1282
+ return this.engine.getMeshStrategy();
1283
+ }
1284
+ getClusteringStrategy() {
1285
+ return this.engine.getClusteringStrategy();
1286
+ }
1287
+ getColorStrategy() {
1288
+ return this.engine.getColorStrategy();
1289
+ }
1290
+ getLayoutStrategy() {
1291
+ return this.engine.getLayoutStrategy();
1292
+ }
1293
+ setMeshOptions(options) {
1294
+ this.engine.setMeshOptions(options);
1295
+ this.ensureLoopRunning();
1296
+ }
1297
+ setLayoutOptions(options) {
1298
+ this.engine.setLayoutOptions(options);
1299
+ this.ensureLoopRunning();
1300
+ }
1172
1301
  setTooltip(tooltip) {
1173
1302
  this.tooltipOption = tooltip;
1174
1303
  if (!tooltip) {
@@ -1217,9 +1346,25 @@ ${count} ${count === 1 ? "item" : "items"}`;
1217
1346
  y: clientY - rect.top
1218
1347
  };
1219
1348
  }
1349
+ resetTouchPinchState() {
1350
+ this.lastPinchDist = null;
1351
+ this.lastPinchMid = null;
1352
+ }
1220
1353
  onPointerDown = (e) => {
1221
- if (e.button !== 0) return;
1354
+ if (e.pointerType === "mouse" && e.button !== 0) return;
1222
1355
  const { x, y } = this.toLocalCoords(e.clientX, e.clientY);
1356
+ this.activePointers.set(e.pointerId, { x, y });
1357
+ if (this.activePointers.size >= 2) {
1358
+ this.isDraggingPan = false;
1359
+ this.dragMoved = true;
1360
+ const pts = [...this.activePointers.values()];
1361
+ const p0 = pts[0];
1362
+ const p1 = pts[1];
1363
+ this.lastPinchDist = Math.hypot(p1.x - p0.x, p1.y - p0.y);
1364
+ this.lastPinchMid = { x: (p0.x + p1.x) * 0.5, y: (p0.y + p1.y) * 0.5 };
1365
+ this.hideTooltip();
1366
+ return;
1367
+ }
1223
1368
  const hit = this.engine.hitTest(x, y);
1224
1369
  if (hit.kind === "background") {
1225
1370
  this.isDraggingPan = true;
@@ -1234,10 +1379,34 @@ ${count} ${count === 1 ? "item" : "items"}`;
1234
1379
  };
1235
1380
  onPointerMove = (e) => {
1236
1381
  const { x, y } = this.toLocalCoords(e.clientX, e.clientY);
1382
+ if (this.activePointers.has(e.pointerId)) {
1383
+ this.activePointers.set(e.pointerId, { x, y });
1384
+ }
1385
+ if (this.activePointers.size >= 2) {
1386
+ const pts = [...this.activePointers.values()];
1387
+ const p0 = pts[0];
1388
+ const p1 = pts[1];
1389
+ const dist = Math.hypot(p1.x - p0.x, p1.y - p0.y);
1390
+ const mid = { x: (p0.x + p1.x) * 0.5, y: (p0.y + p1.y) * 0.5 };
1391
+ if (this.lastPinchDist !== null && this.lastPinchMid !== null && this.lastPinchDist > 8 && dist > 8) {
1392
+ const dx = mid.x - this.lastPinchMid.x;
1393
+ const dy = mid.y - this.lastPinchMid.y;
1394
+ const zoomFactor = dist / this.lastPinchDist;
1395
+ this.engine.camera.panBy(dx, dy);
1396
+ this.engine.camera.zoomToCursor(mid.x, mid.y, zoomFactor);
1397
+ this.dragMoved = true;
1398
+ this.hideTooltip();
1399
+ this.engine.renderNow();
1400
+ }
1401
+ this.lastPinchDist = dist;
1402
+ this.lastPinchMid = mid;
1403
+ return;
1404
+ }
1237
1405
  if (this.isDraggingPan) {
1238
1406
  const dx = e.clientX - this.dragStartX;
1239
1407
  const dy = e.clientY - this.dragStartY;
1240
- if (Math.hypot(dx, dy) > 3) {
1408
+ const dragThreshold = e.pointerType === "touch" ? 6 : 3;
1409
+ if (Math.hypot(dx, dy) > dragThreshold) {
1241
1410
  this.dragMoved = true;
1242
1411
  }
1243
1412
  if (this.dragMoved) {
@@ -1245,23 +1414,47 @@ ${count} ${count === 1 ? "item" : "items"}`;
1245
1414
  this.dragStartY = e.clientY;
1246
1415
  this.engine.camera.panBy(dx, dy);
1247
1416
  this.hideTooltip();
1248
- this.ensureLoopRunning();
1417
+ this.engine.renderNow();
1249
1418
  }
1250
1419
  return;
1251
1420
  }
1421
+ if (e.pointerType === "touch") {
1422
+ return;
1423
+ }
1252
1424
  this.engine.handlePointerMove(x, y);
1253
1425
  const hit = this.engine.hitTest(x, y);
1254
1426
  this.svgEl.style.cursor = hit.kind === "background" ? "grab" : "pointer";
1255
1427
  this.ensureLoopRunning();
1256
1428
  };
1257
- onPointerUp = () => {
1258
- if (this.isDraggingPan) {
1429
+ onPointerUp = (e) => {
1430
+ this.activePointers.delete(e.pointerId);
1431
+ if (this.activePointers.size < 2) {
1432
+ this.resetTouchPinchState();
1433
+ }
1434
+ if (this.activePointers.size === 0 && this.isDraggingPan) {
1259
1435
  this.isDraggingPan = false;
1260
1436
  this.svgEl.style.cursor = "grab";
1261
1437
  }
1262
1438
  };
1263
- onPointerLeave = () => {
1264
- this.isDraggingPan = false;
1439
+ onPointerCancel = (e) => {
1440
+ this.activePointers.delete(e.pointerId);
1441
+ if (this.activePointers.size < 2) {
1442
+ this.resetTouchPinchState();
1443
+ }
1444
+ if (this.activePointers.size === 0) {
1445
+ this.isDraggingPan = false;
1446
+ this.dragMoved = false;
1447
+ this.svgEl.style.cursor = "grab";
1448
+ }
1449
+ };
1450
+ onPointerLeave = (e) => {
1451
+ this.activePointers.delete(e.pointerId);
1452
+ if (this.activePointers.size < 2) {
1453
+ this.resetTouchPinchState();
1454
+ }
1455
+ if (this.activePointers.size === 0) {
1456
+ this.isDraggingPan = false;
1457
+ }
1265
1458
  this.hideTooltip();
1266
1459
  this.engine.handlePointerLeave();
1267
1460
  this.ensureLoopRunning();
@@ -1318,6 +1511,7 @@ ${count} ${count === 1 ? "item" : "items"}`;
1318
1511
  this.svgEl.addEventListener("pointerdown", this.onPointerDown);
1319
1512
  this.svgEl.addEventListener("pointermove", this.onPointerMove);
1320
1513
  this.svgEl.addEventListener("pointerup", this.onPointerUp);
1514
+ this.svgEl.addEventListener("pointercancel", this.onPointerCancel);
1321
1515
  this.svgEl.addEventListener("pointerleave", this.onPointerLeave);
1322
1516
  this.svgEl.addEventListener("click", this.onClick);
1323
1517
  this.svgEl.addEventListener("contextmenu", this.onContextMenu);
@@ -1327,6 +1521,7 @@ ${count} ${count === 1 ? "item" : "items"}`;
1327
1521
  this.svgEl.removeEventListener("pointerdown", this.onPointerDown);
1328
1522
  this.svgEl.removeEventListener("pointermove", this.onPointerMove);
1329
1523
  this.svgEl.removeEventListener("pointerup", this.onPointerUp);
1524
+ this.svgEl.removeEventListener("pointercancel", this.onPointerCancel);
1330
1525
  this.svgEl.removeEventListener("pointerleave", this.onPointerLeave);
1331
1526
  this.svgEl.removeEventListener("click", this.onClick);
1332
1527
  this.svgEl.removeEventListener("contextmenu", this.onContextMenu);