@fundar/data-chart-telling 1.13.0 → 1.14.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [1.14.0] - 2026-11-04
6
+
7
+ ### Added
8
+
9
+ - The `hover` marker takes `decimals`, `prefix`, `suffix` and `signDisplay` for its label, and its `format` gets a second argument, `number`, that writes a value as they ask. With `numbers.decimals` set, its label is written with that count.
10
+ - The `format` of `styles.values` gets `number` as its fourth argument, to write a value as the display fields ask while choosing which labels to show.
11
+ - The `continuous` and `size` legend sections (so `legend.color` too) take `decimals`, `prefix`, `suffix` and `signDisplay` for their values - without `decimals` they keep the axes' format, and `numbers.decimals` does not reach them. Their `format` gets `number` as a second argument, and can return a number (`v * 100` for a share kept as a ratio), which is written as those fields ask.
12
+ - A `delta`'s `formatDiff` can return a number: it is written as the marker's display fields ask.
13
+
5
14
  ## [1.13.0] - 2026-11-04
6
15
 
7
16
  ### Added
@@ -12,6 +21,7 @@ All notable changes to this project are documented here. The format follows [Kee
12
21
 
13
22
  ### Changed
14
23
 
24
+ - The `continuous` and `size` legend sections (so `legend.color` too) take `decimals`, `prefix`, `suffix` and `signDisplay` for their values - without `decimals` they keep the axes' format, and `numbers.decimals` does not reach them. Their `format` gets `number` as a second argument, and can return a number (`v * 100` for a share kept as a ratio), which is written as those fields ask.
15
25
  - A `delta` writes its distance in `axis.locale` (`12,3`), not with a dot (`12.3`), and with the configuration's `numbers.decimals` when set. One decimal remains the default.
16
26
 
17
27
  ## [1.12.0] - 2026-11-04
@@ -77,6 +87,7 @@ All notable changes to this project are documented here. The format follows [Kee
77
87
 
78
88
  ### Fixed
79
89
 
90
+ - The `continuous` and `size` legend sections (so `legend.color` too) take `decimals`, `prefix`, `suffix` and `signDisplay` for their values - without `decimals` they keep the axes' format, and `numbers.decimals` does not reach them. Their `format` gets `number` as a second argument, and can return a number (`v * 100` for a share kept as a ratio), which is written as those fields ask.
80
91
  - A `delta` marker at a `Date` position no longer turns a date `x` axis into a numeric one: it drew its dots, line and label at the position as a number, which svelteplot read next to the dates of the data as a linear scale, and the axis showed milliseconds.
81
92
 
82
93
  ## [1.6.0] - 2026-11-02
@@ -1,5 +1,6 @@
1
1
  import type { PlotStylesConfig } from '../../types/configuration/styling';
2
- import type { NumberFormat } from '../../utils/NumberFormat';
2
+ import { NumberFormat } from '../../utils/NumberFormat';
3
+ import type { NumberDisplay } from '../../types/layout/numbers';
3
4
  /** Numbers as the plots' axes write them, by `axis.locale` and `axis.compactDisplay`. */
4
5
  export declare class AxisNumberFormat {
5
6
  /**
@@ -8,6 +9,14 @@ export declare class AxisNumberFormat {
8
9
  * spans more than one order of magnitude, plain otherwise.
9
10
  */
10
11
  static of(domain: [number, number], cfg: Pick<PlotStylesConfig, 'axis'>, signDisplay?: ReturnType<typeof NumberFormat.sign>): (value: number) => string;
12
+ /**
13
+ * How a legend section writes its values: as its `decimals`, `prefix`, `suffix` and `signDisplay` ask, else as the
14
+ * axes write theirs over `domain` - the configuration's `numbers.decimals` does not reach it. Its own `format`
15
+ * writes by hand instead: it gets that writer as its second argument, and a number it returns is written by it.
16
+ */
17
+ static legend(section: NumberDisplay & {
18
+ format?: (v: number, number: (v: number) => string) => string | number;
19
+ }, domain: [number, number], cfg: Pick<PlotStylesConfig, 'axis'>): (value: number) => string;
11
20
  /** Whether ticks across `domain` read compact. */
12
21
  static compact([min, max]: [number, number]): boolean;
13
22
  }
@@ -1,3 +1,4 @@
1
+ import { NumberFormat } from '../../utils/NumberFormat';
1
2
  /** Numbers as the plots' axes write them, by `axis.locale` and `axis.compactDisplay`. */
2
3
  export class AxisNumberFormat {
3
4
  /**
@@ -14,6 +15,30 @@ export class AxisNumberFormat {
14
15
  });
15
16
  return (value) => format.format(value);
16
17
  }
18
+ /**
19
+ * How a legend section writes its values: as its `decimals`, `prefix`, `suffix` and `signDisplay` ask, else as the
20
+ * axes write theirs over `domain` - the configuration's `numbers.decimals` does not reach it. Its own `format`
21
+ * writes by hand instead: it gets that writer as its second argument, and a number it returns is written by it.
22
+ */
23
+ static legend(section, domain, cfg) {
24
+ const display = NumberFormat.displayOf(section);
25
+ const { prefix = '', suffix = '' } = display;
26
+ let number;
27
+ if (display.decimals !== undefined) {
28
+ number = NumberFormat.of(display, { ...cfg, numbers: {} });
29
+ }
30
+ else {
31
+ const write = AxisNumberFormat.of(domain, cfg, NumberFormat.sign(display));
32
+ number = (value) => `${prefix}${write(value)}${suffix}`;
33
+ }
34
+ const { format } = section;
35
+ if (!format)
36
+ return number;
37
+ return (value) => {
38
+ const written = format(value, number);
39
+ return typeof written === 'number' ? number(written) : written;
40
+ };
41
+ }
17
42
  /** Whether ticks across `domain` read compact. */
18
43
  static compact([min, max]) {
19
44
  if (!Number.isFinite(min) || !Number.isFinite(max))
@@ -13,7 +13,7 @@
13
13
  const checkerId = `dct-checker-${Math.random().toString(36).slice(2)}`;
14
14
  const barWidth = $derived(section.width ?? 170);
15
15
 
16
- const fmt = $derived(section.format ?? AxisNumberFormat.of([section.min, section.max], cfg));
16
+ const fmt = $derived(AxisNumberFormat.legend(section, [section.min, section.max], cfg));
17
17
  const isInteractive = $derived(section.interactive?.enabled ?? false);
18
18
 
19
19
  // How far the default handle bleeds past its 0/1 track position, so the
@@ -15,7 +15,7 @@
15
15
  let { section }: { section: SizeLegendSection } = $props();
16
16
 
17
17
  const cfg = $derived(getConfiguration());
18
- const fmt = $derived(section.format ?? AxisNumberFormat.of(section.domain, cfg));
18
+ const fmt = $derived(AxisNumberFormat.legend(section, section.domain, cfg));
19
19
  /** A value's radius: `domain` onto `range` on a square-root scale. */
20
20
  function radius(value: number): number {
21
21
  const [d0, d1] = section.domain.map(Math.sqrt);
@@ -34,8 +34,11 @@ export class ValueLabels {
34
34
  // The value as the display fields ask (else as it is), which a plot kind's own text builds on.
35
35
  const number = NumberFormat.maybe(NumberFormat.displayOf(values), cfg) ?? ((v) => `${v}`);
36
36
  const kindFormat = options.format;
37
- this.format =
38
- values?.format ?? (kindFormat ? (v, name) => kindFormat(v, name, number) : (v) => number(v));
37
+ this.format = values?.format
38
+ ? (v, name, row) => values.format(v, name, row, number)
39
+ : kindFormat
40
+ ? (v, name) => kindFormat(v, name, number)
41
+ : (v) => number(v);
39
42
  this.hoverIsolate = values?.hoverIsolate ?? false;
40
43
  this.font = values?.fontStyle;
41
44
  this.fontSize = typeof this.font?.fontSize === 'number' ? this.font.fontSize : 12;
@@ -5,6 +5,7 @@
5
5
  import RuleMarker from '../rule/RuleMarker.svelte';
6
6
  import BarMarker from '../bar/BarMarker.svelte';
7
7
  import { getConfiguration } from '../../configuration/config.svelte';
8
+ import { NumberFormat } from '../../utils/NumberFormat';
8
9
  import { BandNudge } from '../utils/BandNudge';
9
10
  import type { BandSlice } from '../../types/layout/hover';
10
11
  import type { HoverMarkerConfig } from '../../types/markers/common';
@@ -47,6 +48,10 @@
47
48
  | 'area'
48
49
  | 'dot'
49
50
  | 'text'
51
+ | 'decimals'
52
+ | 'prefix'
53
+ | 'suffix'
54
+ | 'signDisplay'
50
55
  >;
51
56
  activePoint?: HoverDisplayPoint | null;
52
57
  impliesCrosshairX?: boolean;
@@ -111,6 +116,16 @@
111
116
  const areaY = $derived(marker.areaY === true ? (activePoint?.y ?? null) : null);
112
117
  const showLabels = $derived(marker.showLabels ?? true);
113
118
  const format = $derived(marker.format);
119
+ /** The hovered value as the marker's display fields ask; `undefined` while nothing does, so the plot's own label stays. */
120
+ const numberOf = $derived(NumberFormat.maybe(NumberFormat.displayOf(marker), cfg));
121
+ const number = $derived(numberOf ?? NumberFormat.of(undefined, cfg));
122
+ const labelOf = (d: HoverDisplayPoint): string => {
123
+ if (format) return format(d, number);
124
+ const value = Number(d.label);
125
+ return numberOf && d.label !== undefined && d.label !== '' && Number.isFinite(value)
126
+ ? numberOf(value)
127
+ : (d.label ?? '');
128
+ };
114
129
  const ruleStyle = $derived(marker.rule);
115
130
  const areaStyle = $derived({ ...cfg.hover.area, ...marker.area });
116
131
  const dotStyle = $derived(marker.dot);
@@ -242,7 +257,7 @@
242
257
  data: [point],
243
258
  x: (d: HoverDisplayPoint) => d.x,
244
259
  y: (d: HoverDisplayPoint) => d.y,
245
- text: (d: HoverDisplayPoint) => (format ? format(d) : (d.label ?? '')),
260
+ text: labelOf,
246
261
  styles: resolveFontStyle(point)
247
262
  }}
248
263
  />
@@ -25,7 +25,7 @@ import type { HoverDisplayPoint } from '../../types/layout/hover';
25
25
  interface Props {
26
26
  data?: HoverDisplayPoint[];
27
27
  /** Narrowed to just the fields this component reads - `HoverMarkerConfig` also carries routing/scoping fields (`series`/`scope`/`strategy`/`sync`) it has no use for. */
28
- marker: Pick<HoverMarkerConfig, 'ruleX' | 'ruleY' | 'areaX' | 'areaY' | 'showLabels' | 'format' | 'rule' | 'area' | 'dot' | 'text'>;
28
+ marker: Pick<HoverMarkerConfig, 'ruleX' | 'ruleY' | 'areaX' | 'areaY' | 'showLabels' | 'format' | 'rule' | 'area' | 'dot' | 'text' | 'decimals' | 'prefix' | 'suffix' | 'signDisplay'>;
29
29
  activePoint?: HoverDisplayPoint | null;
30
30
  impliesCrosshairX?: boolean;
31
31
  impliesCrosshairY?: boolean;
@@ -1,6 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { SymbolType, LineStyle } from '../plots/constants';
3
3
  import type { StrokeStyle, DotStyle } from '../plots/styling';
4
+ import type { NumberDisplay } from './numbers';
4
5
  import type { ColorScaleConfig, OpacityScaleConfig, SymbolScaleConfig } from './scales';
5
6
  export type LegendSectionType = 'discrete' | 'continuous' | 'size' | 'symbol';
6
7
  /**
@@ -127,7 +128,11 @@ export type ContinuousInteractiveConfig = {
127
128
  /** Custom renderer for the draggable lo/hi handles. */
128
129
  handle?: LegendHandleSnippet;
129
130
  };
130
- export type ContinuousLegendSection = {
131
+ /**
132
+ * A continuous color or opacity scale. Its values read as {@link NumberDisplay} asks - with `decimals`, exactly
133
+ * that many; without, as the axes write theirs - unless `format` writes them.
134
+ */
135
+ export type ContinuousLegendSection = NumberDisplay & {
131
136
  type: 'continuous';
132
137
  enabled?: boolean;
133
138
  title?: string;
@@ -146,7 +151,8 @@ export type ContinuousLegendSection = {
146
151
  opacity?: [number, number];
147
152
  ticks?: number[];
148
153
  width?: number;
149
- format?: (v: number) => string;
154
+ /** A value's text, in place of the value. A number it returns (`v * 100` for a share kept as a ratio) is written as the display fields above ask; a string is shown as it is. `number` writes a value that way by hand. */
155
+ format?: (v: number, number: (v: number) => string) => string | number;
150
156
  interactive?: ContinuousInteractiveConfig;
151
157
  };
152
158
  /**
@@ -154,7 +160,7 @@ export type ContinuousLegendSection = {
154
160
  * the radius each value draws at, its value under each. `domain` maps to
155
161
  * `range` (px) on a square-root scale, so a dot's area follows its value.
156
162
  */
157
- export type SizeLegendSection = {
163
+ export type SizeLegendSection = NumberDisplay & {
158
164
  type: 'size';
159
165
  enabled?: boolean;
160
166
  title?: string;
@@ -171,8 +177,8 @@ export type SizeLegendSection = {
171
177
  filled?: boolean;
172
178
  /** The dots' shape. Defaults to `'circle'`. */
173
179
  symbol?: SymbolType;
174
- /** How each value reads. Defaults to the axes' own format (`axis.locale`, `axis.compactDisplay`). */
175
- format?: (v: number) => string;
180
+ /** How each value reads, in place of the display fields' format (which defaults to the axes' own: `axis.locale`, `axis.compactDisplay`). A number it returns is written as those fields ask; `number` does the same by hand. */
181
+ format?: (v: number, number: (v: number) => string) => string | number;
176
182
  };
177
183
  /**
178
184
  * A categorical shape scale - the `symbol` channel of a plot's dots: each
@@ -39,7 +39,7 @@ export type ValuesStyle = NumberDisplay & {
39
39
  */
40
40
  color?: 'series' | (string & {});
41
41
  /** A label's text, in place of the value as `decimals`, `prefix`, `suffix` and `signDisplay` write it, from its row's own value (a histogram bin's count) and its series - and, on the plots that label one row each, that row, for the other fields of the data. */
42
- format?: (value: number, seriesName: string, row?: Record<string, unknown>) => string;
42
+ format?: (value: number, seriesName: string, row?: Record<string, unknown>, number?: (value: number) => string) => string;
43
43
  /** Hovering a label dims every other series, like toggling them off in the legend. */
44
44
  hoverIsolate?: boolean;
45
45
  fontStyle?: FontStyle;
@@ -18,8 +18,8 @@ export type DeltaConfig = NumberDisplay & {
18
18
  at: AxisValue;
19
19
  from: DeltaTarget;
20
20
  to?: DeltaTarget;
21
- /** The distance's text, in place of the number as the display fields above write it. */
22
- formatDiff?: (diff: number) => string;
21
+ /** The distance's text, in place of the distance. A number it returns is written as the display fields above ask (a percentage of a baseline, say); a string is shown as it is. */
22
+ formatDiff?: (diff: number) => string | number;
23
23
  stroke?: StrokeStyle;
24
24
  text?: FontStyle;
25
25
  dot?: DotStyle;
@@ -1,7 +1,12 @@
1
1
  import type { AreaStyle, DotStyle, FontStyle, StrokeStyle } from '../../plots/styling';
2
2
  import type { HoverDisplayPoint, HoverStrategy } from '../../layout/hover';
3
- /** In-SVG hover highlight: crosshair, dot and label. */
4
- export type HoverMarkerConfig = {
3
+ import type { NumberDisplay } from '../../layout/numbers';
4
+ /**
5
+ * In-SVG hover highlight: crosshair, dot and label. The label is the hovered value as `decimals`, `prefix`,
6
+ * `suffix` and `signDisplay` write it - as the plot writes it while none of them (nor `numbers.decimals`) is set -
7
+ * unless `format` writes it.
8
+ */
9
+ export type HoverMarkerConfig = NumberDisplay & {
5
10
  type: 'hover';
6
11
  series?: string;
7
12
  scope?: 'series' | 'segment';
@@ -13,7 +18,8 @@ export type HoverMarkerConfig = {
13
18
  areaX?: boolean;
14
19
  areaY?: boolean;
15
20
  showLabels?: boolean;
16
- format?: (point: HoverDisplayPoint) => string;
21
+ /** The label's text for a point, in place of the value. `number` writes a value as the display fields above ask. */
22
+ format?: (point: HoverDisplayPoint, number: (value: number) => string) => string;
17
23
  rule?: StrokeStyle;
18
24
  area?: AreaStyle;
19
25
  dot?: DotStyle;
package/docs/legend.md CHANGED
@@ -187,8 +187,8 @@ A list replaces the automatic legend with exactly those sections:
187
187
  Section types:
188
188
 
189
189
  - `discrete` - `items` (`name`, `label`, `color`, `symbol`, `filled`, `opacity`, `lineStyle`), `columns`, `layout` (`{ type: 'vertical' }`, `{ type: 'horizontal', maxRows }` paginated, `{ type: 'flex' }`), `interactive`, `navButton`.
190
- - `continuous` - `min`, `max`, `colorMin`, `colorMax`, `scheme`, `pivot`, `opacity`, `ticks`, `width`, `format`, `interactive`.
191
- - `size` - `domain`, `range`, `values`, `color`, `filled`, `symbol`, `format`.
190
+ - `continuous` - `min`, `max`, `colorMin`, `colorMax`, `scheme`, `pivot`, `opacity`, `ticks`, `width`, `decimals`, `prefix`, `suffix`, `signDisplay`, `format`, `interactive`.
191
+ - `size` - `domain`, `range`, `values`, `color`, `filled`, `symbol`, `decimals`, `prefix`, `suffix`, `signDisplay`, `format`.
192
192
  - `symbol` - `items` of `{ name, symbol }`, `color`, `filled`, `layout`.
193
193
 
194
194
  ## Placement and look
package/docs/markers.md CHANGED
@@ -48,7 +48,7 @@ A `text` marker's `text` is a string, a number, the name of a field of its rows,
48
48
  { type: 'text', data: rows, x: 'year', y: 'share', text: 'share', decimals: 1, suffix: ' %' }
49
49
  ```
50
50
 
51
- A `delta` writes its distance the same way, with one decimal unless `decimals` or `numbers.decimals` says otherwise. `formatDiff` writes it by hand instead.
51
+ A `delta` writes its distance the same way, with one decimal unless `decimals` or `numbers.decimals` says otherwise. `formatDiff` writes it by hand instead: a string it returns is shown as it is, and a number (a percentage of a baseline, say) is written as the display fields ask.
52
52
 
53
53
  ## Fields every marker shares
54
54
 
@@ -125,7 +125,7 @@ Every other marker draws from `data` rows, one mark per row, so it already takes
125
125
 
126
126
  ## Hover highlight
127
127
 
128
- `{ type: 'hover' }` draws a crosshair, a dot and a label at the hovered point. It makes the plot hoverable on its own; add `tooltip` for a floating box too. Fields: `strategy`, `sync`, `ruleX`, `ruleY`, `areaX`, `areaY` (a band highlight on band scales), `showLabels`, `format`, and the styles `rule`, `area`, `dot`, `text`. See [Tooltip and hover](tooltip-and-hover.md).
128
+ `{ type: 'hover' }` draws a crosshair, a dot and a label at the hovered point. It makes the plot hoverable on its own; add `tooltip` for a floating box too. Fields: `strategy`, `sync`, `ruleX`, `ruleY`, `areaX`, `areaY` (a band highlight on band scales), `showLabels`, `format`, the number fields `decimals`, `prefix`, `suffix` and `signDisplay` (they write the label's value; `format` gets a `number` function that does the same), and the styles `rule`, `area`, `dot`, `text`. See [Tooltip and hover](tooltip-and-hover.md).
129
129
 
130
130
  ## Custom components
131
131
 
package/docs/theming.md CHANGED
@@ -80,7 +80,7 @@ The same four fields set it where a plot needs something else, in `axis.locale`:
80
80
  | `suffix` | Text after the number, spaces included: `' %'` |
81
81
  | `signDisplay` | `'auto'` (default) writes only a minus, `'always'` also a plus on a positive number (never on zero), `'never'` no sign at all |
82
82
 
83
- They are fields of `scales.x` and `scales.y` (the ticks), of `tooltip`, of `styles.values` (value labels), and of a `text` and a `delta` marker. A tooltip writes the value on the plot's value axis - the `x` of horizontal bars - and a heatmap's color value; a `text` marker writes a number that its `text` gives (see [Markers](markers.md#numbers-in-text-and-delta-markers)).
83
+ They are fields of `scales.x` and `scales.y` (the ticks), of `tooltip`, of `styles.values` (value labels), of a `hover` marker (its label), and of a `text` and a `delta` marker. A tooltip writes the value on the plot's value axis - the `x` of horizontal bars - and a heatmap's color value; a `text` marker writes a number that its `text` gives (see [Markers](markers.md#numbers-in-text-and-delta-markers)).
84
84
 
85
85
  A `scales` axis keeps its automatic ticks (abbreviated when they span orders of magnitude) unless it sets `decimals`: `numbers.decimals` does not reach the ticks, since a fixed count would write `5,0` for `5` on every axis. `prefix` and `suffix` alone keep the automatic format. An axis with its own `tickFormat` or `percent` ignores them.
86
86
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "Declarative charts, maps and small multiples for Svelte, built on svelteplot.",
5
5
  "license": "MIT",
6
6
  "author": "Ezequiel Puerta",