@fundar/data-chart-telling 1.2.0 → 1.4.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 +23 -0
- package/README.md +2 -1
- package/dist/configuration/config.svelte.js +12 -0
- package/dist/layout/facet/scope.d.ts +40 -0
- package/dist/layout/facet/scope.js +104 -0
- package/dist/layout/figure/Figure.svelte +5 -0
- package/dist/layout/legend/DiscreteSymbolIcon.svelte +38 -14
- package/dist/layout/legend/LegendState.svelte.d.ts +11 -0
- package/dist/layout/legend/LegendState.svelte.js +56 -6
- package/dist/layout/margins/PlotMargins.svelte.d.ts +9 -5
- package/dist/layout/margins/PlotMargins.svelte.js +65 -6
- package/dist/layout/plot/Plot.svelte +22 -2
- package/dist/layout/scales/ScalesLayout.svelte +21 -0
- package/dist/markers/delta/Delta.d.ts +6 -1
- package/dist/markers/delta/Delta.js +15 -0
- package/dist/markers/delta/DeltaMarker.svelte +9 -87
- package/dist/markers/delta/DeltaPoint.svelte +97 -0
- package/dist/markers/delta/DeltaPoint.svelte.d.ts +29 -0
- package/dist/markers/rule/RuleMarker.svelte +5 -2
- package/dist/phases/facet/FacetPhase.d.ts +2 -1
- package/dist/phases/facet/FacetPhase.js +19 -4
- package/dist/phases/statistics/StatisticsPhase.js +9 -1
- package/dist/plots/utils/segments.d.ts +4 -1
- package/dist/plots/utils/segments.js +19 -6
- package/dist/types/configuration/styling.d.ts +8 -0
- package/dist/types/layout/facet.d.ts +18 -0
- package/dist/types/layout/legend.d.ts +16 -0
- package/dist/types/layout/plot.d.ts +2 -0
- package/dist/types/markers/axis/delta.d.ts +15 -2
- package/dist/types/markers/axis/rule.d.ts +9 -2
- package/dist/types/phases/facet/phase.d.ts +4 -0
- package/dist/types/plots/props.d.ts +6 -3
- package/dist/types/plots/segments/config.d.ts +4 -2
- package/docs/facets.md +47 -0
- package/docs/legend.md +14 -0
- package/docs/markers.md +54 -26
- package/docs/styles-and-segments.md +1 -0
- package/docs/theming.md +1 -1
- package/examples/FacetsScoped.svelte +26 -0
- package/llms-full.txt +5 -3
- package/llms.txt +1 -0
- 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.4.0] - 2026-10-30
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `legend.off` in the automatic legend's configuration turns series off from code, as if the reader had clicked them: a list of names (dimmed), or a map to an opacity or to a `dim`, `hide` or `omit` action; a series left out is turned back on. `legend.highlight` is its inverse, keeping the series it names and dimming the rest. A plot with facets can dim a series exactly when no panel shows it.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Value labels that fit margins measure what reaches past the plot against the area svelteplot lays the plot out by, not against the estimate an `'auto'` margin has until its axes are sized. A label inside the plot could read as overflow in that moment and grow the margins for good, so the same plot drew with different margins from one render to the next.
|
|
14
|
+
|
|
15
|
+
## [1.3.0] - 2026-10-30
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- `legend.icon` in the configuration sets the icon a discrete legend item draws for its series: `rounded` softens its corners, and `marks` picks the shape by how the series draws (`line`, `dot`, `area`, `bar`, `cell`, `rect` or `geo`), unless the series sets its own `symbol`. A legend item can name its `mark` to choose from it.
|
|
20
|
+
- A `rule` marker takes a list as its `value`, and a `delta` marker a list as its `at`: one line, or one delta, at each position, all with the same style, instead of one marker for each. A delta's `line`, `text` and `dot` can be functions of the position, to style each one on its own.
|
|
21
|
+
- `facet` on a marker or a segment limits it to some facet panels, on every plot kind: a `by` value, a list of them, or `{ row, col }` in a `rows`/`cols` grid. Without it a marker or a segment is drawn in every panel, as before, and a plot with no facet leaves out whatever names one.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Automatic margins reserve what a plot needs without taking its drawing area: the label of the top y tick is no longer cut off, since an `'auto'` top margin now leaves room for half its height, and margins that grow to fit labels stop growing once they would leave the plot less than a quarter of its box, which kept svelteplot from drawing marks with negative sizes on small plots.
|
|
26
|
+
|
|
5
27
|
## [1.2.0] - 2026-10-24
|
|
6
28
|
|
|
7
29
|
### Added
|
|
@@ -28,6 +50,7 @@ All notable changes to this project are documented here. The format follows [Kee
|
|
|
28
50
|
### Changed
|
|
29
51
|
|
|
30
52
|
- The default palette is Tableau 10, and the default continuous ramp, the timeline and player accents and the connector colors follow it.
|
|
53
|
+
- Plots draw faster: a plot in a facet, a grid or a composer no longer remounts its svelteplot chart each time the margins its labels need settle after mount. `pnpm test:perf` checks that every plot kind draws all the panels of a facet grid within a time budget; it runs before a push but not in CI, where the speed of the machine makes a time limit flaky.
|
|
31
54
|
|
|
32
55
|
### Fixed
|
|
33
56
|
|
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
[](https://github.com/datos-Fundar/data-chart-telling/releases/latest)
|
|
11
11
|

|
|
12
12
|
[](https://github.com/datos-Fundar/data-chart-telling/actions/workflows/ci.yml)
|
|
13
|
+
[](https://datos-fundar.github.io/data-chart-telling/)
|
|
13
14
|
[](https://github.com/datos-Fundar/data-chart-telling/actions/workflows/codeql.yml)
|
|
14
15
|
|
|
15
16
|
## Introduction
|
|
@@ -150,7 +151,7 @@ Start with the concepts; the rest is organised by what you want a plot to do.
|
|
|
150
151
|
| [Architecture](docs/architecture.md) | How a plot is assembled, for contributors |
|
|
151
152
|
| [Development](docs/development.md) | Building, testing, Storybook and publishing |
|
|
152
153
|
|
|
153
|
-
Working examples live in [examples/](examples/); the Storybook (`pnpm storybook`) shows every option live.
|
|
154
|
+
Working examples live in [examples/](examples/); the [online Storybook](https://datos-fundar.github.io/data-chart-telling/) (or `pnpm storybook` locally) shows every option live.
|
|
154
155
|
|
|
155
156
|
## For LLMs and coding agents
|
|
156
157
|
|
|
@@ -92,6 +92,18 @@ export const DEFAULT_CONFIG = {
|
|
|
92
92
|
titleWeight: 500,
|
|
93
93
|
gap: '0.5rem 2rem',
|
|
94
94
|
swatchSize: '0.9rem',
|
|
95
|
+
icon: {
|
|
96
|
+
rounded: false,
|
|
97
|
+
marks: {
|
|
98
|
+
line: 'line',
|
|
99
|
+
dot: 'circle',
|
|
100
|
+
area: 'square',
|
|
101
|
+
bar: 'square',
|
|
102
|
+
cell: 'square',
|
|
103
|
+
rect: 'square',
|
|
104
|
+
geo: 'square'
|
|
105
|
+
}
|
|
106
|
+
},
|
|
95
107
|
disabledOpacity: 0.12,
|
|
96
108
|
pretty: true,
|
|
97
109
|
seriesTitle: { enabled: true, text: 'Series' },
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { FacetTarget } from '../../types/layout/facet';
|
|
2
|
+
import type { SlicePlace } from '../../types/layout/plot';
|
|
3
|
+
import type { FacetPanel } from '../../types/phases';
|
|
4
|
+
/** Where a facet panel sits, as a segment's or a marker's `facet` names it. */
|
|
5
|
+
type PanelPlace = SlicePlace['facet'];
|
|
6
|
+
/**
|
|
7
|
+
* Limiting a plot's segments and markers to some facet panels: which panels a `facet` names, and each
|
|
8
|
+
* panel's share of a list of them. A plot with no facet has no panel, so what names one is left out.
|
|
9
|
+
*/
|
|
10
|
+
export declare class FacetScope {
|
|
11
|
+
/** Where a panel the "facet" phase laid out sits. */
|
|
12
|
+
static placeOf(panel: FacetPanel): NonNullable<PanelPlace>;
|
|
13
|
+
/**
|
|
14
|
+
* Whether something limited to `target` applies at the facet panel `place`. Without a target it
|
|
15
|
+
* applies everywhere; with one, only to the panels it names, and to no plot that has no facet.
|
|
16
|
+
*/
|
|
17
|
+
static targets(target: FacetTarget | undefined, place: PanelPlace): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* `items` as the facet panel `place` sees them: those that name no facet, or name this one, with
|
|
20
|
+
* their `facet` taken off. The same list comes back when none names a facet.
|
|
21
|
+
*/
|
|
22
|
+
static scope<T extends object>(items: T[] | undefined, place: PanelPlace): T[] | undefined;
|
|
23
|
+
/** A `segments` config as the facet panel `place` sees it: each series' segments, {@link FacetScope.scope}d. */
|
|
24
|
+
static scopeSegments<T extends object>(segments: Record<string, T[]> | undefined, place: PanelPlace): Record<string, T[]> | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* `segments` for a plot whose series are split by facet panel: each segment that names a facet moves
|
|
27
|
+
* under the key of its series in every panel it names - the id of that series' part there, as
|
|
28
|
+
* {@link FacetPanels.buildSeriesId} writes it, or `default` for the panel's series without their own -
|
|
29
|
+
* and loses its `facet`. Segments that name none stay where they were. The same config comes back
|
|
30
|
+
* when none names a facet.
|
|
31
|
+
*/
|
|
32
|
+
static distributeSegments<T extends object>(segments: Record<string, T[]> | undefined, panels: FacetPanel[]): Record<string, T[]> | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* `markers` for a plot split by facet panel: each marker that names a facet becomes one copy per
|
|
35
|
+
* panel it names, tagged with that panel ({@link PANEL_KEY}) and without its `facet`, in the marker's
|
|
36
|
+
* own place in the list. Markers that name none stay as they are, for every panel.
|
|
37
|
+
*/
|
|
38
|
+
static distributeMarkers<T extends object>(markers: T[] | undefined, panels: FacetPanel[]): T[] | undefined;
|
|
39
|
+
}
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { FacetPanels, PANEL_KEY } from '../../phases/facet/FacetPanels';
|
|
2
|
+
/**
|
|
3
|
+
* Limiting a plot's segments and markers to some facet panels: which panels a `facet` names, and each
|
|
4
|
+
* panel's share of a list of them. A plot with no facet has no panel, so what names one is left out.
|
|
5
|
+
*/
|
|
6
|
+
export class FacetScope {
|
|
7
|
+
/** Where a panel the "facet" phase laid out sits. */
|
|
8
|
+
static placeOf(panel) {
|
|
9
|
+
return {
|
|
10
|
+
facetValue: panel.label,
|
|
11
|
+
...(panel.row !== undefined ? { row: panel.row } : {}),
|
|
12
|
+
...(panel.col !== undefined ? { col: panel.col } : {})
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Whether something limited to `target` applies at the facet panel `place`. Without a target it
|
|
17
|
+
* applies everywhere; with one, only to the panels it names, and to no plot that has no facet.
|
|
18
|
+
*/
|
|
19
|
+
static targets(target, place) {
|
|
20
|
+
if (target === undefined)
|
|
21
|
+
return true;
|
|
22
|
+
if (!place)
|
|
23
|
+
return false;
|
|
24
|
+
const names = (value) => (Array.isArray(value) ? value : [value]).map(String);
|
|
25
|
+
if (typeof target === 'object' && !Array.isArray(target)) {
|
|
26
|
+
const matches = (wanted, actual) => wanted === undefined || (actual !== undefined && names(wanted).includes(actual));
|
|
27
|
+
return matches(target.row, place.row) && matches(target.col, place.col);
|
|
28
|
+
}
|
|
29
|
+
return names(target).includes(place.facetValue);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* `items` as the facet panel `place` sees them: those that name no facet, or name this one, with
|
|
33
|
+
* their `facet` taken off. The same list comes back when none names a facet.
|
|
34
|
+
*/
|
|
35
|
+
static scope(items, place) {
|
|
36
|
+
const facetOf = (item) => item.facet;
|
|
37
|
+
if (!items || items.every((item) => facetOf(item) === undefined))
|
|
38
|
+
return items;
|
|
39
|
+
return items
|
|
40
|
+
.filter((item) => FacetScope.targets(facetOf(item), place))
|
|
41
|
+
.map((item) => {
|
|
42
|
+
const rest = { ...item };
|
|
43
|
+
delete rest.facet;
|
|
44
|
+
return rest;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** A `segments` config as the facet panel `place` sees it: each series' segments, {@link FacetScope.scope}d. */
|
|
48
|
+
static scopeSegments(segments, place) {
|
|
49
|
+
if (!segments)
|
|
50
|
+
return segments;
|
|
51
|
+
const scoped = Object.entries(segments).map(([series, list]) => [series, FacetScope.scope(list, place) ?? list]);
|
|
52
|
+
if (scoped.every(([series, list]) => list === segments[series]))
|
|
53
|
+
return segments;
|
|
54
|
+
return Object.fromEntries(scoped);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* `segments` for a plot whose series are split by facet panel: each segment that names a facet moves
|
|
58
|
+
* under the key of its series in every panel it names - the id of that series' part there, as
|
|
59
|
+
* {@link FacetPanels.buildSeriesId} writes it, or `default` for the panel's series without their own -
|
|
60
|
+
* and loses its `facet`. Segments that name none stay where they were. The same config comes back
|
|
61
|
+
* when none names a facet.
|
|
62
|
+
*/
|
|
63
|
+
static distributeSegments(segments, panels) {
|
|
64
|
+
if (!segments)
|
|
65
|
+
return segments;
|
|
66
|
+
const named = Object.values(segments).some((list) => list.some((item) => item.facet !== undefined));
|
|
67
|
+
if (!named)
|
|
68
|
+
return segments;
|
|
69
|
+
const distributed = {};
|
|
70
|
+
for (const [series, list] of Object.entries(segments)) {
|
|
71
|
+
for (const item of list) {
|
|
72
|
+
const { facet, ...rest } = item;
|
|
73
|
+
if (facet === undefined) {
|
|
74
|
+
(distributed[series] ??= []).push(item);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
for (const panel of panels) {
|
|
78
|
+
if (!FacetScope.targets(facet, FacetScope.placeOf(panel)))
|
|
79
|
+
continue;
|
|
80
|
+
(distributed[FacetPanels.buildSeriesId(panel.key, series)] ??= []).push(rest);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return distributed;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* `markers` for a plot split by facet panel: each marker that names a facet becomes one copy per
|
|
88
|
+
* panel it names, tagged with that panel ({@link PANEL_KEY}) and without its `facet`, in the marker's
|
|
89
|
+
* own place in the list. Markers that name none stay as they are, for every panel.
|
|
90
|
+
*/
|
|
91
|
+
static distributeMarkers(markers, panels) {
|
|
92
|
+
if (!markers || markers.every((marker) => marker.facet === undefined)) {
|
|
93
|
+
return markers;
|
|
94
|
+
}
|
|
95
|
+
return markers.flatMap((marker) => {
|
|
96
|
+
const { facet, ...rest } = marker;
|
|
97
|
+
if (facet === undefined)
|
|
98
|
+
return [marker];
|
|
99
|
+
return panels
|
|
100
|
+
.filter((panel) => FacetScope.targets(facet, FacetScope.placeOf(panel)))
|
|
101
|
+
.map((panel) => ({ ...rest, [PANEL_KEY]: panel.key }));
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -98,6 +98,11 @@
|
|
|
98
98
|
legend && typeof legend === 'object' && !Array.isArray(legend) ? legend : undefined
|
|
99
99
|
);
|
|
100
100
|
|
|
101
|
+
const offTargets = $derived(legendState.offTargets(extension));
|
|
102
|
+
$effect(() => {
|
|
103
|
+
legendState.setOff(offTargets);
|
|
104
|
+
});
|
|
105
|
+
|
|
101
106
|
/** What the legend shows: the author's sections, the ones its plots read as (with `extension`), or none. */
|
|
102
107
|
const legendMode = $derived(
|
|
103
108
|
Array.isArray(legend)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { getConfiguration } from '../../configuration/config.svelte';
|
|
2
3
|
import type { LegendItem } from '../../types/layout/legend';
|
|
3
|
-
import type { LineStyle } from '../../types/plots/constants';
|
|
4
|
+
import type { LineStyle, SymbolType } from '../../types/plots/constants';
|
|
4
5
|
import { SymbolPath } from '../../utils/SymbolPath';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -8,16 +9,35 @@
|
|
|
8
9
|
* (`DiscreteSwatchItem` and `DiscreteBadgeItem`) so the two stay in sync.
|
|
9
10
|
* `color` is caller-supplied rather than read off `item` - the swatch look
|
|
10
11
|
* fills it with the series color, the badge look with its own label color.
|
|
11
|
-
*
|
|
12
|
+
* The shape is the item's own `symbol`, or the one `legend.icon.marks` gives
|
|
13
|
+
* the way it draws. A shape draws as the plot's dot does, fitted to the icon;
|
|
14
|
+
* `legend.icon.rounded` softens its corners.
|
|
12
15
|
*/
|
|
13
16
|
let { item, color }: { item: LegendItem; color: string } = $props();
|
|
14
17
|
|
|
18
|
+
const cfg = $derived(getConfiguration());
|
|
19
|
+
const icon = $derived(cfg.legend.icon);
|
|
20
|
+
const shape = $derived<SymbolType>(
|
|
21
|
+
item.symbol ?? (item.mark ? icon.marks[item.mark] : undefined) ?? 'square'
|
|
22
|
+
);
|
|
23
|
+
const rounded = $derived(icon.rounded);
|
|
15
24
|
const filled = $derived(item.filled ?? true);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
25
|
+
// A rounded filled shape grows a same-colored stroke with round joins, so it is drawn smaller by that much.
|
|
26
|
+
const paint = $derived<Record<string, string | number>>(
|
|
27
|
+
!filled
|
|
28
|
+
? {
|
|
29
|
+
fill: 'none',
|
|
30
|
+
stroke: color,
|
|
31
|
+
'stroke-width': 1.5,
|
|
32
|
+
...(rounded ? { 'stroke-linejoin': 'round' } : {}),
|
|
33
|
+
'vector-effect': 'non-scaling-stroke'
|
|
34
|
+
}
|
|
35
|
+
: rounded
|
|
36
|
+
? { fill: color, stroke: color, 'stroke-width': 1.5, 'stroke-linejoin': 'round' }
|
|
37
|
+
: { fill: color }
|
|
20
38
|
);
|
|
39
|
+
const size = $derived(filled ? (rounded ? 6.5 : 8) : 7);
|
|
40
|
+
const offset = $derived(!filled ? 1.5 : rounded ? 1.75 : 1);
|
|
21
41
|
|
|
22
42
|
function dashArray(style?: LineStyle): string | undefined {
|
|
23
43
|
switch (style) {
|
|
@@ -34,13 +54,7 @@
|
|
|
34
54
|
</script>
|
|
35
55
|
|
|
36
56
|
<svg class="dct-discrete__swatch" viewBox="0 0 15 10">
|
|
37
|
-
{#if
|
|
38
|
-
<path
|
|
39
|
-
d={SymbolPath.fitted(item.symbol, filled ? 8 : 7)}
|
|
40
|
-
transform={filled ? 'translate(1,1)' : 'translate(1.5,1.5)'}
|
|
41
|
-
{...paint}
|
|
42
|
-
/>
|
|
43
|
-
{:else if item.symbol === 'line'}
|
|
57
|
+
{#if shape === 'line'}
|
|
44
58
|
<line
|
|
45
59
|
x1="2"
|
|
46
60
|
y1="5"
|
|
@@ -51,8 +65,18 @@
|
|
|
51
65
|
stroke-linecap="round"
|
|
52
66
|
stroke-dasharray={dashArray(item.lineStyle)}
|
|
53
67
|
/>
|
|
54
|
-
{:else}
|
|
68
|
+
{:else if rounded && filled && shape === 'square'}
|
|
69
|
+
<rect x="1" y="1" width="8" height="8" rx="2" fill={color} />
|
|
70
|
+
{:else if rounded && filled && shape === 'triangle'}
|
|
71
|
+
<path
|
|
72
|
+
d="M 6 2 L 9 7.5 A 1.5 1.5 0 0 1 7.8 10 L 2.2 10 A 1.5 1.5 0 0 1 1 7.5 L 4 2 A 1.5 1.5 0 0 1 6 2"
|
|
73
|
+
fill={color}
|
|
74
|
+
transform="translate(0,-1)"
|
|
75
|
+
/>
|
|
76
|
+
{:else if shape === 'square' && filled && !rounded}
|
|
55
77
|
<rect x="1" y="1" width="8" height="8" fill={color} />
|
|
78
|
+
{:else}
|
|
79
|
+
<path d={SymbolPath.fitted(shape, size)} transform="translate({offset},{offset})" {...paint} />
|
|
56
80
|
{/if}
|
|
57
81
|
</svg>
|
|
58
82
|
|
|
@@ -37,6 +37,17 @@ export declare class LegendState {
|
|
|
37
37
|
turnOn(series: string): void;
|
|
38
38
|
/** Turns `series` off with `action`, or back on. */
|
|
39
39
|
toggle(series: string, action: LegendDisabledAction): void;
|
|
40
|
+
/**
|
|
41
|
+
* What `extension`'s `off` and `highlight` turn off, by series name: `off`
|
|
42
|
+
* as listed, and every other series the plots draw for a `highlight`.
|
|
43
|
+
*/
|
|
44
|
+
offTargets(extension: Pick<LegendExtension, 'off' | 'highlight'> | undefined): Map<string, LegendDisabledAction>;
|
|
45
|
+
/**
|
|
46
|
+
* Turns off exactly `targets`, turning back on what an earlier call turned
|
|
47
|
+
* off and this one leaves out. A series the reader turned off, or turned
|
|
48
|
+
* off any other way, is left alone.
|
|
49
|
+
*/
|
|
50
|
+
setOff(targets: Map<string, LegendDisabledAction>): void;
|
|
40
51
|
/** Whether any series is off with `'omit'` - which changes the plot's own rows, not just how they draw. */
|
|
41
52
|
get omits(): boolean;
|
|
42
53
|
/** `series` without those turned off with `'omit'`. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getContext, setContext, untrack } from 'svelte';
|
|
2
|
-
import { SvelteMap } from 'svelte/reactivity';
|
|
2
|
+
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
3
3
|
import { getConfiguration } from '../../configuration/config.svelte';
|
|
4
4
|
import { MarkerKinds } from '../../markers/MarkerKinds';
|
|
5
5
|
import { LegendChannels } from './LegendChannels';
|
|
@@ -20,6 +20,8 @@ export class LegendState {
|
|
|
20
20
|
/** The narrowed range of the first interactive continuous section, or `null`. */
|
|
21
21
|
range = $state(null);
|
|
22
22
|
#sources = new SvelteMap();
|
|
23
|
+
/** What {@link setOff} last turned off, to turn back on what it no longer lists. */
|
|
24
|
+
#managed = new SvelteMap();
|
|
23
25
|
/** The enclosing figure's legend, or `null` outside one. Call during component init. */
|
|
24
26
|
static current() {
|
|
25
27
|
return getContext(LEGEND_KEY) ?? null;
|
|
@@ -67,6 +69,56 @@ export class LegendState {
|
|
|
67
69
|
else
|
|
68
70
|
this.turnOff(series, action);
|
|
69
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* What `extension`'s `off` and `highlight` turn off, by series name: `off`
|
|
74
|
+
* as listed, and every other series the plots draw for a `highlight`.
|
|
75
|
+
*/
|
|
76
|
+
offTargets(extension) {
|
|
77
|
+
const targets = new SvelteMap();
|
|
78
|
+
if (extension?.highlight !== undefined) {
|
|
79
|
+
const kept = new SvelteSet([extension.highlight].flat());
|
|
80
|
+
for (const entry of this.entries) {
|
|
81
|
+
if (entry.type === 'series' && !kept.has(entry.name))
|
|
82
|
+
targets.set(entry.name, { mode: 'dim' });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const off = extension?.off;
|
|
86
|
+
const listed = Array.isArray(off)
|
|
87
|
+
? off.map((name) => [name, undefined])
|
|
88
|
+
: Object.entries(off ?? {});
|
|
89
|
+
for (const [name, what] of listed) {
|
|
90
|
+
targets.set(name, typeof what === 'number'
|
|
91
|
+
? { mode: 'dim', style: { opacity: what } }
|
|
92
|
+
: (what ?? { mode: 'dim' }));
|
|
93
|
+
}
|
|
94
|
+
return targets;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Turns off exactly `targets`, turning back on what an earlier call turned
|
|
98
|
+
* off and this one leaves out. A series the reader turned off, or turned
|
|
99
|
+
* off any other way, is left alone.
|
|
100
|
+
*/
|
|
101
|
+
setOff(targets) {
|
|
102
|
+
untrack(() => {
|
|
103
|
+
const ours = (name) => {
|
|
104
|
+
const current = this.actionOf(name);
|
|
105
|
+
const set = this.#managed.get(name);
|
|
106
|
+
return current && set && JSON.stringify(current) === JSON.stringify(set);
|
|
107
|
+
};
|
|
108
|
+
for (const name of this.#managed.keys()) {
|
|
109
|
+
if (!targets.has(name) && ours(name))
|
|
110
|
+
this.turnOn(name);
|
|
111
|
+
}
|
|
112
|
+
for (const [name, action] of targets) {
|
|
113
|
+
if (this.isOff(name) && !ours(name))
|
|
114
|
+
continue;
|
|
115
|
+
if (ours(name) && JSON.stringify(this.#managed.get(name)) === JSON.stringify(action))
|
|
116
|
+
continue;
|
|
117
|
+
this.turnOff(name, action);
|
|
118
|
+
}
|
|
119
|
+
this.#managed = new SvelteMap(targets);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
70
122
|
/** Whether any series is off with `'omit'` - which changes the plot's own rows, not just how they draw. */
|
|
71
123
|
get omits() {
|
|
72
124
|
return [...this.disabled.values()].some((action) => action.mode === 'omit');
|
|
@@ -340,11 +392,9 @@ export class LegendState {
|
|
|
340
392
|
...(entry.label ? { label: entry.label } : {}),
|
|
341
393
|
color: entry.color,
|
|
342
394
|
...(entry.filled === false ? { filled: false } : {}),
|
|
343
|
-
...(entry.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
? { symbol: 'line', lineStyle: entry.lineStyle ?? 'solid' }
|
|
347
|
-
: {})
|
|
395
|
+
...(entry.mark ? { mark: entry.mark } : {}),
|
|
396
|
+
...(entry.symbol ? { symbol: entry.symbol } : {}),
|
|
397
|
+
...(entry.mark === 'line' ? { lineStyle: entry.lineStyle ?? 'solid' } : {})
|
|
348
398
|
};
|
|
349
399
|
}
|
|
350
400
|
}
|
|
@@ -55,9 +55,12 @@ export declare class PlotMargins {
|
|
|
55
55
|
static current(): PlotMargins | null;
|
|
56
56
|
/**
|
|
57
57
|
* Measures `el`'s rendered box once its readings settle - again whenever
|
|
58
|
-
* the
|
|
59
|
-
*
|
|
60
|
-
*
|
|
58
|
+
* the plot's area or `deps` change - and reports how far it reaches past
|
|
59
|
+
* that area: to `onOverflow`, and, when `grow` holds, into the room the
|
|
60
|
+
* margins grow by. The area is where svelteplot draws the marks (see
|
|
61
|
+
* {@link follow}), not the {@link contentBox}'s estimate: an `'auto'` side
|
|
62
|
+
* is only its estimate until svelteplot sizes its axes, and what a label
|
|
63
|
+
* reaches past that is no overflow. Call during component init.
|
|
61
64
|
*/
|
|
62
65
|
watch(el: () => SVGGraphicsElement | undefined, options: {
|
|
63
66
|
grow?: () => boolean;
|
|
@@ -68,8 +71,9 @@ export declare class PlotMargins {
|
|
|
68
71
|
reportOverflow(overflow: Margins): void;
|
|
69
72
|
/**
|
|
70
73
|
* Follows the margins svelteplot settles on - `plot` is its `usePlot()`,
|
|
71
|
-
* only readable inside its `<Plot>` -
|
|
72
|
-
*
|
|
74
|
+
* only readable inside its `<Plot>` - to measure against the area they
|
|
75
|
+
* leave ({@link watch}), and reports them to the enclosing aligned grid
|
|
76
|
+
* cell, if any. Call during the init of a component inside it.
|
|
73
77
|
*/
|
|
74
78
|
follow(plot: {
|
|
75
79
|
options: Record<string, unknown>;
|
|
@@ -25,6 +25,8 @@ export class PlotMargins {
|
|
|
25
25
|
#measured = $state.raw(Margins.ZERO);
|
|
26
26
|
/** The sides the labels settled on, as last handed to `settle`. */
|
|
27
27
|
#settled;
|
|
28
|
+
/** What svelteplot's own `<Plot>` lays the plot out by, once {@link follow} is handed it. */
|
|
29
|
+
#plot = $state.raw();
|
|
28
30
|
constructor(inputs) {
|
|
29
31
|
this.#inputs = inputs;
|
|
30
32
|
setContext(PLOT_MARGINS_KEY, this);
|
|
@@ -54,14 +56,17 @@ export class PlotMargins {
|
|
|
54
56
|
}
|
|
55
57
|
/**
|
|
56
58
|
* Measures `el`'s rendered box once its readings settle - again whenever
|
|
57
|
-
* the
|
|
58
|
-
*
|
|
59
|
-
*
|
|
59
|
+
* the plot's area or `deps` change - and reports how far it reaches past
|
|
60
|
+
* that area: to `onOverflow`, and, when `grow` holds, into the room the
|
|
61
|
+
* margins grow by. The area is where svelteplot draws the marks (see
|
|
62
|
+
* {@link follow}), not the {@link contentBox}'s estimate: an `'auto'` side
|
|
63
|
+
* is only its estimate until svelteplot sizes its axes, and what a label
|
|
64
|
+
* reaches past that is no overflow. Call during component init.
|
|
60
65
|
*/
|
|
61
66
|
watch(el, options) {
|
|
62
67
|
$effect(() => {
|
|
63
68
|
const element = el();
|
|
64
|
-
const bounds = this.contentBox;
|
|
69
|
+
const bounds = this.#plotArea ?? this.contentBox;
|
|
65
70
|
options.deps?.();
|
|
66
71
|
if (!element)
|
|
67
72
|
return;
|
|
@@ -113,10 +118,12 @@ export class PlotMargins {
|
|
|
113
118
|
}
|
|
114
119
|
/**
|
|
115
120
|
* Follows the margins svelteplot settles on - `plot` is its `usePlot()`,
|
|
116
|
-
* only readable inside its `<Plot>` -
|
|
117
|
-
*
|
|
121
|
+
* only readable inside its `<Plot>` - to measure against the area they
|
|
122
|
+
* leave ({@link watch}), and reports them to the enclosing aligned grid
|
|
123
|
+
* cell, if any. Call during the init of a component inside it.
|
|
118
124
|
*/
|
|
119
125
|
follow(plot) {
|
|
126
|
+
this.#plot = plot;
|
|
120
127
|
const cell = this.#cell;
|
|
121
128
|
if (!cell)
|
|
122
129
|
return;
|
|
@@ -148,8 +155,39 @@ export class PlotMargins {
|
|
|
148
155
|
if (held !== undefined || grown !== undefined)
|
|
149
156
|
out[side] = Math.max(held ?? 0, grown ?? 0);
|
|
150
157
|
}
|
|
158
|
+
for (const [a, b, extent] of [
|
|
159
|
+
['left', 'right', width],
|
|
160
|
+
['top', 'bottom', height]
|
|
161
|
+
]) {
|
|
162
|
+
PlotMargins.#keepContent(out, [a, b], extent, author, estimate);
|
|
163
|
+
}
|
|
151
164
|
return out;
|
|
152
165
|
});
|
|
166
|
+
/** The share of a box's extent its margins always leave to the plot's own area. */
|
|
167
|
+
static #MIN_CONTENT = 0.25;
|
|
168
|
+
/**
|
|
169
|
+
* Cuts the growth of two opposite sides of `out`, in proportion, until they
|
|
170
|
+
* leave the plot's area its minimum share of `extent` - a negative area makes
|
|
171
|
+
* svelteplot draw marks with negative sizes. A side never goes below the
|
|
172
|
+
* estimate, and a side the author fixed is left alone.
|
|
173
|
+
*/
|
|
174
|
+
static #keepContent(out, sides, extent, author, estimate) {
|
|
175
|
+
const fixed = (side) => {
|
|
176
|
+
const own = author?.[side];
|
|
177
|
+
return typeof own === 'number' ? own : estimate[side];
|
|
178
|
+
};
|
|
179
|
+
const used = (side) => out[side] ?? fixed(side);
|
|
180
|
+
const over = used(sides[0]) + used(sides[1]) - extent * (1 - PlotMargins.#MIN_CONTENT);
|
|
181
|
+
const room = sides.map((side) => out[side] === undefined ? 0 : Math.max(0, out[side] - estimate[side]));
|
|
182
|
+
const total = room[0] + room[1];
|
|
183
|
+
if (over <= 0 || total <= 0)
|
|
184
|
+
return;
|
|
185
|
+
const cut = Math.min(over, total);
|
|
186
|
+
sides.forEach((side, i) => {
|
|
187
|
+
if (out[side] !== undefined)
|
|
188
|
+
out[side] -= (cut * room[i]) / total;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
153
191
|
/** The author's margins, with the room labels need. */
|
|
154
192
|
#labels = $derived.by(() => {
|
|
155
193
|
const author = this.#inputs.author();
|
|
@@ -205,6 +243,27 @@ export class PlotMargins {
|
|
|
205
243
|
});
|
|
206
244
|
/** The plot's own area, in pixels: what its margins leave inside its box. */
|
|
207
245
|
contentBox = $derived.by(() => this.numeric.contentBox(this.box.width, this.box.height));
|
|
246
|
+
/**
|
|
247
|
+
* The plot's own area as svelteplot lays it out, by the margins it settled on, or `undefined` until
|
|
248
|
+
* the plot is followed. {@link contentBox} reads an `'auto'` side as its estimate, which is not where
|
|
249
|
+
* svelteplot draws the marks until its axes are sized.
|
|
250
|
+
*/
|
|
251
|
+
#plotArea = $derived.by(() => {
|
|
252
|
+
const options = this.#plot?.options;
|
|
253
|
+
if (!options)
|
|
254
|
+
return undefined;
|
|
255
|
+
const sides = [
|
|
256
|
+
options.marginTop,
|
|
257
|
+
options.marginRight,
|
|
258
|
+
options.marginBottom,
|
|
259
|
+
options.marginLeft
|
|
260
|
+
];
|
|
261
|
+
if (sides.some((side) => typeof side !== 'number' || !Number.isFinite(side)))
|
|
262
|
+
return undefined;
|
|
263
|
+
const [top, right, bottom, left] = sides;
|
|
264
|
+
const { width, height } = this.#inputs.size();
|
|
265
|
+
return new Margins(top, right, bottom, left).contentBox(width, height);
|
|
266
|
+
});
|
|
208
267
|
/**
|
|
209
268
|
* Where something of `size` sits next to `point`, on its preferred side
|
|
210
269
|
* per axis, without leaving the plot's box - see {@link Box.placeNear}.
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { merge } from '../../utils/merge';
|
|
19
19
|
import { Pipeline } from '../../phases/Pipeline';
|
|
20
20
|
import { Slice } from './Slice';
|
|
21
|
+
import { FacetScope } from '../facet/scope';
|
|
21
22
|
import type { TooltipProp } from '../../types/layout/tooltip';
|
|
22
23
|
import type { MarginConfig, PlotChromeConfig } from '../../types/plots/props';
|
|
23
24
|
import type { PlotCellContext, PlotCellSnippet, PlotCoreBase } from '../../types/layout/plot';
|
|
@@ -153,6 +154,24 @@
|
|
|
153
154
|
return rest;
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
/**
|
|
158
|
+
* The props of the core that draws the slice `ctx`: the kind's `slice`, or the plot's own, with the
|
|
159
|
+
* `markers` and `segments` that name a facet left to the panels they name. The data phases already
|
|
160
|
+
* handed them to their panels; this is for the cores that read them from their own props.
|
|
161
|
+
*/
|
|
162
|
+
function coreProps(ctx: PlotCellContext<TRow, TScales>) {
|
|
163
|
+
const props = (slice ?? buildSliceProps)(ctx) as Record<string, unknown>;
|
|
164
|
+
const { markers, segments } = props as {
|
|
165
|
+
markers?: object[];
|
|
166
|
+
segments?: Record<string, object[]>;
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
...props,
|
|
170
|
+
...(markers ? { markers: FacetScope.scope(markers, ctx.facet) } : {}),
|
|
171
|
+
...(segments ? { segments: FacetScope.scopeSegments(segments, ctx.facet) } : {})
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
156
175
|
function buildSliceProps(ctx: PlotCellContext<TRow, TScales>) {
|
|
157
176
|
return {
|
|
158
177
|
...core,
|
|
@@ -172,9 +191,9 @@
|
|
|
172
191
|
{#if cell}
|
|
173
192
|
{@render cell(ctx)}
|
|
174
193
|
{:else if Core && sliced}
|
|
175
|
-
<Core {...(
|
|
194
|
+
<Core {...coreProps(ctx)} {pipeline} />
|
|
176
195
|
{:else if Core}
|
|
177
|
-
<Core {...withoutMargins((
|
|
196
|
+
<Core {...withoutMargins(coreProps(ctx))} bind:margins {pipeline} />
|
|
178
197
|
{/if}
|
|
179
198
|
{/snippet}
|
|
180
199
|
|
|
@@ -194,6 +213,7 @@
|
|
|
194
213
|
panel: panels
|
|
195
214
|
? FacetPanels.buildPanelState(panels, slicing.panelKey(place) ?? '')
|
|
196
215
|
: undefined,
|
|
216
|
+
facet: place,
|
|
197
217
|
keep: slicing.keep({ time, facet: place })
|
|
198
218
|
})}
|
|
199
219
|
</HoverScope>
|
|
@@ -243,6 +243,27 @@
|
|
|
243
243
|
$autoMarginLeft.delete(marginId);
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
+
// The top y tick's label is centered on the plot's top edge, so it reaches
|
|
247
|
+
// half its height above it - more than svelteplot's own 5px `'auto'` top margin.
|
|
248
|
+
const { autoMarginTop } = getContext<{ autoMarginTop: Writable<Map<string, number>> }>(
|
|
249
|
+
'svelteplot/autoMargins'
|
|
250
|
+
);
|
|
251
|
+
const tickMarginId = `dct-y-tick-${Math.random().toString(36).slice(2)}`;
|
|
252
|
+
$effect(() => {
|
|
253
|
+
const wanted =
|
|
254
|
+
(scales?.y?.axisDisplay ?? 'visible') === 'visible'
|
|
255
|
+
? Math.ceil(cfg.axis.fontSize / 2) + 2
|
|
256
|
+
: undefined;
|
|
257
|
+
untrack(() => {
|
|
258
|
+
if (wanted === undefined) $autoMarginTop.delete(tickMarginId);
|
|
259
|
+
else if ($autoMarginTop.get(tickMarginId) !== wanted)
|
|
260
|
+
$autoMarginTop.set(tickMarginId, wanted);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
$effect(() => () => {
|
|
264
|
+
$autoMarginTop.delete(tickMarginId);
|
|
265
|
+
});
|
|
266
|
+
|
|
246
267
|
// An x label centred on the domain's end reaches past the plot's right side; an `'auto'` right margin takes that much room.
|
|
247
268
|
let xAxisEl: SVGGElement | undefined = $state();
|
|
248
269
|
const { autoMarginRight } = getContext<{ autoMarginRight: Writable<Map<string, number>> }>(
|