@unovis/ts 1.6.2 → 1.7.0-stellar.1

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.
Files changed (42) hide show
  1. package/components/area/config.d.ts +2 -0
  2. package/components/area/config.js +1 -1
  3. package/components/area/config.js.map +1 -1
  4. package/components/area/index.js +6 -3
  5. package/components/area/index.js.map +1 -1
  6. package/components/crosshair/config.d.ts +1 -1
  7. package/components/crosshair/config.js.map +1 -1
  8. package/components/crosshair/index.d.ts +1 -1
  9. package/components/crosshair/index.js.map +1 -1
  10. package/components/topojson-map/config.d.ts +57 -1
  11. package/components/topojson-map/config.js +6 -2
  12. package/components/topojson-map/config.js.map +1 -1
  13. package/components/topojson-map/index.d.ts +25 -0
  14. package/components/topojson-map/index.js +708 -32
  15. package/components/topojson-map/index.js.map +1 -1
  16. package/components/topojson-map/modules/donut.d.ts +3 -0
  17. package/components/topojson-map/modules/donut.js +25 -0
  18. package/components/topojson-map/modules/donut.js.map +1 -0
  19. package/components/topojson-map/style.d.ts +6 -0
  20. package/components/topojson-map/style.js +45 -1
  21. package/components/topojson-map/style.js.map +1 -1
  22. package/components/topojson-map/types.d.ts +82 -0
  23. package/components/topojson-map/types.js +8 -1
  24. package/components/topojson-map/types.js.map +1 -1
  25. package/components/topojson-map/utils.d.ts +17 -0
  26. package/components/topojson-map/utils.js +177 -3
  27. package/components/topojson-map/utils.js.map +1 -1
  28. package/components.d.ts +2 -0
  29. package/components.js +1 -0
  30. package/components.js.map +1 -1
  31. package/containers/single-container/config.d.ts +3 -0
  32. package/containers/single-container/config.js.map +1 -1
  33. package/containers/single-container/index.js +2 -1
  34. package/containers/single-container/index.js.map +1 -1
  35. package/containers/xy-container/config.d.ts +3 -0
  36. package/containers/xy-container/config.js.map +1 -1
  37. package/containers/xy-container/index.d.ts +1 -0
  38. package/containers/xy-container/index.js +13 -9
  39. package/containers/xy-container/index.js.map +1 -1
  40. package/index.js +1 -1
  41. package/package.json +1 -1
  42. package/types.js +1 -1
@@ -1,19 +1,24 @@
1
+ import { select } from 'd3-selection';
1
2
  import { zoom, zoomIdentity } from 'd3-zoom';
2
3
  import { timeout } from 'd3-timer';
4
+ import { easeCubicInOut } from 'd3-ease';
3
5
  import { geoPath } from 'd3-geo';
4
6
  import { color } from 'd3-color';
7
+ import { packSiblings } from 'd3-hierarchy';
5
8
  import { feature } from 'topojson-client';
6
9
  import { ComponentCore } from '../../core/component/index.js';
7
10
  import { MapGraphDataModel } from '../../data-models/map-graph.js';
8
- import { isNumber, getString, getNumber, clamp } from '../../utils/data.js';
11
+ import { getNumber, isNumber, getString, clamp } from '../../utils/data.js';
9
12
  import { smartTransition } from '../../utils/d3.js';
10
13
  import { getColor, hexToBrightness } from '../../utils/color.js';
11
- import { isStringCSSVariable, getCSSVariableValue } from '../../utils/misc.js';
12
- import { MapProjection, MapPointLabelPosition } from './types.js';
14
+ import { rectIntersect, isStringCSSVariable, getCSSVariableValue } from '../../utils/misc.js';
15
+ import { estimateStringPixelLength } from '../../utils/text.js';
16
+ import { MapProjection, TopoJSONMapPointShape, MapPointLabelPosition } from './types.js';
13
17
  import { TopoJSONMapDefaultConfig } from './config.js';
14
- import { getLonLat, arc } from './utils.js';
18
+ import { calculateClusterIndex, getLonLat, arc, getDonutData, getPointPathData, getClustersAndPoints, geoJsonPointToScreenPoint, getNextZoomLevelOnClusterClick } from './utils.js';
19
+ import { updateDonut } from './modules/donut.js';
15
20
  import * as style from './style.js';
16
- import { background, features, links, points, feature as feature$1, link, point, pointCircle, pointLabel } from './style.js';
21
+ import { background, features, areaLabel, links, points, flowParticles, sourcePoints, feature as feature$1, link, point, pointCircle, pointLabel, sourcePoint, flowParticle } from './style.js';
17
22
 
