@fundar/data-chart-telling 0.0.24 → 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;
@@ -1,33 +1,30 @@
1
1
  <script lang="ts" generics="TData extends Record<string, unknown>">
2
+ import { getContext } from 'svelte';
3
+ import { get } from 'svelte/store';
2
4
  import { SvelteMap } from 'svelte/reactivity';
5
+ import type { Writable } from 'svelte/store';
3
6
  import { Text, usePlot } from 'svelteplot';
4
7
  import { resolveAccessor } from '../utils/accessors';
5
8
  import { getConfiguration } from '../../configuration/config.svelte';
6
9
  import { paletteColor } from '../../utils/color';
7
- import { declutter1D, mirrorRanks } from '../utils/declutter';
10
+ import { declutter1D, laneAssignment } from '../utils/declutter';
8
11
  import type { Series } from '../../types/plots/data/common';
9
12
  import type { ValuesStyle } from '../../types/plots/styles/common';
10
13
  import type { ValueAnchor } from '../../types/plots/constants';
11
14
 
12
- // Elbow-routed leader line geometry (pixels) a run's top/bottom pair
13
- // share the shortest lane, its next-in pair the next lane out, and so on
14
- // (see `mirrorRanks`'s doc comment for why sharing a lane is safe). Each
15
- // lane is `LANE_STEP` further out than the last; `ELBOW_GAP` is the
16
- // shortest lane (rank 0, right off the data point); `LABEL_GAP` is the
17
- // small clearance between the outermost lane and the label text it feeds
18
- // into.
19
- const ELBOW_GAP = 10;
20
- const LANE_STEP = 7;
21
- const LABEL_GAP = 6;
22
-
23
- // Default connector look — deliberately *not* each series' own colour or
24
- // stroke weight: a leader line only needs to be traceable back to its
25
- // label, and matching the data line's own styling reads as if it were
26
- // more data, which is confusing right where several lines already
27
- // converge. Neutral, thin, and solid keeps it legible as a leader — the
28
- // colour and weight are still fully overridable via `values.strokeStyle`.
29
- const DEFAULT_CONNECTOR_COLOR = '#94a3b8';
30
- const DEFAULT_CONNECTOR_WIDTH = 0.5;
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
+ };
31
28
 
32
29
  /**
33
30
  * End-point value labels for `LinePlot` — one label per series at its last
@@ -46,6 +43,24 @@
46
43
  } = $props();
47
44
 
48
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
+
49
64
  const font = $derived(values?.fontStyle);
50
65
  const connector = $derived(values?.strokeStyle);
51
66
  const fmtVal = $derived(values?.format ?? ((v: number, name: string) => `${name} - ${v}`));
@@ -64,6 +79,53 @@
64
79
 
65
80
  const plot = usePlot();
66
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
+
67
129
  /**
68
130
  * Pixel-space collision fix-up for end-point labels: resolves each label's
69
131
  * true pixel Y through the plot's own live y-scale, then runs the shared
@@ -74,19 +136,18 @@
74
136
  * which would extend the y-scale's own domain and provoke an infinite
75
137
  * autoscale ↔ declutter feedback loop.
76
138
  *
77
- * `inGroup`/`rank`/`depth` come from `mirrorRanks`, fed by `declutter1D`'s
78
- * own `connected` record rather than re-inferred from positions — see
79
- * both functions' doc comments for why that's the only reliable source:
80
- * position-based heuristics either miss a series that only had to move
81
- * because a neighbour got pushed into it, or wrongly bridge two unrelated
82
- * clusters that each happened to need decluttering on their own. The
83
- * elbow connector below turns `rank` into a lane index — mirrored
84
- * top/bottom pairs share a lane, so a run only ever needs `ceil(depth / 2)`
85
- * distinct ones. A series with nothing nearby is absent from the map, and
86
- * its label renders with no extra offset.
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.
87
148
  */
88
149
  const declutteredOffsets = $derived.by(() => {
89
- const map = new SvelteMap<string, { extraDy: number; inGroup: boolean; rank: number; depth: number }>();
150
+ const map = new SvelteMap<string, { extraDy: number; inGroup: boolean; rank: number; lanesUsed: number }>();
90
151
  if (lastPoints.length < 2) return map;
91
152
  const yScale = plot.scales.y?.fn;
92
153
  if (!yScale) return map;
@@ -97,22 +158,28 @@
97
158
  pos: Number(yScale(Number(resolveAccessor(s.y)(lastRow)))),
98
159
  }));
99
160
  const sorted = [...items].sort((a, b) => a.pos - b.pos);
100
- const { positions: spread, connected } = declutter1D(items, minGap);
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);
101
166
  const spreadByName = new Map(spread.map((d) => [d.name, d.pos]));
