@unovis/ts 1.6.2 → 1.7.0-pre.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/components/area/config.d.ts +2 -0
  2. package/components/area/config.js +1 -1
  3. package/components/area/config.js.map +1 -1
  4. package/components/area/index.js +6 -3
  5. package/components/area/index.js.map +1 -1
  6. package/components/crosshair/config.d.ts +1 -1
  7. package/components/crosshair/config.js.map +1 -1
  8. package/components/crosshair/index.d.ts +1 -1
  9. package/components/crosshair/index.js.map +1 -1
  10. package/components/free-brush/types.js +1 -0
  11. package/components/free-brush/types.js.map +1 -1
  12. package/components/graph/index.d.ts +1 -0
  13. package/components/graph/index.js +35 -14
  14. package/components/graph/index.js.map +1 -1
  15. package/components/graph/modules/link/index.js +1 -1
  16. package/components/graph/modules/link/index.js.map +1 -1
  17. package/components/leaflet-map/leaflet.css.js +2 -2
  18. package/components/leaflet-map/modules/map.js +2 -2
  19. package/components/leaflet-map/modules/map.js.map +1 -1
  20. package/components/leaflet-map/renderer/mapboxgl-utils.d.ts +0 -1
  21. package/components/leaflet-map/renderer/mapboxgl-utils.js +1 -2
  22. package/components/leaflet-map/renderer/mapboxgl-utils.js.map +1 -1
  23. package/components/sankey/config.d.ts +25 -2
  24. package/components/sankey/config.js +2 -2
  25. package/components/sankey/config.js.map +1 -1
  26. package/components/sankey/index.d.ts +26 -2
  27. package/components/sankey/index.js +341 -46
  28. package/components/sankey/index.js.map +1 -1
  29. package/components/sankey/modules/label.d.ts +8 -5
  30. package/components/sankey/modules/label.js +70 -32
  31. package/components/sankey/modules/label.js.map +1 -1
  32. package/components/sankey/modules/link.d.ts +1 -0
  33. package/components/sankey/modules/link.js +20 -25
  34. package/components/sankey/modules/link.js.map +1 -1
  35. package/components/sankey/modules/node.d.ts +5 -4
  36. package/components/sankey/modules/node.js +65 -16
  37. package/components/sankey/modules/node.js.map +1 -1
  38. package/components/sankey/style.d.ts +67 -1
  39. package/components/sankey/style.js +77 -77
  40. package/components/sankey/style.js.map +1 -1
  41. package/components/sankey/types.d.ts +5 -0
  42. package/components/sankey/types.js +9 -2
  43. package/components/sankey/types.js.map +1 -1
  44. package/components/treemap/index.js +2 -4
  45. package/components/treemap/index.js.map +1 -1
  46. package/containers/single-container/config.d.ts +3 -0
  47. package/containers/single-container/config.js.map +1 -1
  48. package/containers/single-container/index.js +2 -1
  49. package/containers/single-container/index.js.map +1 -1
  50. package/containers/xy-container/config.d.ts +3 -0
  51. package/containers/xy-container/config.js.map +1 -1
  52. package/containers/xy-container/index.d.ts +1 -0
  53. package/containers/xy-container/index.js +13 -9
  54. package/containers/xy-container/index.js.map +1 -1
  55. package/index.js +1 -1
  56. package/package.json +42 -10
  57. package/types.js +1 -1
@@ -1,6 +1,7 @@
1
- import { select } from 'd3-selection';
1
+ import { pointer, select } from 'd3-selection';
2
+ import { zoom, zoomIdentity } from 'd3-zoom';
2
3
  import { sankey } from 'd3-sankey';
3
- import { max, extent, sum } from 'd3-array';
4
+ import { max, min, extent, sum } from 'd3-array';
4
5
  import { scaleLinear } from 'd3-scale';
5
6
  import { ComponentCore } from '../../core/component/index.js';
6
7
  import { GraphDataModel } from '../../data-models/graph.js';
@@ -8,28 +9,37 @@ import { Sizing } from '../../types/component.js';
8
9
  import { Position } from '../../types/position.js';
9
10
  import { VerticalAlign } from '../../types/text.js';
10
11
  import { smartTransition } from '../../utils/d3.js';
11
- import { getString, isNumber, getNumber, groupBy } from '../../utils/data.js';
12
- import { getCSSVariableValueInPixels } from '../../utils/misc.js';
12
+ import { getString, isNumber, clamp, getNumber, groupBy } from '../../utils/data.js';
13
13
  import { SankeyDefaultConfig } from './config.js';
14
14
  import * as style from './style.js';
15
15
  import { background, links, nodes, link, nodeGroup, nodeExit } from './style.js';
16
- import { SankeyLayout } from './types.js';
16
+ import { SankeyLayout, SankeyZoomMode } from './types.js';
17
17
  import { removeLinks, createLinks, updateLinks } from './modules/link.js';