18
23
  class TopoJSONMap extends ComponentCore {
19
24
  constructor(config, data) {
@@ -26,11 +31,21 @@ class TopoJSONMap extends ComponentCore {
26
31
  this._initialScale = undefined;
27
32
  this._currentZoomLevel = undefined;
28
33
  this._path = geoPath();
34
+ this._clusterIndex = null;
35
+ this._expandedCluster = null;
36
+ this._eventInitiatedByComponent = false;
29
37
  this._zoomBehavior = zoom();
30
38
  this._backgroundRect = this.g.append('rect').attr('class', background);
31
39
  this._featuresGroup = this.g.append('g').attr('class', features);
40
+ this._areaLabelsGroup = this.g.append('g').attr('class', areaLabel);
32
41
  this._linksGroup = this.g.append('g').attr('class', links);
33
42
  this._pointsGroup = this.g.append('g').attr('class', points);
43
+ // Flow-related properties
44
+ this._flowParticlesGroup = this.g.append('g').attr('class', flowParticles);
45
+ this._sourcePointsGroup = this.g.append('g').attr('class', sourcePoints);
46
+ this._flowParticles = [];
47
+ this._sourcePoints = [];
48
+ this._animationId = null;
34
49
  this.events = {
35
50
  [TopoJSONMap.selectors.point]: {},
36
51
  [TopoJSONMap.selectors.feature]: {},
@@ -49,11 +64,26 @@ class TopoJSONMap extends ComponentCore {
49
64
  `);
50
65
  }
51
66
  setData(data) {
67
+ var _a;
52
68
  const { config } = this;
53
69
  this.datamodel.pointId = config.pointId;
54
70
  this.datamodel.linkSource = config.linkSource;
55
71
  this.datamodel.linkTarget = config.linkTarget;
56
72
  this.datamodel.data = data;
73
+ // Reset expanded cluster when data changes
74
+ this._resetExpandedCluster();
75
+ // Initialize clustering if enabled
76
+ if (config.clustering && ((_a = data.points) === null || _a === void 0 ? void 0 : _a.length)) {
77
+ const dataValid = data.points.filter(d => {
78
+ const lat = getNumber(d, config.latitude);
79
+ const lon = getNumber(d, config.longitude);
80
+ return isNumber(lat) && isNumber(lon);
81
+ });
82
+ this._clusterIndex = calculateClusterIndex(dataValid, this.config);
83
+ }
84
+ else {
85
+ this._clusterIndex = null;
86
+ }
57
87
  // If there was a data change and mapFitToPoints is enabled, we will need to re-fit the map
58
88
  this._firstRender = this._firstRender || config.mapFitToPoints;
59
89
  }
@@ -72,9 +102,20 @@ class TopoJSONMap extends ComponentCore {
72
102
  const duration = isNumber(customDuration) ? customDuration : config.duration;
73
103
  this._renderBackground();
74
104
  this._renderMap(duration);
105
+ this._renderAreaLabels(duration);
75
106
  this._renderGroups(duration);
76
107
  this._renderLinks(duration);
77
108
  this._renderPoints(duration);
109
+ // Flow features
110
+ if (config.enableFlowAnimation) {
111
+ this._initFlowFeatures();
112
+ this._renderSourcePoints(duration);
113
+ this._renderFlowParticles(duration);
114
+ this._startFlowAnimation();
115
+ }
116
+ else {
117
+ this._stopFlowAnimation();
118
+ }
78
119
  // When animation is running we need to temporary disable zoom behaviour
79
120
  if (duration && !config.disableZoom) {
80
121
  this.g.on('.zoom', null);
@@ -97,10 +138,17 @@ class TopoJSONMap extends ComponentCore {
97
138
  smartTransition(this._featuresGroup, duration)
98
139
  .attr('transform', transformString)
99
140
  .attr('stroke-width', 1 / this._currentZoomLevel);
141
+ smartTransition(this._areaLabelsGroup, duration)
142
+ .attr('transform', transformString);
100
143
  smartTransition(this._linksGroup, duration)
101
144
  .attr('transform', transformString);
102
145
  smartTransition(this._pointsGroup, duration)
103
146
  .attr('transform', transformString);
147
+ // Flow groups
148
+ smartTransition(this._sourcePointsGroup, duration)
149
+ .attr('transform', transformString);
150
+ smartTransition(this._flowParticlesGroup, duration)
151
+ .attr('transform', transformString);
104
152
  }
105
153
  _renderMap(duration) {
106
154
  var _a, _b, _c;
@@ -154,6 +202,130 @@ class TopoJSONMap extends ComponentCore {
154
202
  .style('cursor', d => d.data ? getString(d.data, config.areaCursor) : null);
155
203
  features.exit().remove();
156
204
  }
205
+ _renderAreaLabels(duration) {
206
+ var _a, _b;
207
+ const { config } = this;
208
+ // Early return if no area label configuration
209
+ if (!config.areaLabel) {
210
+ this._areaLabelsGroup.selectAll('*').remove();
211
+ return;
212
+ }
213
+ const featureData = ((_b = (_a = this._featureCollection) === null || _a === void 0 ? void 0 : _a.features) !== null && _b !== void 0 ? _b : []);
214
+ // Prepare candidate labels with optimized filtering and calculations
215
+ const candidateLabels = featureData
216
+ .map(feature => {
217
+ var _a;
218
+ // Get label text from user-provided area data only
219
+ const labelText = feature.data ? getString(feature.data, config.areaLabel) : null;
220
+ if (!labelText)
221
+ return null;
222
+ const centroid = this._path.centroid(feature);
223
+ // Skip if centroid is invalid (e.g., for very small or complex shapes)
224
+ if (!centroid || centroid.some(coord => !isFinite(coord)))
225
+ return null;
226
+ const bounds = this._path.bounds(feature);
227
+ const area = (bounds[1][0] - bounds[0][0]) * (bounds[1][1] - bounds[0][1]);
228
+ return {
229
+ feature,
230
+ centroid,
231
+ area,
232
+ labelText,
233
+ id: feature.data ? getString(feature.data, config.areaId) : (_a = feature.id) === null || _a === void 0 ? void 0 : _a.toString(),
234
+ };
235
+ })
236
+ .filter(Boolean) // Remove null entries
237
+ .sort((a, b) => b.area - a.area); // Prioritize larger areas
238
+ // D3 data binding with improved key function
239
+ const labels = this._areaLabelsGroup
240
+ .selectAll(`.${areaLabel}`)
241
+ .data(candidateLabels, d => d.id || '');
242
+ // Handle entering labels
243
+ const labelsEnter = labels.enter()
244
+ .append('text')
245
+ .attr('class', areaLabel)
246
+ .attr('transform', d => `translate(${d.centroid[0]},${d.centroid[1]})`)
247
+ .style('opacity', 0)
248
+ .style('pointer-events', 'none');
249
+ // Update all labels (enter + update)
250
+ const labelsMerged = labelsEnter.merge(labels);
251
+ labelsMerged
252
+ .text(d => d.labelText)
253
+ .attr('transform', d => `translate(${d.centroid[0]},${d.centroid[1]})`)
254
+ .style('font-size', `calc(var(--vis-map-point-label-font-size) / ${this._currentZoomLevel})`)
255
+ .style('text-anchor', 'middle')
256
+ .style('dominant-baseline', 'middle');
257
+ // Handle exiting labels
258
+ smartTransition(labels.exit(), duration)
259
+ .style('opacity', 0)
260
+ .remove();
261
+ // Run collision detection immediately to prevent flickering during zoom
262
+ if (candidateLabels.length > 0) {
263
+ window.cancelAnimationFrame(this._collideLabelsAnimFrameId);
264
+ // Run collision detection synchronously first
265
+ this._collideLabels();
266
+ // Then schedule follow-up collision detection for any dynamic changes
267
+ this._collideLabelsAnimFrameId = window.requestAnimationFrame(() => {
268
+ this._collideLabels();
269
+ });
270
+ }
271
+ // Animate to visible state AFTER collision detection
272
+ smartTransition(labelsMerged, duration)
273
+ .style('opacity', 1);
274
+ }
275
+ _collideLabels() {
276
+ const labels = this._areaLabelsGroup.selectAll(`.${areaLabel}`);
277
+ const labelNodes = labels.nodes();
278
+ const labelData = labels.data();
279
+ if (labelNodes.length === 0)
280
+ return;
281
+ // Reset all labels to visible and mark them as such
282
+ labelNodes.forEach((node) => {
283
+ node._labelVisible = true;
284
+ });
285
+ // Helper function to get bounding box
286
+ const getBBox = (labelData) => {
287
+ const [x, y] = labelData.centroid;
288
+ const labelText = labelData.labelText || '';
289
+ const fontSize = 12; // Default font size
290
+ const width = estimateStringPixelLength(labelText, fontSize, 0.6);
291
+ const height = fontSize * 1.2; // Line height factor
292
+ return {
293
+ x: x - width / 2,
294
+ y: y - height / 2,
295
+ width,
296
+ height,
297
+ };
298
+ };
299
+ // Run collision detection similar to scatter plot
300
+ labelNodes.forEach((node1, i) => {
301
+ const data1 = labelData[i];
302
+ if (!node1._labelVisible)
303
+ return;
304
+ const label1BoundingRect = getBBox(data1);
305
+ for (let j = 0; j < labelNodes.length; j++) {
306
+ if (i === j)
307
+ continue;
308
+ const node2 = labelNodes[j];
309
+ const data2 = labelData[j];
310
+ if (!node2._labelVisible)
311
+ continue;
312
+ const label2BoundingRect = getBBox(data2);
313
+ const intersect = rectIntersect(label1BoundingRect, label2BoundingRect, 2);
314
+ if (intersect) {
315
+ // Priority based on area size - larger areas keep their labels
316
+ if (data1.area >= data2.area) {
317
+ node2._labelVisible = false;
318
+ }
319
+ else {
320
+ node1._labelVisible = false;
321
+ break;
322
+ }
323
+ }
324
+ }
325
+ });
326
+ // Apply visibility based on collision detection
327
+ labels.style('opacity', (d, i) => labelNodes[i]._labelVisible ? 1 : 0);
328
+ }
157
329
  _renderLinks(duration) {
158
330
  const { config, datamodel } = this;
159
331
  const links = datamodel.links;
@@ -173,48 +345,163 @@ class TopoJSONMap extends ComponentCore {
173
345
  .style('stroke', (link, i) => getColor(link, config.linkColor, i));
174
346
  edges.exit().remove();
175
347
  }
176
- _renderPoints(duration) {
348
+ _getPointData() {
177
349
  const { config, datamodel } = this;
178
- const pointData = datamodel.points;
350
+ if (!config.clustering || !this._clusterIndex) {
351
+ // Return regular points when clustering is disabled
352
+ return datamodel.points.map((d, i) => {
353
+ const pos = this._projection(getLonLat(d, config.longitude, config.latitude));
354
+ const radius = getNumber(d, config.pointRadius);
355
+ const shape = getString(d, config.pointShape) || TopoJSONMapPointShape.Circle;
356
+ const donutData = getDonutData(d, config.colorMap);
357
+ const pointColor = getColor(d, config.pointColor, i);
358
+ return {
359
+ geometry: { type: 'Point', coordinates: getLonLat(d, config.longitude, config.latitude) },
360
+ bbox: { x1: pos[0] - radius, y1: pos[1] - radius, x2: pos[0] + radius, y2: pos[1] + radius },
361
+ radius,
362
+ path: getPointPathData({ x: 0, y: 0 }, radius, shape),
363
+ color: pointColor,
364
+ id: getString(d, config.pointId, i),
365
+ properties: d,
366
+ donutData,
367
+ isCluster: false,
368
+ _zIndex: 0,
369
+ };
370
+ });
371
+ }
372
+ // Get bounds for clustering
373
+ const bounds = this._projection.invert ? [
374
+ this._projection.invert([0, this._height])[0],
375
+ this._projection.invert([0, this._height])[1],
376
+ this._projection.invert([this._width, 0])[0],
377
+ this._projection.invert([this._width, 0])[1],
378
+ ] : [-180, -90, 180, 90];
379
+ // Use a capped zoom level for clustering to prevent over-fragmentation
380
+ const clusterZoom = Math.min(Math.round(this._currentZoomLevel || 1), 8);
381
+ let geoJsonPoints = getClustersAndPoints(this._clusterIndex, bounds, clusterZoom);
382
+ // Handle expanded cluster points - replace the expanded cluster with individual points
383
+ if (this._expandedCluster) {
384
+ // Remove expanded cluster from the data
385
+ geoJsonPoints = geoJsonPoints.filter((c) => { var _a; return c.properties.clusterId !== ((_a = this._expandedCluster) === null || _a === void 0 ? void 0 : _a.cluster.properties).clusterId; });
386
+ // Add points from the expanded cluster
387
+ geoJsonPoints = geoJsonPoints.concat(this._expandedCluster.points);
388
+ }
389
+ return geoJsonPoints.map((geoPoint, i) => geoJsonPointToScreenPoint(geoPoint, i, this._projection, this.config, this._currentZoomLevel || 1));
390
+ }
391
+ _renderPoints(duration) {
392
+ const { config } = this;
393
+ const hasColorMap = config.colorMap && Object.keys(config.colorMap).length > 0;
394
+ const pointData = this._getPointData();
179
395
  const points = this._pointsGroup
180
396
  .selectAll(`.${point}`)
181
- .data(pointData, (d, i) => getString(d, config.pointId, i));
397
+ .data(pointData, (d, i) => d.id.toString());
182
398
  // Enter
183
399
  const pointsEnter = points.enter().append('g').attr('class', point)
184
400
  .attr('transform', d => {
185
- const pos = this._projection(getLonLat(d, config.longitude, config.latitude));
186
- return `translate(${pos[0]},${pos[1]})`;
401
+ const pos = this._projection(d.geometry.coordinates);
402
+ const expandedPoint = d;
403
+ const dx = expandedPoint.dx || 0;
404
+ const dy = expandedPoint.dy || 0;
405
+ return `translate(${pos[0] + dx},${pos[1] + dy})`;
187
406
  })
188
407
  .style('opacity', 0);
189
- pointsEnter.append('circle').attr('class', pointCircle)
190
- .attr('r', 0)
191
- .style('fill', (d, i) => getColor(d, config.pointColor, i))
192
- .style('stroke-width', d => getNumber(d, config.pointStrokeWidth));
408
+ pointsEnter.append('path').attr('class', pointCircle)
409
+ .attr('d', 'M0,0')
410
+ .style('fill', (d, i) => d.color)
411
+ .style('stroke-width', d => getNumber(d.properties, config.pointStrokeWidth));
412
+ // Add donut chart group
413
+ pointsEnter.append('g').attr('class', 'donut-group');
193
414
  pointsEnter.append('text').attr('class', pointLabel)
194
415
  .style('opacity', 0);
195
416
  // Update
196
417
  const pointsMerged = pointsEnter.merge(points);
197
418
  smartTransition(pointsMerged, duration)
198
419
  .attr('transform', d => {
199
- const pos = this._projection(getLonLat(d, config.longitude, config.latitude));
200
- return `translate(${pos[0]},${pos[1]})`;
420
+ const pos = this._projection(d.geometry.coordinates);
421
+ const expandedPoint = d;
422
+ const dx = expandedPoint.dx || 0;
423
+ const dy = expandedPoint.dy || 0;
424
+ return `translate(${pos[0] + dx},${pos[1] + dy})`;
425
+ })
426
+ .style('cursor', d => {
427
+ return (d.isCluster && (config.clusterExpandOnClick || hasColorMap)) ? 'pointer' : getString(d.properties, config.pointCursor);
201
428
  })
202
- .style('cursor', d => getString(d, config.pointCursor))
203
429
  .style('opacity', 1);
430
+ // Cursor is handled by the parent point element
431
+ // Add click event handler for clusters
432
+ pointsMerged
433
+ .style('pointer-events', 'all') // Ensure pointer events are enabled
434
+ .on('click', (event, d) => {
435
+ this._onPointClick(d, event);
436
+ });
204
437
  smartTransition(pointsMerged.select(`.${pointCircle}`), duration)
205
- .attr('r', d => getNumber(d, config.pointRadius) / this._currentZoomLevel)
206
- .style('fill', (d, i) => getColor(d, config.pointColor, i))
207
- .style('stroke', (d, i) => getColor(d, config.pointColor, i))
208
- .style('stroke-width', d => getNumber(d, config.pointStrokeWidth) / this._currentZoomLevel);
438
+ .attr('d', d => {
439
+ const radius = d.radius / (this._currentZoomLevel || 1);
440
+ const shape = getString(d.properties, config.pointShape) || TopoJSONMapPointShape.Circle;
441
+ return getPointPathData({ x: 0, y: 0 }, radius, shape);
442
+ })
443
+ .style('fill', d => {
444
+ const donutData = d.donutData;
445
+ const shape = getString(d.properties, config.pointShape) || TopoJSONMapPointShape.Circle;
446
+ const isRing = shape === TopoJSONMapPointShape.Ring;
447
+ const expandedPoint = d;
448
+ // For expanded cluster points, use the preserved cluster color
449
+ if (expandedPoint.expandedClusterPoint) {
450
+ return expandedPoint.clusterColor || expandedPoint.expandedClusterPoint.color;
451
+ }
452
+ if (donutData.length > 0)
453
+ return 'transparent';
454
+ return isRing ? 'transparent' : d.color;
455
+ })
456
+ .style('stroke', d => {
457
+ const expandedPoint = d;
458
+ // For expanded cluster points, use the preserved cluster color
459
+ if (expandedPoint.expandedClusterPoint) {
460
+ return expandedPoint.clusterColor || expandedPoint.expandedClusterPoint.color;
461
+ }
462
+ return d.color;
463
+ })
464
+ .style('stroke-width', d => {
465
+ const shape = getString(d.properties, config.pointShape) || TopoJSONMapPointShape.Circle;
466
+ const isRing = shape === TopoJSONMapPointShape.Ring;
467
+ const baseStrokeWidth = isRing ? getNumber(d.properties, config.pointRingWidth) : getNumber(d.properties, config.pointStrokeWidth);
468
+ return baseStrokeWidth / (this._currentZoomLevel || 1);
469
+ });
470
+ // Update donut charts
471
+ const currentZoomLevel = this._currentZoomLevel;
472
+ pointsMerged.select('.donut-group')
473
+ .style('pointer-events', 'none') // Allow clicks to pass through donut charts
474
+ .each(function (d) {
475
+ if (d.donutData.length > 0) {
476
+ const radius = getNumber(d.properties, config.pointRadius, 0) / (currentZoomLevel || 1);
477
+ const arcWidth = (d.isCluster ? 4 : 2) / (currentZoomLevel || 1); // Thicker ring for clusters
478
+ updateDonut(select(this), d.donutData, radius, arcWidth, 0.05);
479
+ }
480
+ else {
481
+ select(this).selectAll('*').remove();
482
+ }
483
+ });
209
484
  const pointLabelsMerged = pointsMerged.select(`.${pointLabel}`);
210
485
  pointLabelsMerged
211
- .text(d => { var _a; return (_a = getString(d, config.pointLabel)) !== null && _a !== void 0 ? _a : ''; })
486
+ .text(d => {
487
+ var _a, _b;
488
+ if (d.isCluster) {
489
+ // Use cluster label for clusters
490
+ return (_a = getString(d, config.clusterLabel)) !== null && _a !== void 0 ? _a : '';
491
+ }
492
+ else {
493
+ // Use point label for regular points
494
+ return (_b = getString(d.properties, config.pointLabel)) !== null && _b !== void 0 ? _b : '';
495
+ }
496
+ })
212
497
  .style('font-size', d => {
213
498
  if (config.pointLabelPosition === MapPointLabelPosition.Bottom) {
214
499
  return `calc(var(--vis-map-point-label-font-size) / ${this._currentZoomLevel}`;
215
500
  }
216
- const pointDiameter = 2 * getNumber(d, config.pointRadius);
217
- const pointLabelText = getString(d, config.pointLabel) || '';
501
+ const pointDiameter = 2 * getNumber(d.properties, config.pointRadius, 0);
502
+ const pointLabelText = d.isCluster
503
+ ? (getString(d, config.clusterLabel) || '')
504
+ : (getString(d.properties, config.pointLabel) || '');
218
505
  const textLength = pointLabelText.length;
219
506
  const fontSize = 0.5 * pointDiameter / Math.pow(textLength, 0.4);
220
507
  return clamp(fontSize, fontSize, 16);
@@ -222,22 +509,36 @@ class TopoJSONMap extends ComponentCore {
222
509
  .attr('y', d => {
223
510
  if (config.pointLabelPosition === MapPointLabelPosition.Center)
224
511
  return null;
225
- const pointRadius = getNumber(d, config.pointRadius) / this._currentZoomLevel;
512
+ const pointRadius = getNumber(d.properties, config.pointRadius, 0) / this._currentZoomLevel;
226
513
  return pointRadius;
227
514
  })
228
515
  .attr('dy', config.pointLabelPosition === MapPointLabelPosition.Center ? '0.32em' : '1em');
229
516
  smartTransition(pointLabelsMerged, duration)
230
517
  .style('fill', (d, i) => {
231
- var _a;
518
+ var _a, _b;
232
519
  if (config.pointLabelPosition === MapPointLabelPosition.Bottom)
233
520
  return null;
234
- const pointColor = getColor(d, config.pointColor, i);
235
- const hex = (_a = color(isStringCSSVariable(pointColor) ? getCSSVariableValue(pointColor, this.element) : pointColor)) === null || _a === void 0 ? void 0 : _a.hex();
236
- if (!hex)
237
- return null;
238
- const brightness = hexToBrightness(hex);
239
- return brightness > config.pointLabelTextBrightnessRatio ? 'var(--vis-map-point-label-text-color-dark)' : 'var(--vis-map-point-label-text-color-light)';
521
+ if (d.isCluster) {
522
+ // For clusters, use contrasting color against cluster background
523
+ const clusterColor = getColor(d, config.clusterColor, i) || '#2196F3';
524
+ const hex = (_a = color(isStringCSSVariable(clusterColor) ? getCSSVariableValue(clusterColor, this.element) : clusterColor)) === null || _a === void 0 ? void 0 : _a.hex();
525
+ if (hex) {
526
+ const brightness = hexToBrightness(hex);
527
+ return brightness > 0.5 ? '#333' : '#fff';
528
+ }
529
+ return '#fff';
530
+ }
531
+ else {
532
+ // Regular point label color logic
533
+ const pointColor = getColor(d.properties, config.pointColor, i);
534
+ const hex = (_b = color(isStringCSSVariable(pointColor) ? getCSSVariableValue(pointColor, this.element) : pointColor)) === null || _b === void 0 ? void 0 : _b.hex();
535
+ if (!hex)
536
+ return null;
537
+ const brightness = hexToBrightness(hex);
538
+ return brightness > config.pointLabelTextBrightnessRatio ? 'var(--vis-map-point-label-text-color-dark)' : 'var(--vis-map-point-label-text-color-light)';
539
+ }
240
540
  })
541
+ .style('font-weight', d => d.isCluster ? 'bold' : 'normal')
241
542
  .style('opacity', 1);
242
543
  // Exit
243
544
  points.exit().remove();
@@ -311,6 +612,13 @@ class TopoJSONMap extends ComponentCore {
311
612
  return; // To prevent double render because of binding zoom behaviour
312
613
  const isMouseEvent = event.sourceEvent !== undefined;
313
614
  const isExternalEvent = !(event === null || event === void 0 ? void 0 : event.sourceEvent) && !this._isResizing;
615
+ // Reset expanded cluster when manually zooming (but not during component-initiated zoom)
616
+ if (isMouseEvent && !this._eventInitiatedByComponent)
617
+ this._resetExpandedCluster();
618
+ // Reset the flag after handling the zoom
619
+ if (this._eventInitiatedByComponent && !isMouseEvent) {
620
+ this._eventInitiatedByComponent = false;
621
+ }
314
622
  window.cancelAnimationFrame(this._animFrameId);
315
623
  this._animFrameId = window.requestAnimationFrame(this._onZoomHandler.bind(this, event.transform, isMouseEvent, isExternalEvent));
316
624
  if (isMouseEvent) {
@@ -331,8 +639,19 @@ class TopoJSONMap extends ComponentCore {
331
639
  : (isMouseEvent ? 0 : null);
332
640
  // Call render functions that depend on this._transform
333
641
  this._renderGroups(customDuration);
642
+ this._renderAreaLabels(customDuration);
334
643
  this._renderLinks(customDuration);
335
644
  this._renderPoints(customDuration);
645
+ // Update flow features on zoom
646
+ if (this.config.enableFlowAnimation) {
647
+ this._renderSourcePoints(customDuration);
648
+ this._renderFlowParticles(customDuration);
649
+ }
650
+ // Update flow features on zoom
651
+ if (this.config.enableFlowAnimation) {
652
+ this._renderSourcePoints(customDuration);
653
+ this._renderFlowParticles(customDuration);
654
+ }
336
655
  }
337
656
  zoomIn(increment = 0.5) {
338
657
  this.setZoom(this._currentZoomLevel + increment);
@@ -361,8 +680,365 @@ class TopoJSONMap extends ComponentCore {
361
680
  // that the zoom state has changed
362
681
  this._applyZoom();
363
682
  }
683
+ fitViewToFlows(pad = 0.1) {
684
+ const { config, datamodel } = this;
685
+ const points = [...(datamodel.points || [])];
686
+ const links = datamodel.links || [];
687
+ // Add flow endpoints to the points to consider for fitting
688
+ links.forEach((link) => {
689
+ var _a, _b;
690
+ // Try to get source point
691
+ if (config.sourceLongitude && config.sourceLatitude) {
692
+ const sourceLon = getNumber(link, config.sourceLongitude);
693
+ const sourceLat = getNumber(link, config.sourceLatitude);
694
+ if (isNumber(sourceLon) && isNumber(sourceLat)) {
695
+ points.push({ longitude: sourceLon, latitude: sourceLat });
696
+ }
697
+ }
698
+ else {
699
+ const sourcePoint = (_a = config.linkSource) === null || _a === void 0 ? void 0 : _a.call(config, link);
700
+ if (typeof sourcePoint === 'object' && sourcePoint !== null) {
701
+ points.push(sourcePoint);
702
+ }
703
+ }
704
+ // Try to get target point
705
+ if (config.targetLongitude && config.targetLatitude) {
706
+ const targetLon = getNumber(link, config.targetLongitude);
707
+ const targetLat = getNumber(link, config.targetLatitude);
708
+ if (isNumber(targetLon) && isNumber(targetLat)) {
709
+ points.push({ longitude: targetLon, latitude: targetLat });
710
+ }
711
+ }
712
+ else {
713
+ const targetPoint = (_b = config.linkTarget) === null || _b === void 0 ? void 0 : _b.call(config, link);
714
+ if (typeof targetPoint === 'object' && targetPoint !== null) {
715
+ points.push(targetPoint);
716
+ }
717
+ }
718
+ });
719
+ if (points.length > 0) {
720
+ this._fitToPoints(points, pad);
721
+ }
722
+ else {
723
+ this.fitView();
724
+ }
725
+ }
726
+ _onPointClick(d, event) {
727
+ const { config } = this;
728
+ event.stopPropagation();
729
+ // Handle clicking on expanded cluster points to collapse them
730
+ const expandedPoint = d;
731
+ if (expandedPoint.expandedClusterPoint) {
732
+ this._resetExpandedCluster();
733
+ this._renderPoints(config.duration);
734
+ return;
735
+ }
736
+ if (d.isCluster && d.properties.cluster) {
737
+ // Enable click expansion when clusterExpandOnClick is true OR when colorMap is enabled (for pie chart clusters)
738
+ const hasColorMap = config.colorMap && Object.keys(config.colorMap).length > 0;
739
+ if (config.clusterExpandOnClick || hasColorMap) {
740
+ // Always expand the cluster to show individual points
741
+ const expandedPoints = this._expandCluster(d);
742
+ // Calculate the geographic center (centroid) of the expanded points and zoom to it
743
+ if (expandedPoints && expandedPoints.length > 0) {
744
+ const avgLat = expandedPoints.reduce((sum, p) => sum + getNumber(p, config.latitude), 0) / expandedPoints.length;
745
+ const avgLon = expandedPoints.reduce((sum, p) => sum + getNumber(p, config.longitude), 0) / expandedPoints.length;
746
+ const centroidCoordinates = [avgLon, avgLat];
747
+ // Calculate appropriate zoom level
748
+ const newZoomLevel = getNextZoomLevelOnClusterClick(this._currentZoomLevel || 1);
749
+ // Start zoom immediately for smoother transition
750
+ this._zoomToLocation(centroidCoordinates, newZoomLevel);
751
+ }
752
+ }
753
+ }
754
+ }
755
+ _expandCluster(clusterPoint) {
756
+ const { config } = this;
757
+ if (!clusterPoint.clusterIndex)
758
+ return undefined;
759
+ const padding = 1;
760
+ const clusterId = clusterPoint.properties.clusterId;
761
+ const points = clusterPoint.clusterIndex.getLeaves(clusterId, Infinity);
762
+ // Calculate positions for expanded points using d3.packSiblings (same as leaflet map)
763
+ const packPoints = points.map(() => ({
764
+ x: 0,
765
+ y: 0,
766
+ r: 8 + padding, // Base radius for individual points
767
+ }));
768
+ packSiblings(packPoints);
769
+ // Create expanded points with relative positions
770
+ const expandedPoints = points.map((point, i) => {
771
+ const originalData = point.properties;
772
+ const radius = getNumber(originalData, config.pointRadius) || 8;
773
+ const shape = getString(originalData, config.pointShape) || TopoJSONMapPointShape.Circle;
774
+ const donutData = getDonutData(originalData, config.colorMap);
775
+ // Use the cluster's exact color for all expanded points to maintain visual consistency
776
+ const pointColor = clusterPoint.color;
777
+ return {
778
+ geometry: { type: 'Point', coordinates: clusterPoint.geometry.coordinates },
779
+ bbox: { x1: 0, y1: 0, x2: 0, y2: 0 },
780
+ radius,
781
+ path: getPointPathData({ x: 0, y: 0 }, radius, shape),
782
+ color: pointColor,
783
+ id: getString(originalData, config.pointId, i),
784
+ properties: originalData,
785
+ donutData,
786
+ isCluster: false,
787
+ _zIndex: 1,
788
+ expandedClusterPoint: clusterPoint,
789
+ clusterColor: pointColor,
790
+ dx: packPoints[i].x,
791
+ dy: packPoints[i].y,
792
+ };
793
+ });
794
+ this._resetExpandedCluster();
795
+ this._expandedCluster = {
796
+ cluster: clusterPoint,
797
+ points: expandedPoints,
798
+ };
799
+ // Re-render to show expanded points with smooth animation
800
+ this._renderPoints(config.duration / 2);
801
+ // Return the original point data for centroid calculation
802
+ return points.map(p => p.properties);
803
+ }
804
+ _zoomToLocation(coordinates, zoomLevel) {
805
+ const { config } = this;
806
+ const clampedZoomLevel = clamp(zoomLevel, config.zoomExtent[0], config.zoomExtent[1]);
807
+ // Project the target coordinates using the current projection
808
+ const targetPoint = this._projection(coordinates);
809
+ if (!targetPoint)
810
+ return;
811
+ // Calculate the center of the viewport
812
+ const centerX = this._width / 2;
813
+ const centerY = this._height / 2;
814
+ // Calculate the scale factor
815
+ const k = this._initialScale * clampedZoomLevel;
816
+ // Calculate translation to center the target point
817
+ // We need to account for the current projection center
818
+ const currentCenter = this._projection.translate();
819
+ const x = currentCenter[0] + (centerX - targetPoint[0]) * (k / this._initialScale);
820
+ const y = currentCenter[1] + (centerY - targetPoint[1]) * (k / this._initialScale);
821
+ const transform = zoomIdentity.translate(x, y).scale(k);
822
+ // Update internal state
823
+ this._currentZoomLevel = clampedZoomLevel;
824
+ this._center = [x, y];
825
+ // Set flag to indicate this is a component-initiated zoom
826
+ this._eventInitiatedByComponent = true;
827
+ // Apply the transform with smooth eased animation
828
+ this.g
829
+ .transition()
830
+ .duration(config.zoomDuration)
831
+ .ease(easeCubicInOut)
832
+ .call(this._zoomBehavior.transform, transform);
833
+ }
834
+ _resetExpandedCluster() {
835
+ var _a, _b;
836
+ (_b = (_a = this._expandedCluster) === null || _a === void 0 ? void 0 : _a.points) === null || _b === void 0 ? void 0 : _b.forEach((d) => { delete d.expandedClusterPoint; });
837
+ this._expandedCluster = null;
838
+ }
839
+ _initFlowFeatures() {
840
+ var _a;
841
+ const { config, datamodel } = this;
842
+ // Use raw links data instead of processed links to avoid point lookup issues for flows
843
+ const rawLinks = ((_a = datamodel.data) === null || _a === void 0 ? void 0 : _a.links) || [];
844
+ // Clear existing flow data
845
+ this._flowParticles = [];
846
+ this._sourcePoints = [];
847
+ if (!rawLinks || rawLinks.length === 0)
848
+ return;
849
+ // Create source points and flow particles for each link
850
+ rawLinks.forEach((link, i) => {
851
+ var _a, _b;
852
+ // Try to get coordinates from flow-specific accessors first, then fall back to link endpoints
853
+ let sourceLon, sourceLat, targetLon, targetLat;
854
+ if (config.sourceLongitude && config.sourceLatitude) {
855
+ sourceLon = getNumber(link, config.sourceLongitude);
856
+ sourceLat = getNumber(link, config.sourceLatitude);
857
+ }
858
+ else {
859
+ // Fall back to using linkSource point coordinates
860
+ const sourcePoint = (_a = config.linkSource) === null || _a === void 0 ? void 0 : _a.call(config, link);
861
+ if (typeof sourcePoint === 'object' && sourcePoint !== null) {
862
+ sourceLon = getNumber(sourcePoint, config.longitude);
863
+ sourceLat = getNumber(sourcePoint, config.latitude);
864
+ }
865
+ else {
866
+ return; // Skip if can't resolve source coordinates
867
+ }
868
+ }
869
+ if (config.targetLongitude && config.targetLatitude) {
870
+ targetLon = getNumber(link, config.targetLongitude);
871
+ targetLat = getNumber(link, config.targetLatitude);
872
+ }
873
+ else {
874
+ // Fall back to using linkTarget point coordinates
875
+ const targetPoint = (_b = config.linkTarget) === null || _b === void 0 ? void 0 : _b.call(config, link);
876
+ if (typeof targetPoint === 'object' && targetPoint !== null) {
877
+ targetLon = getNumber(targetPoint, config.longitude);
878
+ targetLat = getNumber(targetPoint, config.latitude);
879
+ }
880
+ else {
881
+ return; // Skip if can't resolve target coordinates
882
+ }
883
+ }
884
+ if (!isNumber(sourceLon) || !isNumber(sourceLat) || !isNumber(targetLon) || !isNumber(targetLat)) {
885
+ return;
886
+ }
887
+ const source = { lat: sourceLat, lon: sourceLon };
888
+ const target = { lat: targetLat, lon: targetLon };
889
+ // Create source point
890
+ const sourcePos = this._projection([sourceLon, sourceLat]);
891
+ if (sourcePos) {
892
+ const sourcePoint = {
893
+ lat: sourceLat,
894
+ lon: sourceLon,
895
+ x: sourcePos[0],
896
+ y: sourcePos[1],
897
+ radius: getNumber(link, config.sourcePointRadius),
898
+ color: getColor(link, config.sourcePointColor, i),
899
+ flowData: link,
900
+ };
901
+ this._sourcePoints.push(sourcePoint);
902
+ }
903
+ // Create flow particles
904
+ const dist = Math.sqrt(Math.pow((targetLat - sourceLat), 2) + Math.pow((targetLon - sourceLon), 2));
905
+ const numParticles = Math.max(1, Math.round(dist * getNumber(link, config.flowParticleDensity)));
906
+ const velocity = getNumber(link, config.flowParticleSpeed);
907
+ const radius = getNumber(link, config.flowParticleRadius);
908
+ const color = getColor(link, config.flowParticleColor, i);
909
+ for (let j = 0; j < numParticles; j += 1) {
910
+ const progress = j / numParticles;
911
+ const location = {
912
+ lat: sourceLat + (targetLat - sourceLat) * progress,
913
+ lon: sourceLon + (targetLon - sourceLon) * progress,
914
+ };
915
+ const pos = this._projection([location.lon, location.lat]);
916
+ if (pos) {
917
+ const particle = {
918
+ x: pos[0],
919
+ y: pos[1],
920
+ source,
921
+ target,
922
+ location,
923
+ velocity,
924
+ radius,
925
+ color,
926
+ flowData: link,
927
+ progress: 0,
928
+ id: `${getString(link, config.linkId, i) || i}-${j}`,
929
+ };
930
+ this._flowParticles.push(particle);
931
+ }
932
+ }
933
+ });
934
+ }
935
+ _renderSourcePoints(duration) {
936
+ const { config } = this;
937
+ const sourcePoints = this._sourcePointsGroup
938
+ .selectAll(`.${sourcePoint}`)
939
+ .data(this._sourcePoints, (d, i) => `${d.flowData}-${i}`);
940
+ const sourcePointsEnter = sourcePoints.enter()
941
+ .append('circle')
942
+ .attr('class', sourcePoint)
943
+ .attr('r', 0)
944
+ .style('opacity', 0)
945
+ .on('click', (event, d) => {
946
+ var _a;
947
+ event.stopPropagation();
948
+ (_a = config.onSourcePointClick) === null || _a === void 0 ? void 0 : _a.call(config, d.flowData, d.x, d.y, event);
949
+ })
950
+ .on('mouseenter', (event, d) => {
951
+ var _a;
952
+ (_a = config.onSourcePointMouseEnter) === null || _a === void 0 ? void 0 : _a.call(config, d.flowData, d.x, d.y, event);
953
+ })
954
+ .on('mouseleave', (event, d) => {
955
+ var _a;
956
+ (_a = config.onSourcePointMouseLeave) === null || _a === void 0 ? void 0 : _a.call(config, d.flowData, event);
957
+ });
958
+ smartTransition(sourcePointsEnter.merge(sourcePoints), duration)
959
+ .attr('cx', d => d.x)
960
+ .attr('cy', d => d.y)
961
+ .attr('r', d => d.radius / (this._currentZoomLevel || 1))
962
+ .style('fill', d => d.color)
963
+ .style('stroke', d => d.color)
964
+ .style('opacity', 1);
965
+ sourcePoints.exit().remove();
966
+ }
967
+ _renderFlowParticles(duration) {
968
+ const flowParticles = this._flowParticlesGroup
969
+ .selectAll(`.${flowParticle}`)
970
+ .data(this._flowParticles, d => d.id);
971
+ const flowParticlesEnter = flowParticles.enter()
972
+ .append('circle')
973
+ .attr('class', flowParticle)
974
+ .attr('r', 0)
975
+ .style('opacity', 0);
976
+ smartTransition(flowParticlesEnter.merge(flowParticles), duration)
977
+ .attr('cx', d => d.x)
978
+ .attr('cy', d => d.y)
979
+ .attr('r', d => d.radius / (this._currentZoomLevel || 1))
980
+ .style('fill', d => d.color)
981
+ .style('opacity', 0.8);
982
+ flowParticles.exit().remove();
983
+ }
984
+ _startFlowAnimation() {
985
+ if (this._animationId)
986
+ return; // Animation already running
987
+ this._animateFlow();
988
+ }
989
+ _animateFlow() {
990
+ if (!this.config.enableFlowAnimation)
991
+ return;
992
+ this._animationId = requestAnimationFrame(() => {
993
+ this._updateFlowParticles();
994
+ this._animateFlow(); // Recursive call like LeafletFlowMap
995
+ });
996
+ }
997
+ _stopFlowAnimation() {
998
+ if (this._animationId) {
999
+ cancelAnimationFrame(this._animationId);
1000
+ this._animationId = null;
1001
+ }
1002
+ }
1003
+ _updateFlowParticles() {
1004
+ if (this._flowParticles.length === 0)
1005
+ return;
1006
+ const zoomLevel = this._currentZoomLevel || 1;
1007
+ this._flowParticles.forEach(particle => {
1008
+ const { source, target } = particle;
1009
+ // Calculate movement using angle-based velocity (like LeafletFlowMap)
1010
+ const fullDist = Math.sqrt(Math.pow((target.lat - source.lat), 2) + Math.pow((target.lon - source.lon), 2));
1011
+ const remainedDist = Math.sqrt(Math.pow((target.lat - particle.location.lat), 2) + Math.pow((target.lon - particle.location.lon), 2));
1012
+ const angle = Math.atan2(target.lat - source.lat, target.lon - source.lon);
1013
+ // Update geographic location
1014
+ particle.location.lat += particle.velocity * Math.sin(angle);
1015
+ particle.location.lon += particle.velocity * Math.cos(angle);
1016
+ // Reset to start when reaching target (like LeafletFlowMap)
1017
+ if ((((target.lat > source.lat) && (particle.location.lat > target.lat)) || ((target.lon > source.lon) && (particle.location.lon > target.lon))) ||
1018
+ (((target.lat < source.lat) && (particle.location.lat < target.lat)) || ((target.lon < source.lon) && (particle.location.lon < target.lon)))) {
1019
+ particle.location.lat = source.lat;
1020
+ particle.location.lon = source.lon;
1021
+ }
1022
+ // Project to screen coordinates
1023
+ const pos = this._projection([particle.location.lon, particle.location.lat]);
1024
+ if (pos) {
1025
+ // Add orthogonal arc shift (adapted from LeafletFlowMap)
1026
+ const orthogonalArcShift = -(Math.pow(zoomLevel, 2) * fullDist / 8) * Math.cos(Math.PI / 2 * (fullDist / 2 - remainedDist) / (fullDist / 2)) || 0;
1027
+ particle.x = pos[0];
1028
+ particle.y = pos[1] + orthogonalArcShift;
1029
+ }
1030
+ });
1031
+ // Update DOM elements directly without data rebinding (for performance)
1032
+ this._flowParticlesGroup
1033
+ .selectAll(`.${flowParticle}`)
1034
+ .attr('cx', (d, i) => { var _a; return ((_a = this._flowParticles[i]) === null || _a === void 0 ? void 0 : _a.x) || 0; })
1035
+ .attr('cy', (d, i) => { var _a; return ((_a = this._flowParticles[i]) === null || _a === void 0 ? void 0 : _a.y) || 0; })
1036
+ .attr('r', (d, i) => { var _a; return (((_a = this._flowParticles[i]) === null || _a === void 0 ? void 0 : _a.radius) || 1) / zoomLevel; });
1037
+ }
364
1038
  destroy() {
365
1039
  window.cancelAnimationFrame(this._animFrameId);
1040
+ this._stopFlowAnimation();
1041
+ window.cancelAnimationFrame(this._collideLabelsAnimFrameId);
366
1042
  }
367
1043
  }
368
1044
  TopoJSONMap.selectors = style;