@fundar/data-chart-telling 1.6.1 → 1.8.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,19 @@
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.8.0] - 2026-11-02
6
+
7
+ ### Added
8
+
9
+ - `scales.x.lines` and `scales.y.lines` write each label of a category axis on up to that many lines, split at its spaces so the lines are as even as they can be, and keep the `x` labels upright instead of turning them. A `tickFormat` is applied first.
10
+ - A band `y` axis whose `scales.y.tickFormat` gives a label on several lines (a list of strings, or a text with line breaks) writes it on those lines, and its left margin grows to the widest one. svelteplot's own axis joined the lines with spaces, so only the `x` axis could wrap its labels.
11
+
12
+ ## [1.7.0] - 2026-11-02
13
+
14
+ ### Added
15
+
16
+ - `legend.removeItems` leaves automatic items out of the series section by name: a series, or a segment or marker that names itself. A plot that colors its marks through `segments` with a `legend` of their own can list only those, without the one series they come from.
17
+
5
18
  ## [1.6.1] - 2026-11-02
6
19
 
7
20
  ### Fixed
@@ -14,6 +14,7 @@
14
14
  import { getConfiguration } from '../../configuration/config.svelte';
15
15
  import { SveltePlotScale } from '../scales/SveltePlotScale';
16
16
  import { TickLabels } from '../scales/TickLabels';
17
+ import { TickLines } from '../scales/TickLines';
17
18
  import ScalesLayout from '../scales/ScalesLayout.svelte';
18
19
  import GeometryLayout from '../geometry/GeometryLayout.svelte';
19
20
  import CoordinatesLayout from '../coordinates/CoordinatesLayout.svelte';
@@ -160,7 +161,12 @@
160
161
  x?: ReturnType<typeof TickLabels.layout>;
161
162
  y?: { every: number; values: string[] };
162
163
  } = {};
163
- if (bands.includes('x') && scales?.x?.tickRotate === undefined) {
164
+ // Labels split in lines are meant to stay upright, so they are not judged by how long one line would be.
165
+ if (
166
+ bands.includes('x') &&
167
+ scales?.x?.tickRotate === undefined &&
168
+ !((scales?.x?.lines ?? 1) > 1)
169
+ ) {
164
170
  out.x = TickLabels.layout(
165
171
  categories('x'),
166
172
  width - estimate.left - estimate.right,
@@ -180,11 +186,19 @@
180
186
  }
181
187
  return out;
182
188
  });
183
- /** `scales` with the rotation the tick layout chose for the x labels. */
189
+ /** An axis' scale with its labels split in the `lines` it asks for, as a `tickFormat` that gives them. */
190
+ function withLines<TScale extends AxisScale<TData> | undefined>(scale: TScale): TScale {
191
+ if (!scale?.lines || scale.lines < 2) return scale;
192
+ const { lines, tickFormat, ...rest } = scale;
193
+ const format = typeof tickFormat === 'function' ? tickFormat : undefined;
194
+ return { ...rest, tickFormat: TickLines.wrapped(format, lines) } as TScale;
195
+ }
196
+ /** `scales` with the rotation the tick layout chose for the x labels, and the labels split in lines. */
184
197
  const layoutScales = $derived.by(() => {
198
+ if (scales === undefined) return scales;
185
199
  const rotate = tickLayout?.x?.rotate;
186
- if (!rotate || scales === undefined) return scales;
187
- return { ...scales, x: { ...scales.x, tickRotate: rotate } };
200
+ const x = withLines(rotate ? { ...scales.x, tickRotate: rotate } : scales.x);
201
+ return { ...scales, ...(x ? { x } : {}), ...(scales.y ? { y: withLines(scales.y) } : {}) };
188
202
  });
189
203
  /** The category labels a crowded axis still writes: one in every few. */
190
204
  const tickValues = $derived.by(() => {
@@ -252,9 +266,9 @@
252
266
  return (label ? { ...out, label } : out) as SveltePlotProps['x'];
253
267
  });
