@fundar/data-chart-telling 1.12.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/configuration/config.svelte.js +1 -0
  3. package/dist/index.d.ts +1 -0
  4. package/dist/layout/base/BasePlotLayout.svelte +5 -2
  5. package/dist/layout/hover/HoverSettings.d.ts +1 -0
  6. package/dist/layout/hover/HoverSettings.js +2 -1
  7. package/dist/layout/legend/AxisNumberFormat.d.ts +11 -1
  8. package/dist/layout/legend/AxisNumberFormat.js +28 -2
  9. package/dist/layout/legend/ContinuousSection.svelte +1 -1
  10. package/dist/layout/legend/SizeSection.svelte +1 -1
  11. package/dist/layout/scales/TickNumbers.d.ts +22 -0
  12. package/dist/layout/scales/TickNumbers.js +54 -0
  13. package/dist/layout/styles/ValueLabels.js +9 -1
  14. package/dist/layout/tooltip/TooltipState.svelte.d.ts +5 -4
  15. package/dist/layout/tooltip/TooltipState.svelte.js +18 -13
  16. package/dist/markers/delta/DeltaPoint.svelte +7 -2
  17. package/dist/markers/hover/HoverMarker.svelte +16 -1
  18. package/dist/markers/hover/HoverMarker.svelte.d.ts +1 -1
  19. package/dist/markers/text/TextMarker.svelte +22 -4
  20. package/dist/phases/hover/HoverPhase.js +3 -1
  21. package/dist/phases/tooltip/TooltipPhase.d.ts +5 -4
  22. package/dist/phases/tooltip/TooltipPhase.js +5 -3
  23. package/dist/plots/evolution/area/defaults.js +1 -1
  24. package/dist/plots/evolution/line/defaults.js +1 -1
  25. package/dist/plots/evolution/slope/defaults.js +1 -1
  26. package/dist/plots/evolution/sparkline/defaults.js +1 -1
  27. package/dist/plots/map/geo/phases/hover.js +3 -1
  28. package/dist/types/configuration/styling.d.ts +5 -0
  29. package/dist/types/layout/legend.d.ts +11 -5
  30. package/dist/types/layout/numbers.d.ts +15 -0
  31. package/dist/types/layout/numbers.js +1 -0
  32. package/dist/types/layout/scales.d.ts +5 -2
  33. package/dist/types/layout/styles/values.d.ts +4 -3
  34. package/dist/types/layout/tooltip.d.ts +9 -4
  35. package/dist/types/markers/axis/delta.d.ts +8 -3
  36. package/dist/types/markers/axis/hover.d.ts +9 -3
  37. package/dist/types/markers/basic/text.d.ts +8 -2
  38. package/dist/types/phases/styles/phase.d.ts +2 -2
  39. package/dist/types/plots/props.d.ts +1 -1
  40. package/dist/utils/NumberFormat.d.ts +25 -0
  41. package/dist/utils/NumberFormat.js +45 -0
  42. package/docs/legend.md +2 -2
  43. package/docs/markers.md +11 -1
  44. package/docs/scales.md +2 -0
  45. package/docs/styles-and-segments.md +12 -11
  46. package/docs/theming.md +21 -0
  47. package/docs/tooltip-and-hover.md +2 -0
  48. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
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
+
14
+ ## [1.13.0] - 2026-11-04
15
+
16
+ ### Added
17
+
18
+ - `decimals`, `prefix`, `suffix` and `signDisplay` (`'auto'`, `'always'` or `'never'`) write the numbers a plot shows, in `axis.locale`: on `scales.x` and `scales.y` (the ticks), `tooltip`, `styles.values` (value labels), and the `text` and `delta` markers. `'always'` writes a plus on positives, never on zero. `numbers.decimals` in the configuration sets the count everywhere but the ticks, which keep their automatic format unless `scales` sets `decimals`. A tooltip writes the value on the plot's value axis - the `x` of horizontal bars, not only the `y` - and a heatmap's color value.
19
+ - A `text` marker's `text` can be a number, the name of a numeric field of the rows, or a function giving a number, besides a string: a number is written as the marker's display fields ask.
20
+ - A tooltip's `format` gets a second argument, `number`, that writes a number as the tooltip's display fields ask. The `format` of a plot kind's value labels (`ValueLabelsOptions.format`) gets it too.
21
+
22
+ ### Changed
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.
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.
26
+
5
27
  ## [1.12.0] - 2026-11-04
6
28
 
7
29
  ### Added
