@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.
- package/dist/configuration/config.svelte.js +11 -1
- package/dist/configuration/themes/index.d.ts +28 -0
- package/dist/plots/line/ValueLabels.svelte +110 -42
- package/dist/plots/utils/declutter.d.ts +63 -32
- package/dist/plots/utils/declutter.js +165 -38
- package/dist/types/configuration/styling.d.ts +17 -0
- package/package.json +1 -1
|
@@ -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: {
|
|
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,
|
|
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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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`/`
|
|
78
|
-
* own `connected` record rather than re-inferred from
|
|
79
|
-
* both functions' doc comments for why that's the only
|
|
80
|
-
* position-based heuristics either miss a series that
|
|
81
|
-
* because a neighbour got pushed into it, or wrongly
|
|
82
|
-
* clusters that each happened to need decluttering
|
|
83
|
-
* elbow connector below turns `rank` into a lane index
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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;
|
|
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
|
|
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
|
|
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:
|
|
107
|
-
inGroup: ranks[i].
|
|
172
|
+
extraDy: finals[i] - originals[i],
|
|
173
|
+
inGroup: ranks[i].runSize > 1,
|
|
108
174
|
rank: ranks[i].rank,
|
|
109
|
-
|
|
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 ?
|
|
191
|
+
{@const numLanes = offset?.inGroup ? offset.lanesUsed : 0}
|
|
125
192
|
{@const labelDx = offset?.inGroup
|
|
126
|
-
? dir * (
|
|
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 * (
|
|
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 ??
|
|
137
|
-
stroke-width={connector?.strokeWidth ??
|
|
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
|
|
30
|
-
* needs and can't reliably reconstruct from positions alone
|
|
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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* innermost lane alone)
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
*
|
|
59
|
-
* point (a short first stub); higher
|
|
60
|
-
* (right up to the shared label
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
|
98
|
+
export declare function laneAssignment(originals: number[], finals: number[], connected: boolean[]): {
|
|
69
99
|
rank: number;
|
|
70
|
-
|
|
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
|
|
19
|
-
* needs and can't reliably reconstruct from positions alone
|
|
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
|
|
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
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* innermost lane alone)
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
-
*
|
|
68
|
-
* point (a short first stub); higher
|
|
69
|
-
* (right up to the shared label
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
|
176
|
+
export function laneAssignment(originals, finals, connected) {
|
|
78
177
|
const n = connected.length + 1;
|
|
79
|
-
const out = Array.from({ length: n }, () => ({ rank: 0,
|
|
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
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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: {
|