@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.
@@ -59,31 +59,32 @@
59
59
  : ('middle' as const),
60
60
  );
61
61
  const groupOffset = $derived(barLayout.groupOffset(seriesIndex));
62
+ const font = $derived(values?.fontStyle);
62
63
 
63
64
  // `textAnchor: 'outside'` is a per-point escape hatch resolved by
64
65
  // HoverMarker (which knows each point's signed x) — value labels already
65
66
  // have their own `anchor: 'outside'` concept (computed above), so if it
66
67
  // leaks through here just fall back to the anchor-derived default.
67
- const resolvedTextAnchor = $derived(values?.textAnchor === 'outside' ? undefined : values?.textAnchor);
68
+ const resolvedTextAnchor = $derived(font?.textAnchor === 'outside' ? undefined : font?.textAnchor);
68
69
  </script>
69
70
 
70
71
  <Text
71
72
  data={group.series.data}
72
73
  {...isHorizontal ? { y: xFn, x: valueTextFn } : { x: xFn, y: valueTextFn }}
73
74
  text={(d: TData) => fmtVal(Number(yFn(d)), group.series.name)}
74
- dx={values?.dx ?? (isHorizontal ? defaultOffset : groupOffset.dx)}
75
- dy={values?.dy ?? (isHorizontal ? groupOffset.dy : defaultOffset)}
75
+ dx={font?.dx ?? (isHorizontal ? defaultOffset : groupOffset.dx)}
76
+ dy={font?.dy ?? (isHorizontal ? groupOffset.dy : defaultOffset)}
76
77
  textAnchor={resolvedTextAnchor ?? (isHorizontal ? (valAnchor === 'outside' || valAnchor === 'end' ? 'start' : valAnchor === 'start' ? 'end' : 'middle') : 'middle')}
77
- lineAnchor={values?.lineAnchor ?? (isHorizontal ? 'middle' : defaultLA)}
78
- lineHeight={values?.lineHeight}
79
- rotate={values?.rotate}
80
- class={values?.class}
81
- textClass={values?.textClass}
82
- fill={values?.fill ?? axisColor}
83
- fontSize={values?.fontSize ?? 12}
84
- fontWeight={values?.fontWeight}
85
- fontStyle={values?.fontStyle}
86
- stroke={values?.stroke}
87
- strokeWidth={values?.strokeWidth}
88
- paintOrder={values?.paintOrder}
78
+ lineAnchor={font?.lineAnchor ?? (isHorizontal ? 'middle' : defaultLA)}
79
+ lineHeight={font?.lineHeight}
80
+ rotate={font?.rotate}
81
+ class={font?.class}
82
+ textClass={font?.textClass}
83
+ fill={font?.fill ?? axisColor}
84
+ fontSize={font?.fontSize ?? 12}
85
+ fontWeight={font?.fontWeight}
86
+ fontStyle={font?.fontStyle}
87
+ stroke={font?.stroke}
88
+ strokeWidth={font?.strokeWidth}
89
+ paintOrder={font?.paintOrder}
89
90
  />
@@ -1,5 +1,5 @@
1
1
  <script lang="ts" generics="TData extends Record<string, unknown>">
2
- import { Cell, Text } from 'svelteplot';
2
+ import { Cell } from 'svelteplot';
3
3
  import { resolveAccessor } from '../utils/accessors';
4
4
  import { validateSegments } from '../utils/segments';
5
5
  import { buildSeries } from '../../utils/grouping';
@@ -11,6 +11,7 @@
11
11
  import AxisLayout from '../../layout/plot/AxisLayout.svelte';
12
12
  import GridLayout from '../../layout/plot/GridLayout.svelte';
13
13
  import RuleLayout from '../../layout/plot/RuleLayout.svelte';
14
+ import ValueLabels from './ValueLabels.svelte';
14
15
  import { hasHoverMarker, resolveHoverStrategy, resolveHoverSync } from '../../layout/tooltip/utils';