254
268
  const resolvedY = $derived.by(() => {
255
- const built = SveltePlotScale.build(scales?.y, data, getY);
269
+ const built = SveltePlotScale.build(layoutScales?.y, data, getY);
256
270
  const domain = edgeDomains.get('y');
257
- const label = titleOfEmpty('y', scales?.y);
271
+ const label = titleOfEmpty('y', layoutScales?.y);
258
272
  const out = domain ? { ...built, domain, nice: false } : built;
259
273
  return (label ? { ...out, label } : out) as SveltePlotProps['y'];
260
274
  });
@@ -77,7 +77,8 @@ export declare class LegendState {
77
77
  * scale, each titled by its field name (see `legend.pretty`);
78
78
  * then `extension.sections`. A lone series, with nothing else to tell it
79
79
  * from, shows no discrete section; with other sections beside it, the
80
- * discrete one is titled `legend.seriesTitle`. A section whose adjustment sets
80
+ * discrete one is titled `legend.seriesTitle`. `extension.removeItems` leaves
81
+ * automatic items out of it by name. A section whose adjustment sets
81
82
  * `enabled: false` is left out.
82
83
  */
83
84
  sections(extension?: LegendExtension): LegendSection[];
@@ -195,7 +195,8 @@ export class LegendState {
195
195
  * scale, each titled by its field name (see `legend.pretty`);
196
196
  * then `extension.sections`. A lone series, with nothing else to tell it
197
197
  * from, shows no discrete section; with other sections beside it, the
198
- * discrete one is titled `legend.seriesTitle`. A section whose adjustment sets
198
+ * discrete one is titled `legend.seriesTitle`. `extension.removeItems` leaves
199
+ * automatic items out of it by name. A section whose adjustment sets
199
200
  * `enabled: false` is left out.
200
201
  */
201
202
  sections(extension) {
@@ -203,8 +204,10 @@ export class LegendState {
203
204
  const cfg = getConfiguration();
204
205
  const format = (title) => LegendText.of(title, cfg);
205
206
  const adjust = (channel, title) => LegendState.#adjustment(extension?.[channel], title, format);
207
+ const removed = new SvelteSet(extension?.removeItems);
206
208
  const items = [];
207
209
  const ramps = [];
210
+ let named = false;
208
211
  let interactive = false;
209
212
  for (const entry of entries) {
210
213
  if (entry.type === 'size') {
@@ -268,12 +271,14 @@ export class LegendState {
268
271
  }
269
272
  ramps.push(section);
270
273
  }
271
- else {
274
+ else if (!removed.has(entry.name)) {
272
275
  items.push(LegendState.#itemOf(entry));
276
+ if (entry.type === 'mark')
277
+ named = true;
273
278
  }
274
279
  }
275
280
  items.push(...(extension?.items ?? []));
276
- const named = entries.some((entry) => entry.type === 'mark') || !!extension?.items?.length;
281
+ named ||= !!extension?.items?.length;
277
282
  const others = [...ramps, ...(extension?.sections ?? [])];
278
283
  const title = cfg.legend.seriesTitle.enabled && others.length > 0;
279
284
  const discrete = items.length > 1 || (named && items.length > 0)
@@ -0,0 +1,101 @@
1
+ <script lang="ts">
2
+ import { getContext, untrack } from 'svelte';
3
+ import type { Writable } from 'svelte/store';
4
+ import { usePlot } from '../plot/usePlot';
5
+
6
+ /**
7
+ * The labels of a band y axis, each on the lines it was given. svelteplot's own axis joins the lines of a
8
+ * label with spaces, so an axis whose `tickFormat` gives several lines hides its labels and has them
9
+ * drawn here. The left margin grows to the widest line, as the axis does for its own labels.
10
+ */
11
+ let {
12
+ ticks,
13
+ lines,
14
+ tickSize,
15
+ tickPadding,
16
+ fontSize
17
+ }: {
18
+ ticks: unknown[];
19
+ /** The lines of each tick, in the order of `ticks`. */
20
+ lines: string[][];
21
+ tickSize: number;
22
+ tickPadding: number;
23
+ fontSize: number;
24
+ } = $props();
25
+
26
+ const LINE_HEIGHT = 1.15;
27
+
28
+ const plot = usePlot().svelteplot;
29
+ const marginLeft = $derived(Number(plot.options.marginLeft) || 0);
30
+
31
+ const placed = $derived(
32
+ ticks.map((tick, i) => {
33
+ const scale = plot.scales.y.fn;
34
+ return {
35
+ lines: lines[i] ?? [],
36
+ y: Number(scale(tick as never)) + (scale.bandwidth?.() ?? 0) / 2
37
+ };
38
+ })
39
+ );
40
+
41
+ let labels: (SVGTextElement | undefined)[] = $state([]);
42
+
43
+ const { autoMarginLeft } = getContext<{ autoMarginLeft: Writable<Map<string, number>> }>(
44
+ 'svelteplot/autoMargins'
45
+ );
46
+ const marginId = `dct-multiline-y-ticks-${Math.random().toString(36).slice(2)}`;
47
+
48
+ $effect(() => {
49
+ // Read so new labels or a new size measure again.
50
+ void [lines, fontSize, tickSize, tickPadding];
51
+ let frame: number | undefined;
52
+ let cancelled = false;
53
+ const measure = () => {
54
+ if (cancelled) return;
55
+ const widest = Math.max(
56
+ 0,
57
+ ...labels.map((label) => label?.getBoundingClientRect().width ?? 0)
58
+ );
59
+ const wanted = Math.ceil(widest) + Math.max(0, tickSize + tickPadding);
60
+ untrack(() => {
61
+ if ($autoMarginLeft.get(marginId) !== wanted) $autoMarginLeft.set(marginId, wanted);
62
+ });
63
+ };
64
+ // Text measures right only once its fonts load.
65
+ (document.fonts?.ready ?? Promise.resolve()).then(() => {
66
+ if (!cancelled) frame = requestAnimationFrame(measure);
67
+ });
68
+ return () => {
69
+ cancelled = true;
70
+ if (frame !== undefined) cancelAnimationFrame(frame);
71
+ };
72
+ });
73
+ $effect(() => () => {
74
+ $autoMarginLeft.delete(marginId);
75
+ });
76
+
77
+ const x = $derived(marginLeft - tickSize - tickPadding);
78
+ </script>
79
+
80
+ <g class="dct-multiline-y-ticks">
81
+ {#each placed as tick, i (i)}
82
+ <text
83
+ bind:this={labels[i]}
84
+ {x}
85
+ y={tick.y}
86
+ text-anchor="end"
87
+ font-size={fontSize}
88
+ fill="currentColor"
89
+ >
90
+ {#each tick.lines as line, l (l)}
91
+ <!-- The block of lines is centred on the tick: the first sits half the others above it. -->
92
+ <tspan
93
+ {x}
94
+ dy={l === 0
95
+ ? `${0.32 - ((tick.lines.length - 1) / 2) * LINE_HEIGHT}em`
96
+ : `${LINE_HEIGHT}em`}>{line}</tspan
97
+ >
98
+ {/each}
99
+ </text>
100
+ {/each}
101
+ </g>
@@ -0,0 +1,11 @@
1
+ type $$ComponentProps = {
2
+ ticks: unknown[];
3
+ /** The lines of each tick, in the order of `ticks`. */
4
+ lines: string[][];
5
+ tickSize: number;
6
+ tickPadding: number;
7
+ fontSize: number;
8
+ };
9
+ declare const MultilineYTicks: import("svelte").Component<$$ComponentProps, {}, "">;
10
+ type MultilineYTicks = ReturnType<typeof MultilineYTicks>;
11
+ export default MultilineYTicks;
@@ -6,6 +6,8 @@
6
6
  import { getConfiguration } from '../../configuration/config.svelte';
7
7
  import { NiceTicks } from './NiceTicks';
8
8
  import { EdgeTicks } from './EdgeTicks';
9
+ import { TickLines } from './TickLines';
10
+ import MultilineYTicks from './MultilineYTicks.svelte';
9
11
  import { DISCRETE_SCALE_TYPES } from '../../types/layout/scales';
10
12
  import type { AxisBasedScalesConfig } from '../../types/layout/scales';
11
13
  import type { AxisValue } from '../../types/layout/mapping';
@@ -200,6 +202,18 @@
200
202
  unknown
201
203
  >;
202
204
 
205
+ /**
206
+ * The labels of a band y axis whose `tickFormat` writes some on several lines. svelteplot's axis joins the
207
+ * lines of a label with spaces, so such an axis hides its labels and `MultilineYTicks` draws them.
208
+ */
209
+ const multilineY = $derived.by(() => {
210
+ const format = scales?.y?.tickFormat;
211
+ if (typeof format !== 'function' || plot.scales.y.type !== 'band') return undefined;
212
+ const ticks: unknown[] = tickValues?.y ?? plot.scales.y.fn?.domain?.() ?? [];
213
+ const lines = TickLines.ofTicks(ticks, format);
214
+ return TickLines.isMultiline(lines) ? { ticks, lines } : undefined;
215
+ });
216
+
203
217
  const yTitleFontSize = $derived(scales?.y?.titleFontSize ?? cfg.axis.titleFontSize);
204
218
 
205
219
  /**
@@ -327,6 +341,7 @@
327
341
  {#snippet axisY()}
328
342
  <AxisY
329
343
  ticks={(yEdgeTicks?.ticks ?? tickValues?.y) as string[] | undefined}
344
+ tickFormat={multilineY ? () => '' : undefined}
330
345
  tickCount={yTickCount}
331
346
  interval={yTickInterval}
332
347
  tickSize={scales?.y?.tickSize ?? cfg.axis.yTickSize}
@@ -339,6 +354,15 @@
339
354
  <g style:visibility="hidden">{@render axisY()}</g>
340
355
  {:else if scales?.y?.axisDisplay !== 'none'}
341
356
  {@render axisY()}
357
+ {#if multilineY}
358
+ <MultilineYTicks
359
+ ticks={multilineY.ticks}
360
+ lines={multilineY.lines}
361
+ tickSize={scales?.y?.tickSize ?? cfg.axis.yTickSize}
362
+ tickPadding={scales?.y?.tickPadding ?? cfg.axis.yTickPadding}
363
+ fontSize={cfg.axis.fontSize}
364
+ />
365
+ {/if}
342
366
  {/if}
343
367
  {#if verticalYLabel && (scales?.y?.axisDisplay ?? 'visible') === 'visible'}
344
368
  <text
@@ -0,0 +1,19 @@
1
+ /** What a `tickFormat` returns for one tick, as the lines it is written on. */
2
+ export declare class TickLines {
3
+ /** The lines of `label`: a list as it is, a text split at its line breaks. */
4
+ static of(label: string | string[]): string[];
5
+ /** The lines of every tick of `ticks` under `format`. */
6
+ static ofTicks(ticks: unknown[], format: (d: unknown, index: number, ticks: unknown[]) => string | string[]): string[][];
7
+ /**
8
+ * `label` on up to `lines` lines, split at its spaces so that the longest line is as short as it can be
9
+ * (ties go to the earlier break). A label of one word, or `lines` below 2, stays on one line.
10
+ */
11
+ static split(label: string, lines: number): string[];
12
+ /**
13
+ * A `tickFormat` that gives the lines of its tick: `format`'s text (or the tick as it is, with none) split in
14
+ * `lines`, and a list `format` gives as it is.
15
+ */
16
+ static wrapped(format: ((d: unknown, index: number, ticks: unknown[]) => string | string[]) | undefined, lines: number): (d: unknown, index: number, ticks: unknown[]) => string[];
17
+ /** Whether some tick needs more than one line. */
18
+ static isMultiline(lines: string[][]): boolean;
19
+ }
@@ -0,0 +1,58 @@
1
+ /** What a `tickFormat` returns for one tick, as the lines it is written on. */
2
+ export class TickLines {
3
+ /** The lines of `label`: a list as it is, a text split at its line breaks. */
4
+ static of(label) {
5
+ return Array.isArray(label) ? label : String(label).split('\n');
6
+ }
7
+ /** The lines of every tick of `ticks` under `format`. */
8
+ static ofTicks(ticks, format) {
9
+ return ticks.map((tick, i) => TickLines.of(format(tick, i, ticks)));
10
+ }
11
+ /**
12
+ * `label` on up to `lines` lines, split at its spaces so that the longest line is as short as it can be
13
+ * (ties go to the earlier break). A label of one word, or `lines` below 2, stays on one line.
14
+ */
15
+ static split(label, lines) {
16
+ const words = label.split(' ').filter((word) => word !== '');
17
+ const count = Math.min(Math.floor(lines), words.length);
18
+ if (count < 2)
19
+ return [label];
20
+ const width = (from, to) => words.slice(from, to).reduce((sum, word) => sum + word.length, 0) + (to - from - 1);
21
+ // best[k][i]: the shortest the longest line can be when the first i words take k lines, and where the last begins.
22
+ const best = Array.from({ length: count + 1 }, () => []);
23
+ for (let i = 1; i <= words.length; i++)
24
+ best[1][i] = { cost: width(0, i), from: 0 };
25
+ for (let k = 2; k <= count; k++) {
26
+ for (let i = k; i <= words.length; i++) {
27
+ let choice = { cost: Infinity, from: k - 1 };
28
+ for (let from = k - 1; from < i; from++) {
29
+ const cost = Math.max(best[k - 1][from].cost, width(from, i));
30
+ if (cost < choice.cost)
31
+ choice = { cost, from };
32
+ }
33
+ best[k][i] = choice;
34
+ }
35
+ }
36
+ const out = [];
37
+ for (let k = count, end = words.length; k >= 1; k--) {
38
+ const { from } = best[k][end];
39
+ out.unshift(words.slice(from, end).join(' '));
40
+ end = from;
41
+ }
42
+ return out;
43
+ }
44
+ /**
45
+ * A `tickFormat` that gives the lines of its tick: `format`'s text (or the tick as it is, with none) split in
46
+ * `lines`, and a list `format` gives as it is.
47
+ */
48
+ static wrapped(format, lines) {
49
+ return (d, index, ticks) => {
50
+ const text = format ? format(d, index, ticks) : String(d);
51
+ return Array.isArray(text) ? text : TickLines.split(text, lines);
52
+ };
53
+ }
54
+ /** Whether some tick needs more than one line. */
55
+ static isMultiline(lines) {
56
+ return lines.some((tick) => tick.length > 1);
57
+ }
58
+ }
@@ -311,6 +311,7 @@ export type LegendExtension = {
311
311
  */
312
312
  highlight?: string | string[];
313
313
  items?: LegendItem[];
314
+ removeItems?: string[];
314
315
  sections?: LegendSection[];
315
316
  series?: Omit<DiscreteLegendSection, 'type' | 'items'>;
316
317
  color?: ColorOverride | ColorOverride[];
@@ -169,6 +169,7 @@ export type AxisScale<TData = Record<string, unknown>> = {
169
169
  tickSize?: number;
170
170
  tickPadding?: number;
171
171
  tickRotate?: number;
172
+ lines?: number;
172
173
  tickFormat?: false | Intl.NumberFormatOptions | Intl.DateTimeFormatOptions | ((d: unknown, index: number, ticks: unknown[]) => string | string[]);
173
174
  };
174
175
  /** Each {@link AxisChannelName}'s own scale-config shape - both use {@link AxisScale}. */
package/docs/legend.md CHANGED
@@ -110,6 +110,14 @@ legend={{ series: { enabled: false } }} // no series secti
110
110
  legend={{ color: { match: 'temperature', enabled: false } }} // not that ramp
111
111
  ```
112
112
 
113
+ ### Removing items
114
+
115
+ `removeItems` takes automatic items out of the series section by name - a series, or a segment or marker by the label it names itself with. A name nothing carries is ignored, and items added with `items` stay:
116
+
117
+ ```ts
118
+ legend={{ removeItems: ['2017/18'] }}
119
+ ```
120
+
113
121
  ### Turning series off from code
114
122
 
115
123
  `off` turns series off as if the reader had clicked them, without a click: a list of names (each dimmed, to `legend.disabledOpacity`), or a name → opacity or name → action map, where an action is the same `{ mode: 'dim' | 'hide' | 'omit' }` a toggle does. `highlight` is its inverse: it keeps the series it names on and dims all the others. An `off` entry for a series wins over `highlight`.
package/docs/scales.md CHANGED
@@ -50,7 +50,8 @@ Each axis takes an `AxisScale`.
50
50
  | `edgeTicks` | Ticks at the data's first and last values beside the nice ones. `false` turns them off, `true` on, `{ collision: 'drop-nice' \| 'drop-edge' \| 'extend' }` picks what happens when one crowds a nice tick. Defaults to `axis.edgeTicks.x` / `.y` in the [configuration](theming.md#edge-ticks) (on for x, off for y); ignored when the axis sets `ticks`, `tickSpacing`, `interval` or `domain` |
51
51
  | `label`, `labelAnchor`, `labelOrientation`, `labelPosition`, `titleFontSize` | The axis title (`label: false` hides it) |
52
52
  | `ticks`, `tickSpacing`, `tickSize`, `tickPadding`, `tickRotate` | Tick placement and size |
53
- | `tickFormat` | `false`, an `Intl.NumberFormatOptions` / `Intl.DateTimeFormatOptions` object, or `(d, index, ticks) => string` |
53
+ | `tickFormat` | `false`, an `Intl.NumberFormatOptions` / `Intl.DateTimeFormatOptions` object, or `(d, index, ticks) => string \| string[]` |
54
+ | `lines` | Labels of a category axis on up to this many lines, split at their spaces |
54
55
  | `grid` | `true` or a stroke style for gridlines |
55
56
  | `axisDisplay` | `'visible'` (default), `'hidden'` (keeps the space, so aligned panels stay aligned) or `'none'` |
56
57
  | `padding`, `paddingInner`, `paddingOuter`, `align` | Band and point spacing |
@@ -58,6 +59,8 @@ Each axis takes an `AxisScale`.
58
59
  | `interval`, `base`, `constant` | Time/discrete intervals, log base, symlog constant |
59
60
  | `sort` | Category order: a comparator of two rows, or by what the categories hold (below) |
60
61
 
62
+ A category axis writes its labels on several lines with `lines`: `lines: 2` splits each label at its spaces, as evenly as it can, in up to two lines (a `tickFormat` is applied first), and the `x` labels stay upright. To split them where you choose, `tickFormat` returns a list of strings, one for each line, or a text with line breaks. A band `y` axis grows its left margin to the widest line.
63
+
61
64
  A category axis whose labels do not fit side by side turns them by itself: to 45 degrees, else to 90, and when even that is not enough it writes only every few labels (and a vertical category axis, such as the one of horizontal bars, does the same). The margin grows to fit the turned labels. An axis with a `tickRotate` of its own - 0 included, which keeps the labels flat - is left as the author set it, and `axis.autoRotate: false` in the [configuration](theming.md) turns this off for every plot.
62
65
 
63
66
  A category axis can be ordered by what its categories hold, without writing a comparator: `sort: { by: 'value', series?, order? }` orders them by their value in one `series` (or, without it, by the sum of their values) and `sort: { by: 'range', order? }` by the spread between their lowest and their highest value; `order` is `'ascending'` (default) or `'descending'`. `grouped: true` orders within each series instead of across all of them: the series stay together, in the order they first appear. On a plot with intervals (`lower`/`upper`) the range counts the interval's ends. A category with nothing to order by goes last. An explicit `domain` wins. With [facets](facets.md) the order is read off every panel's rows, so the panels share it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "1.6.1",
3
+ "version": "1.8.0",
4
4
  "description": "Declarative charts, maps and small multiples for Svelte, built on svelteplot.",
5
5
  "license": "MIT",
6
6
  "author": "Ezequiel Puerta",