@@ -65,6 +87,7 @@ All notable changes to this project are documented here. The format follows [Kee
65
87
 
66
88
  ### Fixed
67
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.
68
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.
69
92
 
70
93
  ## [1.6.0] - 2026-11-02
@@ -65,6 +65,7 @@ export const DEFAULT_CONFIG = {
65
65
  margins: { top: 'auto', right: 'auto', bottom: 'auto', left: 'auto' },
66
66
  frameClip: { tolerance: 2 },
67
67
  // ── Features ────────────────────────────────────────────────────────────────
68
+ numbers: {},
68
69
  axis: {
69
70
  color: 'currentColor',
70
71
  fontSize: 14,
package/dist/index.d.ts CHANGED
@@ -35,6 +35,7 @@ export { resolveSymbolScale } from './utils/SymbolScale';
35
35
  export { Statistics } from './phases/statistics/Statistics';
36
36
  export type { AxisValue, ChannelValue, ChannelName, AxisChannelName, IndependentChannelName, SizeChannelName, ChannelAccessors } from './types/layout/mapping';
37
37
  export type { AxisScale, AxisBasedScalesConfig, ChannelScalesConfig } from './types/layout/scales';
38
+ export type { NumberDisplay } from './types/layout/numbers';
38
39
  export type { Accessor, Series, SeriesProps, DataProps } from './types/plots/data/common';
39
40
  export type { BasePlotProps, PlotCoreProps, PlotChromeProps, FigureBoxProps, SegmentsConfig, ScalesConfig, MarginConfig, TooltipConfig, HoverConfig } from './types/plots/props';
40
41
  export type { AxisBasedStylesConfig, ConnectorBasedStylesConfig, BulletStylesConfig, GapsAwarePlotStylesConfig, CurveAwarePlotStylesConfig, GeoStylesConfig, GeoTileLayerConfig } from './types/layout/styles';
@@ -15,6 +15,7 @@
15
15
  import { SveltePlotScale } from '../scales/SveltePlotScale';
16
16
  import { TickLabels } from '../scales/TickLabels';
17
17
  import { TickLines } from '../scales/TickLines';
18
+ import { TickNumbers } from '../scales/TickNumbers';
18
19
  import ScalesLayout from '../scales/ScalesLayout.svelte';
19
20
  import GeometryLayout from '../geometry/GeometryLayout.svelte';
20
21
  import CoordinatesLayout from '../coordinates/CoordinatesLayout.svelte';
@@ -197,8 +198,10 @@
197
198
  const layoutScales = $derived.by(() => {
198
199
  if (scales === undefined) return scales;
199
200
  const rotate = tickLayout?.x?.rotate;
200
- const x = withLines(rotate ? { ...scales.x, tickRotate: rotate } : scales.x);
201
- return { ...scales, ...(x ? { x } : {}), ...(scales.y ? { y: withLines(scales.y) } : {}) };
201
+ const written = (scale: AxisScale<TData> | undefined) =>
202
+ withLines(TickNumbers.apply(scale as AxisScale<never> | undefined, cfg) as typeof scale);
203
+ const x = written(rotate ? { ...scales.x, tickRotate: rotate } : scales.x);
204
+ return { ...scales, ...(x ? { x } : {}), ...(scales.y ? { y: written(scales.y) } : {}) };
202
205
  });
203
206
  /** The category labels a crowded axis still writes: one in every few. */
204
207
  const tickValues = $derived.by(() => {
@@ -30,5 +30,6 @@ export declare class HoverSettings<TRow extends Record<string, unknown>> {
30
30
  label: (row: TRow) => string;
31
31
  defaultStrategy: HoverStrategy;
32
32
  pointMatching?: PointMatchingStrategy;
33
+ number?: (value: number) => string;
33
34
  }): HoverConfig<TRow>;
34
35
  }
@@ -34,6 +34,7 @@ export class HoverSettings {
34
34
  /** A plot's `hover` config; its tooltip reads as `format` unless it sets its own. */
35
35
  static buildConfig(args) {
36
36
  const { tooltip, format, sort, markers, points, label, defaultStrategy, pointMatching } = args;
37
+ const number = args.number ?? String;
37
38
  const settings = new HoverSettings(tooltip, markers, defaultStrategy);
38
39
  return {
39
40
  active: settings.active,
@@ -43,7 +44,7 @@ export class HoverSettings {
43
44
  sync: settings.sync,
44
45
  tooltip: tooltip && {
45
46
  ...tooltip,
46
- format: tooltip.format ?? format,
47
+ format: tooltip.format ? (row) => tooltip.format(row, number) : format,
47
48
  ...(sort ? { sort } : {})
48
49
  },
49
50
  ...(pointMatching ? { pointMatching } : {})
@@ -1,4 +1,6 @@
1
1
  import type { PlotStylesConfig } from '../../types/configuration/styling';
2
+ import { NumberFormat } from '../../utils/NumberFormat';
3
+ import type { NumberDisplay } from '../../types/layout/numbers';
2
4
  /** Numbers as the plots' axes write them, by `axis.locale` and `axis.compactDisplay`. */
3
5
  export declare class AxisNumberFormat {
4
6
  /**
@@ -6,7 +8,15 @@ export declare class AxisNumberFormat {
6
8
  * ticks are: compact (`1,2 mil`, `1.2K`) when the domain crosses zero or
7
9
  * spans more than one order of magnitude, plain otherwise.
8
10
  */
9
- static of(domain: [number, number], cfg: Pick<PlotStylesConfig, 'axis'>): (value: number) => string;
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;
10
20
  /** Whether ticks across `domain` read compact. */
11
21
  static compact([min, max]: [number, number]): boolean;
12
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
  /**
@@ -5,14 +6,39 @@ export class AxisNumberFormat {
5
6
  * ticks are: compact (`1,2 mil`, `1.2K`) when the domain crosses zero or
6
7
  * spans more than one order of magnitude, plain otherwise.
7
8
  */
8
- static of(domain, cfg) {
9
+ static of(domain, cfg, signDisplay) {
9
10
  const format = new Intl.NumberFormat(cfg.axis.locale, {
10
11
  ...(AxisNumberFormat.compact(domain) ? { notation: 'compact' } : {}),
11
12
  useGrouping: 'min2',
12
- compactDisplay: cfg.axis.compactDisplay
13
+ compactDisplay: cfg.axis.compactDisplay,
14
+ ...(signDisplay ? { signDisplay } : {})
13
15
  });
14
16
  return (value) => format.format(value);
15
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
+ }
16
42
  /** Whether ticks across `domain` read compact. */
17
43
  static compact([min, max]) {
18
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);
@@ -0,0 +1,22 @@
1
+ import type { PlotStylesConfig } from '../../types/configuration/styling';
2
+ import type { NumberDisplay } from '../../types/layout/numbers';
3
+ import type { AxisScale } from '../../types/layout/scales';
4
+ type Config = Pick<PlotStylesConfig, 'axis' | 'numbers'>;
5
+ type TickFormat = (d: unknown, index: number, ticks: unknown[]) => string;
6
+ /** An axis' ticks written as the scale's `decimals`, `prefix`, `suffix` and `signDisplay` ask. */
7
+ export declare class TickNumbers {
8
+ /** Whether `scale` sets any of the display fields. */
9
+ static isSet(scale: NumberDisplay | undefined): boolean;
10
+ /**
11
+ * `scale` with its display fields turned into a `tickFormat`, unless it sets one of its own (which wins) or reads
12
+ * as a `percent`. The fields are taken out either way.
13
+ */
14
+ static apply<TScale extends AxisScale<never> | undefined>(scale: TScale, cfg: Config): TScale;
15
+ /**
16
+ * A `tickFormat` for `display`. With `decimals`, every tick has exactly that many; without, ticks read as the
17
+ * axis writes them on its own (compact when they span orders of magnitude) - the configuration's
18
+ * `numbers.decimals` does not reach the ticks.
19
+ */
20
+ static format(display: NumberDisplay, cfg: Config): TickFormat;
21
+ }
22
+ export {};
@@ -0,0 +1,54 @@
1
+ import { AxisNumberFormat } from '../legend/AxisNumberFormat';
2
+ import { NumberFormat } from '../../utils/NumberFormat';
3
+ /** An axis' ticks written as the scale's `decimals`, `prefix`, `suffix` and `signDisplay` ask. */
4
+ export class TickNumbers {
5
+ /** Whether `scale` sets any of the display fields. */
6
+ static isSet(scale) {
7
+ return (scale?.decimals !== undefined ||
8
+ !!scale?.prefix ||
9
+ !!scale?.suffix ||
10
+ scale?.signDisplay !== undefined);
11
+ }
12
+ /**
13
+ * `scale` with its display fields turned into a `tickFormat`, unless it sets one of its own (which wins) or reads
14
+ * as a `percent`. The fields are taken out either way.
15
+ */
16
+ static apply(scale, cfg) {
17
+ if (!scale)
18
+ return scale;
19
+ const { decimals, prefix, suffix, signDisplay, ...rest } = scale;
20
+ const display = { decimals, prefix, suffix, signDisplay };
21
+ if (!TickNumbers.isSet(display))
22
+ return scale;
23
+ if (rest.tickFormat !== undefined || rest.percent)
24
+ return rest;
25
+ return { ...rest, tickFormat: TickNumbers.format(display, cfg) };
26
+ }
27
+ /**
28
+ * A `tickFormat` for `display`. With `decimals`, every tick has exactly that many; without, ticks read as the
29
+ * axis writes them on its own (compact when they span orders of magnitude) - the configuration's
30
+ * `numbers.decimals` does not reach the ticks.
31
+ */
32
+ static format(display, cfg) {
33
+ const { prefix = '', suffix = '' } = display;
34
+ const own = { ...cfg, numbers: {} };
35
+ if (display.decimals !== undefined) {
36
+ const fixed = NumberFormat.of(display, own);
37
+ return (d) => (typeof d === 'number' ? fixed(d) : String(d));
38
+ }
39
+ // As svelteplot's automatic format, judged by the ticks' own range, once per set of ticks.
40
+ let ticks;
41
+ let write = String;
42
+ const sign = NumberFormat.sign(display);
43
+ return (d, _index, all) => {
44
+ if (typeof d !== 'number')
45
+ return String(d);
46
+ if (ticks !== all) {
47
+ const numbers = all.filter((t) => typeof t === 'number' && Number.isFinite(t));
48
+ ticks = all;
49
+ write = AxisNumberFormat.of([Math.min(...numbers), Math.max(...numbers)], cfg, sign);
50
+ }
51
+ return `${prefix}${write(d)}${suffix}`;
52
+ };
53
+ }
54
+ }
@@ -5,6 +5,7 @@ import { SERIES_ID_KEY, SERIES_KEY } from '../../phases/transforms/TransformsPha
5
5
  import { seriesIdOf } from '../../utils/grouping';
6
6
  import { isGapValue } from '../../plots/utils/gaps';
7
7
  import { PlotAxes } from '../../phases/data/PlotAxes';
8
+ import { NumberFormat } from '../../utils/NumberFormat';
8
9
  /**
9
10
  * A plot's value labels: the author's `styles.values` over the plot kind's
10
11
  * own defaults, how their text measures, where they are expected to sit
@@ -30,7 +31,14 @@ export class ValueLabels {
30
31
  constructor(values, cfg, options = {}, reach) {
31
32
  this.show = values?.show ?? options.show ?? false;
32
33
  this.anchor = values?.anchor ?? options.anchor ?? 'outside';
33
- this.format = values?.format ?? options.format ?? ((v) => `${v}`);
34
+ // The value as the display fields ask (else as it is), which a plot kind's own text builds on.
35
+ const number = NumberFormat.maybe(NumberFormat.displayOf(values), cfg) ?? ((v) => `${v}`);
36
+ const kindFormat = options.format;
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);
34
42
  this.hoverIsolate = values?.hoverIsolate ?? false;
35
43
  this.font = values?.fontStyle;
36
44
  this.fontSize = typeof this.font?.fontSize === 'number' ? this.font.fontSize : 12;
@@ -43,14 +43,15 @@ export declare class TooltipState {
43
43
  * the order the hover matched them in.
44
44
  */
45
45
  static descending<TRow>(valueOf: (row: TRow) => unknown): (a: TRow, b: TRow) => number;
46
- /** A value as plain text: a date as a locale date, anything else as itself. */
47
- static value(value: unknown): string;
46
+ /** A value as plain text: a date as a locale date, a number as `number` writes it (when given), anything else as itself. */
47
+ static value(value: unknown, number?: (value: number) => string): string;
48
48
  /**
49
49
  * How a hovered row reads by default, on every plot: its series, when the
50
50
  * plot groups its rows; then where it sits - `category: value` when one
51
51
  * axis is a band of categories (the row's own value, as its series reads
52
52
  * it), else `x: y` as the transforms left them; then the value its color
53
- * shows, when that is a number (a heatmap's cells).
53
+ * shows, when that is a number (a heatmap's cells). With `number`, the values - the one on the value axis
54
+ * (the plot's `y` when neither axis holds categories) and the color's - read as it writes them.
54
55
  */
55
56
  static default(state: {
56
57
  processedData?: ProcessedData;
@@ -63,5 +64,5 @@ export declare class TooltipState {
63
64
  };
64
65
  };
65
66
  axes?: PlotAxes;
66
- }): (row: ProcessedRow) => string;
67
+ }, number?: (value: number) => string): (row: ProcessedRow) => string;
67
68
  }
@@ -62,31 +62,36 @@ export class TooltipState {
62
62
  return right - left;
63
63
  };
64
64
  }
65
- /** A value as plain text: a date as a locale date, anything else as itself. */
66
- static value(value) {
67
- return value instanceof Date ? value.toLocaleDateString() : String(value);
65
+ /** A value as plain text: a date as a locale date, a number as `number` writes it (when given), anything else as itself. */
66
+ static value(value, number) {
67
+ if (value instanceof Date)
68
+ return value.toLocaleDateString();
69
+ return number && typeof value === 'number' ? number(value) : String(value);
68
70
  }
69
71
  /**
70
72
  * How a hovered row reads by default, on every plot: its series, when the
71
73
  * plot groups its rows; then where it sits - `category: value` when one
72
74
  * axis is a band of categories (the row's own value, as its series reads
73
75
  * it), else `x: y` as the transforms left them; then the value its color
74
- * shows, when that is a number (a heatmap's cells).
76
+ * shows, when that is a number (a heatmap's cells). With `number`, the values - the one on the value axis
77
+ * (the plot's `y` when neither axis holds categories) and the color's - read as it writes them.
75
78
  */
76
- static default(state) {
79
+ static default(state, number) {
77
80
  const processed = state.processedData;
78
81
  if (!processed)
79
82
  return () => '';
80
83
  const x = TooltipState.#axisText(processed, 'x');
81
84
  const y = TooltipState.#axisText(processed, 'y');
85
+ // With no band axis, the value is the y.
86
+ const valueY = TooltipState.#axisText(processed, 'y', number);
82
87
  const bandX = state.axes?.bands.includes('x') ?? false;
83
88
  const bandY = state.axes?.bands.includes('y') ?? false;
84
89
  const valueOf = ValueLabels.valueOf(state);
85
90
  const position = bandX && !bandY
86
- ? (row) => `${x(row)}: ${TooltipState.value(valueOf(row))}`
91
+ ? (row) => `${x(row)}: ${TooltipState.value(valueOf(row), number)}`
87
92
  : bandY && !bandX
88
- ? (row) => `${y(row)}: ${TooltipState.value(valueOf(row))}`
89
- : (row) => `${x(row)}: ${y(row)}`;
93
+ ? (row) => `${y(row)}: ${TooltipState.value(valueOf(row), number)}`
94
+ : (row) => `${x(row)}: ${valueY(row)}`;
90
95
  const grouped = state.channels?.grouping !== undefined;
91
96
  const color = state.channels?.color;
92
97
  const colorName = typeof color?.source === 'string' ? color.source : 'color';
@@ -95,7 +100,7 @@ export class TooltipState {
95
100
  lines.push(position(row));
96
101
  const shown = color?.accessor(row);
97
102
  if (typeof shown === 'number' && Number.isFinite(shown)) {
98
- lines.push(`${colorName}: ${TooltipState.value(shown)}`);
103
+ lines.push(`${colorName}: ${TooltipState.value(shown, number)}`);
99
104
  }
100
105
  return lines.join('\n');
101
106
  };
@@ -104,13 +109,13 @@ export class TooltipState {
104
109
  * How one axis reads on a processed row: its own channel, else the range
105
110
  * the transforms gave it - a stack's own share (`2 - 1`), any other span as `1-2`.
106
111
  */
107
- static #axisText(processed, axis) {
112
+ static #axisText(processed, axis, number) {
108
113
  const { channels, transforms } = processed;
109
114
  const read = (channel) => resolveAccessor(channel);
110
115
  const point = channels[axis];
111
116
  if (point !== undefined) {
112
117
  const value = read(point);
113
- return (row) => TooltipState.value(value(row));
118
+ return (row) => TooltipState.value(value(row), number);
114
119
  }
115
120
  const low = channels[`${axis}1`];
116
121
  const high = channels[`${axis}2`];
@@ -119,8 +124,8 @@ export class TooltipState {
119
124
  const from = read(low);
120
125
  const to = read(high);
121
126
  if (transforms.some((t) => t.type === STACKS[axis])) {
122
- return (row) => TooltipState.value(Number(to(row)) - Number(from(row)));
127
+ return (row) => TooltipState.value(Number(to(row)) - Number(from(row)), number);
123
128
  }
124
- return (row) => `${TooltipState.value(from(row))}-${TooltipState.value(to(row))}`;
129
+ return (row) => `${TooltipState.value(from(row), number)}-${TooltipState.value(to(row), number)}`;
125
130
  }
126
131
  }
@@ -14,7 +14,8 @@
14
14
  const plot = usePlot();
15
15
 
16
16
  const resolved = $derived(Delta.build(marker.axis, data, marker));
17
- const label = $derived(getConfiguration().delta.text[marker.axis]);
17
+ const cfg = $derived(getConfiguration());
18
+ const label = $derived(cfg.delta.text[marker.axis]);
18
19
  const lineAnchor = $derived(marker.text?.lineAnchor ?? label.lineAnchor);
19
20
 
20
21
  const clampRange = $derived.by((): [number, number] | undefined => {
@@ -97,7 +98,11 @@
97
98
  type: 'text',
98
99
  x: coords.textPt.x,
99
100
  y: coords.textPt.y,
100
- text: (marker.formatDiff ?? ((v: number) => v.toFixed(1)))(coords.diff),
101
+ text: marker.formatDiff ? marker.formatDiff(coords.diff) : coords.diff,
102
+ decimals: marker.decimals ?? cfg.numbers.decimals ?? 1,
103
+ prefix: marker.prefix,
104
+ suffix: marker.suffix,
105
+ signDisplay: marker.signDisplay,
101
106
  styles: {
102
107
  fontWeight: 'bold',
103
108
  fontSize: 14,
@@ -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;
@@ -6,13 +6,16 @@
6
6
  import { RowOffset } from '../utils/RowOffset';
7
7
  import RowOffsetMarks from '../utils/RowOffsetMarks.svelte';
8
8
  import { Declutter } from './Declutter';
9
+ import { NumberFormat } from '../../utils/NumberFormat';
9
10
  import { PlotMargins } from '../../layout/margins/PlotMargins.svelte';
10
11
  import { Box } from '../../layout/margins/Box';
11
12
  import type { TextMarkerConfig } from '../../types/markers/common';
12
13
  import type { TextAnchor } from '../../types/plots/constants';
13
14
 
14
15
  /**
15
- * Renders one `'text'` marker. `x`/`y` also accept a literal number or Date.
16
+ * Renders one `'text'` marker. `x`/`y` also accept a literal number or Date. A number its `text`
17
+ * gives - a number, a numeric field of the rows, a function's result - is written as the marker's
18
+ * `decimals`, `prefix`, `suffix` and `signDisplay` (over the configuration's `numbers`) ask.
16
19
  * A marker that declutters, flips at the edges or listens to the pointer
17
20
  * draws one label per row, inside the plot's content box. With
18
21
  * `fitMargins`, the plot's margins grow until the labels fit.
@@ -34,6 +37,7 @@
34
37
  const x = $derived(marker.x);
35
38
  const y = $derived(marker.y);
36
39
  const text = $derived(marker.text ?? '');
40
+ const numberText = $derived(NumberFormat.of(NumberFormat.displayOf(marker), cfg));
37
41
  const styles = $derived(marker.styles);
38
42
 
39
43
  // A label placed by a literal (or by the frame) draws once without rows; one placed by a field of its rows draws nothing.
@@ -109,8 +113,19 @@
109
113
  return () => value;
110
114
  }
111
115
 
116
+ /** What a row's `text` comes to: a function's result, a field of the row, else the text itself. */
117
+ function valueOf(d: TData): unknown {
118
+ if (typeof text === 'function') return text(d);
119
+ if (typeof text === 'string' && typeof d === 'object' && d !== null && text in d) {
120
+ return (d as Record<string, unknown>)[text];
121
+ }
122
+ return text;
123
+ }
124
+
125
+ /** A row's text, a number written as the marker asks. */
112
126
  function textOf(d: TData): string {
113
- return typeof text === 'function' ? text(d) : text;
127
+ const value = valueOf(d);
128
+ return typeof value === 'number' ? numberText(value) : String(value ?? '');
114
129
  }
115
130
 
116
131
  function opacityOf(d: TData): number | undefined {
@@ -195,7 +210,10 @@
195
210
  x={x ?? (marker.frameAnchor ? undefined : 0)}
196
211
  y={y ?? (marker.frameAnchor ? undefined : 0)}
197
212
  frameAnchor={marker.frameAnchor}
198
- text={text ?? ''}
213
+ text={((d: TData) => {
214
+ const value = valueOf(d);
215
+ return typeof value === 'number' ? numberText(value) : value;
216
+ }) as (d: TData) => string}
199
217
  dx={projected ? 0 : rowDx}
200
218
  dy={projected ? 0 : rowDy}
201
219
  fill={fill as never}
@@ -278,7 +296,7 @@
278
296
  {/if}
279
297
  {/snippet}
280
298
 
281
- {#if text}
299
+ {#if text !== ''}
282
300
  {#if measured}
283
301
  <g bind:this={groupEl}>{@render labels()}</g>
284
302
  {:else}
@@ -2,6 +2,7 @@ import { ProcessedPositions } from '../transforms/ProcessedPositions';
2
2
  import { GAP_VALUE, SERIES_KEY } from '../transforms/TransformsPhase';
3
3
  import { HoverSettings } from '../../layout/hover/HoverSettings';
4
4
  import { PlotAxes } from '../data/PlotAxes';
5
+ import { NumberFormat } from '../../utils/NumberFormat';
5
6
  import { isGapValue } from '../../plots/utils/gaps';
6
7
  import { LegendState } from '../../layout/legend/LegendState.svelte';
7
8
  /**
@@ -50,7 +51,8 @@ export class HoverPhase {
50
51
  points: points,
51
52
  label: label,
52
53
  defaultStrategy: options.strategy ?? HoverSettings.strategyOf(state.axes),
53
- pointMatching: options.pointMatching
54
+ pointMatching: options.pointMatching,
55
+ number: NumberFormat.of(NumberFormat.displayOf(state.tooltip), ctx.cfg)
54
56
  });
55
57
  const hitAreas = options.hitAreas?.(state, hover, ctx) ?? [];
56
58
  return {
@@ -1,14 +1,15 @@
1
- import type { AxisPlotState, PhaseStage } from '../../types/phases';
1
+ import type { AxisPlotCtx, AxisPlotState, PhaseStage } from '../../types/phases';
2
2
  type AnyAxisState = AxisPlotState<any, any, any, any>;
3
3
  /**
4
4
  * The "tooltip" phase every axis-based plot shares: how a hovered row reads,
5
5
  * as `tooltipFormat` - the same default on every plot, {@link TooltipState.default} -
6
6
  * and the order its rows read in, as `tooltipSort`: highest value first, by
7
- * each row's own value (a stacked row's share, not its running total). It
7
+ * each row's own value (a stacked row's share, not its running total). The value reads as the tooltip's
8
+ * `decimals`, `prefix`, `suffix` and `signDisplay` ask. It
8
9
  * runs after the scales, which say which axis holds categories.
9
10
  */
10
- export declare class TooltipPhase<TState extends AnyAxisState> implements PhaseStage<TState> {
11
+ export declare class TooltipPhase<TState extends AnyAxisState> implements PhaseStage<TState, AxisPlotCtx> {
11
12
  readonly phase: "tooltip";
12
- run: (state: TState) => TState;
13
+ run: (state: TState, ctx: AxisPlotCtx) => TState;
13
14
  }
14
15
  export {};
@@ -1,17 +1,19 @@
1
1
  import { TooltipState } from '../../layout/tooltip/TooltipState.svelte';
2
2
  import { ValueLabels } from '../../layout/styles/ValueLabels';
3
+ import { NumberFormat } from '../../utils/NumberFormat';
3
4
  /**
4
5
  * The "tooltip" phase every axis-based plot shares: how a hovered row reads,
5
6
  * as `tooltipFormat` - the same default on every plot, {@link TooltipState.default} -
6
7
  * and the order its rows read in, as `tooltipSort`: highest value first, by
7
- * each row's own value (a stacked row's share, not its running total). It
8
+ * each row's own value (a stacked row's share, not its running total). The value reads as the tooltip's
9
+ * `decimals`, `prefix`, `suffix` and `signDisplay` ask. It
8
10
  * runs after the scales, which say which axis holds categories.
9
11
  */
10
12
  export class TooltipPhase {
11
13
  phase = 'tooltip';
12
- run = (state) => ({
14
+ run = (state, ctx) => ({
13
15
  ...state,
14
- tooltipFormat: TooltipState.default(state),
16
+ tooltipFormat: TooltipState.default(state, NumberFormat.maybe(NumberFormat.displayOf(state.tooltip), ctx.cfg)),
15
17
  tooltipSort: TooltipState.descending(ValueLabels.valueOf(state))
16
18
  });
17
19
  }
@@ -12,7 +12,7 @@ export function buildAreaDefaults() {
12
12
  styles: {
13
13
  values: {
14
14
  anchor: 'start',
15
- format: (v, name) => `${name} - ${v}`,
15
+ format: (v, name, number) => `${name} - ${number(v)}`,
16
16
  reach: (labels, state) => labels.reachAtSeriesEnds(state)
17
17
  }
18
18
  },
@@ -23,7 +23,7 @@ export function buildLineDefaults() {
23
23
  styles: {
24
24
  values: {
25
25
  anchor: 'start',
26
- format: (v, name) => `${name} - ${v}`,
26
+ format: (v, name, number) => `${name} - ${number(v)}`,
27
27
  reach: (labels, state) => labels.reachAtSeriesEnds(state)
28
28
  }
29
29
  },
@@ -23,7 +23,7 @@ export function buildSlopeDefaults() {
23
23
  styles: {
24
24
  values: {
25
25
  anchor: 'start',
26
- format: (v, name) => `${name} - ${v}`
26
+ format: (v, name, number) => `${name} - ${number(v)}`
27
27
  }
28
28
  },
29
29
  markers: { marks: buildMarks, valueLabels: buildValueLabels }
@@ -15,7 +15,7 @@ export function buildSparklineDefaults() {
15
15
  values: {
16
16
  ...line.styles?.values,
17
17
  show: true,
18
- format: (value) => `${value}`
18
+ format: (value, _name, number) => number(value)
19
19
  }
20
20
  },
21
21
  markers: { marks: buildMarks, valueLabels: line.markers.valueLabels }
@@ -1,5 +1,6 @@
1
1
  import { LegendState } from '../../../../layout/legend/LegendState.svelte';
2
2
  import { HoverSettings } from '../../../../layout/hover/HoverSettings';
3
+ import { NumberFormat } from '../../../../utils/NumberFormat';
3
4
  /** Whether a pointer event is over an `inset` marker's panel, which has its own hover. */
4
5
  export function isOverInset(e) {
5
6
  return e.target?.closest?.('.dct-geo-inset') != null;
@@ -26,7 +27,8 @@ export function buildHover(state, ctx) {
26
27
  label,
27
28
  defaultStrategy: 'punctual',
28
29
  // Match the feature under the cursor, not the nearest centroid.
29
- pointMatching: 'contains'
30
+ pointMatching: 'contains',
31
+ number: NumberFormat.of(NumberFormat.displayOf(tooltip), ctx.cfg)
30
32
  }),
31
33
  // A map has no axes for a crosshair: 'punctual' only means an exact match.
32
34
  strategy: 'punctual',
@@ -105,6 +105,11 @@ export type PlotStylesConfig = {
105
105
  */
106
106
  tolerance: number | string;
107
107
  };
108
+ /** How numbers read wherever a plot writes one, other than the automatic ticks. */
109
+ numbers: {
110
+ /** Exactly this many decimals in tooltips, value labels and text and delta markers. Unset (the default), as many as each number needs. An axis' ticks keep their automatic format unless `scales` sets `decimals`. */
111
+ decimals?: number;
112
+ };
108
113
  axis: {
109
114
  color: string;
110
115
  fontSize: number;
@@ -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
@@ -0,0 +1,15 @@
1
+ /**
2
+ * How a number reads where this package writes one: a tooltip, a value label, a text or delta marker, an
3
+ * axis' ticks. Each part is optional; what a place leaves unset takes the configuration's `numbers`
4
+ * (only `decimals`), else the natural reading in `axis.locale`.
5
+ */
6
+ export type NumberDisplay = {
7
+ /** Exactly this many decimals (`1` writes `12` as `12,0`). Unset, the configuration's `numbers.decimals`, else as many as the number needs. */
8
+ decimals?: number;
9
+ /** Text before the number, spaces included (`'US$ '`). */
10
+ prefix?: string;
11
+ /** Text after the number, spaces included (`' %'`). */
12
+ suffix?: string;
13
+ /** The sign: `'auto'` (default) writes only a minus; `'always'` also writes a plus on a positive number - never on zero; `'never'` writes none. */
14
+ signDisplay?: 'auto' | 'always' | 'never';
15
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,4 +1,5 @@
1
1
  import type { SymbolType } from '../plots/constants';
2
+ import type { NumberDisplay } from './numbers';
2
3
  import type { AxisChannelName, IndependentChannelName } from './mapping';
3
4
  /** A continuous or categorical color ramp. */
4
5
  export type ColorScaleConfig = {
@@ -124,10 +125,12 @@ export type CategorySort = {
124
125
  };
125
126
  /**
126
127
  * Scale overrides for one plot axis - svelteplot's `ScaleOptions` and
127
- * `XScaleOptions`, plus this package's own fields. `TData` is the row type
128
+ * `XScaleOptions`, plus this package's own fields. `decimals`, `prefix`, `suffix` and `signDisplay` write the
129
+ * ticks of a numeric axis ({@link NumberDisplay}); they are ignored when `tickFormat` or `percent` is set. Without `decimals`
130
+ * the ticks keep their automatic format, abbreviated when they span orders of magnitude. `TData` is the row type
128
131
  * {@link AxisScale.sort} compares.
129
132
  */
130
- export type AxisScale<TData = Record<string, unknown>> = {
133
+ export type AxisScale<TData = Record<string, unknown>> = NumberDisplay & {
131
134
  type?: 'auto' | ContinuousScaleType | TemporalScaleType | DiscreteScaleType | SequentialScaleType | DivergingScaleType | BinnedScaleType;
132
135
  domain?: (number | string | boolean | Date | null)[];
133
136
  range?: (number | string | boolean | Date | null)[];
@@ -1,5 +1,6 @@
1
1
  import type { ValueAnchor } from '../../plots/constants';
2
2
  import type { FontStyle, StrokeStyle } from '../../plots/styling';
3
+ import type { NumberDisplay } from '../numbers';
3
4
  /**
4
5
  * Which of a group's rows get a value label: its first or last, or the one
5
6
  * with the lowest or highest value (read on the plot's value axis). The group
@@ -18,7 +19,7 @@ export type ValueSelect = 'first' | 'last' | 'min' | 'max';
18
19
  * provided - mirrors {@link DeltaConfig}'s `fontStyle`/`strokeStyle` split, so
19
20
  * label text and connector-line styling never mix into one flat bag of props.
20
21
  */
21
- export type ValuesStyle = {
22
+ export type ValuesStyle = NumberDisplay & {
22
23
  /** Whether the labels are drawn. Off by default, except on a heatmap. */
23
24
  show?: boolean;
24
25
  /** Where each label sits on its mark - see {@link ValueAnchor}. */
@@ -37,8 +38,8 @@ export type ValuesStyle = {
37
38
  * CSS color writes them all in it. `fontStyle.fill` wins when it is set.
38
39
  */
39
40
  color?: 'series' | (string & {});
40
- /** A label's text, 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. */
41
- format?: (value: number, seriesName: string, row?: Record<string, unknown>) => string;
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>, number?: (value: number) => string) => string;
42
43
  /** Hovering a label dims every other series, like toggling them off in the legend. */
43
44
  hoverIsolate?: boolean;
44
45
  fontStyle?: FontStyle;
@@ -1,5 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { HoverStrategy } from './hover';
3
+ import type { NumberDisplay } from './numbers';
3
4
  /**
4
5
  * Preferred horizontal placement of the tooltip beside the cursor; it moves
5
6
  * to the other side when it would overflow. Defaults to `'right'`.
@@ -11,14 +12,18 @@ export type TooltipAnchorY = 'top' | 'middle' | 'bottom';
11
12
  export type TooltipRow<TRow> = TRow & {
12
13
  seriesColor?: string;
13
14
  };
14
- /** Author-facing tooltip option on the chart kinds. `true` = enable with defaults. */
15
- export type TooltipOptions<TRow extends Record<string, unknown>> = {
15
+ /**
16
+ * Author-facing tooltip option on the chart kinds. `true` = enable with defaults. `decimals`, `prefix`,
17
+ * `suffix` and `signDisplay` write the value the default text shows - the one on the plot's value axis (the
18
+ * x of a horizontal bar chart), and a heatmap's color value - and the numbers a custom `format` writes with its `number`.
19
+ */
20
+ export type TooltipOptions<TRow extends Record<string, unknown>> = NumberDisplay & {
16
21
  /** Matching strategy. Defaults to the chart kind's own default. */
17
22
  strategy?: HoverStrategy;
18
23
  /** Highlight the hovered point in every synced plot too. Defaults to `false`. */
19
24
  sync?: boolean;
20
- /** Custom tooltip text for a row (supports `\n`). Defaults per chart kind. */
21
- format?: (row: TRow) => string;
25
+ /** Custom tooltip text for a row (supports `\n`). Defaults per chart kind. `number` writes a value as the display fields above ask. */
26
+ format?: (row: TRow, number: (value: number) => string) => string;
22
27
  /** Named subset of crossing series to include. Omitted = all matched. */
23
28
  series?: string[];
24
29
  /** Custom tooltip body; replaces `format` when given. Its rows carry a `seriesColor` - see {@link TooltipRow}. */
@@ -1,5 +1,6 @@
1
1
  import type { AxisValue } from '../../layout/mapping';
2
2
  import type { DotStyle, FontStyle, StrokeStyle } from '../../plots/styling';
3
+ import type { NumberDisplay } from '../../layout/numbers';
3
4
  /** Which endpoint a delta measures from or to: a named series, or a fixed axis value. */
4
5
  export type DeltaTarget = {
5
6
  type: 'series';
@@ -8,13 +9,17 @@ export type DeltaTarget = {
8
9
  type: 'axis';
9
10
  value?: number;
10
11
  };
11
- /** Distance between two points on a line chart, along one axis. */
12
- export type DeltaConfig = {
12
+ /**
13
+ * Distance between two points on a line chart, along one axis. The distance reads as {@link NumberDisplay}
14
+ * asks - `decimals` (default one), `prefix`, `suffix` and `signDisplay` - unless `formatDiff` writes it.
15
+ */
16
+ export type DeltaConfig = NumberDisplay & {
13
17
  axis: 'x' | 'y';
14
18
  at: AxisValue;
15
19
  from: DeltaTarget;
16
20
  to?: DeltaTarget;
17
- 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;
18
23
  stroke?: StrokeStyle;
19
24
  text?: FontStyle;
20
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;
@@ -4,6 +4,7 @@ import type { CommonMarkerConfigWithRowOffset } from '../channels/commonMarkerCo
4
4
  import type { Margins } from '../../../layout/margins/Margins';
5
5
  import type { BandSlice } from '../../layout/hover';
6
6
  import type { TextAnchor } from '../../plots/constants';
7
+ import type { NumberDisplay } from '../../layout/numbers';
7
8
  /**
8
9
  * Spreads labels that would overlap apart vertically, all reaching out to one
9
10
  * shared column, each routed back to its own point by an elbow connector.
@@ -26,7 +27,12 @@ export type TextDeclutterConfig = {
26
27
  /** A text label per row (svelteplot's `Text`), usable on any plot kind. */
27
28
  export type TextMarkerConfig = CommonMarkerConfigWithRowOffset<{
28
29
  type: 'text';
29
- text: string | ((d: any) => string);
30
+ /**
31
+ * What each label says: a text; a number; the name of a field of the rows (a number there reads as a number); or a function of
32
+ * the row giving a text or a number. A number is written as {@link NumberDisplay} asks - this marker's own
33
+ * `decimals`, `prefix`, `suffix` and `signDisplay`, over the configuration's `numbers`.
34
+ */
35
+ text: string | number | ((d: any) => string | number);
30
36
  styles?: FontStyle;
31
37
  /** Which side of its point the text sits on: a value, or a function of the row. Wins over `styles.textAnchor`. */
32
38
  textAnchor?: TextAnchor | ((d: any) => TextAnchor);
@@ -54,7 +60,7 @@ export type TextMarkerConfig = CommonMarkerConfigWithRowOffset<{
54
60
  onPointerEnter?: (d: any) => void;
55
61
  /** Fired when the pointer leaves a row's label. */
56
62
  onPointerLeave?: (d: any) => void;
57
- } & CommonMarkerConfig>;
63
+ } & NumberDisplay & CommonMarkerConfig>;
58
64
  /** Where one decluttered label goes - see `Declutter.buildLanes`. */
59
65
  export type LabelLane = {
60
66
  /** Vertical shift (px) clearing its neighbours and the content box's edges. */
@@ -7,8 +7,8 @@ export type ValueLabelsOptions<TState> = {
7
7
  show?: boolean;
8
8
  /** Where a label sits when `styles.values.anchor` is unset. Defaults to `'outside'`. */
9
9
  anchor?: ValueAnchor;
10
- /** A label's text when `styles.values.format` is unset. Defaults to the bare value. */
11
- format?: (value: number, seriesName: string) => string;
10
+ /** A label's text when `styles.values.format` is unset. Defaults to the bare value. `number` writes a value as the plot's `styles.values` display fields (`decimals`, `prefix`, `suffix`, `signDisplay`) ask. */
11
+ format?: (value: number, seriesName: string, number: (value: number) => string) => string;
12
12
  /**
13
13
  * Where the labels are expected to sit, from the whole data: boxes around
14
14
  * the points they label, on the plot edges those points can reach (see
@@ -52,7 +52,7 @@ export type HoverConfig<TRow extends Record<string, unknown>> = {
52
52
  /** Resolved sync flag for the *local* hover store (ignored once a shared store is in scope). */
53
53
  sync?: boolean;
54
54
  /** The resolved tooltip: its `format`, and the order its text reads the rows in (a `content` snippet gets them as matched). */
55
- tooltip?: TooltipConfig<TRow> & {
55
+ tooltip?: Omit<TooltipConfig<TRow>, 'format'> & {
56
56
  format: (row: TRow) => string;
57
57
  sort?: (a: TRow, b: TRow) => number;
58
58
  };
@@ -0,0 +1,25 @@
1
+ import type { PlotStylesConfig } from '../types/configuration/styling';
2
+ import type { NumberDisplay } from '../types/layout/numbers';
3
+ declare const SIGNS: {
4
+ readonly auto: "auto";
5
+ readonly always: "exceptZero";
6
+ readonly never: "never";
7
+ };
8
+ type Config = Pick<PlotStylesConfig, 'axis' | 'numbers'>;
9
+ /** Numbers as a {@link NumberDisplay} writes them, in the configuration's `axis.locale`. */
10
+ export declare class NumberFormat {
11
+ /** Whether `display`, or the configuration's `numbers.decimals`, asks for anything: when not, a number is left as the plot writes it by default. */
12
+ static isSet(display: NumberDisplay | undefined, cfg: Config): boolean;
13
+ /** `display`'s sign as `Intl.NumberFormat` names it. */
14
+ static sign(display: NumberDisplay | undefined): (typeof SIGNS)[keyof typeof SIGNS];
15
+ /**
16
+ * A formatter for numbers as `display` asks, over the configuration. `fallbackDecimals` is the count of a
17
+ * place with a default of its own, used when neither `display` nor the configuration sets one.
18
+ */
19
+ static of(display: NumberDisplay | undefined, cfg: Config, fallbackDecimals?: number): (value: number) => string;
20
+ /** {@link NumberFormat.of}, or `undefined` while nothing asks for it ({@link NumberFormat.isSet}). */
21
+ static maybe(display: NumberDisplay | undefined, cfg: Config): ((value: number) => string) | undefined;
22
+ /** The fields of `source` that are a {@link NumberDisplay}'s, for a config that carries them beside its own. */
23
+ static displayOf(source: NumberDisplay | boolean | undefined): NumberDisplay;
24
+ }
25
+ export {};
@@ -0,0 +1,45 @@
1
+ // `'always'` never writes `+0`: Intl's `'exceptZero'`.
2
+ const SIGNS = { auto: 'auto', always: 'exceptZero', never: 'never' };
3
+ /** Numbers as a {@link NumberDisplay} writes them, in the configuration's `axis.locale`. */
4
+ export class NumberFormat {
5
+ /** Whether `display`, or the configuration's `numbers.decimals`, asks for anything: when not, a number is left as the plot writes it by default. */
6
+ static isSet(display, cfg) {
7
+ return ((display?.decimals ?? cfg.numbers.decimals) !== undefined ||
8
+ !!display?.prefix ||
9
+ !!display?.suffix ||
10
+ display?.signDisplay !== undefined);
11
+ }
12
+ /** `display`'s sign as `Intl.NumberFormat` names it. */
13
+ static sign(display) {
14
+ return SIGNS[display?.signDisplay ?? 'auto'];
15
+ }
16
+ /**
17
+ * A formatter for numbers as `display` asks, over the configuration. `fallbackDecimals` is the count of a
18
+ * place with a default of its own, used when neither `display` nor the configuration sets one.
19
+ */
20
+ static of(display, cfg, fallbackDecimals) {
21
+ const decimals = display?.decimals ?? cfg.numbers.decimals ?? fallbackDecimals;
22
+ const format = new Intl.NumberFormat(cfg.axis.locale, {
23
+ ...(decimals === undefined
24
+ ? {}
25
+ : { minimumFractionDigits: decimals, maximumFractionDigits: decimals }),
26
+ signDisplay: NumberFormat.sign(display)
27
+ });
28
+ const { prefix = '', suffix = '' } = display ?? {};
29
+ return (value) => `${prefix}${format.format(value)}${suffix}`;
30
+ }
31
+ /** {@link NumberFormat.of}, or `undefined` while nothing asks for it ({@link NumberFormat.isSet}). */
32
+ static maybe(display, cfg) {
33
+ return NumberFormat.isSet(display, cfg) ? NumberFormat.of(display, cfg) : undefined;
34
+ }
35
+ /** The fields of `source` that are a {@link NumberDisplay}'s, for a config that carries them beside its own. */
36
+ static displayOf(source) {
37
+ const own = typeof source === 'object' ? source : undefined;
38
+ return {
39
+ decimals: own?.decimals,
40
+ prefix: own?.prefix,
41
+ suffix: own?.suffix,
42
+ signDisplay: own?.signDisplay
43
+ };
44
+ }
45
+ }
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
@@ -40,6 +40,16 @@
40
40
 
41
41
  Markers draw in array order. Type a markers array with `satisfies AxisBasedMarkersConfig[]` (or `GeoMarkersConfig[]`) to get completion.
42
42
 
43
+ ### Numbers in `text` and `delta` markers
44
+
45
+ A `text` marker's `text` is a string, a number, the name of a field of its rows, or a function of the row giving a string or a number. A number - given, read from a field, or returned - is written as the marker's own `decimals`, `prefix`, `suffix` and `signDisplay` ask ([Number format](theming.md#number-format)); a string is drawn as it is.
46
+
47
+ ```ts
48
+ { type: 'text', data: rows, x: 'year', y: 'share', text: 'share', decimals: 1, suffix: ' %' }
49
+ ```
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: 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
+
43
53
  ## Fields every marker shares
44
54
 
45
55
  | Field | Meaning |
@@ -115,7 +125,7 @@ Every other marker draws from `data` rows, one mark per row, so it already takes
115
125
 
116
126
  ## Hover highlight
117
127
 
118
- `{ 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).
119
129
 
120
130
  ## Custom components
121
131
 
package/docs/scales.md CHANGED
@@ -67,6 +67,8 @@ A category axis can be ordered by what its categories hold, without writing a co
67
67
 
68
68
  Number and date text defaults follow `axis.locale` and `axis.compactDisplay` in the [configuration](theming.md#locale).
69
69
 
70
+ A numeric axis takes `prefix`, `suffix`, `decimals` and `signDisplay` to write its ticks (`suffix: '%'`, `prefix: 'US$ '`) without a `tickFormat`; see [Number format](theming.md#number-format). A `tickFormat` of its own wins over them.
71
+
70
72
  ## Color
71
73
 
72
74
  `scales.color` configures the `color` channel (and a choropleth on a map).
@@ -46,17 +46,18 @@
46
46
 
47
47
  ![End-of-line value labels](images/value-labels.png)
48
48
 
49
- | Field | Meaning |
50
- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
51
- | `show` | Draws the labels. Off by default, except on a heatmap |
52
- | `anchor` | `'outside'`, `'start'`, `'middle'`, `'end'` - where a label sits on its mark |
53
- | `select` | Which row of each group is labelled: `'first'`, `'last'`, `'min'`, `'max'`, or a list of them. A group is a series on a line or area plot (default `'last'`), a category on the plots that join dots (default: every dot) |
54
- | `series` | Labels only these series, by name |
55
- | `color` | `'series'` writes each label in its series' color; any other CSS color writes them all in it (`fontStyle.fill` wins) |
56
- | `format` | `(value, seriesName, row) => string`; `row` is the labelled row, for its other fields |
57
- | `hoverIsolate` | Hovering a label dims every other series |
58
- | `fontStyle` | Text style: `fontSize`, `fill`, `dx`, `dy`, `textAnchor`, `lineAnchor`, `rotate`, `class`, ... Overrides the anchor's placement field by field |
59
- | `strokeStyle` | Style of the connector lines drawn from labels that had to move apart |
49
+ | Field | Meaning |
50
+ | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
51
+ | `show` | Draws the labels. Off by default, except on a heatmap |
52
+ | `anchor` | `'outside'`, `'start'`, `'middle'`, `'end'` - where a label sits on its mark |
53
+ | `select` | Which row of each group is labelled: `'first'`, `'last'`, `'min'`, `'max'`, or a list of them. A group is a series on a line or area plot (default `'last'`), a category on the plots that join dots (default: every dot) |
54
+ | `series` | Labels only these series, by name |
55
+ | `color` | `'series'` writes each label in its series' color; any other CSS color writes them all in it (`fontStyle.fill` wins) |
56
+ | `decimals`, `prefix`, `suffix`, `signDisplay` | How the value reads, over `numbers.decimals` ([Number format](theming.md#number-format)); `format` replaces them |
57
+ | `format` | `(value, seriesName, row) => string`; `row` is the labelled row, for its other fields |
58
+ | `hoverIsolate` | Hovering a label dims every other series |
59
+ | `fontStyle` | Text style: `fontSize`, `fill`, `dx`, `dy`, `textAnchor`, `lineAnchor`, `rotate`, `class`, ... Overrides the anchor's placement field by field |
60
+ | `strokeStyle` | Style of the connector lines drawn from labels that had to move apart |
60
61
 
61
62
  Where the labels go depends on the plot: the end of each series on a line or area plot, one per bar on a bar, pyramid or histogram, one per point on a scatter, one per cell on a heatmap. On lines, overlapping labels spread apart, share one column and are joined to their points by elbow connectors; the right margin grows to fit them when it is `'auto'`. The spread is tuned by `text.declutter` in the [configuration](theming.md).
62
63
 
package/docs/theming.md CHANGED
@@ -63,6 +63,27 @@ setConfiguration({ axis: { locale: 'en-US', compactDisplay: 'long' } });
63
63
 
64
64
  `axis.locale` updates plots already on screen. `axis.compactDisplay` is read when a plot mounts, so a later change shows on the next mount.
65
65
 
66
+ ## Number format
67
+
68
+ `numbers.decimals` fixes how many decimals the numbers a plot writes have: tooltips, value labels, `text` and `delta` markers. Unset (the default), each number has as many as it needs.
69
+
70
+ ```ts
71
+ setConfiguration({ numbers: { decimals: 1 } }); // 12 reads as 12,0
72
+ ```
73
+
74
+ The same four fields set it where a plot needs something else, in `axis.locale`:
75
+
76
+ | Field | Meaning |
77
+ | ------------- | ----------------------------------------------------------------------------------------------------------------------------- |
78
+ | `decimals` | Exactly this many decimals, over `numbers.decimals` |
79
+ | `prefix` | Text before the number, spaces included: `'US$ '` |
80
+ | `suffix` | Text after the number, spaces included: `' %'` |
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
+
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
+
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
+
66
87
  ## Edge ticks
67
88
 
68
89
  `axis.edgeTicks.x` and `axis.edgeTicks.y` (`{ enabled, collision }`, on for x and off for y, both `'drop-nice'`) add a tick at the data's first and last values beside the nice ones, on a continuous axis whose other axis is continuous too, so a reader sees where the data starts and ends. When one lands too close to a nice tick for both labels, `collision` picks what gives way: `'drop-nice'` keeps the data's tick, `'drop-edge'` keeps the nice one, `'extend'` widens the axis to the next nice tick past the data.
@@ -43,6 +43,8 @@ A plot becomes interactive when it is given a `tooltip`, a `{ type: 'hover' }` m
43
43
  | `series` | Only these series appear in the tooltip |
44
44
  | `anchorX`, `anchorY` | Preferred side of the cursor: `'left' \| 'middle' \| 'right'` (default `'right'`) and `'top' \| 'middle' \| 'bottom'` (default `'bottom'`). It moves to the other side when it would overflow |
45
45
 
46
+ `decimals`, `prefix`, `suffix` and `signDisplay` ([Number format](theming.md#number-format)) write the value the default text shows - the one on the value axis, the `x` of horizontal bars - and a heatmap's color value. A custom `format` gets a second argument, `number`, that writes a number the same way: `format: (row, number) => `${row.region}: ${number(row.value)}``.
47
+
46
48
  <!-- example: examples/CustomTooltip.svelte -->
47
49
 
48
50
  ```svelte
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "1.12.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",