15
16
  import type { BasePlotProps } from '../../types/plots/props';
16
17
  import type { AxisValue } from '../../types/plots/axis';
@@ -77,9 +78,6 @@
77
78
  const maxColor = $derived(styles.colors?.max ?? cfg.continuous.max);
78
79
  const showVals = $derived(styles.values?.show ?? true);
79
80
  const fmtVal = $derived(styles.values?.format ?? ((v: number) => `${v}`));
80
- // 'outside' is a per-point escape hatch resolved by HoverMarker; heatmaps
81
- // have no signed axis to resolve it against, so treat it as unset.
82
- const resolvedTextAnchor = $derived(styles.values?.textAnchor === 'outside' ? undefined : styles.values?.textAnchor);
83
81
 
84
82
  const resolvedSeries = $derived(series ?? buildSeries(data!, x!, y!, undefined));
85
83
  const flat = $derived(resolvedSeries.flatMap((s) => s.data));
@@ -264,26 +262,14 @@
264
262
  {/each}
265
263
 
266
264
  {#if showVals}
267
- <Text
265
+ <ValueLabels
268
266
  data={flat}
269
- x={getX}
270
- y={getY}
271
- text={(d) => fmtVal(Number(getZ(d)), '')}
272
- fill={styles.values?.fill ?? cfg.continuous.labelColor}
273
- fontSize={styles.values?.fontSize ?? 12}
274
- fontWeight={styles.values?.fontWeight ?? 600}
275
- fontStyle={styles.values?.fontStyle}
276
- textAnchor={resolvedTextAnchor ?? 'middle'}
277
- lineAnchor={styles.values?.lineAnchor ?? 'middle'}
278
- dx={styles.values?.dx ?? 0}
279
- dy={styles.values?.dy ?? 0}
280
- lineHeight={styles.values?.lineHeight}
281
- rotate={styles.values?.rotate}
282
- stroke={styles.values?.stroke}
283
- strokeWidth={styles.values?.strokeWidth}
284
- paintOrder={styles.values?.paintOrder}
285
- class={styles.values?.class}
286
- textClass={styles.values?.textClass}
267
+ {getX}
268
+ {getY}
269
+ {getZ}
270
+ {fmtVal}
271
+ values={styles.values}
272
+ defaultColor={cfg.continuous.labelColor}
287
273
  />
288
274
  {/if}
289
275
  </RuleLayout>
@@ -0,0 +1,56 @@
1
+ <script lang="ts" generics="TData extends Record<string, unknown>">
2
+ import { Text } from 'svelteplot';
3
+ import type { AxisValue } from '../../types/plots/axis';
4
+ import type { ValuesStyle } from '../../types/plots/styles/common';
5
+
6
+ /**
7
+ * Renders every cell's value label in one mark — heatmaps have no
8
+ * per-series concept to loop over, so unlike bar/line/pyramid this covers
9
+ * the whole flat dataset at once. Centered by default, overridable
10
+ * per-field via `styles.values.fontStyle`.
11
+ */
12
+ let {
13
+ data,
14
+ getX,
15
+ getY,
16
+ getZ,
17
+ fmtVal,
18
+ values,
19
+ defaultColor,
20
+ }: {
21
+ data: TData[];
22
+ getX: (d: TData) => AxisValue;
23
+ getY: (d: TData) => AxisValue;
24
+ getZ: (d: TData) => unknown;
25
+ fmtVal: (value: number, seriesName: string) => string;
26
+ values: ValuesStyle | undefined;
27
+ defaultColor: string;
28
+ } = $props();
29
+
30
+ const font = $derived(values?.fontStyle);
31
+ // 'outside' is a per-point escape hatch resolved by HoverMarker; heatmaps
32
+ // have no signed axis to resolve it against, so treat it as unset.
33
+ const resolvedTextAnchor = $derived(font?.textAnchor === 'outside' ? undefined : font?.textAnchor);
34
+ </script>
35
+
36
+ <Text
37
+ {data}
38
+ x={getX}
39
+ y={getY}
40
+ text={(d: TData) => fmtVal(Number(getZ(d)), '')}
41
+ fill={font?.fill ?? defaultColor}
42
+ fontSize={font?.fontSize ?? 12}
43
+ fontWeight={font?.fontWeight ?? 600}
44
+ fontStyle={font?.fontStyle}
45
+ textAnchor={resolvedTextAnchor ?? 'middle'}
46
+ lineAnchor={font?.lineAnchor ?? 'middle'}
47
+ dx={font?.dx ?? 0}
48
+ dy={font?.dy ?? 0}
49
+ lineHeight={font?.lineHeight}
50
+ rotate={font?.rotate}
51
+ stroke={font?.stroke}
52
+ strokeWidth={font?.strokeWidth}
53
+ paintOrder={font?.paintOrder}
54
+ class={font?.class}
55
+ textClass={font?.textClass}
56
+ />
@@ -0,0 +1,34 @@
1
+ import type { AxisValue } from '../../types/plots/axis';
2
+ import type { ValuesStyle } from '../../types/plots/styles/common';
3
+ declare function $$render<TData extends Record<string, unknown>>(): {
4
+ props: {
5
+ data: TData[];
6
+ getX: (d: TData) => AxisValue;
7
+ getY: (d: TData) => AxisValue;
8
+ getZ: (d: TData) => unknown;
9
+ fmtVal: (value: number, seriesName: string) => string;
10
+ values: ValuesStyle | undefined;
11
+ defaultColor: string;
12
+ };
13
+ exports: {};
14
+ bindings: "";
15
+ slots: {};
16
+ events: {};
17
+ };
18
+ declare class __sveltets_Render<TData extends Record<string, unknown>> {
19
+ props(): ReturnType<typeof $$render<TData>>['props'];
20
+ events(): ReturnType<typeof $$render<TData>>['events'];
21
+ slots(): ReturnType<typeof $$render<TData>>['slots'];
22
+ bindings(): "";
23
+ exports(): {};
24
+ }
25
+ interface $$IsomorphicComponent {
26
+ new <TData extends Record<string, unknown>>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<TData>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<TData>['props']>, ReturnType<__sveltets_Render<TData>['events']>, ReturnType<__sveltets_Render<TData>['slots']>> & {
27
+ $$bindings?: ReturnType<__sveltets_Render<TData>['bindings']>;
28
+ } & ReturnType<__sveltets_Render<TData>['exports']>;
29
+ <TData extends Record<string, unknown>>(internal: unknown, props: ReturnType<__sveltets_Render<TData>['props']> & {}): ReturnType<__sveltets_Render<TData>['exports']>;
30
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
31
+ }
32
+ declare const ValueLabels: $$IsomorphicComponent;
33
+ type ValueLabels<TData extends Record<string, unknown>> = InstanceType<typeof ValueLabels<TData>>;
34
+ export default ValueLabels;
@@ -3,7 +3,7 @@
3
3
  generics="TData extends Record<string, unknown>"
4
4
  >
5
5
  import { SvelteMap } from 'svelte/reactivity';
6
- import { Line, Dot, Text } from 'svelteplot';
6
+ import { Line, Dot } from 'svelteplot';
7
7
  import { resolveAccessor } from '../utils/accessors';
8
8
  import { getConfiguration } from '../../configuration/config.svelte';
9
9
  import { paletteColor } from '../../utils/color';
@@ -15,6 +15,7 @@
15
15
  import GridLayout from '../../layout/plot/GridLayout.svelte';
16
16
  import RuleLayout from '../../layout/plot/RuleLayout.svelte';
17
17
  import HoverMarker from '../../markers/HoverMarker.svelte';
18
+ import ValueLabels from './ValueLabels.svelte';
18
19
  import { hasHoverMarker, resolveHoverStrategy, resolveHoverSync } from '../../layout/tooltip/utils';
19
20
  import { buildGroupedSeries, validateSegments, resolveSegmentsForSeries, matchesSegmentX, getDataForSegments } from '../utils/segments';
20
21
  import type { BasePlotProps } from '../../types/plots/props';
@@ -22,7 +23,6 @@
22
23
  import type { Series, SeriesProps, DataProps } from '../../types/plots/data/common';
23
24
  import type { AxisBasedScalesConfig } from '../../types/plots/props';
24
25
  import type { BasePlotStyles } from '../../types/plots/styles/common';
25
- import type { ValueAnchor } from '../../types/plots/constants';
26
26
  import type { LineSegmentStyle } from '../../types/plots/segments/common';
27
27
  import type { Marker } from '../../types/markers/common';
28
28
 
@@ -73,14 +73,6 @@
73
73
 
74
74
  const cfg = $derived(getConfiguration());
75
75
  const showVals = $derived(styles.values?.show ?? false);
76
- const fmtVal = $derived(
77
- styles.values?.format ?? ((v: number, name: string) => `${name} - ${v}`),
78
- );
79
- const valAnchor = $derived<ValueAnchor>(styles.values?.anchor ?? 'start');
80
- // 'outside' is a per-point escape hatch resolved by HoverMarker; line's own
81
- // value labels already have their own `anchor: 'outside'` concept above,
82
- // so treat it as unset here.
83
- const resolvedTextAnchor = $derived(styles.values?.textAnchor === 'outside' ? undefined : styles.values?.textAnchor);
84
76
 
85
77
  const xAcc = $derived(resolveAccessor(resolvedSeries[0]?.x ?? ((d: TData) => d)));
86
78
  const yAcc = $derived(resolveAccessor(resolvedSeries[0]?.y ?? ((d: TData) => d)));
@@ -236,37 +228,7 @@
236
228
  {/each}
237
229
 
238
230
  {#if showVals}
239
- {@const defaultDx = valAnchor === 'start' ? 6 : valAnchor === 'end' ? -6 : 0}
240
- {@const defaultDy = valAnchor === 'outside' ? -8 : 0}
241
- {@const defaultTA = valAnchor === 'start'
242
- ? ('start' as const)
243
- : valAnchor === 'end'
244
- ? ('end' as const)
245
- : ('middle' as const)}
246
- {@const defaultLA = valAnchor === 'outside'
247
- ? ('bottom' as const)
248
- : ('middle' as const)}
249
- {#each lastPoints as { series: s, lastRow, seriesIndex } (s.name)}
250
- {@const xFn = resolveAccessor(s.x)}
251
- {@const yFn = resolveAccessor(s.y)}
252
- {@const seriesColor = paletteColor(seriesIndex, cfg.palette)}
253
- <Text
254
- data={[lastRow]}
255
- x={xFn}
256
- y={yFn}
257
- text={() => fmtVal(Number(yFn(lastRow)), s.name)}
258
- dx={styles.values?.dx ?? defaultDx}
259
- dy={styles.values?.dy ?? defaultDy}
260
- textAnchor={resolvedTextAnchor ?? defaultTA}
261
- lineAnchor={styles.values?.lineAnchor ?? defaultLA}
262
- lineHeight={styles.values?.lineHeight}
263
- rotate={styles.values?.rotate}
264
- class={styles.values?.class}
265
- textClass={styles.values?.textClass}
266
- fill={seriesColor}
267
- fontSize={12}
268
- />
269
- {/each}
231
+ <ValueLabels {lastPoints} values={styles.values} />
270
232
  {/if}
271
233
  </RuleLayout>
272
234
  {/snippet}
@@ -0,0 +1,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, Text } from 'svelteplot';
3
+ import { BarX } from 'svelteplot';
4
4
  import { resolveAccessor } from '../utils/accessors';
5
5
  import { getConfiguration } from '../../configuration/config.svelte';
6
6
  import { paletteColor } from '../../utils/color';
@@ -13,6 +13,7 @@
13
13
  import GridLayout from '../../layout/plot/GridLayout.svelte';
14
14
  import RuleLayout from '../../layout/plot/RuleLayout.svelte';
15
15
  import HoverMarker from '../../markers/HoverMarker.svelte';
16
+ import ValueLabels from './ValueLabels.svelte';
16
17
  import { hasHoverMarker, resolveHoverStrategy, resolveHoverSync } from '../../layout/tooltip/utils';
17
18
  import type { BasePlotProps } from '../../types/plots/props';
18
19
  import type { AxisValue } from '../../types/plots/axis';
@@ -73,10 +74,6 @@
73
74
  const showVals = $derived(styles.values?.show ?? false);
74
75
  const fmtVal = $derived(styles.values?.format ?? ((v: number) => `${v}`));
75
76
  const valAnchor = $derived<ValueAnchor>(styles.values?.anchor ?? 'outside');
76
- // 'outside' is a per-point escape hatch resolved by HoverMarker; pyramid's
77
- // own value labels already have their own `anchor: 'outside'` concept
78
- // above, so treat it as unset here.
79
- const resolvedTextAnchor = $derived(styles.values?.textAnchor === 'outside' ? undefined : styles.values?.textAnchor);
80
77
 
81
78
  // Sort series names alphabetically; even index → left, odd → right.
82
79
  // scales.z.reverse flips the assignment so the second name goes left.
@@ -236,41 +233,7 @@
236
233
  {/each}
237
234
 
238
235
  {#if showVals}
239
- {@const isLeft = group.series.side === 'left'}
240
- {@const xFn = valAnchor === 'middle'
241
- ? (d: TData) => signedValue(group.series, d) / 2
242
- : valAnchor === 'end'
243
- ? () => 0
244
- : (d: TData) => signedValue(group.series, d)}
245
- {@const defaultDx = valAnchor === 'middle' ? 0
246
- : valAnchor === 'end' ? (isLeft ? -4 : 4)
247
- : (isLeft ? -6 : 6)}
248
- {@const defaultTextAnchor = valAnchor === 'middle'
249
- ? ('middle' as const)
250
- : isLeft
251
- ? ('end' as const)
252
- : ('start' as const)}
253
- <Text
254
- data={group.series.data}
255
- x={xFn}
256
- y={getCategory}
257
- text={(d: TData) => fmtVal(Math.abs(Number(resolveAccessor(group.series.y)(d))), group.series.name)}
258
- dx={styles.values?.dx ?? defaultDx}
259
- dy={styles.values?.dy ?? 0}
260
- textAnchor={resolvedTextAnchor ?? defaultTextAnchor}
261
- lineAnchor={styles.values?.lineAnchor ?? 'middle'}
262
- lineHeight={styles.values?.lineHeight}
263
- rotate={styles.values?.rotate}
264
- class={styles.values?.class}
265
- textClass={styles.values?.textClass}
266
- fill={styles.values?.fill ?? cfg.axis.color}
267
- fontSize={styles.values?.fontSize ?? 12}
268
- fontWeight={styles.values?.fontWeight}
269
- fontStyle={styles.values?.fontStyle}
270
- stroke={styles.values?.stroke}
271
- strokeWidth={styles.values?.strokeWidth}
272
- paintOrder={styles.values?.paintOrder}
273
- />
236
+ <ValueLabels {group} {valAnchor} {fmtVal} values={styles.values} axisColor={cfg.axis.color} />
274
237
  {/if}
275
238
 
276
239
  {#each seriesMarkers.get(group.series.name) ?? [] as marker, i (`series-${group.series.name}-${marker.type}-${i}`)}
@@ -0,0 +1,80 @@
1
+ <script lang="ts" generics="TData extends Record<string, unknown>">
2
+ import { Text } from 'svelteplot';
3
+ import { resolveAccessor } from '../utils/accessors';
4
+ import type { Series } from '../../types/plots/data/common';
5
+ import type { ValuesStyle } from '../../types/plots/styles/common';
6
+ import type { ValueAnchor } from '../../types/plots/constants';
7
+ import type { BarSegmentStyle } from '../../types/plots/segments/common';
8
+ import type { VisualGroup } from '../../types/plots/segments/config';
9
+
10
+ /**
11
+ * Renders one series' value label on a pyramid bar — anchor-driven default
12
+ * position/offset, mirrored left/right of the shared zero baseline
13
+ * depending on `group.series.side`, overridable per-field via
14
+ * `styles.values.fontStyle`.
15
+ */
16
+ let {
17
+ group,
18
+ valAnchor,
19
+ fmtVal,
20
+ values,
21
+ axisColor,
22
+ }: {
23
+ group: VisualGroup<Series<TData> & { side: 'left' | 'right' }, BarSegmentStyle>;
24
+ valAnchor: ValueAnchor;
25
+ fmtVal: (value: number, seriesName: string) => string;
26
+ values: ValuesStyle | undefined;
27
+ axisColor: string;
28
+ } = $props();
29
+
30
+ const isLeft = $derived(group.series.side === 'left');
31
+ const getCategory = $derived(resolveAccessor(group.series.x));
32
+ const getMagnitude = $derived(resolveAccessor(group.series.y));
33
+ const signedValue = $derived((d: TData) => {
34
+ const v = Math.abs(Number(getMagnitude(d)));
35
+ return isLeft ? -v : v;
36
+ });
37
+
38
+ const xFn = $derived(
39
+ valAnchor === 'middle'
40
+ ? (d: TData) => signedValue(d) / 2
41
+ : valAnchor === 'end'
42
+ ? () => 0
43
+ : (d: TData) => signedValue(d),
44
+ );
45
+ const defaultDx = $derived(
46
+ valAnchor === 'middle' ? 0 : valAnchor === 'end' ? (isLeft ? -4 : 4) : isLeft ? -6 : 6,
47
+ );
48
+ const defaultTextAnchor = $derived(
49
+ valAnchor === 'middle' ? ('middle' as const) : isLeft ? ('end' as const) : ('start' as const),
50
+ );
51
+
52
+ const font = $derived(values?.fontStyle);
53
+ // 'outside' is a per-point escape hatch resolved by HoverMarker; pyramid's
54
+ // own value labels already have their own `anchor: 'outside'` concept
55
+ // (resolved above via `valAnchor`), so if it leaks through here just fall
56
+ // back to the anchor-derived default.
57
+ const resolvedTextAnchor = $derived(font?.textAnchor === 'outside' ? undefined : font?.textAnchor);
58
+ </script>
59
+
60
+ <Text
61
+ data={group.series.data}
62
+ x={xFn}
63
+ y={getCategory}
64
+ text={(d: TData) => fmtVal(Math.abs(Number(getMagnitude(d))), group.series.name)}
65
+ dx={font?.dx ?? defaultDx}
66
+ dy={font?.dy ?? 0}
67
+ textAnchor={resolvedTextAnchor ?? defaultTextAnchor}
68
+ lineAnchor={font?.lineAnchor ?? 'middle'}
69
+ lineHeight={font?.lineHeight}
70
+ rotate={font?.rotate}
71
+ class={font?.class}
72
+ textClass={font?.textClass}
73
+ fill={font?.fill ?? axisColor}
74
+ fontSize={font?.fontSize ?? 12}
75
+ fontWeight={font?.fontWeight}
76
+ fontStyle={font?.fontStyle}
77
+ stroke={font?.stroke}
78
+ strokeWidth={font?.strokeWidth}
79
+ paintOrder={font?.paintOrder}
80
+ />
@@ -0,0 +1,37 @@
1
+ import type { Series } from '../../types/plots/data/common';
2
+ import type { ValuesStyle } from '../../types/plots/styles/common';
3
+ import type { ValueAnchor } from '../../types/plots/constants';
4
+ import type { BarSegmentStyle } from '../../types/plots/segments/common';
5
+ import type { VisualGroup } from '../../types/plots/segments/config';
6
+ declare function $$render<TData extends Record<string, unknown>>(): {
7
+ props: {
8
+ group: VisualGroup<Series<TData> & {
9
+ side: "left" | "right";
10
+ }, BarSegmentStyle>;
11
+ valAnchor: ValueAnchor;
12
+ fmtVal: (value: number, seriesName: string) => string;
13
+ values: ValuesStyle | undefined;
14
+ axisColor: string;
15
+ };
16
+ exports: {};
17
+ bindings: "";
18
+ slots: {};
19
+ events: {};
20
+ };
21
+ declare class __sveltets_Render<TData extends Record<string, unknown>> {
22
+ props(): ReturnType<typeof $$render<TData>>['props'];
23
+ events(): ReturnType<typeof $$render<TData>>['events'];
24
+ slots(): ReturnType<typeof $$render<TData>>['slots'];
25
+ bindings(): "";
26
+ exports(): {};
27
+ }
28
+ interface $$IsomorphicComponent {
29
+ new <TData extends Record<string, unknown>>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<TData>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<TData>['props']>, ReturnType<__sveltets_Render<TData>['events']>, ReturnType<__sveltets_Render<TData>['slots']>> & {
30
+ $$bindings?: ReturnType<__sveltets_Render<TData>['bindings']>;
31
+ } & ReturnType<__sveltets_Render<TData>['exports']>;
32
+ <TData extends Record<string, unknown>>(internal: unknown, props: ReturnType<__sveltets_Render<TData>['props']> & {}): ReturnType<__sveltets_Render<TData>['exports']>;
33
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
34
+ }
35
+ declare const ValueLabels: $$IsomorphicComponent;
36
+ type ValueLabels<TData extends Record<string, unknown>> = InstanceType<typeof ValueLabels<TData>>;
37
+ export default ValueLabels;
@@ -0,0 +1,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; the inherited {@link FontStyle}
6
- * fields (`dx`, `dy`, `textAnchor`, `lineAnchor`, `lineHeight`, `rotate`,
7
- * `class`, `textClass`, `fill`, …) override those defaults when provided.
5
+ * `anchor` sets sensible defaults for placement; `fontStyle` (`dx`, `dy`,
6
+ * `textAnchor`, `lineAnchor`, `lineHeight`, `rotate`, `class`, `textClass`,
7
+ * `fill`, …) overrides those defaults field-by-field when provided — mirrors
8
+ * {@link DeltaConfig}'s `fontStyle`/`strokeStyle` split, so label text and
9
+ * connector-line styling never mix into one flat bag of props.
8
10
  */
9
- export type ValuesStyle = FontStyle & {
11
+ export type ValuesStyle = {
10
12
  show?: boolean;
11
13
  anchor?: ValueAnchor;
12
14
  /** Called with the numeric value and the series name. Return a string to render. */
13
15
  format?: (value: number, seriesName: string) => string;
16
+ /** Text styling for the label itself. */
17
+ fontStyle?: FontStyle;
18
+ /**
19
+ * Style for the leader line drawn from a label displaced to clear an
20
+ * overlap with another series' label, back to its real data point.
21
+ * Labels only ever move when they'd otherwise collide — this is a no-op
22
+ * style until that happens. Falls back to a neutral grey, thin, solid
23
+ * stroke — deliberately not the series' own colour or stroke width, since
24
+ * matching the data line reads as more data rather than as a leader,
25
+ * right where several lines are already converging. Unset fields keep
26
+ * that fallback.
27
+ */
28
+ strokeStyle?: StrokeStyle;
14
29
  };
15
30
  /**
16
31
  * Color overrides for plots that derive their palette from data.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"