@fundar/data-chart-telling 0.0.22 → 0.0.24
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/plots/bar/ValueLabels.svelte +16 -15
- package/dist/plots/heatmap/Plot.svelte +9 -23
- package/dist/plots/heatmap/ValueLabels.svelte +56 -0
- package/dist/plots/heatmap/ValueLabels.svelte.d.ts +34 -0
- package/dist/plots/line/Plot.svelte +3 -41
- package/dist/plots/line/ValueLabels.svelte +165 -0
- package/dist/plots/line/ValueLabels.svelte.d.ts +33 -0
- package/dist/plots/pyramid/Plot.svelte +3 -40
- package/dist/plots/pyramid/ValueLabels.svelte +80 -0
- package/dist/plots/pyramid/ValueLabels.svelte.d.ts +37 -0
- package/dist/plots/utils/declutter.d.ts +71 -0
- package/dist/plots/utils/declutter.js +95 -0
- package/dist/types/plots/styles/common.d.ts +20 -5
- package/package.json +1 -1
|
@@ -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(
|
|
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={
|
|
75
|
-
dy={
|
|
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={
|
|
78
|
-
lineHeight={
|
|
79
|
-
rotate={
|
|
80
|
-
class={
|
|
81
|
-
textClass={
|
|
82
|
-
fill={
|
|
83
|
-
fontSize={
|
|
84
|
-
fontWeight={
|
|
85
|
-
fontStyle={
|
|
86
|
-
stroke={
|
|
87
|
-
strokeWidth={
|
|
88
|
-
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
|
|
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
|
-
<
|
|
265
|
+
<ValueLabels
|
|
268
266
|
data={flat}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
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
|
-
{
|
|
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,165 @@
|
|
|
1
|
+
<script lang="ts" generics="TData extends Record<string, unknown>">
|
|
2
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
3
|
+
import { Text, usePlot } from 'svelteplot';
|
|
4
|
+
import { resolveAccessor } from '../utils/accessors';
|
|
5
|
+
import { getConfiguration } from '../../configuration/config.svelte';
|
|
6
|
+
import { paletteColor } from '../../utils/color';
|
|
7
|
+
import { declutter1D, mirrorRanks } from '../utils/declutter';
|
|
8
|
+
import type { Series } from '../../types/plots/data/common';
|
|
9
|
+
import type { ValuesStyle } from '../../types/plots/styles/common';
|
|
10
|
+
import type { ValueAnchor } from '../../types/plots/constants';
|
|
11
|
+
|
|
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;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* End-point value labels for `LinePlot` — one label per series at its last
|
|
34
|
+
* data point, with automatic collision resolution whenever several series
|
|
35
|
+
* end up with close last values. Rendered as a proper descendant of
|
|
36
|
+
* svelteplot's `<Plot>` (not just of `LinePlot` itself) so `usePlot()`
|
|
37
|
+
* below resolves the real, live y-scale needed to measure pixel-space
|
|
38
|
+
* overlap.
|
|
39
|
+
*/
|
|
40
|
+
let {
|
|
41
|
+
lastPoints,
|
|
42
|
+
values,
|
|
43
|
+
}: {
|
|
44
|
+
lastPoints: { series: Series<TData>; lastRow: TData; seriesIndex: number }[];
|
|
45
|
+
values: ValuesStyle | undefined;
|
|
46
|
+
} = $props();
|
|
47
|
+
|
|
48
|
+
const cfg = $derived(getConfiguration());
|
|
49
|
+
const font = $derived(values?.fontStyle);
|
|
50
|
+
const connector = $derived(values?.strokeStyle);
|
|
51
|
+
const fmtVal = $derived(values?.format ?? ((v: number, name: string) => `${name} - ${v}`));
|
|
52
|
+
const valAnchor = $derived<ValueAnchor>(values?.anchor ?? 'start');
|
|
53
|
+
// 'outside' is a per-point escape hatch resolved by HoverMarker; line's own
|
|
54
|
+
// value labels already have their own `anchor: 'outside'` concept above,
|
|
55
|
+
// so treat it as unset here.
|
|
56
|
+
const resolvedTextAnchor = $derived(font?.textAnchor === 'outside' ? undefined : font?.textAnchor);
|
|
57
|
+
|
|
58
|
+
const defaultDx = $derived(valAnchor === 'start' ? 6 : valAnchor === 'end' ? -6 : 0);
|
|
59
|
+
const defaultDy = $derived(valAnchor === 'outside' ? -8 : 0);
|
|
60
|
+
const defaultTA = $derived(
|
|
61
|
+
valAnchor === 'start' ? ('start' as const) : valAnchor === 'end' ? ('end' as const) : ('middle' as const),
|
|
62
|
+
);
|
|
63
|
+
const defaultLA = $derived(valAnchor === 'outside' ? ('bottom' as const) : ('middle' as const));
|
|
64
|
+
|
|
65
|
+
const plot = usePlot();
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pixel-space collision fix-up for end-point labels: resolves each label's
|
|
69
|
+
* true pixel Y through the plot's own live y-scale, then runs the shared
|
|
70
|
+
* two-pass declutter over those pixels to find how far each one needs to
|
|
71
|
+
* move to clear its neighbours. The result is a pure pixel *offset*
|
|
72
|
+
* (`extraDy`), applied the same way `<Text dy>` already is everywhere else
|
|
73
|
+
* in this file — it never becomes a data-space Y fed back into a mark,
|
|
74
|
+
* which would extend the y-scale's own domain and provoke an infinite
|
|
75
|
+
* autoscale ↔ declutter feedback loop.
|
|
76
|
+
*
|
|
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.
|
|
87
|
+
*/
|
|
88
|
+
const declutteredOffsets = $derived.by(() => {
|
|
89
|
+
const map = new SvelteMap<string, { extraDy: number; inGroup: boolean; rank: number; depth: number }>();
|
|
90
|
+
if (lastPoints.length < 2) return map;
|
|
91
|
+
const yScale = plot.scales.y?.fn;
|
|
92
|
+
if (!yScale) return map;
|
|
93
|
+
const fontSize = font?.fontSize ?? 12;
|
|
94
|
+
const minGap = Math.max(16, fontSize * 1.4);
|
|
95
|
+
const items = lastPoints.map(({ series: s, lastRow }) => ({
|
|
96
|
+
name: s.name,
|
|
97
|
+
pos: Number(yScale(Number(resolveAccessor(s.y)(lastRow)))),
|
|
98
|
+
}));
|
|
99
|
+
const sorted = [...items].sort((a, b) => a.pos - b.pos);
|
|
100
|
+
const { positions: spread, connected } = declutter1D(items, minGap);
|
|
101
|
+
const spreadByName = new Map(spread.map((d) => [d.name, d.pos]));
|
|
102
|
+
const ranks = mirrorRanks(connected);
|
|
103
|
+
sorted.forEach((item, i) => {
|
|
104
|
+
const final = spreadByName.get(item.name) ?? item.pos;
|
|
105
|
+
map.set(item.name, {
|
|
106
|
+
extraDy: final - item.pos,
|
|
107
|
+
inGroup: ranks[i].depth > 1,
|
|
108
|
+
rank: ranks[i].rank,
|
|
109
|
+
depth: ranks[i].depth,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
return map;
|
|
113
|
+
});
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
{#each lastPoints as { series: s, lastRow, seriesIndex } (s.name)}
|
|
117
|
+
{@const xFn = resolveAccessor(s.x)}
|
|
118
|
+
{@const yFn = resolveAccessor(s.y)}
|
|
119
|
+
{@const seriesColor = paletteColor(seriesIndex, cfg.palette)}
|
|
120
|
+
{@const offset = declutteredOffsets.get(s.name)}
|
|
121
|
+
{@const extraDy = offset?.inGroup ? offset.extraDy : 0}
|
|
122
|
+
{@const baseDx = font?.dx ?? defaultDx}
|
|
123
|
+
{@const dir = Math.sign(baseDx) || 1}
|
|
124
|
+
{@const numLanes = offset?.inGroup ? Math.ceil(offset.depth / 2) : 0}
|
|
125
|
+
{@const labelDx = offset?.inGroup
|
|
126
|
+
? dir * (ELBOW_GAP + (numLanes - 1) * LANE_STEP + LABEL_GAP)
|
|
127
|
+
: baseDx}
|
|
128
|
+
{#if offset?.inGroup && plot.scales.x?.fn && plot.scales.y?.fn}
|
|
129
|
+
{@const px = Number(plot.scales.x.fn(xFn(lastRow)))}
|
|
130
|
+
{@const py = Number(plot.scales.y.fn(Number(yFn(lastRow))))}
|
|
131
|
+
{@const laneX = px + dir * (ELBOW_GAP + offset.rank * LANE_STEP)}
|
|
132
|
+
{@const targetY = py + extraDy}
|
|
133
|
+
<path
|
|
134
|
+
d={`M ${px} ${py} H ${laneX} V ${targetY} H ${px + labelDx}`}
|
|
135
|
+
fill="none"
|
|
136
|
+
stroke={connector?.stroke ?? DEFAULT_CONNECTOR_COLOR}
|
|
137
|
+
stroke-width={connector?.strokeWidth ?? DEFAULT_CONNECTOR_WIDTH}
|
|
138
|
+
stroke-opacity={connector?.strokeOpacity ?? 1}
|
|
139
|
+
stroke-dasharray={connector?.strokeDasharray}
|
|
140
|
+
stroke-linecap={connector?.strokeLinecap ?? 'square'}
|
|
141
|
+
stroke-linejoin={connector?.strokeLinejoin ?? 'miter'}
|
|
142
|
+
/>
|
|
143
|
+
{/if}
|
|
144
|
+
<Text
|
|
145
|
+
data={[lastRow]}
|
|
146
|
+
x={xFn}
|
|
147
|
+
y={yFn}
|
|
148
|
+
text={() => fmtVal(Number(yFn(lastRow)), s.name)}
|
|
149
|
+
dx={labelDx}
|
|
150
|
+
dy={(font?.dy ?? defaultDy) + extraDy}
|
|
151
|
+
textAnchor={resolvedTextAnchor ?? defaultTA}
|
|
152
|
+
lineAnchor={font?.lineAnchor ?? defaultLA}
|
|
153
|
+
lineHeight={font?.lineHeight}
|
|
154
|
+
rotate={font?.rotate}
|
|
155
|
+
class={font?.class}
|
|
156
|
+
textClass={font?.textClass}
|
|
157
|
+
fill={font?.fill ?? seriesColor}
|
|
158
|
+
fontSize={font?.fontSize ?? 12}
|
|
159
|
+
fontWeight={font?.fontWeight}
|
|
160
|
+
fontStyle={font?.fontStyle}
|
|
161
|
+
stroke={font?.stroke}
|
|
162
|
+
strokeWidth={font?.strokeWidth}
|
|
163
|
+
paintOrder={font?.paintOrder}
|
|
164
|
+
/>
|
|
165
|
+
{/each}
|
|
@@ -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
|
|
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
|
-
{
|
|
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,71 @@
|
|
|
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 `mirrorRanks`
|
|
30
|
+
* needs and can't reliably reconstruct from positions alone after the fact
|
|
31
|
+
* (see its own doc comment).
|
|
32
|
+
*/
|
|
33
|
+
export declare function declutter1D<T extends {
|
|
34
|
+
pos: number;
|
|
35
|
+
}>(items: T[], minGap: number): Declutter1DResult<T>;
|
|
36
|
+
/**
|
|
37
|
+
* Groups `declutter1D`'s own `connected` flags into runs (maximal stretches
|
|
38
|
+
* 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)`.
|
|
45
|
+
*
|
|
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.
|
|
57
|
+
*
|
|
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.
|
|
67
|
+
*/
|
|
68
|
+
export declare function mirrorRanks(connected: boolean[]): {
|
|
69
|
+
rank: number;
|
|
70
|
+
depth: number;
|
|
71
|
+
}[];
|
|
@@ -0,0 +1,95 @@
|
|
|
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 `mirrorRanks`
|
|
19
|
+
* needs and can't reliably reconstruct from positions alone after the fact
|
|
20
|
+
* (see its own doc comment).
|
|
21
|
+
*/
|
|
22
|
+
export function declutter1D(items, minGap) {
|
|
23
|
+
const sorted = [...items].sort((a, b) => a.pos - b.pos);
|
|
24
|
+
const n = sorted.length;
|
|
25
|
+
const connected = new Array(Math.max(0, n - 1)).fill(false);
|
|
26
|
+
const down = sorted.map((d) => d.pos);
|
|
27
|
+
for (let i = 1; i < n; i++) {
|
|
28
|
+
const pushed = down[i - 1] + minGap;
|
|
29
|
+
if (pushed > down[i]) {
|
|
30
|
+
down[i] = pushed;
|
|
31
|
+
connected[i - 1] = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const up = sorted.map((d) => d.pos);
|
|
35
|
+
for (let i = n - 2; i >= 0; i--) {
|
|
36
|
+
const pulled = up[i + 1] - minGap;
|
|
37
|
+
if (pulled < up[i]) {
|
|
38
|
+
up[i] = pulled;
|
|
39
|
+
connected[i] = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const positions = sorted.map((d, i) => ({ ...d, pos: (down[i] + up[i]) / 2 }));
|
|
43
|
+
return { positions, connected };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Groups `declutter1D`'s own `connected` flags into runs (maximal stretches
|
|
47
|
+
* 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)`.
|
|
54
|
+
*
|
|
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.
|
|
66
|
+
*
|
|
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.
|
|
76
|
+
*/
|
|
77
|
+
export function mirrorRanks(connected) {
|
|
78
|
+
const n = connected.length + 1;
|
|
79
|
+
const out = Array.from({ length: n }, () => ({ rank: 0, depth: 1 }));
|
|
80
|
+
let i = 0;
|
|
81
|
+
while (i < n) {
|
|
82
|
+
let j = i;
|
|
83
|
+
while (j < connected.length && connected[j])
|
|
84
|
+
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 };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
i = j + 1;
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
@@ -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;
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
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 =
|
|
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.
|