layerchart 2.0.0-next.57 → 2.0.0-next.59

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 (28) hide show
  1. package/dist/components/AnnotationLine.svelte +112 -66
  2. package/dist/components/AnnotationLine.svelte.d.ts +10 -2
  3. package/dist/components/AnnotationPoint.svelte +97 -23
  4. package/dist/components/AnnotationPoint.svelte.d.ts +8 -1
  5. package/dist/components/GeoPath.svelte +4 -4
  6. package/dist/components/Legend.svelte +1 -0
  7. package/dist/components/Link.svelte +261 -75
  8. package/dist/components/Link.svelte.d.ts +69 -26
  9. package/dist/components/Text.svelte +1 -1
  10. package/dist/components/Voronoi.svelte +35 -6
  11. package/dist/components/Voronoi.svelte.d.ts +9 -0
  12. package/dist/components/charts/__screenshots__/BarChart.svelte.test.ts/BarChart-separate-data-per-series-should-render-stacked-series-with-separate-data-arrays-1.png +0 -0
  13. package/dist/components/charts/__screenshots__/BarChart.svelte.test.ts/BarChart-separate-data-per-series-should-render-stacked-series-with-separate-data-arrays-2.png +0 -0
  14. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-ScatterChart--single-point--quadtree-mode--should-show-series-header-for-multi-series-1.png +0 -0
  15. package/dist/components/charts/__screenshots__/DefaultTooltip.svelte.test.ts/DefaultTooltip-ScatterChart--single-point--quadtree-mode--should-show-series-header-for-multi-series-2.png +0 -0
  16. package/dist/components/index.d.ts +0 -2
  17. package/dist/components/index.js +0 -2
  18. package/dist/components/tooltip/TooltipContext.svelte +39 -10
  19. package/dist/components/tooltip/TooltipContext.svelte.d.ts +14 -0
  20. package/dist/states/brush.svelte.d.ts +1 -1
  21. package/dist/states/chart.svelte.js +24 -8
  22. package/dist/states/chart.svelte.test.js +181 -0
  23. package/dist/utils/linkUtils.d.ts +42 -0
  24. package/dist/utils/{connectorUtils.js → linkUtils.js} +56 -6
  25. package/package.json +1 -1
  26. package/dist/components/Connector.svelte +0 -167
  27. package/dist/components/Connector.svelte.d.ts +0 -56
  28. package/dist/utils/connectorUtils.d.ts +0 -34
@@ -46,8 +46,6 @@ export { default as ClipPath } from './ClipPath.svelte';
46
46
  export * from './ClipPath.svelte';
47
47
  export { default as ColorRamp } from './ColorRamp.svelte';
48
48
  export * from './ColorRamp.svelte';
49
- export { default as Connector } from './Connector.svelte';
50
- export * from './Connector.svelte';
51
49
  export { default as Contour } from './Contour.svelte';
52
50
  export * from './Contour.svelte';
53
51
  export { default as Dagre } from './Dagre.svelte';
@@ -46,8 +46,6 @@ export { default as ClipPath } from './ClipPath.svelte';
46
46
  export * from './ClipPath.svelte';
47
47
  export { default as ColorRamp } from './ColorRamp.svelte';
48
48
  export * from './ColorRamp.svelte';
49
- export { default as Connector } from './Connector.svelte';
50
- export * from './Connector.svelte';
51
49
  export { default as Contour } from './Contour.svelte';
52
50
  export * from './Contour.svelte';
53
51
  export { default as Dagre } from './Dagre.svelte';
@@ -2,6 +2,7 @@
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
3
  import { asAny, type Without } from '../../utils/types.js';
4
4
  import type { TooltipState as TooltipStateType } from '../../states/tooltip.svelte.js';
5
+ import type { Accessor } from '../../utils/common.js';
5
6
 
6
7
  export type TooltipMode =
7
8
  | 'bisect-x' // requires values to be sorted
@@ -55,6 +56,21 @@
55
56
  */
56
57
  radius?: number;
57
58
 
