Skip to content

Commit 4e8afd3

Browse files
committed
Component | TopoJSON: Move certain functions to modules
1 parent 5934eaa commit 4e8afd3

File tree

7 files changed

+1331
-1312
lines changed

7 files changed

+1331
-1312
lines changed

packages/angular/src/components/topojson-map/topojson-map.component.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,16 @@ export class VisTopoJSONMapComponent<AreaDatum, PointDatum, LinkDatum> implement
8888
/** Initial zoom level. Default: `undefined` */
8989
@Input() zoomFactor?: number
9090

91+
/** Zoom to a specific location. When set, the map will zoom to the specified coordinates at the given zoom level.
92+
* Format: `{ coordinates: [longitude, latitude], zoomLevel: number, expandCluster?: boolean }`
93+
* When `expandCluster` is true, the cluster at or nearest to the coordinates will be expanded.
94+
* Default: `undefined` */
95+
@Input() zoomToLocation?: {
96+
coordinates: [number, number];
97+
zoomLevel: number;
98+
expandCluster?: boolean;
99+
}
100+
91101
/** Disable pan / zoom interactions. Default: `false` */
92102
@Input() disableZoom?: boolean
93103

@@ -294,8 +304,8 @@ export class VisTopoJSONMapComponent<AreaDatum, PointDatum, LinkDatum> implement
294304
}
295305