18
- import { removeNodes, createNodes, updateNodes, onNodeMouseOver, onNodeMouseOut } from './modules/node.js';
19
- import { requiredLabelSpace, getLabelOrientation } from './modules/label.js';
18
+ import { NODE_SELECTION_RECT_DELTA, removeNodes, createNodes, updateNodes, onNodeMouseOver, onNodeMouseOut } from './modules/node.js';
19
+ import { getLabelFontSize, getSubLabelFontSize, SANKEY_LABEL_BLOCK_PADDING, SANKEY_LABEL_SPACING, getLabelOrientation, estimateRequiredLabelWidth } from './modules/label.js';
20
20
 
21
21
  class Sankey extends ComponentCore {
22
22
  constructor(config) {
23
+ var _a;
23
24
  super();
24
25
  this._defaultConfig = SankeyDefaultConfig;
25
26
  this.config = this._defaultConfig;
26
27
  this.datamodel = new GraphDataModel();
28
+ this._prevWidth = undefined;
27
29
  this._extendedWidth = undefined;
28
30
  this._extendedHeight = undefined;
29
31
  this._extendedHeightIncreased = undefined;
32
+ this._extendedWidthIncreased = undefined;
30
33
  this._sankey = sankey();
31
34
  this._highlightTimeoutId = null;
32
35
  this._highlightActive = false;
36
+ // Zoom / Pan
37
+ this._zoomScale = [1, 1];
38
+ this._pan = [0, 0];
39
+ this._prevZoomTransform = { x: 0, y: 0, k: 1 };
40
+ this._animationFrameId = null;
41
+ this._bleedCached = null;
42
+ // Events
33
43
  this.events = {
34
44
  [Sankey.selectors.nodeGroup]: {
35
45
  mouseenter: this._onNodeMouseOver.bind(this),
@@ -46,50 +56,97 @@ class Sankey extends ComponentCore {
46
56
  };
47
57
  if (config)
48
58
  this.setConfig(config);
49
- this._backgroundRect = this.g.append('rect').attr('class', background);
59
+ // eslint-disable-next-line @typescript-eslint/naming-convention
60
+ this._gNode = this.g.node();
61
+ this._backgroundRect = this.g.append('rect').attr('class', background).style('pointer-events', 'all');
50
62
  this._linksGroup = this.g.append('g').attr('class', links);
51
63
  this._nodesGroup = this.g.append('g').attr('class', nodes);
64
+ // Initialize scale values from config
65
+ this._zoomScale = (_a = this.config.zoomScale) !== null && _a !== void 0 ? _a : [1, 1];
66
+ // Set up d3-zoom to handle wheel/pinch/drag smoothly
67
+ this._zoomBehavior = zoom()
68
+ .scaleExtent(this.config.zoomExtent)
69
+ .on('zoom', (event) => this._onZoom(event));
70
+ if (this.config.enableZoom)
71
+ this.g.call(this._zoomBehavior);
52
72
  }
53
73
  get bleed() {
54
- var _a;
55
74
  const { config, datamodel: { nodes, links } } = this;
56
- const labelFontSize = (_a = config.labelFontSize) !== null && _a !== void 0 ? _a : getCSSVariableValueInPixels('var(--vis-sankey-label-font-size)', this.element);
57
- const labelSize = requiredLabelSpace(config.labelMaxWidth, labelFontSize);
58
- let left = 0;
59
- let right = 0;
60
- // We pre-calculate sankey layout to get information about node labels placement and calculate bleed properly
61
- // Potentially it can be a performance bottleneck for large layouts, but generally rendering of such layouts is much more computationally heavy
75
+ let bleed = { top: 0, bottom: 0, left: 0, right: 0 };
62
76
  if (nodes.length) {
77
+ const labelFontSize = getLabelFontSize(config, this.element);
78
+ const subLabelFontSize = getSubLabelFontSize(config, this.element);
79
+ // We pre-calculate sankey layout to get information about node labels placement and calculate bleed properly
80
+ // Potentially it can be a performance bottleneck for large layouts, but generally rendering of such layouts is much more computationally heavy
63
81
  const sankeyProbeSize = 1000;
64
82
  this._populateLinkAndNodeValues();
65
83
  this._sankey.size([sankeyProbeSize, sankeyProbeSize]);
66
84
  this._sankey({ nodes, links });
67
- const maxDepth = max(nodes, d => d.depth);
68
- const zeroDepthNodes = nodes.filter(d => d.depth === 0);
69
- const maxDepthNodes = nodes.filter(d => d.depth === maxDepth);
70
- left = zeroDepthNodes.some(d => getLabelOrientation(d, sankeyProbeSize, config.labelPosition) === Position.Left) ? labelSize.width : 0;
71
- right = maxDepthNodes.some(d => getLabelOrientation(d, sankeyProbeSize, config.labelPosition) === Position.Right) ? labelSize.width : 0;
85
+ const requiredLabelHeight = labelFontSize * 2.5 + 2 * SANKEY_LABEL_BLOCK_PADDING; // Assuming 2.5 lines per label
86
+ const maxLayer = max(nodes, d => d.layer);
87
+ const zeroLayerNodes = nodes.filter(d => d.layer === 0);
88
+ const maxLayerNodes = nodes.filter(d => d.layer === maxLayer);
89
+ const layerSpacing = this._getLayerSpacing(nodes);
90
+ let left = 0;
91
+ const fallbackLabelMaxWidth = Math.min(this._width / 6, layerSpacing);
92
+ const labelMaxWidth = config.labelMaxWidth || fallbackLabelMaxWidth;
93
+ const labelHorizontalPadding = 2 * SANKEY_LABEL_SPACING + 2 * SANKEY_LABEL_BLOCK_PADDING;
94
+ const hasLabelsOnTheLeft = zeroLayerNodes.some(d => getLabelOrientation(d, sankeyProbeSize, config.labelPosition) === Position.Left);
95
+ if (hasLabelsOnTheLeft) {
96
+ const maxLeftLabelWidth = max(zeroLayerNodes, d => estimateRequiredLabelWidth(d, config, labelFontSize, subLabelFontSize));
97
+ left = min([labelMaxWidth, maxLeftLabelWidth]) + labelHorizontalPadding;
98
+ }
99
+ let right = 0;
100
+ const hasLabelsOnTheRight = maxLayerNodes.some(d => getLabelOrientation(d, sankeyProbeSize, config.labelPosition) === Position.Right);
101
+ if (hasLabelsOnTheRight) {
102
+ const maxRightLabelWidth = max(maxLayerNodes, d => estimateRequiredLabelWidth(d, config, labelFontSize, subLabelFontSize));
103
+ right = min([labelMaxWidth, maxRightLabelWidth]) + labelHorizontalPadding;
104
+ }
105
+ const top = config.labelVerticalAlign === VerticalAlign.Top ? 0
106
+ : config.labelVerticalAlign === VerticalAlign.Bottom ? requiredLabelHeight
107
+ : requiredLabelHeight / 3;
108
+ const bottom = config.labelVerticalAlign === VerticalAlign.Top ? requiredLabelHeight
109
+ : config.labelVerticalAlign === VerticalAlign.Bottom ? 0
110
+ : requiredLabelHeight / 3;
111
+ const nodeSelectionRectBleed = config.selectedNodeIds ? 1 + NODE_SELECTION_RECT_DELTA : 0;
112
+ bleed = {
113
+ top: nodeSelectionRectBleed + top,
114
+ bottom: nodeSelectionRectBleed + bottom,
115
+ left: left + (hasLabelsOnTheLeft ? 0 : nodeSelectionRectBleed),
116
+ right: right + (hasLabelsOnTheRight ? 0 : nodeSelectionRectBleed),
117
+ };
72
118
  }
73
- const top = config.labelVerticalAlign === VerticalAlign.Top ? 0
74
- : config.labelVerticalAlign === VerticalAlign.Bottom ? labelSize.height
75
- : labelSize.height / 2;
76
- const bottom = config.labelVerticalAlign === VerticalAlign.Top ? labelSize.height
77
- : config.labelVerticalAlign === VerticalAlign.Bottom ? 0
78
- : labelSize.height / 2;
79
- return { top, bottom, left, right };
119
+ // Cache bleed for onZoom
120
+ this._bleedCached = bleed;
121
+ return bleed;
80
122
  }
81
123
  setData(data) {
82
124
  super.setData(data);
83
125
  // Pre-calculate component size for Sizing.EXTEND
84
126
  if ((this.sizing !== Sizing.Fit) || !this._hasLinks())
85
127
  this._preCalculateComponentSize();
128
+ this._bleedCached = null;
86
129
  }
87
130
  setConfig(config) {
88
131
  super.setConfig(config);
132
+ // Update zoom scale from config
133
+ if (this.config.zoomScale !== undefined) {
134
+ this._zoomScale = this.config.zoomScale;
135
+ }
136
+ else if (this.prevConfig.zoomScale !== undefined) {
137
+ this._zoomScale = [1, 1];
138
+ this._pan = [0, 0];
139
+ }
140
+ // Update zoom pan from config
141
+ if (this.config.zoomPan !== undefined) {
142
+ this._pan = this.config.zoomPan;
143
+ }
144
+ else if (this.prevConfig.zoomPan !== undefined) {
145
+ this._pan = [0, 0];
146
+ }
89
147
  // Pre-calculate component size for Sizing.EXTEND
90
148
  if ((this.sizing !== Sizing.Fit) || !this._hasLinks())
91
149
  this._preCalculateComponentSize();
92
- // Using "as any" because typings are not full ("@types/d3-sankey": "^0.11.2")
93
150
  const nodeId = ((d, i) => getString(d, this.config.id, i));
94
151
  this._sankey.linkSort(this.config.linkSort);
95
152
  this._sankey
@@ -99,9 +156,21 @@ class Sankey extends ComponentCore {
99
156
  .nodeAlign(SankeyLayout[this.config.nodeAlign])
100
157
  .nodeSort(this.config.nodeSort)
101
158
  .iterations(this.config.iterations);
159
+ // Update zoom behavior if already initialized
160
+ if (this._zoomBehavior) {
161
+ this._zoomBehavior.scaleExtent(this.config.zoomExtent);
162
+ if (this.config.enableZoom)
163
+ this.g.call(this._zoomBehavior);
164
+ else
165
+ this.g.on('.zoom', null);
166
+ }
167
+ this._bleedCached = null;
102
168
  }
103
169
  _render(customDuration) {
104
- const { config, bleed, datamodel: { nodes, links } } = this;
170
+ const { config, datamodel: { nodes, links } } = this;
171
+ const wasResized = this._prevWidth !== this._width;
172
+ this._prevWidth = this._width;
173
+ const bleed = wasResized || !this._bleedCached ? this.bleed : this._bleedCached;
105
174
  const duration = isNumber(customDuration) ? customDuration : config.duration;
106
175
  if ((nodes.length === 0) ||
107
176
  (nodes.length === 1 && links.length > 0) ||
@@ -113,29 +182,162 @@ class Sankey extends ComponentCore {
113
182
  // Prepare Layout
114
183
  this._prepareLayout();
115
184
  // Links
116
- smartTransition(this._linksGroup, duration).attr('transform', `translate(${bleed.left},${bleed.top})`);
117
185
  const linkSelection = this._linksGroup.selectAll(`.${link}`)
118
- .data(links, (d, i) => { var _a; return (_a = config.id(d, i)) !== null && _a !== void 0 ? _a : i; });
186
+ .data(links, (d, i) => { var _a; return (_a = config.id(d, i)) !== null && _a !== void 0 ? _a : `${d.source.id}-${d.target.id}`; });
119
187
  const linkSelectionEnter = linkSelection.enter().append('g').attr('class', link);
120
188
  linkSelectionEnter.call(createLinks);
121
189
  linkSelection.merge(linkSelectionEnter).call(updateLinks, config, duration);
122
190
  linkSelection.exit().call(removeLinks);
123
191
  // Nodes
124
- smartTransition(this._nodesGroup, duration).attr('transform', `translate(${bleed.left},${bleed.top})`);
192
+ // Sort nodes by x0 for optimize label rendering performance (see `getXDistanceToNextNode` in `modules/node.ts`)
193
+ nodes.sort((a, b) => a.x0 - b.x0);
194
+ const nodeSpacing = this._getLayerSpacing(nodes);
125
195
  const nodeSelection = this._nodesGroup.selectAll(`.${nodeGroup}`)
126
196
  .data(nodes, (d, i) => { var _a; return (_a = config.id(d, i)) !== null && _a !== void 0 ? _a : i; });
127
197
  const nodeSelectionEnter = nodeSelection.enter().append('g').attr('class', nodeGroup);
128
- nodeSelectionEnter.call(createNodes, this.config, this._width, bleed);
129
- nodeSelection.merge(nodeSelectionEnter).call(updateNodes, config, this._width, bleed, this._hasLinks(), duration);
198
+ const sankeyWidth = (this.sizing === Sizing.Fit ? this._width : this._extendedWidth) * this._zoomScale[0];
199
+ nodeSelectionEnter.call(createNodes, this.config, sankeyWidth, bleed);
200
+ nodeSelection.merge(nodeSelectionEnter).call(updateNodes, config, sankeyWidth, bleed, this._hasLinks(), duration, nodeSpacing);
130
201
  nodeSelection.exit()
131
202
  .attr('class', nodeExit)
132
203
  .call(removeNodes, config, duration);
204
+ // Pan
205
+ this._applyPanTransform(duration, bleed);
133
206
  // Background
134
207
  this._backgroundRect
135
208
  .attr('width', this.getWidth())
136
209
  .attr('height', this.getHeight())
137
210
  .attr('opacity', 0);
138
211
  }
212
+ _applyPanTransform(duration, bleed) {
213
+ var _a;
214
+ const pan = (_a = this.config.zoomPan) !== null && _a !== void 0 ? _a : this._pan;
215
+ const tx = bleed.left + pan[0];
216
+ const ty = bleed.top + pan[1];
217
+ smartTransition(this._linksGroup, duration).attr('transform', `translate(${tx},${ty})`);
218
+ smartTransition(this._nodesGroup, duration).attr('transform', `translate(${tx},${ty})`);
219
+ }
220
+ _scheduleRender(duration) {
221
+ if (this._animationFrameId != null)
222
+ return;
223
+ this._animationFrameId = requestAnimationFrame(() => {
224
+ this._render(duration);
225
+ this._animationFrameId = null;
226
+ });
227
+ }
228
+ setZoomScale(horizontalScale, verticalScale, duration = this.config.duration) {
229
+ var _a, _b;
230
+ // If zoomScale is controlled by config, do nothing
231
+ if (this.config.zoomScale !== undefined)
232
+ return;
233
+ const [min, max] = this.config.zoomExtent;
234
+ if (isNumber(horizontalScale))
235
+ this._zoomScale[0] = Math.min(max, Math.max(min, horizontalScale));
236
+ if (isNumber(verticalScale))
237
+ this._zoomScale[1] = Math.min(max, Math.max(min, verticalScale));
238
+ // Sync D3's zoom transform to match our scale
239
+ // Use the geometric mean as a reasonable approximation for D3's single scale
240
+ const effectiveScale = Math.sqrt(((_a = this._zoomScale[0]) !== null && _a !== void 0 ? _a : 1) * ((_b = this._zoomScale[1]) !== null && _b !== void 0 ? _b : 1));
241
+ const currentTransform = zoomIdentity.scale(effectiveScale);
242
+ this._gNode.__zoom = currentTransform;
243
+ this._prevZoomTransform.k = effectiveScale;
244
+ this._render(duration);
245
+ }
246
+ getZoomScale() {
247
+ // If zoomPan is controlled by config, do nothing
248
+ if (this.config.zoomPan !== undefined)
249
+ return;
250
+ return [this._zoomScale[0] || 1, this._zoomScale[1] || 1];
251
+ }
252
+ setPan(x, y, duration = this.config.duration) {
253
+ this._pan = [x !== null && x !== void 0 ? x : 0, y !== null && y !== void 0 ? y : 0];
254
+ this._prevZoomTransform.x = 0;
255
+ this._prevZoomTransform.y = 0;
256
+ this._scheduleRender(duration);
257
+ }
258
+ getPan() {
259
+ return this._pan;
260
+ }
261
+ fitView(duration = this.config.duration) {
262
+ var _a, _b;
263
+ this._zoomScale = (_a = this.config.zoomScale) !== null && _a !== void 0 ? _a : [1, 1];
264
+ this._pan = (_b = this.config.zoomPan) !== null && _b !== void 0 ? _b : [0, 0];
265
+ // Sync D3 zoom transform with our scales
266
+ const effectiveScale = Math.sqrt(this._zoomScale[0] * this._zoomScale[1]);
267
+ const currentTransform = zoomIdentity.scale(effectiveScale);
268
+ this._gNode.__zoom = currentTransform;
269
+ this._prevZoomTransform.k = effectiveScale;
270
+ this._prevZoomTransform.x = 0;
271
+ this._prevZoomTransform.y = 0;
272
+ this._render(duration);
273
+ }
274
+ _onZoom(event) {
275
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
276
+ const { datamodel, config } = this;
277
+ if (config.zoomScale || config.zoomPan)
278
+ return;
279
+ const nodes = datamodel.nodes;
280
+ const transform = event.transform;
281
+ const sourceEvent = event.sourceEvent;
282
+ const zoomMode = config.zoomMode || SankeyZoomMode.XY;
283
+ const bleed = (_a = this._bleedCached) !== null && _a !== void 0 ? _a : this.bleed;
284
+ // Zoom pivots
285
+ const minX = (_b = min(nodes, d => d.x0)) !== null && _b !== void 0 ? _b : 0;
286
+ const minY = (_c = min(nodes, d => d.y0)) !== null && _c !== void 0 ? _c : 0;
287
+ // Determine whether this is a zoom (wheel/pinch) or a pan (drag)
288
+ const isZoomEvent = Math.abs(transform.k - this._prevZoomTransform.k) > 1e-6;
289
+ if (isZoomEvent) { // Zoom and Pan
290
+ // Compute delta factor from transform.k.
291
+ // If Cmd (metaKey) is pressed, only change horizontal scale.
292
+ // If Alt/Option (altKey) is pressed, only change vertical scale.
293
+ const deltaK = transform.k / this._prevZoomTransform.k;
294
+ const isHorizontalOnlyKey = Boolean(sourceEvent === null || sourceEvent === void 0 ? void 0 : sourceEvent.metaKey);
295
+ const isVerticalOnlyKey = !isHorizontalOnlyKey && Boolean(sourceEvent === null || sourceEvent === void 0 ? void 0 : sourceEvent.altKey);
296
+ const isHorizontalOnly = isHorizontalOnlyKey || zoomMode === SankeyZoomMode.X;
297
+ const isVerticalOnly = isVerticalOnlyKey || zoomMode === SankeyZoomMode.Y;
298
+ // Use our scale state as the source of truth (not config, not D3's k)
299
+ const [hCurrent, vCurrent] = this._zoomScale;
300
+ const hNext = isVerticalOnly ? hCurrent : clamp(hCurrent * deltaK, config.zoomExtent[0], config.zoomExtent[1]);
301
+ const vNext = isHorizontalOnly ? vCurrent : clamp(vCurrent * deltaK, config.zoomExtent[0], config.zoomExtent[1]);
302
+ this._zoomScale = [hNext, vNext];
303
+ // Pointer-centric compensation: keep the point under cursor fixed
304
+ const pos = sourceEvent ? pointer(sourceEvent, this.g.node()) : [this._width / 2, this._height / 2];
305
+ // Invert current mapping to get layout coordinates under pointer
306
+ const panX = (_e = (_d = config.zoomPan) === null || _d === void 0 ? void 0 : _d[0]) !== null && _e !== void 0 ? _e : this._pan[0];
307
+ const panY = (_g = (_f = config.zoomPan) === null || _f === void 0 ? void 0 : _f[1]) !== null && _g !== void 0 ? _g : this._pan[1];
308
+ const layoutX = minX + (pos[0] - bleed.left - panX - minX) / hCurrent;
309
+ const layoutY = minY + (pos[1] - bleed.top - panY - minY) / vCurrent;
310
+ // Solve for new pan to keep pointer fixed after new scales
311
+ if (!isVerticalOnly && !isFinite((_h = config.zoomPan) === null || _h === void 0 ? void 0 : _h[0]) && zoomMode !== SankeyZoomMode.Y) {
312
+ this._pan[0] = pos[0] - bleed.left - (minX + (layoutX - minX) * hNext);
313
+ }
314
+ if (!isHorizontalOnly && !isFinite((_j = config.zoomPan) === null || _j === void 0 ? void 0 : _j[1]) && zoomMode !== SankeyZoomMode.X) {
315
+ this._pan[1] = pos[1] - bleed.top - (minY + (layoutY - minY) * vNext);
316
+ }
317
+ }
318
+ else { // Just Pan: apply translation delta directly
319
+ const dx = transform.x - this._prevZoomTransform.x;
320
+ const dy = transform.y - this._prevZoomTransform.y;
321
+ if (zoomMode !== SankeyZoomMode.Y)
322
+ this._pan[0] += dx;
323
+ if (zoomMode !== SankeyZoomMode.X)
324
+ this._pan[1] += dy;
325
+ }
326
+ // Horizontal Pan Constraint
327
+ const maxX = (_k = max(nodes, d => d.x1)) !== null && _k !== void 0 ? _k : 0;
328
+ const viewportWidth = this.getWidth() - bleed.left - bleed.right;
329
+ this._pan[0] = clamp(this._pan[0], viewportWidth - maxX, minX);
330
+ // Vertical Pan Constraint
331
+ const maxY = (_l = max(nodes, d => d.y1)) !== null && _l !== void 0 ? _l : 0;
332
+ const viewportHeight = this.getHeight() - bleed.top - bleed.bottom;
333
+ this._pan[1] = clamp(this._pan[1], viewportHeight - maxY, minY);
334
+ // Update last zoom state
335
+ this._prevZoomTransform.k = transform.k;
336
+ this._prevZoomTransform.x = transform.x;
337
+ this._prevZoomTransform.y = transform.y;
338
+ (_m = config.onZoom) === null || _m === void 0 ? void 0 : _m.call(config, this._zoomScale[0], this._zoomScale[1], this._pan[0], this._pan[1], config.zoomExtent, event);
339
+ this._scheduleRender(0);
340
+ }
139
341
  _populateLinkAndNodeValues() {
140
342
  const { config, datamodel } = this;
141
343
  const nodes = datamodel.nodes;
@@ -168,8 +370,9 @@ class Sankey extends ComponentCore {
168
370
  this._extendedWidth = Math.max(0, (config.nodeWidth + config.nodeHorizontalSpacing) * Object.keys(groupedByColumn).length - config.nodeHorizontalSpacing + bleed.left + bleed.right);
169
371
  }
170
372
  _prepareLayout() {
171
- var _a, _b;
172
- const { config, bleed, datamodel } = this;
373
+ var _a, _b, _c;
374
+ const { config, datamodel } = this;
375
+ const bleed = (_a = this._bleedCached) !== null && _a !== void 0 ? _a : this.bleed;
173
376
  const isExtendedSize = this.sizing === Sizing.Extend;
174
377
  const sankeyHeight = this.sizing === Sizing.Fit ? this._height : this._extendedHeight;
175
378
  const sankeyWidth = this.sizing === Sizing.Fit ? this._width : this._extendedWidth;
@@ -196,7 +399,18 @@ class Sankey extends ComponentCore {
196
399
  node.layer = 0;
197
400
  y = node.y1 + config.nodePadding;
198
401
  }
199
- this._extendedHeightIncreased = undefined;
402
+ // Apply scaling for manual layout as well
403
+ this._applyLayoutScaling();
404
+ if (isExtendedSize) {
405
+ const height = max(nodes, d => d.y1) || 0;
406
+ const width = max(nodes, d => d.x1) || 0;
407
+ this._extendedHeightIncreased = height + bleed.top + bleed.bottom;
408
+ this._extendedWidthIncreased = width + bleed.left + bleed.right;
409
+ }
410
+ else {
411
+ this._extendedHeightIncreased = undefined;
412
+ this._extendedWidthIncreased = undefined;
413
+ }
200
414
  return;
201
415
  }
202
416
  // Calculate sankey
@@ -206,30 +420,81 @@ class Sankey extends ComponentCore {
206
420
  // Default: 1px
207
421
  // Extended size nodes that have no links: config.nodeMinHeight
208
422
  for (const node of nodes) {
209
- const singleExtendedSize = isExtendedSize && !((_a = node.sourceLinks) === null || _a === void 0 ? void 0 : _a.length) && !((_b = node.targetLinks) === null || _b === void 0 ? void 0 : _b.length);
423
+ const singleExtendedSize = isExtendedSize && !((_b = node.sourceLinks) === null || _b === void 0 ? void 0 : _b.length) && !((_c = node.targetLinks) === null || _c === void 0 ? void 0 : _c.length);
210
424
  const h = Math.max(singleExtendedSize ? config.nodeMinHeight : 1, node.y1 - node.y0);
211
425
  const y = (node.y0 + node.y1) / 2;
212
426
  node.y0 = y - h / 2;
213
427
  node.y1 = y + h / 2;
214
428
  }
429
+ // Apply layout scaling (affects spacing only, not node width/height)
430
+ this._applyLayoutScaling();
215
431
  if (isExtendedSize) {
216
- const height = max(nodes, d => d.y1);
432
+ const height = max(nodes, d => d.y1) || 0;
433
+ const width = max(nodes, d => d.x1) || 0;
217
434
  this._extendedHeightIncreased = height + bleed.top + bleed.bottom;
435
+ this._extendedWidthIncreased = width + bleed.left + bleed.right;
436
+ }
437
+ }
438
+ _applyLayoutScaling() {
439
+ var _a, _b;
440
+ const { datamodel } = this;
441
+ const nodes = datamodel.nodes;
442
+ const links = datamodel.links;
443
+ // Use our scale state as the single source of truth
444
+ const [hScale, vScale] = this._zoomScale;
445
+ if ((hScale === 1 || !isFinite(hScale)) && (vScale === 1 || !isFinite(vScale)))
446
+ return;
447
+ const minX = (_a = min(nodes, d => d.x0)) !== null && _a !== void 0 ? _a : 0;
448
+ // Preserve original node positions to realign link anchors after scaling
449
+ const prevNodePos = new Map(nodes.map(n => [n, { x0: n.x0, x1: n.x1, y0: n.y0, y1: n.y1 }]));
450
+ const prevLinkY = new Map(links.map(l => [l, { y0: l.y0, y1: l.y1 }]));
451
+ // Horizontal spacing: scale relative to leftmost x
452
+ if (isFinite(hScale) && hScale !== 1) {
453
+ for (const n of nodes) {
454
+ const nodeWidth = n.width || (n.x1 - n.x0);
455
+ const relX0 = n.x0 - minX;
456
+ n.x0 = minX + relX0 * hScale;
457
+ n.x1 = n.x0 + nodeWidth;
458
+ }
459
+ }
460
+ // Vertical spacing: scale from the topmost node of the graph
461
+ if (isFinite(vScale) && vScale !== 1) {
462
+ const minY = (_b = min(nodes, d => d.y0)) !== null && _b !== void 0 ? _b : 0;
463
+ for (const n of nodes) {
464
+ const nodeHeight = n.y1 - n.y0;
465
+ const relY0 = n.y0 - minY;
466
+ n.y0 = minY + relY0 * vScale;
467
+ n.y1 = n.y0 + nodeHeight;
468
+ }
469
+ // Re-anchor links by maintaining their offset within the source/target nodes
470
+ for (const l of links) {
471
+ const prev = prevLinkY.get(l);
472
+ const prevSrc = prevNodePos.get(l.source);
473
+ const prevTrg = prevNodePos.get(l.target);
474
+ const deltaSrc = l.source.y0 - prevSrc.y0;
475
+ const deltaTrg = l.target.y0 - prevTrg.y0;
476
+ l.y0 = prev.y0 + deltaSrc;
477
+ l.y1 = prev.y1 + deltaTrg;
478
+ }
218
479
  }
219
480
  }
220
481
  getWidth() {
221
- return this.sizing === Sizing.Fit ? this._width : (this._extendedWidth || 0);
482
+ return this.sizing === Sizing.Fit ? this._width : Math.max(this._extendedWidthIncreased || 0, this._extendedWidth || 0);
222
483
  }
223
484
  getHeight() {
224
485
  return this.sizing === Sizing.Fit ? this._height : Math.max(this._extendedHeightIncreased || 0, this._extendedHeight || 0);
225
486
  }
226
487
  getLayoutWidth() {
227
- return this.sizing === Sizing.Fit ? this._width : this._extendedWidth;
488
+ return this.sizing === Sizing.Fit ? this._width : (this._extendedWidthIncreased || this._extendedWidth);
228
489
  }
229
490
  getLayoutHeight() {
230
491
  return this.sizing === Sizing.Fit ? this._height : (this._extendedHeightIncreased || this._extendedHeight);
231
492
  }
493
+ /** @deprecated Use getLayerXCenters instead */
232
494
  getColumnCenters() {
495
+ return this.getLayerXCenters();
496
+ }
497
+ getLayerXCenters() {
233
498
  const { datamodel } = this;
234
499
  const nodes = datamodel.nodes;
235
500
  const centers = nodes.reduce((pos, node) => {
@@ -241,6 +506,18 @@ class Sankey extends ComponentCore {
241
506
  }, []);
242
507
  return centers;
243
508
  }
509
+ getLayerYCenters() {
510
+ const { datamodel } = this;
511
+ const nodes = datamodel.nodes;
512
+ const nodesByLayer = groupBy(nodes, d => d.layer);
513
+ const layerYCenters = [];
514
+ Object.values(nodesByLayer).forEach((layerNodes, idx) => {
515
+ const minY = Math.min(...layerNodes.map(n => n.y0));
516
+ const maxY = Math.max(...layerNodes.map(n => n.y1));
517
+ layerYCenters[idx] = (minY + maxY) / 2;
518
+ });
519
+ return layerYCenters;
520
+ }
244
521
  highlightSubtree(node) {
245
522
  const { config, datamodel } = this;
246
523
  clearTimeout(this._highlightTimeoutId);
@@ -278,26 +555,44 @@ class Sankey extends ComponentCore {
278
555
  const { datamodel } = this;
279
556
  return datamodel.links.length > 0;
280
557
  }
558
+ _getLayerSpacing(nodes) {
559
+ const { config } = this;
560
+ if (!(nodes === null || nodes === void 0 ? void 0 : nodes.length))
561
+ return 0;
562
+ const firstLayerNode = nodes.find(d => d.layer === 0);
563
+ const nextLayerNode = nodes.find(d => d.layer === firstLayerNode.layer + 1);
564
+ return nextLayerNode ? nextLayerNode.x0 - (firstLayerNode.x0 + config.nodeWidth) : this._width - firstLayerNode.x1;
565
+ }
281
566
  _onNodeMouseOver(d, event) {
282
- onNodeMouseOver(d, select(event.currentTarget), this.config, this._width);
567
+ var _a;
568
+ const { datamodel } = this;
569
+ const bleed = (_a = this._bleedCached) !== null && _a !== void 0 ? _a : this.bleed;
570
+ const nodeSelection = select(event.currentTarget);
571
+ const sankeyWidth = this.sizing === Sizing.Fit ? this._width : this._extendedWidth;
572
+ onNodeMouseOver(d, datamodel.nodes, nodeSelection, this.config, sankeyWidth, this._getLayerSpacing(this.datamodel.nodes), bleed);
283
573
  }
284
574
  _onNodeMouseOut(d, event) {
285
- onNodeMouseOut(d, select(event.currentTarget), this.config, this._width);
575
+ var _a;
576
+ const { datamodel } = this;
577
+ const bleed = (_a = this._bleedCached) !== null && _a !== void 0 ? _a : this.bleed;
578
+ const nodeSelection = select(event.currentTarget);
579
+ const sankeyWidth = this.sizing === Sizing.Fit ? this._width : this._extendedWidth;
580
+ onNodeMouseOut(d, datamodel.nodes, nodeSelection, this.config, sankeyWidth, this._getLayerSpacing(this.datamodel.nodes), bleed);
286
581
  }
287
582
  _onNodeRectMouseOver(d) {
288
583
  const { config } = this;
289
584
  if (config.highlightSubtreeOnHover)
290
585
  this.highlightSubtree(d);
291
586
  }
292
- _onNodeRectMouseOut(d) {
587
+ _onNodeRectMouseOut() {
293
588
  this.disableHighlight();
294
589
  }
295
- _onLinkMouseOver(d, event) {
590
+ _onLinkMouseOver(d) {
296
591
  const { config } = this;
297
592
  if (config.highlightSubtreeOnHover)
298
593
  this.highlightSubtree(d.target);
299
594
  }
300
- _onLinkMouseOut(d, event) {
595
+ _onLinkMouseOut() {
301
596
  this.disableHighlight();
302
597
  }
303
598
  }