59
+ /**
60
+ * Override the `x` accessor used for quadtree/voronoi hit detection.
61
+ * Useful when the chart's `x` accessor returns an array of values and you
62
+ * want hit detection at a specific endpoint.
63
+ *
64
+ * Accepts a string property name (e.g. `'POP_2015'`) or a function.
65
+ */
66
+ x?: Accessor;
67
+
68
+ /**
69
+ * Override the `y` accessor used for quadtree/voronoi hit detection.
70
+ * See `x` above.
71
+ */
72
+ y?: Accessor;
73
+
58
74
  /**
59
75
  * Enable debug view (show hit targets, etc)
60
76
  * @default false
@@ -131,6 +147,8 @@
131
147
  radius = Infinity,
132
148
  raiseTarget = false,
133
149
  state: stateProp = $bindable() as TooltipStateType<TData>,
150
+ x: xProp,
151
+ y: yProp,
134
152
  children,
135
153
  }: TooltipContextProps<TData> = $props();
136
154
 
@@ -411,6 +429,9 @@
411
429
  }, hideDelay);
412
430
  }
413
431
 
432
+ const xAccessorOverride = $derived(xProp != null ? accessor(xProp) : undefined);
433
+ const yAccessorOverride = $derived(yProp != null ? accessor(yProp) : undefined);
434
+
414
435
  const quadtree: Quadtree<[number, number]> | undefined = $derived.by(() => {
415
436
  if (['quadtree', 'quadtree-x', 'quadtree-y'].includes(mode)) {
416
437
  return d3Quadtree()
@@ -419,6 +440,11 @@
419
440
  return 0;
420
441
  }
421
442
 
443
+ if (xAccessorOverride) {
444
+ const scaled = ctx.xScale(xAccessorOverride(d));
445
+ return typeof scaled === 'number' ? scaled : 0;
446
+ }
447
+
422
448
  if (geo.projection) {
423
449
  const lat = ctx.x(d);
424
450
  const long = ctx.y(d);
@@ -429,11 +455,10 @@
429
455
  const value = ctx.xGet(d);
430
456
 
431
457
  if (Array.isArray(value)) {
432
- // `x` accessor with multiple properties (ex. `x={['start', 'end']})`)
433
- // Using first value. Consider using average, max, etc
434
- // const midpoint = new Date((value[1].valueOf() + value[0].getTime()) / 2);
435
- // return midpoint;
436
- return min(value);
458
+ // `x` accessor with multiple properties (ex. `x={['start', 'end']})`).
459
+ // Default to the max (typically the "target"/"end" endpoint); override
460
+ // via the `x` prop for explicit control.
461
+ return max(value);
437
462
  } else {
438
463
  return value;
439
464
  }
@@ -443,6 +468,11 @@
443
468
  return 0;
444
469
  }
445
470
 
471
+ if (yAccessorOverride) {
472
+ const scaled = ctx.yScale(yAccessorOverride(d));
473
+ return typeof scaled === 'number' ? scaled : 0;
474
+ }
475
+
446
476
  if (geo.projection) {
447
477
  const lat = ctx.x(d);
448
478
  const long = ctx.y(d);
@@ -453,11 +483,8 @@
453
483
  const value = ctx.yGet(d);
454
484
 
455
485
  if (Array.isArray(value)) {
456
- // `x` accessor with multiple properties (ex. `x={['start', 'end']})`)
457
- // Using first value. Consider using average, max, etc
458
- // const midpoint = new Date((value[1].valueOf() + value[0].getTime()) / 2);
459
- // return midpoint;
460
- return min(value);
486
+ // `y` accessor with multiple properties default to max endpoint.
487
+ return max(value);
461
488
  } else {
462
489
  return value;
463
490
  }
@@ -676,6 +703,8 @@
676
703
  {#if mode === 'voronoi'}
677
704
  <Svg>
678
705
  <Voronoi
706
+ x={xProp}
707
+ y={yProp}
679
708
  r={radius}
680
709
  onpointerenter={(e, { data }) => {
681
710
  showTooltip(e, data);
@@ -1,6 +1,7 @@
1
1
  import type { HTMLAttributes } from 'svelte/elements';
2
2
  import { type Without } from '../../utils/types.js';
3
3
  import type { TooltipState as TooltipStateType } from '../../states/tooltip.svelte.js';
4
+ import type { Accessor } from '../../utils/common.js';
4
5
  export type TooltipMode = 'bisect-x' | 'bisect-y' | 'band' | 'bisect-band' | 'bounds' | 'voronoi' | 'quadtree' | 'quadtree-x' | 'quadtree-y' | 'manual';
5
6
  type TooltipContextPropsWithoutHTML<T = any> = {
6
7
  /**
@@ -36,6 +37,19 @@ type TooltipContextPropsWithoutHTML<T = any> = {
36
37
  * @default Infinity
37
38
  */