296306
private getConfig (): TopoJSONMapConfigInterface<AreaDatum, PointDatum, LinkDatum> {
297-
const { duration, events, attributes, projection, topojson, mapFeatureName, mapFitToPoints, zoomFactor, disableZoom, zoomExtent, zoomDuration, linkWidth, linkColor, linkCursor, linkId, linkSource, linkTarget, sourceLongitude, sourceLatitude, targetLongitude, targetLatitude, sourcePointRadius, sourcePointColor, flowParticleColor, flowParticleRadius, flowParticleSpeed, flowParticleDensity, enableFlowAnimation, onSourcePointClick, onSourcePointMouseEnter, onSourcePointMouseLeave, areaId, areaColor, areaCursor, areaLabel, pointColor, pointRadius, pointStrokeWidth, pointShape, pointRingWidth, pointCursor, longitude, latitude, pointLabel, pointLabelColor, pointLabelPosition, pointBottomLabel, pointLabelTextBrightnessRatio, pointId, clusterColor, clusterRadius, clusterLabel, clusterLabelColor, clusterBottomLabel, clusterRingWidth, clusterBackground, clusterExpandOnClick, clusteringDistance, clustering, heatmapMode, heatmapModeBlurStdDeviation, heatmapModeZoomLevelThreshold, colorMap } = this
298-
const config = { duration, events, attributes, projection, topojson, mapFeatureName, mapFitToPoints, zoomFactor, disableZoom, zoomExtent, zoomDuration, linkWidth, linkColor, linkCursor, linkId, linkSource, linkTarget, sourceLongitude, sourceLatitude, targetLongitude, targetLatitude, sourcePointRadius, sourcePointColor, flowParticleColor, flowParticleRadius, flowParticleSpeed, flowParticleDensity, enableFlowAnimation, onSourcePointClick, onSourcePointMouseEnter, onSourcePointMouseLeave, areaId, areaColor, areaCursor, areaLabel, pointColor, pointRadius, pointStrokeWidth, pointShape, pointRingWidth, pointCursor, longitude, latitude, pointLabel, pointLabelColor, pointLabelPosition, pointBottomLabel, pointLabelTextBrightnessRatio, pointId, clusterColor, clusterRadius, clusterLabel, clusterLabelColor, clusterBottomLabel, clusterRingWidth, clusterBackground, clusterExpandOnClick, clusteringDistance, clustering, heatmapMode, heatmapModeBlurStdDeviation, heatmapModeZoomLevelThreshold, colorMap }
307+
const { duration, events, attributes, projection, topojson, mapFeatureName, mapFitToPoints, zoomFactor, zoomToLocation, disableZoom, zoomExtent, zoomDuration, linkWidth, linkColor, linkCursor, linkId, linkSource, linkTarget, sourceLongitude, sourceLatitude, targetLongitude, targetLatitude, sourcePointRadius, sourcePointColor, flowParticleColor, flowParticleRadius, flowParticleSpeed, flowParticleDensity, enableFlowAnimation, onSourcePointClick, onSourcePointMouseEnter, onSourcePointMouseLeave, areaId, areaColor, areaCursor, areaLabel, pointColor, pointRadius, pointStrokeWidth, pointShape, pointRingWidth, pointCursor, longitude, latitude, pointLabel, pointLabelColor, pointLabelPosition, pointBottomLabel, pointLabelTextBrightnessRatio, pointId, clusterColor, clusterRadius, clusterLabel, clusterLabelColor, clusterBottomLabel, clusterRingWidth, clusterBackground, clusterExpandOnClick, clusteringDistance, clustering, heatmapMode, heatmapModeBlurStdDeviation, heatmapModeZoomLevelThreshold, colorMap } = this
308+
const config = { duration, events, attributes, projection, topojson, mapFeatureName, mapFitToPoints, zoomFactor, zoomToLocation, disableZoom, zoomExtent, zoomDuration, linkWidth, linkColor, linkCursor, linkId, linkSource, linkTarget, sourceLongitude, sourceLatitude, targetLongitude, targetLatitude, sourcePointRadius, sourcePointColor, flowParticleColor, flowParticleRadius, flowParticleSpeed, flowParticleDensity, enableFlowAnimation, onSourcePointClick, onSourcePointMouseEnter, onSourcePointMouseLeave, areaId, areaColor, areaCursor, areaLabel, pointColor, pointRadius, pointStrokeWidth, pointShape, pointRingWidth, pointCursor, longitude, latitude, pointLabel, pointLabelColor, pointLabelPosition, pointBottomLabel, pointLabelTextBrightnessRatio, pointId, clusterColor, clusterRadius, clusterLabel, clusterLabelColor, clusterBottomLabel, clusterRingWidth, clusterBackground, clusterExpandOnClick, clusteringDistance, clustering, heatmapMode, heatmapModeBlurStdDeviation, heatmapModeZoomLevelThreshold, colorMap }
299309
const keys = Object.keys(config) as (keyof TopoJSONMapConfigInterface<AreaDatum, PointDatum, LinkDatum>)[]
300310
keys.forEach(key => { if (config[key] === undefined) delete config[key] })
301311

packages/ts/src/components/topojson-map/index.ts

Lines changed: 42 additions & 167 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ import { getCSSVariableValue, isStringCSSVariable } from 'utils/misc'
1919
import { trimStringMiddle } from 'utils/text'
2020
// Types
2121
import { MapLink } from 'types/map'
22-
import Supercluster from 'supercluster'
23-
22+
import { GenericDataRecord } from 'types/data'
23+
import Supercluster, { PointFeature, ClusterFeature } from 'supercluster'
2424

2525
// Local Types
2626
import {
@@ -51,10 +51,14 @@ import {
5151
calculateClusterIndex,
5252
getClustersAndPoints,
5353
geoJsonPointToScreenPoint,
54-
getClusterRadius,
5554
getNextZoomLevelOnClusterClick,
55+
getClusterRadius,
56+
PackedPoint,
5657
} from './utils'
5758
import { updateDonut } from './modules/donut'
59+
import { renderBackground } from './modules/background'
60+
import { updateSelectionRing } from './modules/selectionRing'
61+
import { initFlowFeatures, updateFlowParticles, FlowInitContext, FlowUpdateContext } from './modules/flow'
5862

5963
// Styles
6064
import * as s from './style'
@@ -66,8 +70,8 @@ const SUPERCLUSTER_MAX_ZOOM = 22
6670

6771
export class TopoJSONMap<
6872
AreaDatum,
69-
PointDatum = unknown,
70-
LinkDatum = unknown,
73+
PointDatum = GenericDataRecord,
74+
LinkDatum = GenericDataRecord,
7175
> extends ComponentCore<
7276
MapData<AreaDatum, PointDatum, LinkDatum>,
7377
TopoJSONMapConfigInterface<AreaDatum, PointDatum, LinkDatum>
@@ -165,7 +169,7 @@ export class TopoJSONMap<
165169
})
166170
const zoomExtent = this.config.zoomExtent
167171
const maxClusterZoomLevel = Array.isArray(zoomExtent) ? Math.min(zoomExtent[1], SUPERCLUSTER_MAX_ZOOM) : 16
168-
this._clusterIndex = calculateClusterIndex(dataValid as any, this.config as any, maxClusterZoomLevel) as any
172+
this._clusterIndex = calculateClusterIndex(dataValid, this.config, maxClusterZoomLevel)
169173
} else {
170174
this._clusterIndex = null
171175
}
@@ -251,15 +255,10 @@ export class TopoJSONMap<
251255
}
252256

