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