38
39
  radius?: number;
40
+ /**
41
+ * Override the `x` accessor used for quadtree/voronoi hit detection.
42
+ * Useful when the chart's `x` accessor returns an array of values and you
43
+ * want hit detection at a specific endpoint.
44
+ *
45
+ * Accepts a string property name (e.g. `'POP_2015'`) or a function.
46
+ */
47
+ x?: Accessor;
48
+ /**
49
+ * Override the `y` accessor used for quadtree/voronoi hit detection.
50
+ * See `x` above.
51
+ */
52
+ y?: Accessor;
39
53
  /**
40
54
  * Enable debug view (show hit targets, etc)
41
55
  * @default false
@@ -35,7 +35,7 @@ export declare class BrushState {
35
35
  x: BrushDomainType;
36
36
  y: BrushDomainType;
37
37
  active: boolean | undefined;
38
- axis: "both" | "x" | "y";
38
+ axis: "x" | "y" | "both";
39
39
  handleSize: number;
40
40
  constructor(ctx: typeof this.ctx, options?: {
41
41
  x?: BrushDomainType;
@@ -174,6 +174,13 @@ export class ChartState {
174
174
  // Use the value axis accessor (y for horizontal charts, x for vertical).
175
175
  const valueAxis = this.valueAxis;
176
176
  const chartValueProp = valueAxis === 'y' ? this.props.y : this.props.x;
177
+ const chartValueKeys = Array.isArray(chartValueProp)
178
+ ? chartValueProp.filter((k) => typeof k === 'string')
179
+ : typeof chartValueProp === 'string'
180
+ ? [chartValueProp]
181
+ : [];
182
+ const chartHasOwnData = this.props.data != null &&
183
+ (!Array.isArray(this.props.data) || this.props.data.length > 0);
177
184
  const implicitSeries = [];
178
185
  for (const { info } of this._markInfos) {
179
186
  const valueAccessor = valueAxis === 'y' ? info.y : info.x;
@@ -181,10 +188,12 @@ export class ChartState {
181
188
  (typeof valueAccessor === 'string' ? valueAccessor : undefined);
182
189
  if (!key)
183
190
  continue;
184
- // Skip if the mark just reuses the chart's own axis accessor and has no
185
- // separate data it's not defining a new series, just using the chart's axis.
186
- // Marks with their own data arrays are kept (multi-dataset scenario).
187
- if (key === chartValueProp && !info.data)
191
+ // Skip if the mark's key matches one of the chart's axis accessors.
192
+ // When the chart has its own data, any mark using that accessor is just
193
+ // decorating (e.g. a filtered label subset), not defining a new series.
194
+ // Without explicit chart data, still skip unless the mark has its own
195
+ // dataset (multi-dataset scenario).
196
+ if (chartValueKeys.includes(key) && (chartHasOwnData || !info.data))
188
197
  continue;
189
198
  if (implicitSeries.some((s) => s.key === key))
190
199
  continue;
@@ -316,14 +325,21 @@ export class ChartState {
316
325
  // Use $derived fields instead of getters for caching
317
326
  containerWidth = $derived(this.props.width ?? this._containerWidth);
318
327
  containerHeight = $derived(this.props.height ?? this._containerHeight);
319
- // If seriesState has series-specific data, use visible series data (for domain calculations).
320
- // This allows simplified charts to pass raw data and let Chart derive chartData from seriesState.
321
- // Using visibleSeriesData ensures domain recalculates when series are shown/hidden via legend.
328
+ // When `<Chart data>` is passed with a non-empty dataset, it's canonical
329
+ // marks with their own `data` (e.g. filtered label subsets) still contribute
330
+ // to `flatData` for domain calculation but don't replace iteration data.
331
+ // Otherwise fall back to `visibleSeriesData` so simplified charts that pass
332
+ // data via series definitions still work, with reactive recomputation when
333
+ // series are shown/hidden via legend.
322
334
  data = $derived.by(() => {
335
+ const propsData = this.props.data;
336
+ if (propsData != null && (!Array.isArray(propsData) || propsData.length > 0)) {
337
+ return propsData;
338
+ }
323
339
  if (this.seriesState?.visibleSeriesData?.length) {
324
340
  return this.seriesState.visibleSeriesData;
325
341
  }
326
- return this.props.data ?? [];
342
+ return [];
327
343
  });
328
344
  flatData = $derived.by(() => {
329
345
  const base = (this.props.flatData ?? this.data);
@@ -534,6 +534,187 @@ describe('ChartState mark registration', () => {
534
534
  }
535
535
  });
536
536
  });
537
+ describe('ChartState data vs visibleSeriesData', () => {
538
+ it('should return props.data when explicit, even if a mark registers a filtered subset', () => {
539
+ const fullData = [
540
+ { date: '2024-01', value: 10 },
541
+ { date: '2024-02', value: 20 },
542
+ { date: '2024-03', value: 30 },
543
+ ];
544
+ const highlighted = [fullData[0]]; // filtered subset
545
+ const { state, cleanup } = createChartState({
546
+ data: fullData,
547
+ x: 'date',
548
+ y: 'value',
549
+ });
550
+ try {
551
+ // Simulate a decorative mark (e.g. <Text data={highlighted}>) registering
552
+ // its own filtered dataset with the same value accessor as the chart.
553
+ state.registerMark({ y: 'value', data: highlighted });
554
+ flushSync();
555
+ // ctx.data (used by sibling marks for iteration) should remain the full
556
+ // chart data, not be replaced by the filtered subset.
557
+ expect(state.data).toBe(fullData);
558
+ expect(state.data).toHaveLength(3);
559
+ }
560
+ finally {
561
+ cleanup();
562
+ }
563
+ });
564
+ it('should fall back to visibleSeriesData when props.data is not provided', () => {
565
+ const applesData = [
566
+ { date: '2024-01', value: 10 },
567
+ { date: '2024-02', value: 20 },
568
+ ];
569
+ const bananasData = [
570
+ { date: '2024-01', value: 15 },
571
+ { date: '2024-02', value: 25 },
572
+ ];
573
+ const { state, cleanup } = createChartState({
574
+ x: 'date',
575
+ y: 'value',
576
+ series: [
577
+ { key: 'apples', data: applesData },
578
+ { key: 'bananas', data: bananasData },
579
+ ],
580
+ });
581
+ try {
582
+ // No props.data — ctx.data should flatten series data for iteration.
583
+ expect(state.data).toHaveLength(4);
584
+ }
585
+ finally {
586
+ cleanup();
587
+ }
588
+ });
589
+ it('should not create an implicit series for a decorative mark when chart has own data', () => {
590
+ // Scenario: <Chart data={full}> + <Text data={highlighted} y="value"> (labels)
591
+ // The Text mark shouldn't create an implicit series that narrows the domain.
592
+ const fullData = [
593
+ { date: '2024-01', value: 10 },
594
+ { date: '2024-02', value: 50 },
595
+ { date: '2024-03', value: 100 },
596
+ ];
597
+ const highlighted = [fullData[1]];
598
+ const { state, cleanup } = createChartState({
599
+ data: fullData,
600
+ x: 'date',
601
+ y: 'value',
602
+ });
603
+ try {
604
+ state.registerMark({ y: 'value', data: highlighted });
605
+ flushSync();
606
+ // Decorative mark shouldn't turn this into a multi-series chart.
607
+ expect(state.seriesState.isDefaultSeries).toBe(true);
608
+ }
609
+ finally {
610
+ cleanup();
611
+ }
612
+ });
613
+ it('should not create an implicit series when chart uses array y accessor matching the mark key', () => {
614
+ const fullData = [
615
+ { date: '2024-01', v1: 10, v2: 20 },
616
+ { date: '2024-02', v1: 30, v2: 40 },
617
+ ];
618
+ const { state, cleanup } = createChartState({
619
+ data: fullData,
620
+ x: 'date',
621
+ y: ['v1', 'v2'],
622
+ });
623
+ try {
624
+ state.registerMark({ y: 'v2', data: [fullData[0]] });
625
+ flushSync();
626
+ expect(state.seriesState.isDefaultSeries).toBe(true);
627
+ }
628
+ finally {
629
+ cleanup();
630
+ }
631
+ });
632
+ it('should compute yDomain from full chart data when a decorative mark has a filtered subset', () => {
633
+ // Regression: Text labeling highlighted rows shouldn't narrow the y domain.
634
+ const fullData = [
635
+ { date: '2024-01', value: 10 },
636
+ { date: '2024-02', value: 50 },
637
+ { date: '2024-03', value: 100 },
638
+ ];
639
+ const highlighted = [fullData[1]]; // only value=50
640
+ const { state, cleanup } = createChartState({
641
+ data: fullData,
642
+ x: 'date',
643
+ y: 'value',
644
+ });
645
+ try {
646
+ state.registerMark({ y: 'value', data: highlighted });
647
+ flushSync();
648
+ // yDomain should reflect the full data extent [10, 100], not just [50, 50].
649
+ expect(state.yDomain).toEqual([10, 100]);
650
+ }
651
+ finally {
652
+ cleanup();
653
+ }
654
+ });
655
+ it('should compute yDomain across all array y accessors on Chart', () => {
656
+ const data = [
657
+ { date: '2024-01', v1: 3, v2: 5 },
658
+ { date: '2024-02', v1: 2, v2: 8 },
659
+ { date: '2024-03', v1: 4, v2: 9 },
660
+ ];
661
+ const { state, cleanup } = createChartState({
662
+ data,
663
+ x: 'date',
664
+ y: ['v1', 'v2'],
665
+ });
666
+ try {
667
+ // Domain should span min(v1) = 2 to max(v2) = 9
668
+ expect(state.yDomain).toEqual([2, 9]);
669
+ }
670
+ finally {
671
+ cleanup();
672
+ }
673
+ });
674
+ it('should keep full yDomain when decorative mark + array y both present', () => {
675
+ const data = [
676
+ { date: '2024-01', v1: 3, v2: 5 },
677
+ { date: '2024-02', v1: 2, v2: 8 },
678
+ { date: '2024-03', v1: 4, v2: 9 },
679
+ ];
680
+ const { state, cleanup } = createChartState({
681
+ data,
682
+ x: 'date',
683
+ y: ['v1', 'v2'],
684
+ });
685
+ try {
686
+ // Decorative Text mark with subset data and y matching one of chart's keys
687
+ state.registerMark({ y: 'v2', data: [data[0]] });
688
+ flushSync();
689
+ // Still the full range [2, 9], not narrowed to [5, 5]
690
+ expect(state.yDomain).toEqual([2, 9]);
691
+ expect(state.seriesState.isDefaultSeries).toBe(true);
692
+ }
693
+ finally {
694
+ cleanup();
695
+ }
696
+ });
697
+ it('should fall back to visibleSeriesData when props.data is an empty array', () => {
698
+ // Composite charts (BarChart, etc.) default `data = []` when not passed.
699
+ const applesData = [{ date: '2024-01', value: 10 }];
700
+ const bananasData = [{ date: '2024-01', value: 15 }];
701
+ const { state, cleanup } = createChartState({
702
+ data: [],
703
+ x: 'date',
704
+ y: 'value',
705
+ series: [
706
+ { key: 'apples', data: applesData },
707
+ { key: 'bananas', data: bananasData },
708
+ ],
709
+ });
710
+ try {
711
+ expect(state.data).toHaveLength(2);
712
+ }
713
+ finally {
714
+ cleanup();
715
+ }
716
+ });
717
+ });
537
718
  describe('ChartState geo projection skips markInfo', () => {
538
719
  const geoData = [
539
720
  { name: 'New York', longitude: -74.006, latitude: 40.7128 },
@@ -0,0 +1,42 @@
1
+ import { type CurveFactory } from 'd3-shape';
2
+ export type LinkCoords = {
3
+ x: number;
4
+ y: number;
5
+ };
6
+ export type PresetLinkType = 'straight' | 'square' | 'beveled' | 'rounded' | 'swoop';
7
+ export type LinkType = PresetLinkType | 'd3';
8
+ export type LinkSweep = 'horizontal-vertical' | 'vertical-horizontal' | 'none';
9
+ type GetLinkPresetPathProps = {
10
+ source: LinkCoords;
11
+ target: LinkCoords;
12
+ radius: number;
13
+ type: PresetLinkType;
14
+ sweep: LinkSweep;
15
+ /** Bend angle in degrees, used by 'swoop' type. Default 22.5. */
16
+ bend?: number;
17
+ };
18
+ export declare function getLinkPresetPath(opts: GetLinkPresetPathProps): string;
19
+ type GetLinkD3PathProps = Omit<GetLinkPresetPathProps, 'radius' | 'type'> & {
20
+ curve: CurveFactory;
21
+ /**
22
+ * Cartesian orientation hint for axis-dependent curves (d3 step variants step
23
+ * along x by default; for 'vertical' we step along y to match the natural flow).
24
+ */
25
+ orientation?: 'horizontal' | 'vertical';
26
+ };
27
+ export declare function getLinkD3Path({ source, target, sweep, curve, orientation, }: GetLinkD3PathProps): string;
28
+ type GetLinkRadialPresetPathProps = {
29
+ source: LinkCoords;
30
+ target: LinkCoords;
31
+ type: PresetLinkType;
32
+ radius: number;
33
+ bend?: number;
34
+ };
35
+ export declare function getLinkRadialPresetPath({ source, target, type, radius, bend, }: GetLinkRadialPresetPathProps): string;
36
+ type GetLinkRadialD3PathProps = {
37
+ source: LinkCoords;
38
+ target: LinkCoords;
39
+ curve?: CurveFactory;
40
+ };
41
+ export declare function getLinkRadialD3Path({ source, target, curve, }: GetLinkRadialD3PathProps): string;
42
+ export {};
@@ -58,27 +58,63 @@ function createRoundedPath(opts) {
58
58
  return `M ${source.x} ${source.y} L ${pBeforeCorner.x} ${pBeforeCorner.y} A ${effectiveRadius} ${effectiveRadius} 0 0 ${sweepFlag} ${pAfterCorner.x} ${pAfterCorner.y} L ${target.x} ${target.y}`;
59
59
  }