253257
_renderBackground (): void {
254-
this._backgroundRect
255-
.attr('width', '100%')
256-
.attr('height', '100%')
257-
.attr('transform', `translate(${-this.bleed.left}, ${-this.bleed.top})`)
258-
.style('cursor', 'default')
259-
.on('click', () => {
260-
// Collapse expanded cluster when clicking on background
261-
this._collapseExpandedCluster()
262-
})
258+
renderBackground(this._backgroundRect, {
259+
bleed: { left: this.bleed.left, top: this.bleed.top },
260+
onClick: () => this._collapseExpandedCluster(),
261+
})
263262
}
264263

265264
_renderGroups (duration: number): void {
@@ -452,7 +451,7 @@ export class TopoJSONMap<
452451
const cluster = this._expandedCluster.cluster
453452
const pos = this._projection(cluster.geometry.coordinates as [number, number])
454453

455-
const backgroundRadius = getClusterRadius(this._expandedCluster as any)
454+
const backgroundRadius = getClusterRadius(this._expandedCluster as { points: PackedPoint[]; cluster: TopoJSONMapPoint<PointDatum> })
456455
// Divide by zoom level since the group transform will scale it back up
457456
const adjustedRadius = backgroundRadius / currentZoomLevel
458457

@@ -551,7 +550,7 @@ export class TopoJSONMap<
551550
// Supercluster expects zoom 0-22; beyond that, all points are unclustered anyway.
552551
const mapZoom = this._currentZoomLevel || 1
553552
const zoom = Math.max(0, Math.min(Math.round(mapZoom), SUPERCLUSTER_MAX_ZOOM))
554-
let geoJsonPoints = getClustersAndPoints(this._clusterIndex as any, bounds, zoom)
553+
let geoJsonPoints = getClustersAndPoints(this._clusterIndex!, bounds, zoom)
555554

556555
// Handle expanded cluster points - replace the expanded cluster with individual points
557556
if (this._expandedCluster) {
@@ -594,8 +593,14 @@ export class TopoJSONMap<
594593
}
595594

596595
return geoJsonPoints.map((geoPoint, i) =>
597-
geoJsonPointToScreenPoint(geoPoint as any, i, this._projection, this.config as any, this._currentZoomLevel || 1)
598-
) as any
596+
geoJsonPointToScreenPoint(
597+
geoPoint as ClusterFeature<TopoJSONMapClusterDatum<PointDatum>> | PointFeature<TopoJSONMapPointDatum<PointDatum>>,
598+
i,
599+
this._projection,
600+
this.config,
601+
this._currentZoomLevel || 1
602+
)
603+
)
599604
}
600605

601606
_renderPoints (duration: number): void {
@@ -905,30 +910,14 @@ export class TopoJSONMap<
905910
this._pointsGroup.selectAll(`.${s.pointBottomLabel}`).style('display', (config.heatmapMode && (this._currentZoomLevel < config.heatmapModeZoomLevelThreshold)) ? 'none' : null)
906911

907912
// Update selection ring
908-
const pointSelection = this._pointSelectionRing.select(`.${s.pointSelection}`)
909-
if (this._selectedPoint) {
910-
const selectedPointId = getString(this._selectedPoint.properties as PointDatum, config.pointId)
911-
const foundPoint = pointData.find(d =>
912-
this._selectedPoint.isCluster
913-
? (d.id === this._selectedPoint.id)
914-
: (selectedPointId && getString(d.properties as PointDatum, config.pointId) === selectedPointId)
915-
)
916-
const pos = this._projection((foundPoint ?? this._selectedPoint).geometry.coordinates as [number, number])
917-
if (pos) {
918-
const dx = ((foundPoint as any)?.dx || 0) / currentZoomLevel
919-
const dy = ((foundPoint as any)?.dy || 0) / currentZoomLevel
920-
this._pointSelectionRing.attr('transform', `translate(${pos[0] + dx},${pos[1] + dy})`)
921-
}
922-
pointSelection
923-
.classed('active', Boolean(foundPoint))
924-
.attr('d', foundPoint?.path || null)
925-
.style('fill', 'transparent')
926-
.style('stroke-width', 1)
927-
.style('stroke', (foundPoint || this._selectedPoint)?.color)
928-
.style('transform', `scale(${1.25 / currentZoomLevel})`)
929-
} else {
930-
pointSelection.classed('active', false)
931-
}
913+
updateSelectionRing<PointDatum>(
914+
this._pointSelectionRing,
915+
this._selectedPoint,
916+
pointData,
917+
this.config,
918+
this._projection,
919+
currentZoomLevel
920+
)
932921
}
933922

