UNPKG

@amcharts/amcharts5

Version:
883 lines 42 kB
import { MapPolygonSeries } from "./MapPolygonSeries"; import { MapSankeyNodes } from "./MapSankeyNodes"; import { getGeoCentroid, getGeoCircle } from "./MapUtils"; import { geoArea } from "d3-geo"; import * as $array from "../../core/util/Array"; import * as $math from "../../core/util/Math"; /** * Creates a map series for displaying Sankey-style flow bands on a map. * * Generates actual GeoJSON polygon geometries for each flow, so they * properly follow the map projection during panning, zooming, and rotation. * * @see {@link https://www.amcharts.com/docs/v5/charts/map-chart/map-sankey-series/} for more info * @since 5.17.0 * @important */ /** * Settings the band and node geometry is generated from: a change to any of them * means the shapes have to be worked out again. */ const GEOMETRY_SETTINGS = [ "controlPointDistance", "controlPointDistanceSource", "controlPointDistanceTarget", "orientation", "maxWidth", "resolution", "nodePadding", "autoSort", "antimeridian", "nodeType", "nodeWidth" ]; export class MapSankeySeries extends MapPolygonSeries { constructor() { super(...arguments); /** * Don't consume polygon features from geodata - our data comes from user's data array. */ this._types = []; /** * A sub-series that manages node data items and visuals. * * Nodes are auto-created from link data. You can also set * `nodes.data.setAll([...])` with custom names and fills. * * `nodes.mapPolygons.template` configures node appearance. */ this.nodes = this.addDisposer(MapSankeyNodes.new(this._root, {})); /** * Stored bezier segment params per data item for bullet positioning. * Each link can have multiple segments (when waypoints are used). * @ignore */ this._bezierSegments = new Map(); /** * Cumulative arc-length array per data item for uniform-speed bullet positioning. * Entry i = cumulative length from segment 0 through segment i. * Last entry = total path length. * @ignore */ this._segmentCumulativeLengths = new Map(); // Reusable scratch Maps for _generateGeometries — cleared and reused each call // to avoid allocating 10 fresh Maps per dirty cycle. this._geoSourceCoords = new Map(); this._geoTargetCoords = new Map(); this._geoWidths = new Map(); this._geoSourceGroups = new Map(); this._geoTargetGroups = new Map(); this._geoNodeSourceTotal = new Map(); this._geoNodeTargetTotal = new Map(); this._geoNodeRange = new Map(); this._geoSourceOffsets = new Map(); this._geoTargetOffsets = new Map(); } /** * @ignore */ _afterNew() { this.fields.push("sourceId", "targetId", "sourceLongitude", "sourceLatitude", "targetLongitude", "targetLatitude", "waypoints"); this.valueFields.push("value"); this._setRawDefault("sourceIdField", "sourceId"); this._setRawDefault("targetIdField", "targetId"); this._setRawDefault("sourceLongitudeField", "sourceLongitude"); this._setRawDefault("sourceLatitudeField", "sourceLatitude"); this._setRawDefault("targetLongitudeField", "targetLongitude"); this._setRawDefault("targetLatitudeField", "targetLatitude"); this._setRawDefault("valueField", "value"); this._setRawDefault("waypointsField", "waypoints"); this.nodes.flow = this; // Watch for polygonSeries changes — re-attach the datavalidated // listener so users don't have to wait for the polygon series to // be ready before calling sankey.data.setAll(). this.on("polygonSeries", () => { this._setupPolygonSeriesListener(); }); super._afterNew(); // Initial setup in case polygonSeries was passed in constructor settings. this._setupPolygonSeriesListener(); } /** * (Re)subscribes to the current `polygonSeries` `datavalidated` event so * that any data items added before the polygon series finished loading * its geoJSON get their endpoints resolved automatically. */ _setupPolygonSeriesListener() { if (this._polygonSeriesDP) { this._polygonSeriesDP.dispose(); this._polygonSeriesDP = undefined; } const polygonSeries = this.get("polygonSeries"); if (polygonSeries) { this._polygonSeriesDP = polygonSeries.events.on("datavalidated", () => { this._resolvePendingDataItems(); }); // If the polygon series already has data items (e.g. it was // validated before our data was set), resolve immediately. if (polygonSeries.dataItems.length > 0) { this._resolvePendingDataItems(); } } } /** * Walks all data items and resolves any whose source/target endpoints * weren't resolved during the initial `processDataItem` call (because * the polygon series wasn't ready yet). Marks values dirty so the * geometries get regenerated. */ _resolvePendingDataItems() { let anyResolved = false; $array.each(this.dataItems, (dataItem) => { if (this._resolveEndpoints(dataItem)) { anyResolved = true; } }); if (anyResolved) { // Trigger geometry regeneration on next validation cycle. // Mirrors Component.markDirtyValues without the parent's // MapPolygon geometry sync (handled later in _generateGeometries). this._valuesDirty = true; this.markDirty(); } } /** * @ignore */ markDirtyProjection() { super.markDirtyProjection(); $array.each(this.nodes.dataItems, (nodeDataItem) => { const mapPolygon = nodeDataItem.get("mapPolygon"); if (mapPolygon) { mapPolygon.markDirtyProjection(); } }); } /** * Evaluate a single bezier segment at parameter t. * * Same formula as $math.getPointOnCubicCurve but operates on flat * IBezierSegment fields (lon/lat) to avoid IPoint object allocation. */ _evalBezier(bp, t) { const s = 1 - t; return { longitude: s * s * s * bp.p0Lon + 3 * s * s * t * bp.cp0Lon + 3 * s * t * t * bp.cp1Lon + t * t * t * bp.p1Lon, latitude: s * s * s * bp.p0Lat + 3 * s * s * t * bp.cp0Lat + 3 * s * t * t * bp.cp1Lat + t * t * t * bp.p1Lat }; } /** * Approximates the arc length of a single bezier segment by sampling * points along the curve and summing Haversine distances. * * @param bp Bezier segment parameters * @param samples Number of sample intervals (default 20) * @return Approximate arc length in radians */ _segmentArcLength(bp, samples = 20) { let length = 0; let prev = this._evalBezier(bp, 0); for (let i = 1; i <= samples; i++) { const pt = this._evalBezier(bp, i / samples); const dLon = (pt.longitude - prev.longitude) * Math.PI / 180; const dLat = (pt.latitude - prev.latitude) * Math.PI / 180; // Haversine approximation const a = Math.pow(Math.sin(dLat / 2), 2) + Math.cos(prev.latitude * Math.PI / 180) * Math.cos(pt.latitude * Math.PI / 180) * Math.pow(Math.sin(dLon / 2), 2); length += 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); prev = pt; } return length; } /** * Computes cumulative arc lengths for a set of bezier segments and * stores them in `_segmentCumulativeLengths` for arc-length parameterization. * * @param dataItem Link data item (used as cache key) * @param segments Array of bezier segment parameters */ _computeCumulativeLengths(dataItem, segments) { const cumulative = []; let total = 0; for (let i = 0; i < segments.length; i++) { total += this._segmentArcLength(segments[i]); cumulative.push(total); } this._segmentCumulativeLengths.set(dataItem, cumulative); } /** * Resolves a normalized location (0–1) to a segment index and local t * using arc-length parameterization. Delegates to `$math.resolveLocationOnPath` * when cumulative lengths are available; falls back to equal distribution. * * @param dataItem Link data item (for cumulative length lookup) * @param location Relative position along the full path (0–1) * @param segments Array of bezier segment parameters (used for fallback) * @return Segment index and local t (0–1) */ _resolveLocation(dataItem, location, segments) { const cumLengths = this._segmentCumulativeLengths.get(dataItem); if (cumLengths && cumLengths.length > 0) { const result = $math.resolveLocationOnPath(location, cumLengths); return { segIdx: result.index, t: result.t }; } // Fallback: equal distribution const n = segments.length; const scaled = location * n; const segIdx = Math.min(Math.floor(scaled), n - 1); return { segIdx, t: scaled - segIdx }; } /** * Returns a screen-space point at a relative position (0-1) along * the center-line bezier for the given data item. * * Used internally for bullet positioning, but can also be called * directly to get coordinates along a flow path. * * @param dataItem Target data item * @param location Relative position (0 = source, 1 = target) * @return Screen coordinates and angle */ getPoint(dataItem, location) { const segments = this._bezierSegments.get(dataItem); const chart = this.chart; if (!segments || segments.length === 0 || !chart) return { x: 0, y: 0, angle: 0 }; const { segIdx, t } = this._resolveLocation(dataItem, location, segments); const bp = segments[segIdx]; const pt = this._evalBezier(bp, t); const screenPoint = chart.convert({ longitude: pt.longitude, latitude: pt.latitude }); // Angle from a nearby point — use backward difference near t=1 to avoid identical points const dt = 0.001; let pt2; let sign = 1; if (t + dt <= 1) { pt2 = this._evalBezier(bp, t + dt); } else { pt2 = this._evalBezier(bp, t - dt); sign = -1; } const screenPoint2 = chart.convert({ longitude: pt2.longitude, latitude: pt2.latitude }); const angle = Math.atan2(sign * (screenPoint2.y - screenPoint.y), sign * (screenPoint2.x - screenPoint.x)) * 180 / Math.PI; return { x: screenPoint.x, y: screenPoint.y, angle }; } /** * Returns a geo point at a relative position (0-1) along the center-line bezier. */ getGeoPoint(dataItem, location) { const segments = this._bezierSegments.get(dataItem); if (!segments || segments.length === 0) return undefined; const { segIdx, t } = this._resolveLocation(dataItem, location, segments); return this._evalBezier(segments[segIdx], t); } /** * Returns the approximate arc length of the path for a data item. * Useful for scaling animation duration proportionally to distance. */ getPathLength(dataItem) { const cumLengths = this._segmentCumulativeLengths.get(dataItem); if (cumLengths && cumLengths.length > 0) { return cumLengths[cumLengths.length - 1]; } return 0; } /** * Combined getPoint + getGeoPoint in a single pass (avoids double * _resolveLocation + _evalBezier per bullet per frame). * @ignore */ _getPointWithGeo(dataItem, location) { const segments = this._bezierSegments.get(dataItem); const chart = this.chart; if (!segments || segments.length === 0 || !chart) return undefined; const { segIdx, t } = this._resolveLocation(dataItem, location, segments); const bp = segments[segIdx]; const geoPoint = this._evalBezier(bp, t); const screenPoint = chart.convert({ longitude: geoPoint.longitude, latitude: geoPoint.latitude }); const dt = 0.001; let pt2; let sign = 1; if (t + dt <= 1) { pt2 = this._evalBezier(bp, t + dt); } else { pt2 = this._evalBezier(bp, t - dt); sign = -1; } const screenPoint2 = chart.convert({ longitude: pt2.longitude, latitude: pt2.latitude }); const angle = Math.atan2(sign * (screenPoint2.y - screenPoint.y), sign * (screenPoint2.x - screenPoint.x)) * 180 / Math.PI; return { x: screenPoint.x, y: screenPoint.y, angle, geoPoint }; } /** * Positions a bullet along its parent link's bezier path. * * @param bullet Bullet to position */ _positionBullet(bullet) { const sprite = bullet.get("sprite"); if (sprite) { const dataItem = sprite.dataItem; if (dataItem) { const location = bullet.get("locationX", 0.5); const result = this._getPointWithGeo(dataItem, location); if (!result) return; sprite.setAll({ x: result.x, y: result.y }); if (bullet.get("autoRotate")) { sprite.set("rotation", result.angle + bullet.get("autoRotateAngle", 0)); } // Hide bullets on the back side of the globe (like MapPointSeries clipBack) const chart = this.chart; if (chart) { const geoPath = chart.getPrivate("geoPath"); if (geoPath) { const geometry = { type: "Point", coordinates: [result.geoPoint.longitude, result.geoPoint.latitude] }; sprite.setPrivate("visible", !!geoPath(geometry)); } } } } } /** * @ignore */ disposeDataItem(dataItem) { super.disposeDataItem(dataItem); this._bezierSegments.delete(dataItem); this._segmentCumulativeLengths.delete(dataItem); } /** * @ignore */ _dispose() { super._dispose(); if (this._polygonSeriesDP) { this._polygonSeriesDP.dispose(); this._polygonSeriesDP = undefined; } this._bezierSegments.clear(); this._segmentCumulativeLengths.clear(); this._geoSourceCoords.clear(); this._geoTargetCoords.clear(); this._geoWidths.clear(); this._geoSourceGroups.clear(); this._geoTargetGroups.clear(); this._geoNodeSourceTotal.clear(); this._geoNodeTargetTotal.clear(); this._geoNodeRange.clear(); this._geoSourceOffsets.clear(); this._geoTargetOffsets.clear(); } /** * Processes a newly added data item, creating placeholder geometry and * registering it with source/target nodes. * * @param dataItem Data item to process */ processDataItem(dataItem) { // Set placeholder geometry so parent creates the MapPolygon // Use empty coordinates array (no rings) to avoid d3-geo crash on empty ring if (!dataItem.get("geometry")) { dataItem.set("geometry", { type: "Polygon", coordinates: [] }); } super.processDataItem(dataItem); this._resolveEndpoints(dataItem); } /** * Resolves the source/target endpoints for a single data item: * looks up centroids on the polygon series (if `sourceId`/`targetId` is set), * then creates or attaches the appropriate sankey nodes. * * Returns `true` if anything was newly resolved, so callers can decide * whether to mark geometries dirty. * * Safe to call repeatedly: skips endpoints that are already resolved. */ _resolveEndpoints(dataItem) { const polygonSeries = this.get("polygonSeries"); let resolved = false; // -- Source -- if (!dataItem.get("sourceNode")) { let sourceLon = dataItem.get("sourceLongitude"); let sourceLat = dataItem.get("sourceLatitude"); const sourceId = dataItem.get("sourceId"); if (sourceId && polygonSeries) { const polygonDI = polygonSeries.getDataItemById(sourceId); if (polygonDI) { const geometry = polygonDI.get("geometry"); if (geometry) { const centroid = getGeoCentroid(geometry); sourceLon = centroid.longitude; sourceLat = centroid.latitude; } } } if (sourceLon != null && sourceLat != null) { const sourceKey = sourceId !== null && sourceId !== void 0 ? sourceId : (Number(sourceLon) + "," + Number(sourceLat)); let sourceNode = this.nodes.getDataItemById(sourceKey); if (!sourceNode) { const ctx = dataItem.dataContext; const name = (ctx && ctx.source) || sourceKey; this.nodes.data.push({ id: sourceKey, name, longitude: Number(sourceLon), latitude: Number(sourceLat) }); sourceNode = this.nodes.dataItems[this.nodes.dataItems.length - 1]; } if (sourceNode) { dataItem.setRaw("sourceNode", sourceNode); this.nodes.addOutgoingLink(sourceNode, dataItem); resolved = true; } } } // -- Target -- if (!dataItem.get("targetNode")) { let targetLon = dataItem.get("targetLongitude"); let targetLat = dataItem.get("targetLatitude"); const targetId = dataItem.get("targetId"); if (targetId && polygonSeries) { const polygonDI = polygonSeries.getDataItemById(targetId); if (polygonDI) { const geometry = polygonDI.get("geometry"); if (geometry) { const centroid = getGeoCentroid(geometry); targetLon = centroid.longitude; targetLat = centroid.latitude; } } } if (targetLon != null && targetLat != null) { const targetKey = targetId !== null && targetId !== void 0 ? targetId : (Number(targetLon) + "," + Number(targetLat)); let targetNode = this.nodes.getDataItemById(targetKey); if (!targetNode) { const ctx = dataItem.dataContext; const name = (ctx && ctx.target) || targetKey; this.nodes.data.push({ id: targetKey, name, longitude: Number(targetLon), latitude: Number(targetLat) }); targetNode = this.nodes.dataItems[this.nodes.dataItems.length - 1]; } if (targetNode) { dataItem.setRaw("targetNode", targetNode); this.nodes.addIncomingLink(targetNode, dataItem); resolved = true; } } } return resolved; } /** * @ignore */ _onDataClear() { // Clear link references on preserved nodes $array.each(this.nodes.dataItems, (nodeDataItem) => { nodeDataItem.setRaw("incomingLinks", []); nodeDataItem.setRaw("outgoingLinks", []); }); if (!this.nodes._userDataSet) { // setAll([]) triggers _onDataClear which sets _userDataSet = true; // reset it so auto-created nodes work again on next data cycle. this.nodes.data.setAll([]); this.nodes._userDataSet = false; } } /** * @ignore */ _prepareChildren() { // The shape of the bands and the nodes is worked out from these, and the // only thing that used to re-run that was the data changing - so setting // any of them on a chart that was already up did nothing until it did if ($array.any(GEOMETRY_SETTINGS, (setting) => this.isDirty(setting))) { this._valuesDirty = true; } if (this._valuesDirty || this._sizeDirty) { this._generateGeometries(); } super._prepareChildren(); } /** * Rebuilds all link band geometries from current data. * * Computes bezier control points, stacking offsets, band polygons, * and cumulative arc lengths for each link. */ _generateGeometries() { const maxWidthDeg = this.get("maxWidth", 5); const cpdBase = Math.min(0.4999, this.get("controlPointDistance", 0.5)); const cpdSource = Math.min(0.4999, this.get("controlPointDistanceSource", cpdBase)); const cpdTarget = Math.min(0.4999, this.get("controlPointDistanceTarget", cpdBase)); const resolution = this.get("resolution", 50); const linkColorMode = this.get("linkColorMode", "solid"); // Find max value let maxValue = 0; $array.each(this.dataItems, (dataItem) => { const value = dataItem.get("value"); if (value != null && value > maxValue) { maxValue = value; } }); if (maxValue === 0) maxValue = 1; const sourceCoords = this._geoSourceCoords; sourceCoords.clear(); const targetCoords = this._geoTargetCoords; targetCoords.clear(); const widths = this._geoWidths; widths.clear(); const sourceGroups = this._geoSourceGroups; sourceGroups.clear(); const targetGroups = this._geoTargetGroups; targetGroups.clear(); // Build coord maps and groups from node references on links $array.each(this.dataItems, (dataItem) => { const value = +(dataItem.get("value") || 0); widths.set(dataItem, maxWidthDeg * value / maxValue); const sourceNode = dataItem.get("sourceNode"); if (sourceNode) { const lon = sourceNode.get("longitude"); const lat = sourceNode.get("latitude"); if (lon != null && lat != null) { sourceCoords.set(dataItem, { longitude: lon, latitude: lat }); const key = sourceNode.get("id"); if (!sourceGroups.has(key)) sourceGroups.set(key, []); sourceGroups.get(key).push(dataItem); } } const targetNode = dataItem.get("targetNode"); if (targetNode) { const lon = targetNode.get("longitude"); const lat = targetNode.get("latitude"); if (lon != null && lat != null) { targetCoords.set(dataItem, { longitude: lon, latitude: lat }); const key = targetNode.get("id"); if (!targetGroups.has(key)) targetGroups.set(key, []); targetGroups.get(key).push(dataItem); } } }); // Coordinated stacking at intermediate nodes: // At nodes with both incoming and outgoing flows, use the same // vertical range for both sides so bands align like in a Sankey. const nodePadding = this.get("nodePadding", 0.3); const autoSort = this.get("autoSort", true); // First: compute the unified stacking range at each node const nodeSourceTotal = this._geoNodeSourceTotal; nodeSourceTotal.clear(); const nodeTargetTotal = this._geoNodeTargetTotal; nodeTargetTotal.clear(); sourceGroups.forEach((items, key) => { let total = 0; $array.each(items, (item) => { total += widths.get(item) || 0; }); nodeSourceTotal.set(key, total); }); targetGroups.forEach((items, key) => { let total = 0; $array.each(items, (item) => { total += widths.get(item) || 0; }); nodeTargetTotal.set(key, total); }); // Unified range = max(incoming, outgoing) at each node const nodeRange = this._geoNodeRange; nodeRange.clear(); const allKeys = new Set([...nodeSourceTotal.keys(), ...nodeTargetTotal.keys()]); allKeys.forEach((key) => { nodeRange.set(key, Math.max(nodeSourceTotal.get(key) || 0, nodeTargetTotal.get(key) || 0)); }); // Stack outgoing flows (sourceGroups) centered within the unified range const sourceOffsets = this._geoSourceOffsets; sourceOffsets.clear(); const targetOffsets = this._geoTargetOffsets; targetOffsets.clear(); sourceGroups.forEach((items, key) => { if (autoSort) { items.sort((a, b) => { var _a, _b; return ((((_a = targetCoords.get(a)) === null || _a === void 0 ? void 0 : _a.latitude) || 0) - (((_b = targetCoords.get(b)) === null || _b === void 0 ? void 0 : _b.latitude) || 0)); }); } const ownTotal = nodeSourceTotal.get(key) || 0; const range = nodeRange.get(key) || ownTotal; // Center this side's bands within the unified range let offset = -range / 2 + (range - ownTotal) / 2; $array.each(items, (item) => { const w = widths.get(item) || 0; sourceOffsets.set(item, offset + w / 2); offset += w; }); }); // Stack incoming flows (targetGroups) centered within the unified range targetGroups.forEach((items, key) => { if (autoSort) { items.sort((a, b) => { var _a, _b; return ((((_a = sourceCoords.get(a)) === null || _a === void 0 ? void 0 : _a.latitude) || 0) - (((_b = sourceCoords.get(b)) === null || _b === void 0 ? void 0 : _b.latitude) || 0)); }); } const ownTotal = nodeTargetTotal.get(key) || 0; const range = nodeRange.get(key) || ownTotal; let offset = -range / 2 + (range - ownTotal) / 2; $array.each(items, (item) => { const w = widths.get(item) || 0; targetOffsets.set(item, offset + w / 2); offset += w; }); }); // Generate polygon for each link const orientationSetting = this.get("orientation", "horizontal"); $array.each(this.dataItems, (dataItem) => { var _a, _b, _c, _d, _e; const source = sourceCoords.get(dataItem); const target = targetCoords.get(dataItem); if (!source || !target) return; const width = widths.get(dataItem) || 0; if (width <= 0) return; const halfWidth = width / 2; const sourceOff = sourceOffsets.get(dataItem) || 0; const targetOff = targetOffsets.get(dataItem) || 0; // Build path points: [source, ...waypoints, target] // Apply stacking offset to source/target based on orientation let startLon, startLat, endLon, endLat; if (orientationSetting === "vertical") { startLon = source.longitude + sourceOff; startLat = source.latitude; endLon = target.longitude + targetOff; endLat = target.latitude; } else { startLon = source.longitude; startLat = source.latitude + sourceOff; endLon = target.longitude; endLat = target.latitude + targetOff; } // Per-link control point distances (fall back to series-level) const linkCpd = Math.min(0.4999, (_a = dataItem.get("controlPointDistance")) !== null && _a !== void 0 ? _a : cpdBase); const linkCpdSource = Math.min(0.4999, (_c = (_b = dataItem.get("controlPointDistanceSource")) !== null && _b !== void 0 ? _b : dataItem.get("controlPointDistance")) !== null && _c !== void 0 ? _c : cpdSource); const linkCpdTarget = Math.min(0.4999, (_e = (_d = dataItem.get("controlPointDistanceTarget")) !== null && _d !== void 0 ? _d : dataItem.get("controlPointDistance")) !== null && _e !== void 0 ? _e : cpdTarget); const waypoints = dataItem.get("waypoints"); const hasWaypoints = waypoints && waypoints.length > 0; // Adjust for antimeridian crossing (only without waypoints) if (!hasWaypoints) { const antimeridian = this.get("antimeridian", "short"); if (antimeridian === "short") { let dLon = endLon - startLon; if (dLon > 180) endLon -= 360; else if (dLon < -180) endLon += 360; } } const pathPoints = [{ longitude: startLon, latitude: startLat }]; if (hasWaypoints) { for (const wp of waypoints) { pathPoints.push({ longitude: wp.longitude, latitude: wp.latitude }); } } pathPoints.push({ longitude: endLon, latitude: endLat }); // Build bezier segments between consecutive path points const segments = []; for (let seg = 0; seg < pathPoints.length - 1; seg++) { const p0 = pathPoints[seg]; const p1 = pathPoints[seg + 1]; // Tangent direction at each point: // - First point: direction toward next point // - Last point: direction from previous point // - Middle points: average of incoming and outgoing directions let tangent0Lon, tangent0Lat; let tangent1Lon, tangent1Lat; if (seg === 0 && pathPoints.length === 2) { // Simple case: single segment, use S-curve logic if (orientationSetting === "vertical") { const dLat = p1.latitude - p0.latitude; tangent0Lon = 0; tangent0Lat = dLat; tangent1Lon = 0; tangent1Lat = dLat; } else { const dLon = p1.longitude - p0.longitude; tangent0Lon = dLon; tangent0Lat = 0; tangent1Lon = dLon; tangent1Lat = 0; } } else { // Multi-point: use direction to neighbor if (seg === 0) { // First segment start: direction toward next tangent0Lon = p1.longitude - p0.longitude; tangent0Lat = p1.latitude - p0.latitude; } else { // Average of incoming and outgoing const prev = pathPoints[seg - 1]; tangent0Lon = p1.longitude - prev.longitude; tangent0Lat = p1.latitude - prev.latitude; } if (seg === pathPoints.length - 2) { // Last segment end: direction from previous tangent1Lon = p1.longitude - p0.longitude; tangent1Lat = p1.latitude - p0.latitude; } else { const next = pathPoints[seg + 2]; tangent1Lon = next.longitude - p0.longitude; tangent1Lat = next.latitude - p0.latitude; } } // Normalize tangents and scale by cpd * segment distance // Use cpdSource at the start of the first segment, cpdTarget at the end of the last const segDist = Math.sqrt(Math.pow((p1.longitude - p0.longitude), 2) + Math.pow((p1.latitude - p0.latitude), 2)); const t0Len = Math.sqrt(Math.pow(tangent0Lon, 2) + Math.pow(tangent0Lat, 2)); const t1Len = Math.sqrt(Math.pow(tangent1Lon, 2) + Math.pow(tangent1Lat, 2)); const scale0 = segDist * (seg === 0 ? linkCpdSource : linkCpd); const scale1 = segDist * (seg === pathPoints.length - 2 ? linkCpdTarget : linkCpd); const cp0Lon = p0.longitude + (t0Len > 0 ? tangent0Lon / t0Len * scale0 : 0); const cp0Lat = p0.latitude + (t0Len > 0 ? tangent0Lat / t0Len * scale0 : 0); const cp1Lon = p1.longitude - (t1Len > 0 ? tangent1Lon / t1Len * scale1 : 0); const cp1Lat = p1.latitude - (t1Len > 0 ? tangent1Lat / t1Len * scale1 : 0); segments.push({ p0Lon: p0.longitude, p0Lat: p0.latitude, p1Lon: p1.longitude, p1Lat: p1.latitude, cp0Lon, cp0Lat, cp1Lon, cp1Lat }); } // Store for bullet positioning this._bezierSegments.set(dataItem, segments); this._computeCumulativeLengths(dataItem, segments); // Sample all segments to build left/right edges const leftEdge = []; const rightEdge = []; for (let seg = 0; seg < segments.length; seg++) { const bp = segments[seg]; const startI = (seg === 0) ? 0 : 1; // avoid duplicate at joins for (let i = startI; i <= resolution; i++) { const t = i / resolution; const s = 1 - t; const lon = s * s * s * bp.p0Lon + 3 * s * s * t * bp.cp0Lon + 3 * s * t * t * bp.cp1Lon + t * t * t * bp.p1Lon; const lat = s * s * s * bp.p0Lat + 3 * s * s * t * bp.cp0Lat + 3 * s * t * t * bp.cp1Lat + t * t * t * bp.p1Lat; const dtLon = 3 * s * s * (bp.cp0Lon - bp.p0Lon) + 6 * s * t * (bp.cp1Lon - bp.cp0Lon) + 3 * t * t * (bp.p1Lon - bp.cp1Lon); const dtLat = 3 * s * s * (bp.cp0Lat - bp.p0Lat) + 6 * s * t * (bp.cp1Lat - bp.cp0Lat) + 3 * t * t * (bp.p1Lat - bp.cp1Lat); const tLen = Math.sqrt(dtLon * dtLon + dtLat * dtLat); if (tLen === 0) continue; const perpLon = -dtLat / tLen; const perpLat = dtLon / tLen; leftEdge.push([lon + halfWidth * perpLon, lat + halfWidth * perpLat]); rightEdge.push([lon - halfWidth * perpLon, lat - halfWidth * perpLat]); } } // Simple closed polygon: leftEdge forward + rightEdge backward const ring = []; for (let i = 0; i < leftEdge.length; i++) ring.push(leftEdge[i]); for (let i = rightEdge.length - 1; i >= 0; i--) ring.push(rightEdge[i]); ring.push(leftEdge[0]); // d3-geo requires clockwise winding for small polygons. // If geoArea > 2*PI the winding is inverted — reverse the ring. let geometry = { type: "Polygon", coordinates: [ring] }; if (geoArea(geometry) > 2 * Math.PI) { ring.reverse(); geometry = { type: "Polygon", coordinates: [ring] }; } dataItem.setRaw("geometry", geometry); const mapPolygon = dataItem.get("mapPolygon"); if (mapPolygon) { mapPolygon._setC("geometry", geometry); if (linkColorMode !== "solid") { const nodeDataItem = linkColorMode === "source" ? dataItem.get("sourceNode") : dataItem.get("targetNode"); if (nodeDataItem) { const nodeFill = nodeDataItem.get("fill"); if (nodeFill) { mapPolygon.set("fill", nodeFill); } } } } }); // Compute sums and generate geometries for node shapes const nodeType = this.get("nodeType", "circle"); const nodeWidth = this.get("nodeWidth", 1); $array.each(this.nodes.dataItems, (nodeDataItem) => { const outgoing = nodeDataItem.get("outgoingLinks") || []; const incoming = nodeDataItem.get("incomingLinks") || []; const nodeKey = nodeDataItem.get("id"); // Compute sums and store on node data item for tooltip resolution let sumOutgoing = 0; $array.each(outgoing, (link) => { sumOutgoing += link.get("value") || 0; }); let sumIncoming = 0; $array.each(incoming, (link) => { sumIncoming += link.get("value") || 0; }); nodeDataItem.setRaw("sumOutgoing", sumOutgoing); nodeDataItem.setRaw("sumIncoming", sumIncoming); nodeDataItem.setRaw("sum", sumOutgoing + sumIncoming); const totalWidth = nodeRange.get(nodeKey) || 0; const lon = nodeDataItem.get("longitude"); const lat = nodeDataItem.get("latitude"); if (lon == null || lat == null) return; let nodeGeo; if (nodeType === "bar") { const steps = 10; const halfSpan = totalWidth / 2 + nodePadding; const halfW = nodeWidth / 2; const ring = []; if (orientationSetting === "vertical") { for (let i = 0; i <= steps; i++) { const t = i / steps; ring.push([lon - halfSpan + totalWidth * t, lat - halfW]); } for (let i = 0; i <= steps; i++) { const t = i / steps; ring.push([lon + halfSpan, lat - halfW + nodeWidth * t]); } for (let i = 0; i <= steps; i++) { const t = i / steps; ring.push([lon + halfSpan - totalWidth * t, lat + halfW]); } for (let i = 0; i <= steps; i++) { const t = i / steps; ring.push([lon - halfSpan, lat + halfW - nodeWidth * t]); } ring.push([lon - halfSpan, lat - halfW]); } else { for (let i = 0; i <= steps; i++) { const t = i / steps; ring.push([lon - halfW, lat - halfSpan + totalWidth * t]); } for (let i = 0; i <= steps; i++) { const t = i / steps; ring.push([lon - halfW + nodeWidth * t, lat + halfSpan]); } for (let i = 0; i <= steps; i++) { const t = i / steps; ring.push([lon + halfW, lat + halfSpan - totalWidth * t]); } for (let i = 0; i <= steps; i++) { const t = i / steps; ring.push([lon + halfW - nodeWidth * t, lat - halfSpan]); } ring.push([lon - halfW, lat - halfSpan]); } nodeGeo = { type: "Polygon", coordinates: [ring] }; if (geoArea(nodeGeo) > 2 * Math.PI) { ring.reverse(); nodeGeo = { type: "Polygon", coordinates: [ring] }; } } else { const radius = totalWidth / 2 + nodePadding; nodeGeo = getGeoCircle({ longitude: lon, latitude: lat }, radius); } const mapPolygon = nodeDataItem.get("mapPolygon"); if (mapPolygon) { mapPolygon._setC("geometry", nodeGeo); // Reorder at display level so nodes render above bands. // Avoids Children.moveValue which triggers markDirty() loop. this._childrenDisplay.removeChild(mapPolygon._display); this._childrenDisplay.addChild(mapPolygon._display); } }); } } MapSankeySeries.className = "MapSankeySeries"; MapSankeySeries.classNames = MapPolygonSeries.classNames.concat([MapSankeySeries.className]); //# sourceMappingURL=MapSankeySeries.js.map