60
60
  }
61
+ /**
62
+ * Swoop: circular arc between source and target. Equivalent to ObservablePlot's
63
+ * Arrow `bend` option — positive angle bends right (clockwise from source to
64
+ * target), negative bends left, 0 is a straight line.
65
+ */
66
+ function createSwoopPath({ source, target, dx, dy, bend = 22.5 }) {
67
+ const chordLen = Math.hypot(dx, dy);
68
+ const bendRad = (bend * Math.PI) / 180;
69
+ if (Math.abs(bendRad) < 1e-6 || chordLen < 1e-6) {
70
+ return createDirectPath(source, target);
71
+ }
72
+ // Half-chord subtends `bend` at the arc center, so radius = chord / (2 * sin(bend))
73
+ const arcRadius = chordLen / (2 * Math.sin(Math.abs(bendRad)));
74
+ const largeArc = Math.abs(bend) > 90 ? 1 : 0;
75
+ const sweepFlag = bend > 0 ? 1 : 0;
76
+ return `M${source.x},${source.y}A${arcRadius},${arcRadius} 0 ${largeArc} ${sweepFlag} ${target.x},${target.y}`;
77
+ }
61
78
  const pathStrategies = {
62
79
  square: createSquarePath,
63
80
  beveled: createBeveledPath,
64
81
  rounded: createRoundedPath,
82
+ swoop: createSwoopPath,
65
83
  };