934923
_fitToPoints (points?: PointDatum[], pad = 0.1): void {
@@ -1150,103 +1139,9 @@ export class TopoJSONMap<
11501139
}
11511140

11521141
private _initFlowFeatures (): void {
1153-
const { config, datamodel } = this
1154-
// Use raw links data instead of processed links to avoid point lookup issues for flows
1155-
const rawLinks = datamodel.data?.links || []
1156-
1157-
// Clear existing flow data
1158-
this._flowParticles = []
1159-
this._sourcePoints = []
1160-
1161-
if (!rawLinks || rawLinks.length === 0) return
1162-
1163-
// Create source points and flow particles for each link
1164-
rawLinks.forEach((link, i) => {
1165-
// Try to get coordinates from flow-specific accessors first, then fall back to link endpoints
1166-
let sourceLon: number, sourceLat: number, targetLon: number, targetLat: number
1167-
1168-
if (config.sourceLongitude && config.sourceLatitude) {
1169-
sourceLon = getNumber(link, config.sourceLongitude)
1170-
sourceLat = getNumber(link, config.sourceLatitude)
1171-
} else {
1172-
// Fall back to using linkSource point coordinates
1173-
const sourcePoint = config.linkSource?.(link)
1174-
if (typeof sourcePoint === 'object' && sourcePoint !== null) {
1175-
sourceLon = getNumber(sourcePoint as PointDatum, config.longitude)
1176-
sourceLat = getNumber(sourcePoint as PointDatum, config.latitude)
1177-
} else {
1178-
return // Skip if can't resolve source coordinates
1179-
}
1180-
}
1181-
1182-
if (config.targetLongitude && config.targetLatitude) {
1183-
targetLon = getNumber(link, config.targetLongitude)
1184-
targetLat = getNumber(link, config.targetLatitude)
1185-
} else {
1186-
// Fall back to using linkTarget point coordinates
1187-
const targetPoint = config.linkTarget?.(link)
1188-
if (typeof targetPoint === 'object' && targetPoint !== null) {
1189-
targetLon = getNumber(targetPoint as PointDatum, config.longitude)
1190-
targetLat = getNumber(targetPoint as PointDatum, config.latitude)
1191-
} else {
1192-
return // Skip if can't resolve target coordinates
1193-
}
1194-
}
1195-
1196-
if (!isNumber(sourceLon) || !isNumber(sourceLat) || !isNumber(targetLon) || !isNumber(targetLat)) {
1197-
return
1198-
}
1199-
// Create source point
1200-
const sourcePos = this._projection([sourceLon, sourceLat])
1201-
if (sourcePos) {
1202-
const sourcePoint = {
1203-
lat: sourceLat,
1204-
lon: sourceLon,
1205-
x: sourcePos[0],
1206-
y: sourcePos[1],
1207-
radius: getNumber(link, config.sourcePointRadius),
1208-
color: getColor(link, config.sourcePointColor, i),
1209-
flowData: link,
1210-
}
1211-
this._sourcePoints.push(sourcePoint)
1212-
}
1213-
1214-
// Use the same arc as _renderLinks for flow animation
1215-
const sourceProj = this._projection([sourceLon, sourceLat])
1216-
const targetProj = this._projection([targetLon, targetLat])
1217-
if (!sourceProj || !targetProj) return
1218-
1219-
// Generate SVG arc path string using the same arc() function
1220-
const arcPath = arc(sourceProj, targetProj)
1221-
// Create a temporary SVG path element for sampling
1222-
const tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
1223-
tempPath.setAttribute('d', arcPath)
1224-
const pathLength = tempPath.getTotalLength()
1225-
1226-
const dist = Math.sqrt((targetLat - sourceLat) ** 2 + (targetLon - sourceLon) ** 2)
1227-
const numParticles = Math.max(1, Math.round(dist * getNumber(link, config.flowParticleDensity)))
1228-
const velocity = getNumber(link, config.flowParticleSpeed)
1229-
const radius = getNumber(link, config.flowParticleRadius)
1230-
const color = getColor(link, config.flowParticleColor, i)
1231-
1232-
for (let j = 0; j < numParticles; j += 1) {
1233-
const progress = j / numParticles
1234-
const pt = tempPath.getPointAtLength(progress * pathLength)
1235-
const particle: FlowParticle = {
1236-
x: pt.x,
1237-
y: pt.y,
1238-
velocity,
1239-
radius,
1240-
color,
1241-
progress,
1242-
arcPath,
1243-
pathLength,
1244-
id: `${getString(link, config.linkId, i) || i}-${j}`,
1245-
flowData: undefined,
1246-
}
1247-
this._flowParticles.push(particle)
1248-
}
1249-
})
1142+
initFlowFeatures<PointDatum, LinkDatum>(
1143+
this as unknown as FlowInitContext<PointDatum, LinkDatum>
1144+
)
12501145
}
12511146

