@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.
- package/components/area/config.d.ts +2 -0
- package/components/area/config.js +1 -1
- package/components/area/config.js.map +1 -1
- package/components/area/index.js +6 -3
- package/components/area/index.js.map +1 -1
- package/components/crosshair/config.d.ts +1 -1
- package/components/crosshair/config.js.map +1 -1
- package/components/crosshair/index.d.ts +1 -1
- package/components/crosshair/index.js.map +1 -1
- package/components/topojson-map/config.d.ts +57 -1
- package/components/topojson-map/config.js +6 -2
- package/components/topojson-map/config.js.map +1 -1
- package/components/topojson-map/index.d.ts +25 -0
- package/components/topojson-map/index.js +708 -32
- package/components/topojson-map/index.js.map +1 -1
- package/components/topojson-map/modules/donut.d.ts +3 -0
- package/components/topojson-map/modules/donut.js +25 -0
- package/components/topojson-map/modules/donut.js.map +1 -0
- package/components/topojson-map/style.d.ts +6 -0
- package/components/topojson-map/style.js +45 -1
- package/components/topojson-map/style.js.map +1 -1
- package/components/topojson-map/types.d.ts +82 -0
- package/components/topojson-map/types.js +8 -1
- package/components/topojson-map/types.js.map +1 -1
- package/components/topojson-map/utils.d.ts +17 -0
- package/components/topojson-map/utils.js +177 -3
- package/components/topojson-map/utils.js.map +1 -1
- package/components.d.ts +2 -0
- package/components.js +1 -0
- package/components.js.map +1 -1
- package/containers/single-container/config.d.ts +3 -0
- package/containers/single-container/config.js.map +1 -1
- package/containers/single-container/index.js +2 -1
- package/containers/single-container/index.js.map +1 -1
- package/containers/xy-container/config.d.ts +3 -0
- package/containers/xy-container/config.js.map +1 -1
- package/containers/xy-container/index.d.ts +1 -0
- package/containers/xy-container/index.js +13 -9
- package/containers/xy-container/index.js.map +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- 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,
|
|
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 {
|
|
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
|
-
|
|
348
|
+
_getPointData() {
|
|
177
349
|
const { config, datamodel } = this;
|
|
178
|
-
|
|
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) =>
|
|
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(
|
|
186
|
-
|
|
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('
|
|
190
|
-
.attr('
|
|
191
|
-
.style('fill', (d, 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(
|
|
200
|
-
|
|
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('
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 => {
|
|
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 =
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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;
|