@fundar/data-chart-telling 1.2.0 → 1.3.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 +13 -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/legend/DiscreteSymbolIcon.svelte +38 -14
- package/dist/layout/legend/LegendState.svelte.js +3 -5
- package/dist/layout/margins/PlotMargins.svelte.js +31 -0
- 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 +2 -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/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,18 @@
|
|
|
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.3.0] - 2026-10-30
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `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.
|
|
10
|
+
- 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.
|
|
11
|
+
- `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.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
5
17
|
## [1.2.0] - 2026-10-24
|
|
6
18
|
|
|
7
19
|
### Added
|
|
@@ -28,6 +40,7 @@ All notable changes to this project are documented here. The format follows [Kee
|
|
|
28
40
|
### Changed
|
|
29
41
|
|
|
30
42
|
- The default palette is Tableau 10, and the default continuous ramp, the timeline and player accents and the connector colors follow it.
|
|
43
|
+
- 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
44
|
|
|
32
45
|
### Fixed
|
|
33
46
|
|
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
|
+
}
|
|
@@ -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
|
|
|
@@ -340,11 +340,9 @@ export class LegendState {
|
|
|
340
340
|
...(entry.label ? { label: entry.label } : {}),
|
|
341
341
|
color: entry.color,
|
|
342
342
|
...(entry.filled === false ? { filled: false } : {}),
|
|
343
|
-
...(entry.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
? { symbol: 'line', lineStyle: entry.lineStyle ?? 'solid' }
|
|
347
|
-
: {})
|
|
343
|
+
...(entry.mark ? { mark: entry.mark } : {}),
|
|
344
|
+
...(entry.symbol ? { symbol: entry.symbol } : {}),
|
|
345
|
+
...(entry.mark === 'line' ? { lineStyle: entry.lineStyle ?? 'solid' } : {})
|
|
348
346
|
};
|
|
349
347
|
}
|
|
350
348
|
}
|
|
@@ -148,8 +148,39 @@ export class PlotMargins {
|
|
|
148
148
|
if (held !== undefined || grown !== undefined)
|
|
149
149
|
out[side] = Math.max(held ?? 0, grown ?? 0);
|
|
150
150
|
}
|
|
151
|
+
for (const [a, b, extent] of [
|
|
152
|
+
['left', 'right', width],
|
|
153
|
+
['top', 'bottom', height]
|
|
154
|
+
]) {
|
|
155
|
+
PlotMargins.#keepContent(out, [a, b], extent, author, estimate);
|
|
156
|
+
}
|
|
151
157
|
return out;
|
|
152
158
|
});
|
|
159
|
+
/** The share of a box's extent its margins always leave to the plot's own area. */
|
|
160
|
+
static #MIN_CONTENT = 0.25;
|
|
161
|
+
/**
|
|
162
|
+
* Cuts the growth of two opposite sides of `out`, in proportion, until they
|
|
163
|
+
* leave the plot's area its minimum share of `extent` - a negative area makes
|
|
164
|
+
* svelteplot draw marks with negative sizes. A side never goes below the
|
|
165
|
+
* estimate, and a side the author fixed is left alone.
|
|
166
|
+
*/
|
|
167
|
+
static #keepContent(out, sides, extent, author, estimate) {
|
|
168
|
+
const fixed = (side) => {
|
|
169
|
+
const own = author?.[side];
|
|
170
|
+
return typeof own === 'number' ? own : estimate[side];
|
|
171
|
+
};
|
|
172
|
+
const used = (side) => out[side] ?? fixed(side);
|
|
173
|
+
const over = used(sides[0]) + used(sides[1]) - extent * (1 - PlotMargins.#MIN_CONTENT);
|
|
174
|
+
const room = sides.map((side) => out[side] === undefined ? 0 : Math.max(0, out[side] - estimate[side]));
|
|
175
|
+
const total = room[0] + room[1];
|
|
176
|
+
if (over <= 0 || total <= 0)
|
|
177
|
+
return;
|
|
178
|
+
const cut = Math.min(over, total);
|
|
179
|
+
sides.forEach((side, i) => {
|
|
180
|
+
if (out[side] !== undefined)
|
|
181
|
+
out[side] -= (cut * room[i]) / total;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
153
184
|
/** The author's margins, with the room labels need. */
|
|
154
185
|
#labels = $derived.by(() => {
|
|
155
186
|
const author = this.#inputs.author();
|
|
@@ -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>> }>(
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Series } from '../../types/plots/data/common';
|
|
2
|
-
import type { DeltaConfig } from '../../types/markers/axis/delta';
|
|
2
|
+
import type { DeltaConfig, DeltaMarkerConfig, DeltaPointConfig } from '../../types/markers/axis/delta';
|
|
3
3
|
/** A delta, measured: where it sits (`at`), its two ends and what lies between. */
|
|
4
4
|
export type ResolvedDelta = {
|
|
5
5
|
cfg: DeltaConfig;
|
|
@@ -16,6 +16,11 @@ export type ResolvedDelta = {
|
|
|
16
16
|
*/
|
|
17
17
|
export declare class Delta {
|
|
18
18
|
#private;
|
|
19
|
+
/**
|
|
20
|
+
* The deltas a marker draws: one for its `at`, or one for each position of a list, each with the
|
|
21
|
+
* `line`, `text` and `dot` its style functions give for that position.
|
|
22
|
+
*/
|
|
23
|
+
static points(marker: DeltaMarkerConfig): DeltaPointConfig[];
|
|
19
24
|
/** The delta `config` asks for over `allSeries`, or `undefined` while an end has no value there. */
|
|
20
25
|
static build<T extends Record<string, unknown>>(axis: 'x' | 'y', allSeries: Series<T>[], config: DeltaConfig): ResolvedDelta | undefined;
|
|
21
26
|
}
|
|
@@ -5,6 +5,21 @@ import { resolveAccessor } from '../../utils/accessors';
|
|
|
5
5
|
* it crosses that position, interpolated linearly between its two nearest points.
|
|
6
6
|
*/
|
|
7
7
|
export class Delta {
|
|
8
|
+
/**
|
|
9
|
+
* The deltas a marker draws: one for its `at`, or one for each position of a list, each with the
|
|
10
|
+
* `line`, `text` and `dot` its style functions give for that position.
|
|
11
|
+
*/
|
|
12
|
+
static points(marker) {
|
|
13
|
+
const positions = Array.isArray(marker.at) ? marker.at : [marker.at];
|
|
14
|
+
const own = (style, at) => typeof style === 'function' ? style(at) : style;
|
|
15
|
+
return positions.map((at) => ({
|
|
16
|
+
...marker,
|
|
17
|
+
at,
|
|
18
|
+
line: own(marker.line, at),
|
|
19
|
+
text: own(marker.text, at),
|
|
20
|
+
dot: own(marker.dot, at)
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
8
23
|
/** The delta `config` asks for over `allSeries`, or `undefined` while an end has no value there. */
|
|
9
24
|
static build(axis, allSeries, config) {
|
|
10
25
|
const at = Number(config.at);
|
|
@@ -1,96 +1,18 @@
|
|
|
1
1
|
<script lang="ts" generics="TData extends Record<string, unknown>">
|
|
2
|
-
import
|
|
3
|
-
import DotMarker from '../dot/DotMarker.svelte';
|
|
4
|
-
import TextMarker from '../text/TextMarker.svelte';
|
|
2
|
+
import DeltaPoint from './DeltaPoint.svelte';
|
|
5
3
|
import { Delta } from './Delta';
|
|
6
|
-
import { getConfiguration } from '../../configuration/config.svelte';
|
|
7
4
|
import type { Series } from '../../types/plots/data/common';
|
|
8
5
|
import type { DeltaMarkerConfig } from '../../types/markers/axis/delta';
|
|
9
6
|
|
|
7
|
+
/**
|
|
8
|
+
* A delta annotation: one at `marker.at`, or, when it is a list, one at each position - all with the
|
|
9
|
+
* same ends. `line`, `text` and `dot` can be functions of the position.
|
|
10
|
+
*/
|
|
10
11
|
let { marker, data = [] }: { marker: DeltaMarkerConfig; data?: Series<TData>[] } = $props();
|
|
11
12
|
|
|
12
|
-
const
|
|
13
|
-
const label = $derived(getConfiguration().delta.text[marker.axis]);
|
|
14
|
-
const lineAnchor = $derived(marker.text?.lineAnchor ?? label.lineAnchor);
|
|
15
|
-
|
|
16
|
-
const clampRange = $derived.by((): [number, number] | undefined => {
|
|
17
|
-
const domain = marker.axis === 'x' ? marker.yDomain : marker.xDomain;
|
|
18
|
-
if (!domain || domain.length < 2) return undefined;
|
|
19
|
-
const lo = Number(domain[0]);
|
|
20
|
-
const hi = Number(domain[domain.length - 1]);
|
|
21
|
-
return isNaN(lo) || isNaN(hi) ? undefined : [Math.min(lo, hi), Math.max(lo, hi)];
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
function clamp(v: number): number {
|
|
25
|
-
return clampRange ? Math.max(clampRange[0], Math.min(clampRange[1], v)) : v;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function pt(fixed: number, varying: number) {
|
|
29
|
-
return marker.axis === 'x' ? { x: fixed, y: varying } : { x: varying, y: fixed };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function textVaryCoord(fromVis: number, toVis: number) {
|
|
33
|
-
return lineAnchor === 'top'
|
|
34
|
-
? Math.max(fromVis, toVis)
|
|
35
|
-
: lineAnchor === 'bottom'
|
|
36
|
-
? Math.min(fromVis, toVis)
|
|
37
|
-
: (fromVis + toVis) / 2;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Pre-derive all coordinates as script-level signals so prop getters read safe
|
|
41
|
-
// values during Svelte 5 teardown, before the {#if} guard finishes unmounting.
|
|
42
|
-
const coords = $derived.by(() => {
|
|
43
|
-
if (!resolved) return null;
|
|
44
|
-
const fromVis = clamp(resolved.from ?? 0);
|
|
45
|
-
const toVis = clamp(resolved.to ?? 0);
|
|
46
|
-
const at = resolved.at ?? 0;
|
|
47
|
-
return {
|
|
48
|
-
fromPt: pt(at, fromVis),
|
|
49
|
-
toPt: pt(at, toVis),
|
|
50
|
-
textPt: pt(at, textVaryCoord(fromVis, toVis)),
|
|
51
|
-
lineData: [pt(at, fromVis), pt(at, toVis)],
|
|
52
|
-
diff: resolved.diff ?? 0
|
|
53
|
-
};
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
const dotStyles = $derived({
|
|
57
|
-
dotRadius: 4,
|
|
58
|
-
dotFill: marker.line?.stroke,
|
|
59
|
-
dotStroke: marker.line?.stroke,
|
|
60
|
-
dotStrokeWidth: 2,
|
|
61
|
-
...marker.dot
|
|
62
|
-
});
|
|
13
|
+
const points = $derived(Delta.points(marker));
|
|
63
14
|
</script>
|
|
64
15
|
|
|
65
|
-
{#
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
x={(p) => p.x}
|
|
69
|
-
y={(p) => p.y}
|
|
70
|
-
stroke={marker.line?.stroke ?? 'currentColor'}
|
|
71
|
-
strokeWidth={marker.line?.strokeWidth ?? 2}
|
|
72
|
-
strokeOpacity={marker.line?.strokeOpacity ?? 0.8}
|
|
73
|
-
strokeDasharray={marker.line?.strokeDasharray ?? '6 6'}
|
|
74
|
-
strokeLinecap={marker.line?.strokeLinecap ?? 'round'}
|
|
75
|
-
/>
|
|
76
|
-
<DotMarker marker={{ type: 'dot', x: coords.fromPt.x, y: coords.fromPt.y, styles: dotStyles }} />
|
|
77
|
-
<DotMarker marker={{ type: 'dot', x: coords.toPt.x, y: coords.toPt.y, styles: dotStyles }} />
|
|
78
|
-
<TextMarker
|
|
79
|
-
marker={{
|
|
80
|
-
type: 'text',
|
|
81
|
-
x: coords.textPt.x,
|
|
82
|
-
y: coords.textPt.y,
|
|
83
|
-
text: (marker.formatDiff ?? ((v: number) => v.toFixed(1)))(coords.diff),
|
|
84
|
-
styles: {
|
|
85
|
-
fontWeight: 'bold',
|
|
86
|
-
fontSize: 14,
|
|
87
|
-
textAnchor: label.textAnchor,
|
|
88
|
-
fill: marker.line?.stroke ?? 'currentColor',
|
|
89
|
-
...marker.text,
|
|
90
|
-
dx: marker.text?.dx ?? label.dx,
|
|
91
|
-
dy: marker.text?.dy ?? label.dy,
|
|
92
|
-
lineAnchor
|
|
93
|
-
}
|
|
94
|
-
}}
|
|
95
|
-
/>
|
|
96
|
-
{/if}
|
|
16
|
+
{#each points as point (String(point.at))}
|
|
17
|
+
<DeltaPoint marker={point} {data} />
|
|
18
|
+
{/each}
|