12521147
private _renderSourcePoints (duration: number): void {
@@ -1328,31 +1223,11 @@ export class TopoJSONMap<
13281223
}
13291224

13301225
private _updateFlowParticles (): void {
1331-
if (this._flowParticles.length === 0) return
1332-
1333-
const zoomLevel = this._currentZoomLevel || 1
1334-
1335-
this._flowParticles.forEach(particle => {
1336-
// Move particle along the arc path using progress
1337-
particle.progress += particle.velocity * 0.01
1338-
if (particle.progress > 1) particle.progress = 0
1339-
1340-
// Use the stored SVG path and pathLength
1341-
if (particle.arcPath && typeof particle.pathLength === 'number') {
1342-
const tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
1343-
tempPath.setAttribute('d', particle.arcPath)
1344-
const pt = tempPath.getPointAtLength(particle.progress * particle.pathLength)
1345-
particle.x = pt.x
1346-
particle.y = pt.y
1347-
}
1348-
})
1349-
1350-
// Update DOM elements directly without data rebinding (for performance)
1351-
this._flowParticlesGroup
1352-
.selectAll<SVGCircleElement, any>(`.${s.flowParticle}`)
1353-
.attr('cx', (d, i) => this._flowParticles[i]?.x || 0)
1354-
.attr('cy', (d, i) => this._flowParticles[i]?.y || 0)
1355-
.attr('r', (d, i) => (this._flowParticles[i]?.radius || 1) / zoomLevel)
1226+
updateFlowParticles({
1227+
_flowParticles: this._flowParticles as FlowParticle[],
1228+
_currentZoomLevel: this._currentZoomLevel,
1229+
_flowParticlesGroup: this._flowParticlesGroup,
1230+
} as FlowUpdateContext)
13561231
}
13571232

13581233
private _onPointClick (d: TopoJSONMapPoint<PointDatum>, event: MouseEvent): void {
@@ -1437,7 +1312,7 @@ export class TopoJSONMap<
14371312
return {
14381313
x: null as number | null,
14391314
y: null as number | null,
1440-
r: getPointRadius(point as any, config.pointRadius as any, packingZoomLevel) + padding,
1315+
r: getPointRadius(point as PointFeature<TopoJSONMapPointDatum<PointDatum>>, config.pointRadius, packingZoomLevel) + padding,
14411316
}
14421317
})
14431318
packSiblings(packPoints)
@@ -1450,7 +1325,7 @@ export class TopoJSONMap<
14501325
// Don't show pie charts for expanded cluster points (similar to Leaflet map)
14511326
const donutData: TopoJSONMapPieDatum[] = []
14521327
// Use each point's own color (from colorMap or pointColor) so shapes keep their correct color when expanded
1453-
const explicitPointColor = getColor(originalData, config.pointColor as any)
1328+
const explicitPointColor = getColor(originalData, config.pointColor)
14541329
const pointDonutData = getDonutData(originalData, config.colorMap)
14551330
const maxVal = pointDonutData.length ? Math.max(...pointDonutData.map(d => d.value)) : 0
14561331
const biggestDatum = pointDonutData.find(d => d.value === maxVal) || pointDonutData[0]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Selection } from 'd3-selection'
2+
3+
export interface BackgroundContext {
4+
bleed: { left?: number; top?: number };
5+
onClick: () => void;
6+
}
7+
8+
export function renderBackground (
9+
backgroundRect: Selection<SVGRectElement, unknown, null, undefined>,
10+
bgContext: BackgroundContext
11+
): void {
12+
backgroundRect
13+
.attr('width', '100%')
14+
.attr('height', '100%')
15+
.attr('transform', `translate(${-(bgContext.bleed.left ?? 0)}, ${-(bgContext.bleed.top ?? 0)})`)
16+
.style('cursor', 'default')
17+
.on('click', () => {
18+
bgContext.onClick()
19+
})
20+
}
21+

0 commit comments

Comments
 (0)