66
- export function getConnectorPresetPath(opts) {
84
+ export function getLinkPresetPath(opts) {
67
85
  const { source, target, type } = opts;
68
86
  if (isSamePoint(source, target))
69
87
  return '';
70
88
  const dx = target.x - source.x;
71
89
  const dy = target.y - source.y;
72
- // straight line cases
73
- if (type === 'straight' || isNearZero(dx) || isNearZero(dy)) {
90
+ // straight line cases (swoop still bends even when axis-aligned)
91
+ if (type === 'straight' || (type !== 'swoop' && (isNearZero(dx) || isNearZero(dy)))) {
74
92
  return createDirectPath(source, target);
75
93
  }
76
94
  return (pathStrategies[type] || pathStrategies.square)({ ...opts, dx, dy });
77
95
  }
78
96
  const FALLBACK_PATH = 'M0,0L0,0';
79
- export function getConnectorD3Path({ source, target, sweep, curve }) {
97
+ export function getLinkD3Path({ source, target, sweep, curve, orientation = 'horizontal', }) {
80
98
  const dx = target.x - source.x;
81
99
  const dy = target.y - source.y;
100
+ // d3 step curves always step along x. For vertical orientation, emit a
101
+ // y-axis step manually so the step sits between parent/child along depth.
102
+ if (orientation === 'vertical' && sweep === 'none') {
103
+ const { x: sx, y: sy } = source;
104
+ const { x: tx, y: ty } = target;
105
+ if (curve === curveStep) {
106
+ const my = (sy + ty) / 2;
107
+ return `M${sx},${sy}L${sx},${my}L${tx},${my}L${tx},${ty}`;
108
+ }
109
+ if (curve === curveStepBefore) {
110
+ // Bump near source: sibling (x) changes first, then depth (y)
111
+ return `M${sx},${sy}L${tx},${sy}L${tx},${ty}`;
112
+ }
113
+ if (curve === curveStepAfter) {
114
+ // Bump near target: depth (y) changes first, then sibling (x)
115
+ return `M${sx},${sy}L${sx},${ty}L${tx},${ty}`;
116
+ }
117
+ }
82
118
  const line = d3Line().curve(curve);
83
119
  let points = [];
84
120
  const isAligned = isNearZero(dx) || isNearZero(dy);
@@ -135,12 +171,26 @@ function radialGeometry(source, target) {
135
171
  sweepFlag,
136
172
  };
137
173
  }
138
- export function getConnectorRadialPresetPath({ source, target, type, radius, }) {
174
+ export function getLinkRadialPresetPath({ source, target, type, radius, bend = 22.5, }) {
139
175
  const g = radialGeometry(source, target);
140
176
  const { sr, ta, tr, sc, ss, tc, ts, sx, sy, tx, ty, sweepFlag } = g;
141
177
  if (type === 'straight') {
142
178
  return `M${sx},${sy}L${tx},${ty}`;
143
179
  }
180
+ if (type === 'swoop') {
181
+ // Circular arc in cartesian space between the polar-converted endpoints.
182
+ const dx = tx - sx;
183
+ const dy = ty - sy;
184
+ const chordLen = Math.hypot(dx, dy);
185
+ const bendRad = (bend * Math.PI) / 180;
186
+ if (Math.abs(bendRad) < 1e-6 || chordLen < 1e-6) {
187
+ return `M${sx},${sy}L${tx},${ty}`;
188
+ }
189
+ const arcRadius = chordLen / (2 * Math.sin(Math.abs(bendRad)));
190
+ const largeArc = Math.abs(bend) > 90 ? 1 : 0;
191
+ const arcSweep = bend > 0 ? 1 : 0;
192
+ return `M${sx},${sy}A${arcRadius},${arcRadius} 0 ${largeArc} ${arcSweep} ${tx},${ty}`;
193
+ }
144
194
  if (type === 'rounded') {
145
195
  // visx LinkRadialCurve: cubic Bezier with rotated offset (percent controls tension)
146
196
  const percent = 0.2;
@@ -183,7 +233,7 @@ export function getConnectorRadialPresetPath({ source, target, type, radius, })
183
233
  const p2y = cornerY + radialDir * r * ts;
184
234
  return `M${sx},${sy}L${p1x},${p1y}L${p2x},${p2y}L${tx},${ty}`;
185
235
  }
186
- export function getConnectorRadialD3Path({ source, target, curve, }) {
236
+ export function getLinkRadialD3Path({ source, target, curve, }) {
187
237
  const g = radialGeometry(source, target);
188
238
  const { sr, tr, sc, ss, tc, ts, sx, sy, tx, ty, sweepFlag } = g;
189
239
  // Step curves render as polar arcs/radials rather than cartesian stairs.
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "license": "MIT",
6
6
  "repository": "techniq/layerchart",
7
7
  "homepage": "https://layerchart.com",
8
- "version": "2.0.0-next.57",
8
+ "version": "2.0.0-next.59",
9
9
  "devDependencies": {
10
10
  "@changesets/cli": "^2.30.0",
11
11
  "@napi-rs/canvas": "^0.1.97",