102
- const ranks = mirrorRanks(connected);
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);
103
170
  sorted.forEach((item, i) => {
104
- const final = spreadByName.get(item.name) ?? item.pos;
105
171
  map.set(item.name, {
106
- extraDy: final - item.pos,
107
- inGroup: ranks[i].depth > 1,
172
+ extraDy: finals[i] - originals[i],
173
+ inGroup: ranks[i].runSize > 1,
108
174
  rank: ranks[i].rank,
109
- depth: ranks[i].depth,
175
+ lanesUsed: ranks[i].lanesUsed,
110
176
  });
111
177
  });
112
178
  return map;
113
179
  });
114
180
  </script>
115
181
 
182
+ <g bind:this={groupEl}>
116
183
  {#each lastPoints as { series: s, lastRow, seriesIndex } (s.name)}
117
184
  {@const xFn = resolveAccessor(s.x)}
118
185
  {@const yFn = resolveAccessor(s.y)}
@@ -121,20 +188,20 @@
121
188
  {@const extraDy = offset?.inGroup ? offset.extraDy : 0}
122
189
  {@const baseDx = font?.dx ?? defaultDx}
123
190
  {@const dir = Math.sign(baseDx) || 1}
124
- {@const numLanes = offset?.inGroup ? Math.ceil(offset.depth / 2) : 0}
191
+ {@const numLanes = offset?.inGroup ? offset.lanesUsed : 0}
125
192
  {@const labelDx = offset?.inGroup
126
- ? dir * (ELBOW_GAP + (numLanes - 1) * LANE_STEP + LABEL_GAP)
193
+ ? dir * (elbowGap + (numLanes - 1) * laneStep + labelGap)
127
194
  : baseDx}
128
195
  {#if offset?.inGroup && plot.scales.x?.fn && plot.scales.y?.fn}
129
196
  {@const px = Number(plot.scales.x.fn(xFn(lastRow)))}
130
197
  {@const py = Number(plot.scales.y.fn(Number(yFn(lastRow))))}
131
- {@const laneX = px + dir * (ELBOW_GAP + offset.rank * LANE_STEP)}
198
+ {@const laneX = px + dir * (elbowGap + offset.rank * laneStep)}
132
199
  {@const targetY = py + extraDy}
133
200
  <path
134
201
  d={`M ${px} ${py} H ${laneX} V ${targetY} H ${px + labelDx}`}
135
202
  fill="none"
136
- stroke={connector?.stroke ?? DEFAULT_CONNECTOR_COLOR}
137
- stroke-width={connector?.strokeWidth ?? DEFAULT_CONNECTOR_WIDTH}
203
+ stroke={connector?.stroke ?? defaultConnectorColor}
204
+ stroke-width={connector?.strokeWidth ?? defaultConnectorWidth}
138
205
  stroke-opacity={connector?.strokeOpacity ?? 1}
139
206
  stroke-dasharray={connector?.strokeDasharray}
140
207
  stroke-linecap={connector?.strokeLinecap ?? 'square'}
@@ -163,3 +230,4 @@
163
230
  paintOrder={font?.paintOrder}
164
231
  />
165
232
  {/each}
233
+ </g>
@@ -26,46 +26,77 @@ export type Declutter1DResult<T> = {
26
26
  * already >= minGap and the average of two such gaps is too.
27
27
  *
28
28
  * Also records, per adjacent pair, whether either pass actually had to push
29
- * — the ground truth for "did these two really interact," which `mirrorRanks`
30
- * needs and can't reliably reconstruct from positions alone after the fact
31
- * (see its own doc comment).
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.
32
56
  */
33
57
  export declare function declutter1D<T extends {
34
58
  pos: number;
35
- }>(items: T[], minGap: number): Declutter1DResult<T>;
59
+ }>(items: T[], minGap: number, bounds?: readonly [number, number]): Declutter1DResult<T>;
36
60
  /**
37
61
  * Groups `declutter1D`'s own `connected` flags into runs (maximal stretches
38
62
  * of consecutive connected pairs), then assigns each run member a lane by
39
- * *mirrored* position within it: the run's top and bottom member share lane
40
- * 0, the next-in-from-top and next-in-from-bottom share lane 1, and so on
41
- * toward the run's centre (an odd-sized run's exact middle member gets the
42
- * innermost lane alone). `depth` is the run's own size, the same for every
43
- * member; the number of *distinct* lanes it actually needs is
44
- * `ceil(depth / 2)`.
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.
45
81
  *
46
- * Deliberately doesn't re-derive connectivity from before/after positions
47
- * an early version tried that (some mix of "both moved" and "final gap is
48
- * near minGap") and it's unreliable both ways: a real one-sided interaction
49
- * (an item that only moved because its neighbour got pushed into it) can
50
- * settle with more slack than minGap, so a gap-threshold check misses it;
51
- * and "both moved" over-connects, since two unrelated, far-apart clusters
52
- * that each independently needed to declutter still each have moved members
53
- * sitting next to each other in sort order, which a moved-only check can't
54
- * tell apart from a real interaction. `declutter1D`'s own `connected` array
55
- * has neither problem, because it's not inferred — it's the actual record
56
- * of which pushes happened.
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.
57
90
  *
58
- * Meant to drive a leader line's lane: `rank` 0 bends right off the data
59
- * point (a short first stub); higher ranks travel further before bending
60
- * (right up to the shared label column, for a run's own centre member).
61
- * Two members sharing a lane never collide: by how `declutter1D` centers
62
- * its result, a run's top half only ever ends up at or above its original
63
- * spot and its bottom half only ever at or below, so paired members bend
64
- * away from each other — same lane, opposite direction, no shared space.
65
- * This also roughly halves the lane depth (and so the horizontal room) a
66
- * run needs, versus giving every member its own lane.
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.
67
97
  */
68
- export declare function mirrorRanks(connected: boolean[]): {
98
+ export declare function laneAssignment(originals: number[], finals: number[], connected: boolean[]): {
69
99
  rank: number;
70
- depth: number;
100
+ lanesUsed: number;
101
+ runSize: number;
71
102
  }[];
@@ -15,11 +15,35 @@
15
15
  * already >= minGap and the average of two such gaps is too.
16
16
  *
17
17
  * Also records, per adjacent pair, whether either pass actually had to push
18
- * — the ground truth for "did these two really interact," which `mirrorRanks`
19
- * needs and can't reliably reconstruct from positions alone after the fact
20
- * (see its own doc comment).
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.
21
45
  */
22
- export function declutter1D(items, minGap) {
46
+ export function declutter1D(items, minGap, bounds) {
23
47
  const sorted = [...items].sort((a, b) => a.pos - b.pos);
24
48
  const n = sorted.length;
25
49
  const connected = new Array(Math.max(0, n - 1)).fill(false);
@@ -39,54 +63,157 @@ export function declutter1D(items, minGap) {
39
63
  connected[i] = true;
40
64
  }
41
65
  }
42
- const positions = sorted.map((d, i) => ({ ...d, pos: (down[i] + up[i]) / 2 }));
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] }));
43
70
  return { positions, connected };
