@fundar/data-chart-telling 0.0.23 → 0.0.25

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.
@@ -70,7 +70,17 @@ export const DEFAULT_CONFIG = {
70
70
  margins: { top: 'auto', right: 'auto', bottom: 'auto', left: 'auto' },
71
71
  palette: ['#4f46e5', '#e6580d', '#16a34a', '#dc2626', '#0891b2', '#9333ea', '#ca8a04', '#db2777'],
72
72
  continuous: { min: '#e0e7ff', max: '#4f46e5', labelColor: '#111827' },
73
- line: { strokeWidth: 3, dotRadius: 4 },
73
+ line: {
74
+ strokeWidth: 3,
75
+ dotRadius: 4,
76
+ valueLabels: {
77
+ elbowGap: 10,
78
+ laneStep: 7,
79
+ labelGap: 6,
80
+ connectorColor: '#94a3b8',
81
+ connectorWidth: 0.5,
82
+ },
83
+ },
74
84
  timeline: { axisColor: 'currentColor', activeColor: '#e6580d', thickness: 60 },
75
85
  hover: {
76
86
  rule: { stroke: '#e6580d', strokeOpacity: 0.35, strokeWidth: 1.5, strokeDasharray: '4 4' },
@@ -84,6 +84,13 @@ export declare const THEMES: {
84
84
  line?: {
85
85
  strokeWidth?: number | undefined;
86
86
  dotRadius?: number | undefined;
87
+ valueLabels?: {
88
+ elbowGap?: number | undefined;
89
+ laneStep?: number | undefined;
90
+ labelGap?: number | undefined;
91
+ connectorColor?: string | undefined;
92
+ connectorWidth?: number | undefined;
93
+ } | undefined;
87
94
  } | undefined;
88
95
  timeline?: {
89
96
  axisColor?: string | undefined;
@@ -207,6 +214,13 @@ export declare const THEMES: {
207
214
  line?: {
208
215
  strokeWidth?: number | undefined;
209
216
  dotRadius?: number | undefined;
217
+ valueLabels?: {
218
+ elbowGap?: number | undefined;
219
+ laneStep?: number | undefined;
220
+ labelGap?: number | undefined;
221
+ connectorColor?: string | undefined;
222
+ connectorWidth?: number | undefined;
223
+ } | undefined;
210
224
  } | undefined;
211
225
  timeline?: {
212
226
  axisColor?: string | undefined;
@@ -330,6 +344,13 @@ export declare const THEMES: {
330
344
  line?: {
331
345
  strokeWidth?: number | undefined;
332
346
  dotRadius?: number | undefined;
347
+ valueLabels?: {
348
+ elbowGap?: number | undefined;
349
+ laneStep?: number | undefined;
350
+ labelGap?: number | undefined;
351
+ connectorColor?: string | undefined;
352
+ connectorWidth?: number | undefined;
353
+ } | undefined;
333
354
  } | undefined;
334
355
  timeline?: {
335
356
  axisColor?: string | undefined;
@@ -453,6 +474,13 @@ export declare const THEMES: {
453
474
  line?: {
454
475
  strokeWidth?: number | undefined;
455
476
  dotRadius?: number | undefined;
477
+ valueLabels?: {
478
+ elbowGap?: number | undefined;
479
+ laneStep?: number | undefined;
480
+ labelGap?: number | undefined;
481
+ connectorColor?: string | undefined;
482
+ connectorWidth?: number | undefined;
483
+ } | undefined;
456
484
  } | undefined;
457
485
  timeline?: {
458
486
  axisColor?: string | undefined;
@@ -59,31 +59,32 @@
59
59
  : ('middle' as const),
60
60
  );
61
61
  const groupOffset = $derived(barLayout.groupOffset(seriesIndex));
62
+ const font = $derived(values?.fontStyle);
62
63
 
63
64
  // `textAnchor: 'outside'` is a per-point escape hatch resolved by
64
65
  // HoverMarker (which knows each point's signed x) — value labels already
65
66
  // have their own `anchor: 'outside'` concept (computed above), so if it
66
67
  // leaks through here just fall back to the anchor-derived default.
67
- const resolvedTextAnchor = $derived(values?.textAnchor === 'outside' ? undefined : values?.textAnchor);
68
+ const resolvedTextAnchor = $derived(font?.textAnchor === 'outside' ? undefined : font?.textAnchor);
68
69
  </script>
69
70
 
70
71
  <Text
71
72
  data={group.series.data}
72
73
  {...isHorizontal ? { y: xFn, x: valueTextFn } : { x: xFn, y: valueTextFn }}
73
74
  text={(d: TData) => fmtVal(Number(yFn(d)), group.series.name)}
74
- dx={values?.dx ?? (isHorizontal ? defaultOffset : groupOffset.dx)}
75
- dy={values?.dy ?? (isHorizontal ? groupOffset.dy : defaultOffset)}
75
+ dx={font?.dx ?? (isHorizontal ? defaultOffset : groupOffset.dx)}
76
+ dy={font?.dy ?? (isHorizontal ? groupOffset.dy : defaultOffset)}
76
77
  textAnchor={resolvedTextAnchor ?? (isHorizontal ? (valAnchor === 'outside' || valAnchor === 'end' ? 'start' : valAnchor === 'start' ? 'end' : 'middle') : 'middle')}
77
- lineAnchor={values?.lineAnchor ?? (isHorizontal ? 'middle' : defaultLA)}
78
- lineHeight={values?.lineHeight}
79
- rotate={values?.rotate}
80
- class={values?.class}
81
- textClass={values?.textClass}
82
- fill={values?.fill ?? axisColor}
83
- fontSize={values?.fontSize ?? 12}
84
- fontWeight={values?.fontWeight}
85
- fontStyle={values?.fontStyle}
86
- stroke={values?.stroke}
87
- strokeWidth={values?.strokeWidth}
88
- paintOrder={values?.paintOrder}
78
+ lineAnchor={font?.lineAnchor ?? (isHorizontal ? 'middle' : defaultLA)}
79
+ lineHeight={font?.lineHeight}
80
+ rotate={font?.rotate}
81
+ class={font?.class}
82
+ textClass={font?.textClass}
83
+ fill={font?.fill ?? axisColor}
84
+ fontSize={font?.fontSize ?? 12}
85
+ fontWeight={font?.fontWeight}
86
+ fontStyle={font?.fontStyle}
87
+ stroke={font?.stroke}
88
+ strokeWidth={font?.strokeWidth}
89
+ paintOrder={font?.paintOrder}
89
90
  />
@@ -1,5 +1,5 @@
1
1
  <script lang="ts" generics="TData extends Record<string, unknown>">
2
- import { Cell, Text } from 'svelteplot';
2
+ import { Cell } from 'svelteplot';
3
3
  import { resolveAccessor } from '../utils/accessors';
4
4
  import { validateSegments } from '../utils/segments';
5
5
  import { buildSeries } from '../../utils/grouping';
@@ -11,6 +11,7 @@
11
11
  import AxisLayout from '../../layout/plot/AxisLayout.svelte';
12
12
  import GridLayout from '../../layout/plot/GridLayout.svelte';
13
13
  import RuleLayout from '../../layout/plot/RuleLayout.svelte';
14
+ import ValueLabels from './ValueLabels.svelte';
14
15
  import { hasHoverMarker, resolveHoverStrategy, resolveHoverSync } from '../../layout/tooltip/utils';
15
16
  import type { BasePlotProps } from '../../types/plots/props';
16
17
  import type { AxisValue } from '../../types/plots/axis';
@@ -77,9 +78,6 @@
77
78
  const maxColor = $derived(styles.colors?.max ?? cfg.continuous.max);
78
79
  const showVals = $derived(styles.values?.show ?? true);
79
80
  const fmtVal = $derived(styles.values?.format ?? ((v: number) => `${v}`));
80
- // 'outside' is a per-point escape hatch resolved by HoverMarker; heatmaps
81
- // have no signed axis to resolve it against, so treat it as unset.
82
- const resolvedTextAnchor = $derived(styles.values?.textAnchor === 'outside' ? undefined : styles.values?.textAnchor);
83
81
 
84
82
  const resolvedSeries = $derived(series ?? buildSeries(data!, x!, y!, undefined));
85
83
  const flat = $derived(resolvedSeries.flatMap((s) => s.data));
@@ -264,26 +262,14 @@
264
262
  {/each}
265
263
 
266
264
  {#if showVals}
267
- <Text
265
+ <ValueLabels
268
266
  data={flat}
269
- x={getX}
270
- y={getY}
271
- text={(d) => fmtVal(Number(getZ(d)), '')}
272
- fill={styles.values?.fill ?? cfg.continuous.labelColor}
273
- fontSize={styles.values?.fontSize ?? 12}
274
- fontWeight={styles.values?.fontWeight ?? 600}
275
- fontStyle={styles.values?.fontStyle}
276
- textAnchor={resolvedTextAnchor ?? 'middle'}
277
- lineAnchor={styles.values?.lineAnchor ?? 'middle'}
278
- dx={styles.values?.dx ?? 0}
279
- dy={styles.values?.dy ?? 0}
280
- lineHeight={styles.values?.lineHeight}
281
- rotate={styles.values?.rotate}
282
- stroke={styles.values?.stroke}
283
- strokeWidth={styles.values?.strokeWidth}
284
- paintOrder={styles.values?.paintOrder}
285
- class={styles.values?.class}
286
- textClass={styles.values?.textClass}
267
+ {getX}
268
+ {getY}
269
+ {getZ}
270
+ {fmtVal}
271
+ values={styles.values}
272
+ defaultColor={cfg.continuous.labelColor}
287
273
  />
288
274
  {/if}
289
275
  </RuleLayout>
@@ -0,0 +1,56 @@
1
+ <script lang="ts" generics="TData extends Record<string, unknown>">
2
+ import { Text } from 'svelteplot';
3
+ import type { AxisValue } from '../../types/plots/axis';
4
+ import type { ValuesStyle } from '../../types/plots/styles/common';
5
+
6
+ /**
7
+ * Renders every cell's value label in one mark — heatmaps have no
8
+ * per-series concept to loop over, so unlike bar/line/pyramid this covers
9
+ * the whole flat dataset at once. Centered by default, overridable
10
+ * per-field via `styles.values.fontStyle`.
11
+ */
12
+ let {
13
+ data,
14
+ getX,
15
+ getY,
16
+ getZ,
17
+ fmtVal,
18
+ values,
19
+ defaultColor,
20
+ }: {
21
+ data: TData[];
22
+ getX: (d: TData) => AxisValue;
23
+ getY: (d: TData) => AxisValue;
24
+ getZ: (d: TData) => unknown;
25
+ fmtVal: (value: number, seriesName: string) => string;
26
+ values: ValuesStyle | undefined;
27
+ defaultColor: string;
28
+ } = $props();
29
+
30
+ const font = $derived(values?.fontStyle);
31
+ // 'outside' is a per-point escape hatch resolved by HoverMarker; heatmaps
32
+ // have no signed axis to resolve it against, so treat it as unset.
33
+ const resolvedTextAnchor = $derived(font?.textAnchor === 'outside' ? undefined : font?.textAnchor);
34
+ </script>
35
+
36
+ <Text
37
+ {data}
38
+ x={getX}
39
+ y={getY}
40
+ text={(d: TData) => fmtVal(Number(getZ(d)), '')}
41
+ fill={font?.fill ?? defaultColor}
42
+ fontSize={font?.fontSize ?? 12}
43
+ fontWeight={font?.fontWeight ?? 600}
44
+ fontStyle={font?.fontStyle}
45
+ textAnchor={resolvedTextAnchor ?? 'middle'}
46
+ lineAnchor={font?.lineAnchor ?? 'middle'}
47
+ dx={font?.dx ?? 0}
48
+ dy={font?.dy ?? 0}
49
+ lineHeight={font?.lineHeight}
50
+ rotate={font?.rotate}
51
+ stroke={font?.stroke}
52
+ strokeWidth={font?.strokeWidth}
53
+ paintOrder={font?.paintOrder}
54
+ class={font?.class}
55
+ textClass={font?.textClass}
56
+ />
@@ -0,0 +1,34 @@
1
+ import type { AxisValue } from '../../types/plots/axis';
2
+ import type { ValuesStyle } from '../../types/plots/styles/common';
3
+ declare function $$render<TData extends Record<string, unknown>>(): {
4
+ props: {
5
+ data: TData[];
6
+ getX: (d: TData) => AxisValue;
7
+ getY: (d: TData) => AxisValue;
8
+ getZ: (d: TData) => unknown;
9
+ fmtVal: (value: number, seriesName: string) => string;
10
+ values: ValuesStyle | undefined;
11
+ defaultColor: string;
12
+ };
13
+ exports: {};
14
+ bindings: "";
15
+ slots: {};
16
+ events: {};
17
+ };
18
+ declare class __sveltets_Render<TData extends Record<string, unknown>> {
19
+ props(): ReturnType<typeof $$render<TData>>['props'];
20
+ events(): ReturnType<typeof $$render<TData>>['events'];
21
+ slots(): ReturnType<typeof $$render<TData>>['slots'];
22
+ bindings(): "";
23
+ exports(): {};
24
+ }
25
+ interface $$IsomorphicComponent {
26
+ new <TData extends Record<string, unknown>>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<TData>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<TData>['props']>, ReturnType<__sveltets_Render<TData>['events']>, ReturnType<__sveltets_Render<TData>['slots']>> & {
27
+ $$bindings?: ReturnType<__sveltets_Render<TData>['bindings']>;
28
+ } & ReturnType<__sveltets_Render<TData>['exports']>;
29
+ <TData extends Record<string, unknown>>(internal: unknown, props: ReturnType<__sveltets_Render<TData>['props']> & {}): ReturnType<__sveltets_Render<TData>['exports']>;
30
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
31
+ }
32
+ declare const ValueLabels: $$IsomorphicComponent;
33
+ type ValueLabels<TData extends Record<string, unknown>> = InstanceType<typeof ValueLabels<TData>>;
34
+ export default ValueLabels;
@@ -3,7 +3,7 @@
3
3
  generics="TData extends Record<string, unknown>"
4
4
  >
5
5
  import { SvelteMap } from 'svelte/reactivity';
6
- import { Line, Dot, Text } from 'svelteplot';
6
+ import { Line, Dot } from 'svelteplot';
7
7
  import { resolveAccessor } from '../utils/accessors';
8
8
  import { getConfiguration } from '../../configuration/config.svelte';
9
9
  import { paletteColor } from '../../utils/color';
@@ -15,6 +15,7 @@
15
15
  import GridLayout from '../../layout/plot/GridLayout.svelte';
16
16
  import RuleLayout from '../../layout/plot/RuleLayout.svelte';
17
17
  import HoverMarker from '../../markers/HoverMarker.svelte';
18
+ import ValueLabels from './ValueLabels.svelte';
18
19
  import { hasHoverMarker, resolveHoverStrategy, resolveHoverSync } from '../../layout/tooltip/utils';
19
20
  import { buildGroupedSeries, validateSegments, resolveSegmentsForSeries, matchesSegmentX, getDataForSegments } from '../utils/segments';
20
21
  import type { BasePlotProps } from '../../types/plots/props';
@@ -22,7 +23,6 @@
22
23
  import type { Series, SeriesProps, DataProps } from '../../types/plots/data/common';
23
24
  import type { AxisBasedScalesConfig } from '../../types/plots/props';
24
25
  import type { BasePlotStyles } from '../../types/plots/styles/common';
25
- import type { ValueAnchor } from '../../types/plots/constants';
26
26
  import type { LineSegmentStyle } from '../../types/plots/segments/common';
27
27
  import type { Marker } from '../../types/markers/common';
28
28
 
@@ -73,14 +73,6 @@
73
73
 
74
74
  const cfg = $derived(getConfiguration());
75
75
  const showVals = $derived(styles.values?.show ?? false);
76
- const fmtVal = $derived(
77
- styles.values?.format ?? ((v: number, name: string) => `${name} - ${v}`),
78
- );
79
- const valAnchor = $derived<ValueAnchor>(styles.values?.anchor ?? 'start');
80
- // 'outside' is a per-point escape hatch resolved by HoverMarker; line's own
81
- // value labels already have their own `anchor: 'outside'` concept above,
82
- // so treat it as unset here.
83
- const resolvedTextAnchor = $derived(styles.values?.textAnchor === 'outside' ? undefined : styles.values?.textAnchor);
84
76
 
85
77
  const xAcc = $derived(resolveAccessor(resolvedSeries[0]?.x ?? ((d: TData) => d)));
86
78
  const yAcc = $derived(resolveAccessor(resolvedSeries[0]?.y ?? ((d: TData) => d)));
@@ -236,37 +228,7 @@
236
228
  {/each}
237
229
 
238
230
  {#if showVals}
239
- {@const defaultDx = valAnchor === 'start' ? 6 : valAnchor === 'end' ? -6 : 0}
240
- {@const defaultDy = valAnchor === 'outside' ? -8 : 0}
241
- {@const defaultTA = valAnchor === 'start'
242
- ? ('start' as const)
243
- : valAnchor === 'end'
244
- ? ('end' as const)
245
- : ('middle' as const)}
246
- {@const defaultLA = valAnchor === 'outside'
247
- ? ('bottom' as const)
248
- : ('middle' as const)}
249
- {#each lastPoints as { series: s, lastRow, seriesIndex } (s.name)}
250
- {@const xFn = resolveAccessor(s.x)}
251
- {@const yFn = resolveAccessor(s.y)}
252
- {@const seriesColor = paletteColor(seriesIndex, cfg.palette)}
253
- <Text
254
- data={[lastRow]}
255
- x={xFn}
256
- y={yFn}
257
- text={() => fmtVal(Number(yFn(lastRow)), s.name)}
258
- dx={styles.values?.dx ?? defaultDx}
259
- dy={styles.values?.dy ?? defaultDy}
260
- textAnchor={resolvedTextAnchor ?? defaultTA}
261
- lineAnchor={styles.values?.lineAnchor ?? defaultLA}
262
- lineHeight={styles.values?.lineHeight}
263
- rotate={styles.values?.rotate}
264
- class={styles.values?.class}
265
- textClass={styles.values?.textClass}
266
- fill={seriesColor}
267
- fontSize={12}
268
- />
269
- {/each}
231
+ <ValueLabels {lastPoints} values={styles.values} />
270
232
  {/if}
271
233
  </RuleLayout>
272
234
  {/snippet}
@@ -0,0 +1,233 @@
1
+ <script lang="ts" generics="TData extends Record<string, unknown>">
2
+ import { getContext } from 'svelte';
3
+ import { get } from 'svelte/store';
4
+ import { SvelteMap } from 'svelte/reactivity';
5
+ import type { Writable } from 'svelte/store';
6
+ import { Text, usePlot } from 'svelteplot';
7
+ import { resolveAccessor } from '../utils/accessors';
8
+ import { getConfiguration } from '../../configuration/config.svelte';
9
+ import { paletteColor } from '../../utils/color';
10
+ import { declutter1D, laneAssignment } from '../utils/declutter';
11
+ import type { Series } from '../../types/plots/data/common';
12
+ import type { ValuesStyle } from '../../types/plots/styles/common';
13
+ import type { ValueAnchor } from '../../types/plots/constants';
14
+
15
+ // svelteplot's own type for this context isn't part of its public export
16
+ // surface, but the context itself (keyed by this fixed string, set by its
17
+ // `<Plot>`) is — every axis mark already reads/writes it this way to
18
+ // auto-size margins around what it actually renders. Mirroring that here
19
+ // is what lets `margins.right: 'auto'` (the default) grow to fit these
20
+ // labels on its own, instead of the caller having to guess a fixed
21
+ // `margins.right` up front.
22
+ type AutoMarginStores = {
23
+ autoMarginTop: Writable<Map<string, number>>;
24
+ autoMarginLeft: Writable<Map<string, number>>;
25
+ autoMarginRight: Writable<Map<string, number>>;
26
+ autoMarginBottom: Writable<Map<string, number>>;
27
+ };
28
+
29
+ /**
30
+ * End-point value labels for `LinePlot` — one label per series at its last
31
+ * data point, with automatic collision resolution whenever several series
32
+ * end up with close last values. Rendered as a proper descendant of
33
+ * svelteplot's `<Plot>` (not just of `LinePlot` itself) so `usePlot()`
34
+ * below resolves the real, live y-scale needed to measure pixel-space
35
+ * overlap.
36
+ */
37
+ let {
38
+ lastPoints,
39
+ values,
40
+ }: {
41
+ lastPoints: { series: Series<TData>; lastRow: TData; seriesIndex: number }[];
42
+ values: ValuesStyle | undefined;
43
+ } = $props();
44
+
45
+ const cfg = $derived(getConfiguration());
46
+
47
+ // Elbow-routed leader line geometry (pixels), configurable project-wide
48
+ // via `setConfiguration({ line: { valueLabels: {...} } })` — each lane a
49
+ // run's members get spread across (see `laneAssignment`'s doc comment for
50
+ // how lanes are picked without crossing) is `laneStep` further out than
51
+ // the last; `elbowGap` is the shortest lane (rank 0, right off the data
52
+ // point); `labelGap` is the small clearance between the outermost lane and
53
+ // the label text it feeds into. `connectorColor`/`connectorWidth` are the
54
+ // leader line's own default look — deliberately *not* each series' own
55
+ // colour or stroke weight (matching the data line reads as more data, not
56
+ // as a leader, right where several lines are already converging) — both
57
+ // still overridable per-plot via `values.strokeStyle`.
58
+ const elbowGap = $derived(cfg.line.valueLabels.elbowGap);
59
+ const laneStep = $derived(cfg.line.valueLabels.laneStep);
60
+ const labelGap = $derived(cfg.line.valueLabels.labelGap);
61
+ const defaultConnectorColor = $derived(cfg.line.valueLabels.connectorColor);
62
+ const defaultConnectorWidth = $derived(cfg.line.valueLabels.connectorWidth);
63
+
64
+ const font = $derived(values?.fontStyle);
65
+ const connector = $derived(values?.strokeStyle);
66
+ const fmtVal = $derived(values?.format ?? ((v: number, name: string) => `${name} - ${v}`));
67
+ const valAnchor = $derived<ValueAnchor>(values?.anchor ?? 'start');
68
+ // 'outside' is a per-point escape hatch resolved by HoverMarker; line's own
69
+ // value labels already have their own `anchor: 'outside'` concept above,
70
+ // so treat it as unset here.
71
+ const resolvedTextAnchor = $derived(font?.textAnchor === 'outside' ? undefined : font?.textAnchor);
72
+
73
+ const defaultDx = $derived(valAnchor === 'start' ? 6 : valAnchor === 'end' ? -6 : 0);
74
+ const defaultDy = $derived(valAnchor === 'outside' ? -8 : 0);
75
+ const defaultTA = $derived(
76
+ valAnchor === 'start' ? ('start' as const) : valAnchor === 'end' ? ('end' as const) : ('middle' as const),
77
+ );
78
+ const defaultLA = $derived(valAnchor === 'outside' ? ('bottom' as const) : ('middle' as const));
79
+
80
+ const plot = usePlot();
81
+
82
+ // ── Auto margin ──────────────────────────────────────────────────────────
83
+ // Registers how far these labels (+ their leader lines) actually extend
84
+ // past the plot's own content box, on whichever side they grow toward, so
85
+ // `margins.{left,right}: 'auto'` (the default) sizes itself to fit them —
86
+ // the same mechanism svelteplot's own axis labels use, rather than a
87
+ // fixed margin the caller has to guess and hard-code up front.
88
+ const autoMargins = getContext<AutoMarginStores | undefined>('svelteplot/autoMargins');
89
+ const marginId = `line-value-labels-${Math.random().toString(36).slice(2)}`;
90
+ let groupEl: SVGGElement | undefined = $state();
91
+
92
+ $effect(() => {
93
+ // Depend on whatever actually changes the rendered label layout —
94
+ // getBBox() below is a plain DOM read, not itself tracked, so without
95
+ // this the effect would only ever measure once.
96
+ void lastPoints;
97
+ void values;
98
+ const el = groupEl;
99
+ if (!autoMargins || !el) return;
100
+ const xRange = plot.scales.x?.fn?.range?.()?.map(Number);
101
+ if (!xRange) return;
102
+ const plotLeft = Math.min(xRange[0], xRange[1]);
103
+ const plotRight = Math.max(xRange[0], xRange[1]);
104
+ // getBBox() is in the <svg>'s own user-space coordinates — the same
105
+ // pixel space `plot.scales.*.fn()` already produces everywhere else in
106
+ // this file, so no viewport/DOM conversion is needed here.
107
+ let box: { x: number; width: number };
108
+ try {
109
+ box = el.getBBox();
110
+ } catch {
111
+ return;
112
+ }
113
+ const overflowRight = Math.ceil(Math.max(0, box.x + box.width - plotRight));
114
+ const overflowLeft = Math.ceil(Math.max(0, plotLeft - box.x));
115
+ const rightMargins = get(autoMargins.autoMarginRight);
116
+ const leftMargins = get(autoMargins.autoMarginLeft);
117
+ if (rightMargins.get(marginId) !== overflowRight) rightMargins.set(marginId, overflowRight);
118
+ if (leftMargins.get(marginId) !== overflowLeft) leftMargins.set(marginId, overflowLeft);
119
+ });
120
+
121
+ $effect(() => {
122
+ return () => {
123
+ if (!autoMargins) return;
124
+ get(autoMargins.autoMarginRight).delete(marginId);
125
+ get(autoMargins.autoMarginLeft).delete(marginId);
126
+ };
127
+ });
128
+
129
+ /**
130
+ * Pixel-space collision fix-up for end-point labels: resolves each label's
131
+ * true pixel Y through the plot's own live y-scale, then runs the shared
132
+ * two-pass declutter over those pixels to find how far each one needs to
133
+ * move to clear its neighbours. The result is a pure pixel *offset*
134
+ * (`extraDy`), applied the same way `<Text dy>` already is everywhere else
135
+ * in this file — it never becomes a data-space Y fed back into a mark,
136
+ * which would extend the y-scale's own domain and provoke an infinite
137
+ * autoscale ↔ declutter feedback loop.
138
+ *
139
+ * `inGroup`/`rank`/`lanesUsed` come from `laneAssignment`, fed by
140
+ * `declutter1D`'s own `connected` record rather than re-inferred from
141
+ * positions — see both functions' doc comments for why that's the only
142
+ * reliable source: position-based heuristics either miss a series that
143
+ * only had to move because a neighbour got pushed into it, or wrongly
144
+ * bridge two unrelated clusters that each happened to need decluttering
145
+ * on their own. The elbow connector below turns `rank` into a lane index.
146
+ * A series with nothing nearby is absent from the map, and its label
147
+ * renders with no extra offset.
148
+ */
149
+ const declutteredOffsets = $derived.by(() => {
150
+ const map = new SvelteMap<string, { extraDy: number; inGroup: boolean; rank: number; lanesUsed: number }>();
151
+ if (lastPoints.length < 2) return map;
152
+ const yScale = plot.scales.y?.fn;
153
+ if (!yScale) return map;
154
+ const fontSize = font?.fontSize ?? 12;
155
+ const minGap = Math.max(16, fontSize * 1.4);
156
+ const items = lastPoints.map(({ series: s, lastRow }) => ({
157
+ name: s.name,
158
+ pos: Number(yScale(Number(resolveAccessor(s.y)(lastRow)))),
159
+ }));
160
+ const sorted = [...items].sort((a, b) => a.pos - b.pos);
161
+ const range = yScale.range?.().map(Number);
162
+ const bounds: [number, number] | undefined = range
163
+ ? [Math.min(range[0], range[1]), Math.max(range[0], range[1])]
164
+ : undefined;
165
+ const { positions: spread, connected } = declutter1D(items, minGap, bounds);
166
+ const spreadByName = new Map(spread.map((d) => [d.name, d.pos]));
167
+ const originals = sorted.map((item) => item.pos);
168
+ const finals = sorted.map((item) => spreadByName.get(item.name) ?? item.pos);
169
+ const ranks = laneAssignment(originals, finals, connected);
170
+ sorted.forEach((item, i) => {
171
+ map.set(item.name, {
172
+ extraDy: finals[i] - originals[i],
173
+ inGroup: ranks[i].runSize > 1,
174
+ rank: ranks[i].rank,
175
+ lanesUsed: ranks[i].lanesUsed,
176
+ });
177
+ });
178
+ return map;
179
+ });
180
+ </script>
181
+
182
+ <g bind:this={groupEl}>
183
+ {#each lastPoints as { series: s, lastRow, seriesIndex } (s.name)}
184
+ {@const xFn = resolveAccessor(s.x)}
185
+ {@const yFn = resolveAccessor(s.y)}
186
+ {@const seriesColor = paletteColor(seriesIndex, cfg.palette)}
187
+ {@const offset = declutteredOffsets.get(s.name)}
188
+ {@const extraDy = offset?.inGroup ? offset.extraDy : 0}
189
+ {@const baseDx = font?.dx ?? defaultDx}
190
+ {@const dir = Math.sign(baseDx) || 1}
191
+ {@const numLanes = offset?.inGroup ? offset.lanesUsed : 0}
192
+ {@const labelDx = offset?.inGroup
193
+ ? dir * (elbowGap + (numLanes - 1) * laneStep + labelGap)
194
+ : baseDx}
195
+ {#if offset?.inGroup && plot.scales.x?.fn && plot.scales.y?.fn}
196
+ {@const px = Number(plot.scales.x.fn(xFn(lastRow)))}
197
+ {@const py = Number(plot.scales.y.fn(Number(yFn(lastRow))))}
198
+ {@const laneX = px + dir * (elbowGap + offset.rank * laneStep)}
199
+ {@const targetY = py + extraDy}
200
+ <path
201
+ d={`M ${px} ${py} H ${laneX} V ${targetY} H ${px + labelDx}`}
202
+ fill="none"
203
+ stroke={connector?.stroke ?? defaultConnectorColor}
204
+ stroke-width={connector?.strokeWidth ?? defaultConnectorWidth}
205
+ stroke-opacity={connector?.strokeOpacity ?? 1}
206
+ stroke-dasharray={connector?.strokeDasharray}
207
+ stroke-linecap={connector?.strokeLinecap ?? 'square'}
208
+ stroke-linejoin={connector?.strokeLinejoin ?? 'miter'}
209
+ />
210
+ {/if}
211
+ <Text
212
+ data={[lastRow]}
213
+ x={xFn}
214
+ y={yFn}
215
+ text={() => fmtVal(Number(yFn(lastRow)), s.name)}
216
+ dx={labelDx}
217
+ dy={(font?.dy ?? defaultDy) + extraDy}
218
+ textAnchor={resolvedTextAnchor ?? defaultTA}
219
+ lineAnchor={font?.lineAnchor ?? defaultLA}
220
+ lineHeight={font?.lineHeight}
221
+ rotate={font?.rotate}
222
+ class={font?.class}
223
+ textClass={font?.textClass}
224
+ fill={font?.fill ?? seriesColor}
225
+ fontSize={font?.fontSize ?? 12}
226
+ fontWeight={font?.fontWeight}
227
+ fontStyle={font?.fontStyle}
228
+ stroke={font?.stroke}
229
+ strokeWidth={font?.strokeWidth}
230
+ paintOrder={font?.paintOrder}
231
+ />
232
+ {/each}
233
+ </g>
@@ -0,0 +1,33 @@
1
+ import type { Series } from '../../types/plots/data/common';
2
+ import type { ValuesStyle } from '../../types/plots/styles/common';
3
+ declare function $$render<TData extends Record<string, unknown>>(): {
4
+ props: {
5
+ lastPoints: {
6
+ series: Series<TData>;
7
+ lastRow: TData;
8
+ seriesIndex: number;
9
+ }[];
10
+ values: ValuesStyle | undefined;
11
+ };
12
+ exports: {};
13
+ bindings: "";
14
+ slots: {};
15
+ events: {};
16
+ };
17
+ declare class __sveltets_Render<TData extends Record<string, unknown>> {
18
+ props(): ReturnType<typeof $$render<TData>>['props'];
19
+ events(): ReturnType<typeof $$render<TData>>['events'];
20
+ slots(): ReturnType<typeof $$render<TData>>['slots'];
21
+ bindings(): "";
22
+ exports(): {};
23
+ }
24
+ interface $$IsomorphicComponent {
25
+ new <TData extends Record<string, unknown>>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<TData>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<TData>['props']>, ReturnType<__sveltets_Render<TData>['events']>, ReturnType<__sveltets_Render<TData>['slots']>> & {
26
+ $$bindings?: ReturnType<__sveltets_Render<TData>['bindings']>;
27
+ } & ReturnType<__sveltets_Render<TData>['exports']>;
28
+ <TData extends Record<string, unknown>>(internal: unknown, props: ReturnType<__sveltets_Render<TData>['props']> & {}): ReturnType<__sveltets_Render<TData>['exports']>;
29
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
30
+ }
31
+ declare const ValueLabels: $$IsomorphicComponent;
32
+ type ValueLabels<TData extends Record<string, unknown>> = InstanceType<typeof ValueLabels<TData>>;
33
+ export default ValueLabels;
@@ -1,6 +1,6 @@
1
1
  <script lang="ts" generics="TData extends Record<string, unknown>">
2
2
  import { SvelteMap } from 'svelte/reactivity';
3
- import { BarX, Text } from 'svelteplot';
3
+ import { BarX } from 'svelteplot';
4
4
  import { resolveAccessor } from '../utils/accessors';
5
5
  import { getConfiguration } from '../../configuration/config.svelte';
6
6
  import { paletteColor } from '../../utils/color';
@@ -13,6 +13,7 @@
13
13
  import GridLayout from '../../layout/plot/GridLayout.svelte';
14
14
  import RuleLayout from '../../layout/plot/RuleLayout.svelte';
15
15
  import HoverMarker from '../../markers/HoverMarker.svelte';
16
+ import ValueLabels from './ValueLabels.svelte';
16
17
  import { hasHoverMarker, resolveHoverStrategy, resolveHoverSync } from '../../layout/tooltip/utils';
17
18
  import type { BasePlotProps } from '../../types/plots/props';
18
19
  import type { AxisValue } from '../../types/plots/axis';
@@ -73,10 +74,6 @@
73
74
  const showVals = $derived(styles.values?.show ?? false);
74
75
  const fmtVal = $derived(styles.values?.format ?? ((v: number) => `${v}`));
75
76
  const valAnchor = $derived<ValueAnchor>(styles.values?.anchor ?? 'outside');
76
- // 'outside' is a per-point escape hatch resolved by HoverMarker; pyramid's
77
- // own value labels already have their own `anchor: 'outside'` concept
78
- // above, so treat it as unset here.
79
- const resolvedTextAnchor = $derived(styles.values?.textAnchor === 'outside' ? undefined : styles.values?.textAnchor);
80
77
 
81
78
  // Sort series names alphabetically; even index → left, odd → right.
82
79
  // scales.z.reverse flips the assignment so the second name goes left.
@@ -236,41 +233,7 @@
236
233
  {/each}
237
234
 
238
235
  {#if showVals}
239
- {@const isLeft = group.series.side === 'left'}
240
- {@const xFn = valAnchor === 'middle'
241
- ? (d: TData) => signedValue(group.series, d) / 2
242
- : valAnchor === 'end'
243
- ? () => 0
244
- : (d: TData) => signedValue(group.series, d)}
245
- {@const defaultDx = valAnchor === 'middle' ? 0
246
- : valAnchor === 'end' ? (isLeft ? -4 : 4)
247
- : (isLeft ? -6 : 6)}
248
- {@const defaultTextAnchor = valAnchor === 'middle'
249
- ? ('middle' as const)
250
- : isLeft
251
- ? ('end' as const)
252
- : ('start' as const)}
253
- <Text
254
- data={group.series.data}
255
- x={xFn}
256
- y={getCategory}
257
- text={(d: TData) => fmtVal(Math.abs(Number(resolveAccessor(group.series.y)(d))), group.series.name)}
258
- dx={styles.values?.dx ?? defaultDx}
259
- dy={styles.values?.dy ?? 0}
260
- textAnchor={resolvedTextAnchor ?? defaultTextAnchor}
261
- lineAnchor={styles.values?.lineAnchor ?? 'middle'}
262
- lineHeight={styles.values?.lineHeight}
263
- rotate={styles.values?.rotate}
264
- class={styles.values?.class}
265
- textClass={styles.values?.textClass}
266
- fill={styles.values?.fill ?? cfg.axis.color}
267
- fontSize={styles.values?.fontSize ?? 12}
268
- fontWeight={styles.values?.fontWeight}
269
- fontStyle={styles.values?.fontStyle}
270
- stroke={styles.values?.stroke}
271
- strokeWidth={styles.values?.strokeWidth}
272
- paintOrder={styles.values?.paintOrder}
273
- />
236
+ <ValueLabels {group} {valAnchor} {fmtVal} values={styles.values} axisColor={cfg.axis.color} />
274
237
  {/if}
275
238
 
276
239
  {#each seriesMarkers.get(group.series.name) ?? [] as marker, i (`series-${group.series.name}-${marker.type}-${i}`)}
@@ -0,0 +1,80 @@
1
+ <script lang="ts" generics="TData extends Record<string, unknown>">
2
+ import { Text } from 'svelteplot';
3
+ import { resolveAccessor } from '../utils/accessors';
4
+ import type { Series } from '../../types/plots/data/common';
5
+ import type { ValuesStyle } from '../../types/plots/styles/common';
6
+ import type { ValueAnchor } from '../../types/plots/constants';
7
+ import type { BarSegmentStyle } from '../../types/plots/segments/common';
8
+ import type { VisualGroup } from '../../types/plots/segments/config';
9
+
10
+ /**
11
+ * Renders one series' value label on a pyramid bar — anchor-driven default
12
+ * position/offset, mirrored left/right of the shared zero baseline
13
+ * depending on `group.series.side`, overridable per-field via
14
+ * `styles.values.fontStyle`.
15
+ */
16
+ let {
17
+ group,
18
+ valAnchor,
19
+ fmtVal,
20
+ values,
21
+ axisColor,
22
+ }: {
23
+ group: VisualGroup<Series<TData> & { side: 'left' | 'right' }, BarSegmentStyle>;
24
+ valAnchor: ValueAnchor;
25
+ fmtVal: (value: number, seriesName: string) => string;
26
+ values: ValuesStyle | undefined;
27
+ axisColor: string;
28
+ } = $props();
29
+
30
+ const isLeft = $derived(group.series.side === 'left');
31
+ const getCategory = $derived(resolveAccessor(group.series.x));
32
+ const getMagnitude = $derived(resolveAccessor(group.series.y));
33
+ const signedValue = $derived((d: TData) => {
34
+ const v = Math.abs(Number(getMagnitude(d)));
35
+ return isLeft ? -v : v;
36
+ });
37
+
38
+ const xFn = $derived(
39
+ valAnchor === 'middle'
40
+ ? (d: TData) => signedValue(d) / 2
41
+ : valAnchor === 'end'
42
+ ? () => 0
43
+ : (d: TData) => signedValue(d),
44
+ );
45
+ const defaultDx = $derived(
46
+ valAnchor === 'middle' ? 0 : valAnchor === 'end' ? (isLeft ? -4 : 4) : isLeft ? -6 : 6,
47
+ );
48
+ const defaultTextAnchor = $derived(
49
+ valAnchor === 'middle' ? ('middle' as const) : isLeft ? ('end' as const) : ('start' as const),
50
+ );
51
+
52
+ const font = $derived(values?.fontStyle);
53
+ // 'outside' is a per-point escape hatch resolved by HoverMarker; pyramid's
54
+ // own value labels already have their own `anchor: 'outside'` concept
55
+ // (resolved above via `valAnchor`), so if it leaks through here just fall
56
+ // back to the anchor-derived default.
57
+ const resolvedTextAnchor = $derived(font?.textAnchor === 'outside' ? undefined : font?.textAnchor);
58
+ </script>
59
+
60
+ <Text
61
+ data={group.series.data}
62
+ x={xFn}
63
+ y={getCategory}
64
+ text={(d: TData) => fmtVal(Math.abs(Number(getMagnitude(d))), group.series.name)}
65
+ dx={font?.dx ?? defaultDx}
66
+ dy={font?.dy ?? 0}
67
+ textAnchor={resolvedTextAnchor ?? defaultTextAnchor}
68
+ lineAnchor={font?.lineAnchor ?? 'middle'}
69
+ lineHeight={font?.lineHeight}
70
+ rotate={font?.rotate}
71
+ class={font?.class}
72
+ textClass={font?.textClass}
73
+ fill={font?.fill ?? axisColor}
74
+ fontSize={font?.fontSize ?? 12}
75
+ fontWeight={font?.fontWeight}
76
+ fontStyle={font?.fontStyle}
77
+ stroke={font?.stroke}
78
+ strokeWidth={font?.strokeWidth}
79
+ paintOrder={font?.paintOrder}
80
+ />
@@ -0,0 +1,37 @@
1
+ import type { Series } from '../../types/plots/data/common';
2
+ import type { ValuesStyle } from '../../types/plots/styles/common';
3
+ import type { ValueAnchor } from '../../types/plots/constants';
4
+ import type { BarSegmentStyle } from '../../types/plots/segments/common';
5
+ import type { VisualGroup } from '../../types/plots/segments/config';
6
+ declare function $$render<TData extends Record<string, unknown>>(): {
7
+ props: {
8
+ group: VisualGroup<Series<TData> & {
9
+ side: "left" | "right";
10
+ }, BarSegmentStyle>;
11
+ valAnchor: ValueAnchor;
12
+ fmtVal: (value: number, seriesName: string) => string;
13
+ values: ValuesStyle | undefined;
14
+ axisColor: string;
15
+ };
16
+ exports: {};
17
+ bindings: "";
18
+ slots: {};
19
+ events: {};
20
+ };
21
+ declare class __sveltets_Render<TData extends Record<string, unknown>> {
22
+ props(): ReturnType<typeof $$render<TData>>['props'];
23
+ events(): ReturnType<typeof $$render<TData>>['events'];
24
+ slots(): ReturnType<typeof $$render<TData>>['slots'];
25
+ bindings(): "";
26
+ exports(): {};
27
+ }
28
+ interface $$IsomorphicComponent {
29
+ new <TData extends Record<string, unknown>>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<TData>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<TData>['props']>, ReturnType<__sveltets_Render<TData>['events']>, ReturnType<__sveltets_Render<TData>['slots']>> & {
30
+ $$bindings?: ReturnType<__sveltets_Render<TData>['bindings']>;
31
+ } & ReturnType<__sveltets_Render<TData>['exports']>;
32
+ <TData extends Record<string, unknown>>(internal: unknown, props: ReturnType<__sveltets_Render<TData>['props']> & {}): ReturnType<__sveltets_Render<TData>['exports']>;
33
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
34
+ }
35
+ declare const ValueLabels: $$IsomorphicComponent;
36
+ type ValueLabels<TData extends Record<string, unknown>> = InstanceType<typeof ValueLabels<TData>>;
37
+ export default ValueLabels;
@@ -0,0 +1,102 @@
1
+ export type Declutter1DResult<T> = {
2
+ /** Sorted input, each with `.pos` replaced by its decluttered position. */
3
+ positions: T[];
4
+ /**
5
+ * `connected[i]` is true when `positions[i]` and `positions[i + 1]` were
6
+ * actually pushed apart by the algorithm — either the down-pass pushed
7
+ * `i + 1` down because of `i`, or the up-pass pulled `i` up because of
8
+ * `i + 1`. `length === positions.length - 1`.
9
+ */
10
+ connected: boolean[];
11
+ };
12
+ /**
13
+ * Spreads a set of pixel-space positions apart along one axis so that no two
14
+ * end up closer than `minGap`, while keeping the overall block centered on
15
+ * the original positions. Computes two independent, individually-valid
16
+ * arrangements — a forward pass that only ever pushes items *down* to clear
17
+ * the one above it, and a backward pass that only ever pushes items *up* to
18
+ * clear the one below — then averages them index-by-index.
19
+ *
20
+ * A pass chained directly onto the other's output (push down, *then* pull up
21
+ * on the already-pushed result) is a no-op: by the time the down-pass
22
+ * finishes, `sorted[i] >= sorted[i-1] + minGap` already holds everywhere,
23
+ * which is exactly the condition an up-pass would otherwise "fix". Averaging
24
+ * two independent passes instead is what actually centers the block; it
25
+ * still keeps every adjacent gap >= minGap, since each pass's own gaps are
26
+ * already >= minGap and the average of two such gaps is too.
27
+ *
28
+ * Also records, per adjacent pair, whether either pass actually had to push
29
+ * — the ground truth for "did these two really interact," which
30
+ * `laneAssignment` needs and can't reliably reconstruct from positions alone
31
+ * after the fact (see its own doc comment).
32
+ *
33
+ * `bounds`, if given, keeps every *run* (a maximal stretch of connected
34
+ * items — lone, unconnected items are left at their true position
35
+ * regardless) inside `[lo, hi]`: nothing pushed apart by this function is
36
+ * ever allowed to end up off the edge of whatever pixel range the caller
37
+ * considers in-bounds (typically the plot's own content box). A run that
38
+ * already fits is only shifted, never resized; a run too big to fit even
39
+ * at its natural spacing is shrunk just enough to fit, uniformly, so it's
40
+ * still evenly spaced — just tighter than `minGap` calls for. See
41
+ * `fitRunsWithinBounds` for the actual shift/shrink math.
42
+ *
43
+ * `bounds` also biases *which* pass a run's average leans on, via
44
+ * {@link runBiasWeights}: a run sitting close to one edge has little slack on
45
+ * that side, so a plain 50/50 blend would still ask the up-pass and
46
+ * down-pass to pull the block symmetrically toward *both* edges before
47
+ * `fitRunsWithinBounds` shifts/shrinks it back — the up-pass reaching for
48
+ * room that was never really there. That round trip is exactly what makes
49
+ * individual members travel far past their neighbours' original positions
50
+ * (`laneAssignment`'s doc comment calls this out as the thing that forces an
51
+ * unsafe, overlapping travel span), so a run near an edge leans toward
52
+ * whichever pass already grows away from it instead — the block still
53
+ * expands to clear `minGap` everywhere, just mostly in the one direction
54
+ * that was actually available, the way a person sliding books apart on a
55
+ * shelf pinned against one wall would.
56
+ */
57
+ export declare function declutter1D<T extends {
58
+ pos: number;
59
+ }>(items: T[], minGap: number, bounds?: readonly [number, number]): Declutter1DResult<T>;
60
+ /**
61
+ * Groups `declutter1D`'s own `connected` flags into runs (maximal stretches
62
+ * of consecutive connected pairs), then assigns each run member a lane by
63
+ * *mirrored* position — the run's top and bottom member share lane 0, the
64
+ * next-in-from-top and next-in-from-bottom share lane 1, and so on toward
65
+ * the run's centre (an odd-sized run's exact middle member gets the
66
+ * innermost lane alone) — but only after checking that a proposed pair's
67
+ * *travel spans* (`[min(original, final), max(original, final)]`, the pixel
68
+ * range a leader line's vertical segment actually sweeps through) don't
69
+ * overlap. A pair that would collide is split into two lanes of its own
70
+ * instead of one shared one, so correctness never depends on the pairing
71
+ * being right — only on this check.
72
+ *
73
+ * Mirrored position is still the *first* thing tried, not span-overlap
74
+ * colouring picking lanes on its own, because minimizing lane count isn't
75
+ * actually the goal here — a run of `n` safely-non-overlapping members could
76
+ * often be greedily packed into far fewer than `ceil(n / 2)` lanes, but
77
+ * doing that defeats the point: it's what visually fans a pileup's leader
78
+ * lines apart in the first place. Mirrored pairing keeps that fan-out
79
+ * exactly where it's safe (the common case for `declutter1D`'s own output),
80
+ * and only degrades — never breaks — where it isn't.
81
+ *
82
+ * A run whose members still end up with genuinely overlapping travel spans
83
+ * (heavy, edge-adjacent compression) has no lane assignment that's both
84
+ * crossing-free *and* touch-free under this shared-`px`/shared-`labelX`
85
+ * elbow geometry — sharing draws two segments on top of each other, splitting
86
+ * draws an actual crossing. `declutter1D`'s bounds-aware margin bias (see its
87
+ * own doc comment) exists specifically to keep that from happening in the
88
+ * first place, by keeping a run's displacement mostly one-directional when
89
+ * it sits close to an edge, rather than papering over it here.
90
+ *
91
+ * `rank` is the lane index (0-indexed) to drive a leader line's routing:
92
+ * `rank` 0 bends right off the data point (a short first stub); higher
93
+ * ranks travel further before bending (right up to the shared label
94
+ * column). `lanesUsed` is the run's own total lane count (shared by every
95
+ * member); `runSize` is the run's member count, for callers that just need
96
+ * to know whether a given member belongs to a real run at all.
97
+ */
98
+ export declare function laneAssignment(originals: number[], finals: number[], connected: boolean[]): {
99
+ rank: number;
100
+ lanesUsed: number;
101
+ runSize: number;
102
+ }[];
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Spreads a set of pixel-space positions apart along one axis so that no two
3
+ * end up closer than `minGap`, while keeping the overall block centered on
4
+ * the original positions. Computes two independent, individually-valid
5
+ * arrangements — a forward pass that only ever pushes items *down* to clear
6
+ * the one above it, and a backward pass that only ever pushes items *up* to
7
+ * clear the one below — then averages them index-by-index.
8
+ *
9
+ * A pass chained directly onto the other's output (push down, *then* pull up
10
+ * on the already-pushed result) is a no-op: by the time the down-pass
11
+ * finishes, `sorted[i] >= sorted[i-1] + minGap` already holds everywhere,
12
+ * which is exactly the condition an up-pass would otherwise "fix". Averaging
13
+ * two independent passes instead is what actually centers the block; it
14
+ * still keeps every adjacent gap >= minGap, since each pass's own gaps are
15
+ * already >= minGap and the average of two such gaps is too.
16
+ *
17
+ * Also records, per adjacent pair, whether either pass actually had to push
18
+ * — the ground truth for "did these two really interact," which
19
+ * `laneAssignment` needs and can't reliably reconstruct from positions alone
20
+ * after the fact (see its own doc comment).
21
+ *
22
+ * `bounds`, if given, keeps every *run* (a maximal stretch of connected
23
+ * items — lone, unconnected items are left at their true position
24
+ * regardless) inside `[lo, hi]`: nothing pushed apart by this function is
25
+ * ever allowed to end up off the edge of whatever pixel range the caller
26
+ * considers in-bounds (typically the plot's own content box). A run that
27
+ * already fits is only shifted, never resized; a run too big to fit even
28
+ * at its natural spacing is shrunk just enough to fit, uniformly, so it's
29
+ * still evenly spaced — just tighter than `minGap` calls for. See
30
+ * `fitRunsWithinBounds` for the actual shift/shrink math.
31
+ *
32
+ * `bounds` also biases *which* pass a run's average leans on, via
33
+ * {@link runBiasWeights}: a run sitting close to one edge has little slack on
34
+ * that side, so a plain 50/50 blend would still ask the up-pass and
35
+ * down-pass to pull the block symmetrically toward *both* edges before
36
+ * `fitRunsWithinBounds` shifts/shrinks it back — the up-pass reaching for
37
+ * room that was never really there. That round trip is exactly what makes
38
+ * individual members travel far past their neighbours' original positions
39
+ * (`laneAssignment`'s doc comment calls this out as the thing that forces an
40
+ * unsafe, overlapping travel span), so a run near an edge leans toward
41
+ * whichever pass already grows away from it instead — the block still
42
+ * expands to clear `minGap` everywhere, just mostly in the one direction
43
+ * that was actually available, the way a person sliding books apart on a
44
+ * shelf pinned against one wall would.
45
+ */
46
+ export function declutter1D(items, minGap, bounds) {
47
+ const sorted = [...items].sort((a, b) => a.pos - b.pos);
48
+ const n = sorted.length;
49
+ const connected = new Array(Math.max(0, n - 1)).fill(false);
50
+ const down = sorted.map((d) => d.pos);
51
+ for (let i = 1; i < n; i++) {
52
+ const pushed = down[i - 1] + minGap;
53
+ if (pushed > down[i]) {
54
+ down[i] = pushed;
55
+ connected[i - 1] = true;
56
+ }
57
+ }
58
+ const up = sorted.map((d) => d.pos);
59
+ for (let i = n - 2; i >= 0; i--) {
60
+ const pulled = up[i + 1] - minGap;
61
+ if (pulled < up[i]) {
62
+ up[i] = pulled;
63
+ connected[i] = true;
64
+ }
65
+ }
66
+ const weights = bounds ? runBiasWeights(down, up, connected, bounds) : sorted.map(() => 0.5);
67
+ const averaged = sorted.map((_, i) => down[i] * weights[i] + up[i] * (1 - weights[i]));
68
+ const fitted = bounds ? fitRunsWithinBounds(averaged, connected, bounds) : averaged;
69
+ const positions = sorted.map((d, i) => ({ ...d, pos: fitted[i] }));
70
+ return { positions, connected };
71
+ }
72
+ /**
73
+ * Per-run blend weight (applied to the down-pass; `1 - weight` goes to the
74
+ * up-pass) favouring whichever pass already grows away from the nearer edge
75
+ * — see {@link declutter1D}'s doc comment for why. `0.5` (the down/up
76
+ * midpoint, i.e. no bias) when a run's naturally-centred span has roughly
77
+ * equal room on both sides; sliding toward `1` (all down-pass) as room above
78
+ * vanishes, toward `0` (all up-pass) as room below vanishes. Every member of
79
+ * a run gets the *same* weight — the point is the block moving as one
80
+ * (mostly) one-directional piece, not each member picking its own bias.
81
+ */
82
+ function runBiasWeights(down, up, connected, [lo, hi]) {
83
+ const weights = down.map(() => 0.5);
84
+ let i = 0;
85
+ while (i < down.length) {
86
+ let j = i;
87
+ while (j < connected.length && connected[j])
88
+ j++;
89
+ if (j > i) {
90
+ const center = ((down[i] + up[i]) / 2 + (down[j] + up[j]) / 2) / 2;
91
+ const roomAbove = Math.max(0, center - lo);
92
+ const roomBelow = Math.max(0, hi - center);
93
+ const total = roomAbove + roomBelow;
94
+ const weight = total > 0 ? roomBelow / total : 0.5;
95
+ for (let k = i; k <= j; k++)
96
+ weights[k] = weight;
97
+ }
98
+ i = j + 1;
99
+ }
100
+ return weights;
101
+ }
102
+ /**
103
+ * Shifts, and shrinks only if it must, each run of `connected` positions so
104
+ * it lands entirely inside `[lo, hi]`. A run's own relative spacing is
105
+ * preserved as-is (just translated) whenever it already fits in `hi - lo`;
106
+ * only a run wider than the available space gets uniformly scaled down
107
+ * around its centre first. Lone, unconnected positions pass through
108
+ * untouched — they were never pushed by `declutter1D` in the first place,
109
+ * so clamping one to the bounds would move a label away from the data point
110
+ * it's honestly reporting, for no collision-avoidance reason at all.
111
+ */
112
+ function fitRunsWithinBounds(positions, connected, [lo, hi]) {
113
+ const out = [...positions];
114
+ const available = hi - lo;
115
+ let i = 0;
116
+ while (i < out.length) {
117
+ let j = i;
118
+ while (j < connected.length && connected[j])
119
+ j++;
120
+ if (j > i) {
121
+ const runMin = out[i];
122
+ const runMax = out[j];
123
+ if (runMin < lo || runMax > hi) {
124
+ const naturalSpan = runMax - runMin;
125
+ const scale = naturalSpan > 0 ? Math.min(1, available / naturalSpan) : 1;
126
+ const idealCenter = (runMin + runMax) / 2;
127
+ const newSpan = naturalSpan * scale;
128
+ const newCenter = Math.min(Math.max(idealCenter, lo + newSpan / 2), hi - newSpan / 2);
129
+ for (let k = i; k <= j; k++) {
130
+ out[k] = newCenter + (out[k] - idealCenter) * scale;
131
+ }
132
+ }
133
+ }
134
+ i = j + 1;
135
+ }
136
+ return out;
137
+ }
138
+ /**
139
+ * Groups `declutter1D`'s own `connected` flags into runs (maximal stretches
140
+ * of consecutive connected pairs), then assigns each run member a lane by
141
+ * *mirrored* position — the run's top and bottom member share lane 0, the
142
+ * next-in-from-top and next-in-from-bottom share lane 1, and so on toward
143
+ * the run's centre (an odd-sized run's exact middle member gets the
144
+ * innermost lane alone) — but only after checking that a proposed pair's
145
+ * *travel spans* (`[min(original, final), max(original, final)]`, the pixel
146
+ * range a leader line's vertical segment actually sweeps through) don't
147
+ * overlap. A pair that would collide is split into two lanes of its own
148
+ * instead of one shared one, so correctness never depends on the pairing
149
+ * being right — only on this check.
150
+ *
151
+ * Mirrored position is still the *first* thing tried, not span-overlap
152
+ * colouring picking lanes on its own, because minimizing lane count isn't
153
+ * actually the goal here — a run of `n` safely-non-overlapping members could
154
+ * often be greedily packed into far fewer than `ceil(n / 2)` lanes, but
155
+ * doing that defeats the point: it's what visually fans a pileup's leader
156
+ * lines apart in the first place. Mirrored pairing keeps that fan-out
157
+ * exactly where it's safe (the common case for `declutter1D`'s own output),
158
+ * and only degrades — never breaks — where it isn't.
159
+ *
160
+ * A run whose members still end up with genuinely overlapping travel spans
161
+ * (heavy, edge-adjacent compression) has no lane assignment that's both
162
+ * crossing-free *and* touch-free under this shared-`px`/shared-`labelX`
163
+ * elbow geometry — sharing draws two segments on top of each other, splitting
164
+ * draws an actual crossing. `declutter1D`'s bounds-aware margin bias (see its
165
+ * own doc comment) exists specifically to keep that from happening in the
166
+ * first place, by keeping a run's displacement mostly one-directional when
167
+ * it sits close to an edge, rather than papering over it here.
168
+ *
169
+ * `rank` is the lane index (0-indexed) to drive a leader line's routing:
170
+ * `rank` 0 bends right off the data point (a short first stub); higher
171
+ * ranks travel further before bending (right up to the shared label
172
+ * column). `lanesUsed` is the run's own total lane count (shared by every
173
+ * member); `runSize` is the run's member count, for callers that just need
174
+ * to know whether a given member belongs to a real run at all.
175
+ */
176
+ export function laneAssignment(originals, finals, connected) {
177
+ const n = connected.length + 1;
178
+ const out = Array.from({ length: n }, () => ({ rank: 0, lanesUsed: 1, runSize: 1 }));
179
+ const span = (k) => {
180
+ const a = originals[k];
181
+ const b = finals[k];
182
+ return a < b ? [a, b] : [b, a];
183
+ };
184
+ const overlaps = (a, b) => a[0] < b[1] && b[0] < a[1];
185
+ let i = 0;
186
+ while (i < n) {
187
+ let j = i;
188
+ while (j < connected.length && connected[j])
189
+ j++;
190
+ const runSize = j - i + 1;
191
+ if (runSize > 1) {
192
+ const rankOf = new Array(runSize);
193
+ let lo = 0;
194
+ let hi = runSize - 1;
195
+ let nextLane = 0;
196
+ while (lo <= hi) {
197
+ if (lo === hi) {
198
+ rankOf[lo] = nextLane;
199
+ nextLane += 1;
200
+ }
201
+ else if (!overlaps(span(i + lo), span(i + hi))) {
202
+ rankOf[lo] = nextLane;
203
+ rankOf[hi] = nextLane;
204
+ nextLane += 1;
205
+ }
206
+ else {
207
+ rankOf[lo] = nextLane;
208
+ rankOf[hi] = nextLane + 1;
209
+ nextLane += 2;
210
+ }
211
+ lo += 1;
212
+ hi -= 1;
213
+ }
214
+ const lanesUsed = nextLane;
215
+ for (let k = 0; k < runSize; k++) {
216
+ out[i + k] = { rank: rankOf[k], lanesUsed, runSize };
217
+ }
218
+ }
219
+ i = j + 1;
220
+ }
221
+ return out;
222
+ }
@@ -83,6 +83,23 @@ export type ChartConfig = {
83
83
  line: {
84
84
  strokeWidth: number;
85
85
  dotRadius: number;
86
+ /**
87
+ * End-of-line value-label declutter geometry and connector styling —
88
+ * `LinePlot`-specific, since only line charts spread colliding
89
+ * end-point labels apart with elbow-routed leader lines.
90
+ */
91
+ valueLabels: {
92
+ /** Shortest lane's distance (px) from the data point before a leader line bends. */
93
+ elbowGap: number;
94
+ /** Extra distance (px) each subsequent lane sits out from the last. */
95
+ laneStep: number;
96
+ /** Clearance (px) between the outermost lane and the label text it feeds into. */
97
+ labelGap: number;
98
+ /** Default leader-line colour — overridable per-plot via `styles.values.strokeStyle`. */
99
+ connectorColor: string;
100
+ /** Default leader-line width — overridable per-plot via `styles.values.strokeStyle`. */
101
+ connectorWidth: number;
102
+ };
86
103
  };
87
104
  /** Timeline gutter styling. */
88
105
  timeline: {
@@ -1,16 +1,31 @@
1
1
  import type { ValueAnchor } from '../constants';
2
- import type { FontStyle } from '../styling';
2
+ import type { FontStyle, StrokeStyle } from '../styling';
3
3
  /**
4
4
  * Controls if and how value labels are rendered on a plot. The high-level
5
- * `anchor` sets sensible defaults for placement; the inherited {@link FontStyle}
6
- * fields (`dx`, `dy`, `textAnchor`, `lineAnchor`, `lineHeight`, `rotate`,
7
- * `class`, `textClass`, `fill`, …) override those defaults when provided.
5
+ * `anchor` sets sensible defaults for placement; `fontStyle` (`dx`, `dy`,
6
+ * `textAnchor`, `lineAnchor`, `lineHeight`, `rotate`, `class`, `textClass`,
7
+ * `fill`, …) overrides those defaults field-by-field when provided — mirrors
8
+ * {@link DeltaConfig}'s `fontStyle`/`strokeStyle` split, so label text and
9
+ * connector-line styling never mix into one flat bag of props.
8
10
  */
9
- export type ValuesStyle = FontStyle & {
11
+ export type ValuesStyle = {
10
12
  show?: boolean;
11
13
  anchor?: ValueAnchor;
12
14
  /** Called with the numeric value and the series name. Return a string to render. */
13
15
  format?: (value: number, seriesName: string) => string;
16
+ /** Text styling for the label itself. */
17
+ fontStyle?: FontStyle;
18
+ /**
19
+ * Style for the leader line drawn from a label displaced to clear an
20
+ * overlap with another series' label, back to its real data point.
21
+ * Labels only ever move when they'd otherwise collide — this is a no-op
22
+ * style until that happens. Falls back to a neutral grey, thin, solid
23
+ * stroke — deliberately not the series' own colour or stroke width, since
24
+ * matching the data line reads as more data rather than as a leader,
25
+ * right where several lines are already converging. Unset fields keep
26
+ * that fallback.
27
+ */
28
+ strokeStyle?: StrokeStyle;
14
29
  };
15
30
  /**
16
31
  * Color overrides for plots that derive their palette from data.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"