44
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
+ }
45
138
  /**
46
139
  * Groups `declutter1D`'s own `connected` flags into runs (maximal stretches
47
140
  * of consecutive connected pairs), then assigns each run member a lane by
48
- * *mirrored* position within it: the run's top and bottom member share lane
49
- * 0, the next-in-from-top and next-in-from-bottom share lane 1, and so on
50
- * toward the run's centre (an odd-sized run's exact middle member gets the
51
- * innermost lane alone). `depth` is the run's own size, the same for every
52
- * member; the number of *distinct* lanes it actually needs is
53
- * `ceil(depth / 2)`.
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.
54
159
  *
55
- * Deliberately doesn't re-derive connectivity from before/after positions
56
- * an early version tried that (some mix of "both moved" and "final gap is
57
- * near minGap") and it's unreliable both ways: a real one-sided interaction
58
- * (an item that only moved because its neighbour got pushed into it) can
59
- * settle with more slack than minGap, so a gap-threshold check misses it;
60
- * and "both moved" over-connects, since two unrelated, far-apart clusters
61
- * that each independently needed to declutter still each have moved members
62
- * sitting next to each other in sort order, which a moved-only check can't
63
- * tell apart from a real interaction. `declutter1D`'s own `connected` array
64
- * has neither problem, because it's not inferred — it's the actual record
65
- * of which pushes happened.
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.
66
168
  *
67
- * Meant to drive a leader line's lane: `rank` 0 bends right off the data
68
- * point (a short first stub); higher ranks travel further before bending
69
- * (right up to the shared label column, for a run's own centre member).
70
- * Two members sharing a lane never collide: by how `declutter1D` centers
71
- * its result, a run's top half only ever ends up at or above its original
72
- * spot and its bottom half only ever at or below, so paired members bend
73
- * away from each other — same lane, opposite direction, no shared space.
74
- * This also roughly halves the lane depth (and so the horizontal room) a
75
- * run needs, versus giving every member its own lane.
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.
76
175
  */
77
- export function mirrorRanks(connected) {
176
+ export function laneAssignment(originals, finals, connected) {
78
177
  const n = connected.length + 1;
79
- const out = Array.from({ length: n }, () => ({ rank: 0, depth: 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];
80
185
  let i = 0;
81
186
  while (i < n) {
82
187
  let j = i;
83
188
  while (j < connected.length && connected[j])
84
189
  j++;
85
- const runLength = j - i + 1;
86
- if (runLength > 1) {
87
- for (let k = i; k <= j; k++) {
88
- const posInRun = k - i;
89
- out[k] = { rank: Math.min(posInRun, runLength - 1 - posInRun), depth: runLength };
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 };
90
217
  }
91
218
  }
92
219
  i = j + 1;
@@ -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: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.24",
3
+ "version": "0.0.25",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"