@fundar/data-chart-telling 1.19.0 → 1.20.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 +18 -0
- package/dist/index.d.ts +1 -1
- package/dist/layout/facet/FacetLayout.svelte +54 -41
- package/dist/layout/legend/ContinuousSection.svelte +10 -0
- package/dist/layout/legend/LegendState.svelte.d.ts +13 -1
- package/dist/layout/legend/LegendState.svelte.js +32 -0
- package/dist/layout/plot/AbstractPlot.svelte +83 -6
- package/dist/layout/plot/AbstractPlot.svelte.d.ts +1 -1
- package/dist/layout/plot/PublicPlotContext.d.ts +5 -0
- package/dist/layout/plot/PublicPlotContext.js +7 -0
- package/dist/markers/band/BandMarker.svelte +2 -1
- package/dist/markers/geo/GeoHoverMarker.svelte +14 -11
- package/dist/phases/context/RowContext.js +2 -2
- package/dist/phases/context/rowContextKey.d.ts +2 -0
- package/dist/phases/context/rowContextKey.js +2 -0
- package/dist/phases/sharedScales/SharedStepScales.d.ts +10 -0
- package/dist/phases/sharedScales/SharedStepScales.js +43 -0
- package/dist/plots/map/geo/Core.svelte +29 -6
- package/dist/plots/map/geo/Plot.svelte +38 -10
- package/dist/plots/map/geo/phases/data.d.ts +2 -1
- package/dist/plots/map/geo/phases/data.js +8 -2
- package/dist/plots/map/geo/phases/scales.js +6 -2
- package/dist/plots/map/geo/slices.d.ts +27 -3
- package/dist/plots/map/geo/slices.js +58 -5
- package/dist/plots/ranking/bar/markers.js +2 -2
- package/dist/types/layout/plot.d.ts +2 -0
- package/dist/types/layout/styles/geo.d.ts +3 -3
- package/dist/types/plots/data/geo.d.ts +19 -5
- package/dist/types/plots/map/geo/props.d.ts +9 -3
- package/dist/types/plots/map/geo/state.d.ts +3 -1
- package/dist/utils/ConfigEquality.d.ts +11 -0
- package/dist/utils/ConfigEquality.js +37 -0
- package/docs/facets.md +12 -0
- package/docs/geo.md +35 -7
- package/docs/legend.md +6 -0
- package/docs/plots/ranking/bar.md +1 -1
- package/docs/state.md +1 -1
- package/docs/timeline.md +1 -1
- package/docs/transforms-and-statistics.md +1 -1
- package/llms-full.txt +3 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
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.20.0] - 2026-11-11
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`GeoPlot` facets and timeline from one layer of features.** `facet.values` (or a grid's `rowValues` and `colValues`) and `timeline.values` now create their panels and steps though no feature carries the field, so the same features serve every panel with no copy per value. A slice's values are the ones named, in order, then any more found in the features.
|
|
10
|
+
- **The slice reaches the callbacks of a `GeoPlot`.** `color`, `opacity`, `grouping`, `featureId` and `styles.onFeatureClick` get `{ facet?: { value, row?, col? }, time? }` as their last argument (empty without `facet` or `timeline`), and a tooltip's `format` has the same `facet` and `time` in its context: `color={(f, { facet }) => valueFor(f, facet?.value)}`. The color ramp spans every panel and step, each read in its own slice, unless `facet.scales` (or `timeline.scales`, with no facet) is `'free'`: then each slice fits its own, and a `grouping` by panel with a `segments` `scales.color` per panel gives each its own scheme - and the legend leaves out the plot's own ramp when every colored feature has one of its own. New type `GeoSliceContext`. See [Maps](docs/geo.md#facets-and-timeline).
|
|
11
|
+
- **`useFigure().legend.setRange([min, max] | null, mode?)`.** Narrows an interactive continuous legend from code as dragging its handles does: they move and the plots filter or rescale to the range, in the section's own mode unless one is given. `null`, or a span covering the whole ramp, resets it. For a figure that draws its own mini legends, such as one per map panel, to apply a panel's range to the shared legend. See [Legend](docs/legend.md).
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **`BarPlot` hover on grouped series.** Each series' hover zone now covers only its own strip of the category band, so side-by-side bars report the series under the pointer instead of always the last one.
|
|
16
|
+
- **`band` marker on grouped horizontal bars.** A `slice` of a band on `y` is counted from the bottom, as the bars, their hit areas and the hover's dot, text and rule are, so the hover's band lies over the series under the pointer and not the other one.
|
|
17
|
+
- **`GeoPlot` hover no longer lays the map out again.** The hovered province is drawn on the plot's projection without joining its scales, so entering or leaving one no longer works out the scales and projection anew - which, with facets and a synced tooltip, was repeated in every panel.
|
|
18
|
+
- **Facet grids no longer crawl through a timeline.** A step used to give `FacetLayout` new entry objects, which its each blocks wrote into their items from inside their own effect, and Svelte then walked every derived the write reached, in each panel. The blocks now iterate the panels' keys, and each cell reads its entry from a derived.
|
|
19
|
+
- **Shared domains on a stacked, faceted timeline.** With a `timeline` that shows each step's own rows (`filterMode: 'exact'`), a shared value axis was fitted to the rows of every step stacked together. It now reaches the largest total any step stacks in any panel. With `'upTo'` a step shows every row up to it, so the last step's stack still sets it.
|
|
20
|
+
- **Docs: horizontal stacked bars use `stackX`.** The bar plot's page, the transforms page and the LLM notes said stacking is always `stackY`, also for horizontal bars. A stack runs along the value axis: `stackY` with values on `y` (vertical bars), `stackX` with values on `x` (horizontal bars); the other makes the category axis numeric and the bars come out empty.
|
|
21
|
+
- **A faceted plot repaints when only its `segments` change.** The data phases are run again when a prop of the plot, the configuration, the rows or the facet changes - props that are configuration (`segments`, `styles`, `scales`...) compared as they hold, so building the same ones again changes nothing - where before a change of `segments` alone left every panel as it was unless `data` changed too. Moving a timeline or a pointer still runs nothing again.
|
|
22
|
+
|
|
5
23
|
## [1.19.0] - 2026-11-11
|
|
6
24
|
|
|
7
25
|
### Added
|
package/dist/index.d.ts
CHANGED
|
@@ -57,7 +57,7 @@ export type { FontStyle, StrokeStyle, DotStyle, AreaStyle } from './types/plots/
|
|
|
57
57
|
export type { AxisBasedMarkersConfig, BrushSelection, HoverMarkerConfig, HoverShapeStyle, HoverLayerMarker } from './types/markers/common';
|
|
58
58
|
export type { StatisticConfig } from './types/layout/statistics';
|
|
59
59
|
export type * from './types/transforms';
|
|
60
|
-
export type { GeoFeature, GeoJsonInput, GeoDataProps, Topology } from './types/plots/data/geo';
|
|
60
|
+
export type { GeoFeature, GeoJsonInput, GeoDataProps, GeoSliceContext, Topology } from './types/plots/data/geo';
|
|
61
61
|
export type { GeoCustomProjection, GeoCustomProjectionFactory, GeoScalesConfig, CoordinatesConfig } from './types/layout/coordinates';
|
|
62
62
|
export type { GeoCameraConfig } from './types/plots/camera';
|
|
63
63
|
export type { GeoMarkersConfig } from './types/markers/geo';
|
|
@@ -139,6 +139,13 @@
|
|
|
139
139
|
});
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
// The panels' keys hold still while their data changes, and each cell reads its own entry by key. A timeline step
|
|
143
|
+
// gives every entry a new object; an each block over the entries would write each into its item from inside its
|
|
144
|
+
// own effect, and Svelte then walks every derived that state reaches, panel after panel.
|
|
145
|
+
const entryByKey = $derived(new Map([...wrapEntries, ...matrixEntries].map((e) => [e.key, e])));
|
|
146
|
+
const wrapKeys = $derived(wrapEntries.map((e) => e.key));
|
|
147
|
+
const matrixKeys = $derived(matrixEntries.map((e) => e.key));
|
|
148
|
+
|
|
142
149
|
// ── wrapped modes ────────────────────────────────────────────────────────
|
|
143
150
|
const resolvedMode = $derived(wrap ? resolveResponsive(mode, $isMobile) : 'grid');
|
|
144
151
|
|
|
@@ -150,10 +157,10 @@
|
|
|
150
157
|
const resolvedPageSize = $derived(Math.max(1, pageSize ?? gridCols * gridRows));
|
|
151
158
|
let page = $state(0);
|
|
152
159
|
const totalPages = $derived(Math.max(1, Math.ceil(wrapEntries.length / resolvedPageSize)));
|
|
153
|
-
const
|
|
160
|
+
const pageKeys = $derived(
|
|
154
161
|
resolvedMode === 'paginated'
|
|
155
|
-
?
|
|
156
|
-
:
|
|
162
|
+
? wrapKeys.slice(page * resolvedPageSize, page * resolvedPageSize + resolvedPageSize)
|
|
163
|
+
: wrapKeys
|
|
157
164
|
);
|
|
158
165
|
|
|
159
166
|
$effect(() => {
|
|
@@ -162,7 +169,7 @@
|
|
|
162
169
|
|
|
163
170
|
const showNav = $derived(resolvedMode === 'paginated' && totalPages > 1);
|
|
164
171
|
// A page sizes its grid off what it shows, not the full entry count.
|
|
165
|
-
const pageCols = $derived(Math.max(1, Math.min(gridCols,
|
|
172
|
+
const pageCols = $derived(Math.max(1, Math.min(gridCols, pageKeys.length || 1)));
|
|
166
173
|
const gridHeight = $derived(
|
|
167
174
|
navLayout === 'below' && showNav ? Math.max(0, height - NAV_BELOW_H) : height
|
|
168
175
|
);
|
|
@@ -255,28 +262,31 @@
|
|
|
255
262
|
</span>
|
|
256
263
|
{/snippet}
|
|
257
264
|
|
|
258
|
-
{#snippet facetCells(
|
|
259
|
-
{#each
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
265
|
+
{#snippet facetCells(keys: string[], columnCount: number)}
|
|
266
|
+
{#each keys as key, i (key)}
|
|
267
|
+
{@const entry = entryByKey.get(key)}
|
|
268
|
+
{#if entry}
|
|
269
|
+
<GridCell
|
|
270
|
+
row={entry.r ?? Math.floor(i / columnCount)}
|
|
271
|
+
col={entry.c ?? i % columnCount}
|
|
272
|
+
label={wrap ? entry.label : undefined}
|
|
273
|
+
data-row={entry.row}
|
|
274
|
+
data-col={entry.col}
|
|
275
|
+
>
|
|
276
|
+
{#snippet children(box)}
|
|
277
|
+
{@render cell({
|
|
278
|
+
facetValue: entry.label,
|
|
279
|
+
row: entry.row,
|
|
280
|
+
col: entry.col,
|
|
281
|
+
data: entry.data,
|
|
282
|
+
width: box.width,
|
|
283
|
+
height: box.height,
|
|
284
|
+
margins: box.margins,
|
|
285
|
+
edges: box.edges
|
|
286
|
+
})}
|
|
287
|
+
{/snippet}
|
|
288
|
+
</GridCell>
|
|
289
|
+
{/if}
|
|
280
290
|
{/each}
|
|
281
291
|
{/snippet}
|
|
282
292
|
|
|
@@ -291,7 +301,7 @@
|
|
|
291
301
|
colLabels={getCol ? colKeys : undefined}
|
|
292
302
|
rowLabels={getRow ? rowKeys : undefined}
|
|
293
303
|
>
|
|
294
|
-
{@render facetCells(
|
|
304
|
+
{@render facetCells(matrixKeys, colKeys.length)}
|
|
295
305
|
</Grid>
|
|
296
306
|
{:else if resolvedMode === 'carousel'}
|
|
297
307
|
<div
|
|
@@ -310,19 +320,22 @@
|
|
|
310
320
|
style:gap="{gap}px"
|
|
311
321
|
style:transform="translateX({translate}px)"
|
|
312
322
|
>
|
|
313
|
-
{#each
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
{
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
323
|
+
{#each wrapKeys as key (key)}
|
|
324
|
+
{@const entry = entryByKey.get(key)}
|
|
325
|
+
{#if entry}
|
|
326
|
+
<div class="dct-facet-carousel__cell" style:width="{carouselCellWidth}px">
|
|
327
|
+
<div class="dct-facet-carousel__title" style:height="{TITLE_H}px">{entry.label}</div>
|
|
328
|
+
{#if carouselCellWidth > 0 && carouselCellHeight > 0}
|
|
329
|
+
{@render cell({
|
|
330
|
+
facetValue: entry.label,
|
|
331
|
+
data: entry.data,
|
|
332
|
+
width: carouselCellWidth,
|
|
333
|
+
height: carouselCellHeight,
|
|
334
|
+
edges: CAROUSEL_EDGES
|
|
335
|
+
})}
|
|
336
|
+
{/if}
|
|
337
|
+
</div>
|
|
338
|
+
{/if}
|
|
326
339
|
{/each}
|
|
327
340
|
</div>
|
|
328
341
|
{#if wrapEntries.length > 1}
|
|
@@ -333,7 +346,7 @@
|
|
|
333
346
|
<div class="dct-facet-paginated" style:width="{width}px" style:height="{height}px">
|
|
334
347
|
{#key page}
|
|
335
348
|
<Grid {width} height={gridHeight} {gap} {align} header={TITLE_H} columns={pageCols}>
|
|
336
|
-
{@render facetCells(
|
|
349
|
+
{@render facetCells(pageKeys, pageCols)}
|
|
337
350
|
</Grid>
|
|
338
351
|
{/key}
|
|
339
352
|
{#if showNav}
|
|
@@ -153,6 +153,16 @@
|
|
|
153
153
|
};
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
+
// What a range set from code (`LegendState.setRange`) takes from this section.
|
|
157
|
+
$effect(() => {
|
|
158
|
+
if (!isInteractive) return;
|
|
159
|
+
interaction.narrowing = {
|
|
160
|
+
mode: section.interactive?.mode ?? 'filter',
|
|
161
|
+
disabledStyle: section.interactive?.disabledStyle ?? { opacity: cfg.legend.disabledOpacity },
|
|
162
|
+
domain: [section.min, section.max]
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
|
|
156
166
|
// Mirrors an externally-set `interaction.range` into lo/hi.
|
|
157
167
|
$effect(() => {
|
|
158
168
|
if (!isInteractive) return;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SvelteMap } from 'svelte/reactivity';
|
|
2
|
-
import type { LegendDisabledAction, LegendEntry, LegendExtension, LegendPlot, LegendRange, LegendSection } from '../../types/layout/legend';
|
|
2
|
+
import type { LegendDisabledAction, LegendDisabledStyle, LegendEntry, LegendExtension, LegendPlot, LegendRange, LegendSection } from '../../types/layout/legend';
|
|
3
3
|
/**
|
|
4
4
|
* A figure's legend, shared by every plot and legend under it: what the
|
|
5
5
|
* reader turned off - each series' {@link LegendDisabledAction}, and the
|
|
@@ -14,6 +14,18 @@ export declare class LegendState {
|
|
|
14
14
|
readonly disabled: SvelteMap<string, LegendDisabledAction>;
|
|
15
15
|
/** The narrowed range of the first interactive continuous section, or `null`. */
|
|
16
16
|
range: LegendRange | null;
|
|
17
|
+
/** How narrowing the range acts, and the span it narrows, as the interactive continuous section sets them - for a range set from code to act the same. */
|
|
18
|
+
narrowing: {
|
|
19
|
+
mode: LegendRange["mode"];
|
|
20
|
+
disabledStyle: LegendDisabledStyle;
|
|
21
|
+
domain: [number, number];
|
|
22
|
+
} | null;
|
|
23
|
+
/**
|
|
24
|
+
* Narrows the range as dragging the interactive section's handles would: `[min, max]` in the ramp's own
|
|
25
|
+
* values, or `null` for the whole of it. Its `mode` is the section's (`'filter'` without one), unless given.
|
|
26
|
+
* A span that is not a pair of numbers is left alone.
|
|
27
|
+
*/
|
|
28
|
+
setRange(bounds: [number, number] | null, mode?: LegendRange['mode']): void;
|
|
17
29
|
/** The enclosing figure's legend, or `null` outside one. Call during component init. */
|
|
18
30
|
static current(): LegendState | null;
|
|
19
31
|
/** A new legend, shared with everything below. Call during component init. */
|
|
@@ -19,9 +19,41 @@ export class LegendState {
|
|
|
19
19
|
disabled = new SvelteMap();
|
|
20
20
|
/** The narrowed range of the first interactive continuous section, or `null`. */
|
|
21
21
|
range = $state(null);
|
|
22
|
+
/** How narrowing the range acts, and the span it narrows, as the interactive continuous section sets them - for a range set from code to act the same. */
|
|
23
|
+
narrowing = $state(null);
|
|
22
24
|
#sources = new SvelteMap();
|
|
23
25
|
/** What {@link setOff} last turned off, to turn back on what it no longer lists. */
|
|
24
26
|
#managed = new SvelteMap();
|
|
27
|
+
/**
|
|
28
|
+
* Narrows the range as dragging the interactive section's handles would: `[min, max]` in the ramp's own
|
|
29
|
+
* values, or `null` for the whole of it. Its `mode` is the section's (`'filter'` without one), unless given.
|
|
30
|
+
* A span that is not a pair of numbers is left alone.
|
|
31
|
+
*/
|
|
32
|
+
setRange(bounds, mode) {
|
|
33
|
+
if (bounds === null) {
|
|
34
|
+
this.range = null;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const [a, b] = bounds;
|
|
38
|
+
if (!Number.isFinite(a) || !Number.isFinite(b))
|
|
39
|
+
return;
|
|
40
|
+
const { narrowing } = this;
|
|
41
|
+
const min = Math.min(a, b);
|
|
42
|
+
const max = Math.max(a, b);
|
|
43
|
+
// What spans the whole ramp is no narrowing: the section resets its handles the same way.
|
|
44
|
+
if (narrowing && min <= narrowing.domain[0] && max >= narrowing.domain[1]) {
|
|
45
|
+
this.range = null;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
this.range = {
|
|
49
|
+
min,
|
|
50
|
+
max,
|
|
51
|
+
mode: mode ?? narrowing?.mode ?? 'filter',
|
|
52
|
+
disabledStyle: narrowing?.disabledStyle ?? {
|
|
53
|
+
opacity: getConfiguration().legend.disabledOpacity
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
25
57
|
/** The enclosing figure's legend, or `null` outside one. Call during component init. */
|
|
26
58
|
static current() {
|
|
27
59
|
return getContext(LEGEND_KEY) ?? null;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
lang="ts"
|
|
3
3
|
generics="TRow extends Record<string, unknown>, TScales extends object, TCore extends PlotCoreBase"
|
|
4
4
|
>
|
|
5
|
-
import type
|
|
5
|
+
import { untrack, type Component } from 'svelte';
|
|
6
6
|
import Figure from '../figure/Figure.svelte';
|
|
7
7
|
import TimelineLayout from '../timeline/TimelineLayout.svelte';
|
|
8
8
|
import FacetLayout from '../facet/FacetLayout.svelte';
|
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
import { merge } from '../../utils/merge';
|
|
19
19
|
import { Pipeline } from '../../phases/Pipeline';
|
|
20
20
|
import { Slice } from './Slice';
|
|
21
|
+
import { SharedStepScales } from '../../phases/sharedScales/SharedStepScales';
|
|
22
|
+
import { TimelineTime } from '../timeline/TimelineTime';
|
|
23
|
+
import { TimelineSteps } from '../timeline/TimelineSteps';
|
|
24
|
+
import { resolveAccessor } from '../../utils/accessors';
|
|
25
|
+
import { ConfigEquality } from '../../utils/ConfigEquality';
|
|
21
26
|
import { FacetScope } from '../facet/scope';
|
|
22
27
|
import type { TooltipProp } from '../../types/layout/tooltip';
|
|
23
28
|
import type { MarginConfig, PlotChromeConfig } from '../../types/plots/props';
|
|
@@ -130,12 +135,82 @@
|
|
|
130
135
|
return (r: TRow[], f: FacetConfig<TRow> | undefined, legend: LegendState | null) =>
|
|
131
136
|
run.runData(core ?? {}, r, f as never, legend, cfg);
|
|
132
137
|
});
|
|
133
|
-
//
|
|
138
|
+
// What sets a timeline's steps, as values that change only when they do: a moving `progress` makes a new
|
|
139
|
+
// `timeline` object, which these do not follow, so the frame is not run again for it.
|
|
140
|
+
const stepAccessor = $derived(timeline?.accessor);
|
|
141
|
+
const stepDateFormat = $derived(timeline?.dateFormat);
|
|
142
|
+
const stepValues = $derived(timeline?.values);
|
|
143
|
+
const stepMode = $derived(slicing.filterMode);
|
|
144
|
+
|
|
145
|
+
// The plot's own props: one array that is the same object while none of them changed. Rows are compared as
|
|
146
|
+
// the same object, the rest - `segments`, `styles`, `scales` - as they hold, so a parent that builds the
|
|
147
|
+
// same configuration again does not make the data phases run again.
|
|
148
|
+
const ROW_PROPS = new Set(['data', 'series']);
|
|
149
|
+
let propsSeen: unknown[] = [];
|
|
150
|
+
const propsKey = $derived.by(() => {
|
|
151
|
+
const now = core
|
|
152
|
+
? Object.keys(core).flatMap((key) => [key, (core as Record<string, unknown>)[key]])
|
|
153
|
+
: [];
|
|
154
|
+
const same =
|
|
155
|
+
now.length === propsSeen.length &&
|
|
156
|
+
now.every(
|
|
157
|
+
(value, i) =>
|
|
158
|
+
Object.is(value, propsSeen[i]) ||
|
|
159
|
+
(i % 2 === 1 &&
|
|
160
|
+
!ROW_PROPS.has(String(now[i - 1])) &&
|
|
161
|
+
ConfigEquality.same(value, propsSeen[i]))
|
|
162
|
+
);
|
|
163
|
+
if (same) return propsSeen;
|
|
164
|
+
propsSeen = now;
|
|
165
|
+
return now;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The frame: the run over every row. With a timeline that shows each step's own rows, the domains it
|
|
170
|
+
* shares are the widest any step reaches - a step's rows stack, aggregate or bin among themselves, so the
|
|
171
|
+
* extent over every row at once would be wider than any step's.
|
|
172
|
+
*
|
|
173
|
+
* It is run again when what the runs read changes - the plot's props (`segments` among them), the
|
|
174
|
+
* configuration, the rows, the facet, what sets the steps - and for nothing else: a moving timeline, or
|
|
175
|
+
* a pointer, never makes it run again.
|
|
176
|
+
*/
|
|
134
177
|
let frame: { inputs: unknown[]; state: DataRunState | undefined } | undefined;
|
|
135
|
-
const frameState = $derived.by(() => {
|
|
136
|
-
const inputs = [
|
|
137
|
-
|
|
138
|
-
|
|
178
|
+
const frameState = $derived.by((): DataRunState | undefined => {
|
|
179
|
+
const inputs = [
|
|
180
|
+
propsKey,
|
|
181
|
+
cfg,
|
|
182
|
+
dataRun,
|
|
183
|
+
rows,
|
|
184
|
+
facet,
|
|
185
|
+
stepAccessor,
|
|
186
|
+
stepDateFormat,
|
|
187
|
+
stepValues,
|
|
188
|
+
stepMode
|
|
189
|
+
];
|
|
190
|
+
// Svelte may ask again with the same inputs: the answer is the one it has.
|
|
191
|
+
if (frame?.inputs.every((input, i) => Object.is(input, inputs[i]))) return frame.state;
|
|
192
|
+
const state = untrack(() => {
|
|
193
|
+
if (!dataRun || !rows) return undefined;
|
|
194
|
+
const whole = dataRun(rows, facet, null);
|
|
195
|
+
if (stepAccessor === undefined) return whole;
|
|
196
|
+
const reader = new TimelineTime(stepDateFormat);
|
|
197
|
+
const get = resolveAccessor<TRow>(stepAccessor as never) as (row: TRow) => unknown;
|
|
198
|
+
const timeOf = (row: TRow) => reader.toTime(get(row));
|
|
199
|
+
const named = stepValues?.map((v) => reader.toTime(v)).filter((t) => t !== undefined);
|
|
200
|
+
const steps = TimelineSteps.of(named, rows, (row) => timeOf(row) ?? NaN);
|
|
201
|
+
const perStep = steps.values
|
|
202
|
+
.filter((step) => !Number.isNaN(step))
|
|
203
|
+
.map((step) => {
|
|
204
|
+
// A row without a step shows in every one.
|
|
205
|
+
const shown = rows.filter((row) => {
|
|
206
|
+
const at = timeOf(row);
|
|
207
|
+
return at === undefined || TimelineSteps.matches(at, step, stepMode);
|
|
208
|
+
});
|
|
209
|
+
return dataRun(shown, facet, null).sharedScales;
|
|
210
|
+
});
|
|
211
|
+
const bands = (whole as { axes?: { bands?: string[] } }).axes?.bands ?? [];
|
|
212
|
+
return { ...whole, sharedScales: SharedStepScales.over(whole.sharedScales, bands, perStep) };
|
|
213
|
+
});
|
|
139
214
|
frame = { inputs, state };
|
|
140
215
|
return state;
|
|
141
216
|
});
|
|
@@ -216,6 +291,7 @@
|
|
|
216
291
|
? FacetPanels.buildPanelState(panels, slicing.panelKey(place) ?? '')
|
|
217
292
|
: undefined,
|
|
218
293
|
facet: place,
|
|
294
|
+
time,
|
|
219
295
|
keep: slicing.keep({ time, facet: place })
|
|
220
296
|
})}
|
|
221
297
|
</HoverScope>
|
|
@@ -260,6 +336,7 @@
|
|
|
260
336
|
height: h,
|
|
261
337
|
tooltip: tooltipConfig,
|
|
262
338
|
scales: slicing.timelineScales<TScales>(sharedScales),
|
|
339
|
+
time,
|
|
263
340
|
keep: slicing.keep({ time })
|
|
264
341
|
})}
|
|
265
342
|
{/if}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Component } from 'svelte';
|
|
2
2
|
import { Pipeline } from '../../phases/Pipeline';
|
|
3
3
|
import type { TooltipProp } from '../../types/layout/tooltip';
|
|
4
4
|
import type { MarginConfig, PlotChromeConfig } from '../../types/plots/props';
|
|
@@ -53,6 +53,11 @@ export declare class PublicLegend {
|
|
|
53
53
|
get entries(): LegendEntry[];
|
|
54
54
|
/** The range its continuous section narrows to, or `null`. */
|
|
55
55
|
get range(): LegendRange | null;
|
|
56
|
+
/**
|
|
57
|
+
* Narrows the continuous section to `[min, max]` as dragging its handles would, or resets it with `null`:
|
|
58
|
+
* its plots filter or rescale to it. `mode` is the section's own unless given.
|
|
59
|
+
*/
|
|
60
|
+
setRange(range: [number, number] | null, mode?: LegendRange['mode']): void;
|
|
56
61
|
/** The series that are turned off. */
|
|
57
62
|
get off(): string[];
|
|
58
63
|
isOff(series: string): boolean;
|
|
@@ -73,6 +73,13 @@ export class PublicLegend {
|
|
|
73
73
|
get range() {
|
|
74
74
|
return this.#state.range;
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Narrows the continuous section to `[min, max]` as dragging its handles would, or resets it with `null`:
|
|
78
|
+
* its plots filter or rescale to it. `mode` is the section's own unless given.
|
|
79
|
+
*/
|
|
80
|
+
setRange(range, mode) {
|
|
81
|
+
this.#state.setRange(range, mode);
|
|
82
|
+
}
|
|
76
83
|
/** The series that are turned off. */
|
|
77
84
|
get off() {
|
|
78
85
|
return [...this.#state.disabled.keys()];
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
function boxOf(band: Band) {
|
|
35
35
|
const bandwidth = scale.fn.bandwidth?.() ?? 0;
|
|
36
36
|
const start = Number(scale.fn(band.category as never));
|
|
37
|
-
|
|
37
|
+
// Horizontal bars count their slices from the bottom of the band, as `BandNudge` and the bars' hit areas do.
|
|
38
|
+
const from = start + (marker.axis === 'y' ? band.slice.end : band.slice.start) * bandwidth;
|
|
38
39
|
const size = Math.max(0, bandwidth * (1 - band.slice.start - band.slice.end));
|
|
39
40
|
const [low, high] = extent;
|
|
40
41
|
return marker.axis === 'x'
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { Geo } from 'svelteplot';
|
|
3
|
+
import ScaleFreeMarks from '../../layout/geometry/ScaleFreeMarks.svelte';
|
|
3
4
|
import { getConfiguration } from '../../configuration/config.svelte';
|
|
4
5
|
import type { GeoHoverMarkerConfig } from '../../types/markers/geo';
|
|
5
6
|
import type { HoverPoint } from '../../types/layout/hover';
|
|
6
7
|
|
|
7
|
-
/** Renders the hovered feature restyled in place - no dot, no label. `pointer-events: none` so it never steals hover from the base map underneath it. */
|
|
8
|
+
/** Renders the hovered feature restyled in place - no dot, no label. `pointer-events: none` so it never steals hover from the base map underneath it. Drawn on the plot's projection and scales without joining them, so a feature entering or leaving it never lays the map out again. */
|
|
8
9
|
let {
|
|
9
10
|
marker,
|
|
10
11
|
data
|
|
@@ -15,14 +16,16 @@
|
|
|
15
16
|
</script>
|
|
16
17
|
|
|
17
18
|
{#if data.length > 0}
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
<ScaleFreeMarks>
|
|
20
|
+
<Geo
|
|
21
|
+
data={data.map((p) => p.row)}
|
|
22
|
+
fill={style.fill}
|
|
23
|
+
fillOpacity={style.fillOpacity}
|
|
24
|
+
stroke={style.stroke}
|
|
25
|
+
strokeWidth={style.strokeWidth}
|
|
26
|
+
strokeOpacity={style.strokeOpacity}
|
|
27
|
+
strokeDasharray={style.strokeDasharray}
|
|
28
|
+
style="vector-effect: non-scaling-stroke; pointer-events: none;"
|
|
29
|
+
/>
|
|
30
|
+
</ScaleFreeMarks>
|
|
28
31
|
{/if}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
import { ROW_CONTEXT_KEY } from './rowContextKey';
|
|
1
2
|
import { SERIES_ID_KEY, SERIES_KEY } from '../transforms/TransformsPhase';
|
|
2
3
|
import { ProcessedPositions } from '../transforms/ProcessedPositions';
|
|
3
4
|
import { PlotAxes } from '../data/PlotAxes';
|
|
4
5
|
import { resolveAccessor } from '../../utils/accessors';
|
|
5
6
|
import { seriesIdOf } from '../../utils/grouping';
|
|
6
|
-
|
|
7
|
-
const KEY = Symbol('dct-row-context');
|
|
7
|
+
const KEY = ROW_CONTEXT_KEY;
|
|
8
8
|
/**
|
|
9
9
|
* The context of the rows a plot draws ({@link PlotRowContext}): worked out once per row by the "context" phase,
|
|
10
10
|
* kept on the row itself, and read back from it - or from any copy of it - with {@link of}.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const SharedStepScales: {
|
|
2
|
+
/**
|
|
3
|
+
* The domains the slices of a timeline share, when its steps each draw their own rows: a step's rows
|
|
4
|
+
* stack, aggregate or bin among themselves, so the extent the whole run reads across every step is
|
|
5
|
+
* wider than any step's. `whole` is the shared scales read over every row, and `steps` the same over each
|
|
6
|
+
* step's rows. A numeric (or date) extent becomes the widest any step reaches; a band or categorical
|
|
7
|
+
* domain stays the whole run's, which holds every category.
|
|
8
|
+
*/
|
|
9
|
+
over(whole: Record<string, object> | undefined, bands: string[], steps: (Record<string, object> | undefined)[]): Record<string, object> | undefined;
|
|
10
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** A channel's numeric extent, or a date one - `undefined` for any other domain. */
|
|
2
|
+
function extentOf(domain) {
|
|
3
|
+
if (domain?.length !== 2)
|
|
4
|
+
return undefined;
|
|
5
|
+
const [lo, hi] = domain.map((v) => (v instanceof Date ? +v : v));
|
|
6
|
+
return typeof lo === 'number' && typeof hi === 'number' ? [lo, hi] : undefined;
|
|
7
|
+
}
|
|
8
|
+
export const SharedStepScales = {
|
|
9
|
+
/**
|
|
10
|
+
* The domains the slices of a timeline share, when its steps each draw their own rows: a step's rows
|
|
11
|
+
* stack, aggregate or bin among themselves, so the extent the whole run reads across every step is
|
|
12
|
+
* wider than any step's. `whole` is the shared scales read over every row, and `steps` the same over each
|
|
13
|
+
* step's rows. A numeric (or date) extent becomes the widest any step reaches; a band or categorical
|
|
14
|
+
* domain stays the whole run's, which holds every category.
|
|
15
|
+
*/
|
|
16
|
+
over(whole, bands, steps) {
|
|
17
|
+
if (!whole)
|
|
18
|
+
return whole;
|
|
19
|
+
const out = {};
|
|
20
|
+
for (const [panel, scales] of Object.entries(whole)) {
|
|
21
|
+
out[panel] = { ...scales };
|
|
22
|
+
for (const [channel, scale] of Object.entries(scales)) {
|
|
23
|
+
if (!scale || bands.includes(channel))
|
|
24
|
+
continue;
|
|
25
|
+
const own = extentOf(scale.domain);
|
|
26
|
+
if (!own)
|
|
27
|
+
continue;
|
|
28
|
+
const extents = steps
|
|
29
|
+
.map((step) => extentOf(step?.[panel]?.[channel]?.domain))
|
|
30
|
+
.filter((extent) => extent !== undefined);
|
|
31
|
+
if (!extents.length)
|
|
32
|
+
continue;
|
|
33
|
+
const lo = Math.min(...extents.map(([a]) => a));
|
|
34
|
+
const hi = Math.max(...extents.map(([, b]) => b));
|
|
35
|
+
out[panel][channel] = {
|
|
36
|
+
...scale,
|
|
37
|
+
domain: scale.domain[0] instanceof Date ? [new Date(lo), new Date(hi)] : [lo, hi]
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import BasePlotLayout from '../../../layout/base/BasePlotLayout.svelte';
|
|
8
8
|
import type { GeoPlotCoreProps } from '../../../types/plots/map/geo/props';
|
|
9
9
|
import type { CoordinatesConfig } from '../../../types/layout/coordinates';
|
|
10
|
+
import type { GeoFeature } from '../../../types/plots/data/geo';
|
|
10
11
|
import type { GeoPlotState, GeoPlotEnv } from '../../../types/plots/map/geo/state';
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -19,10 +20,10 @@
|
|
|
19
20
|
let {
|
|
20
21
|
data,
|
|
21
22
|
object,
|
|
22
|
-
featureId,
|
|
23
|
-
color,
|
|
24
|
-
grouping,
|
|
25
|
-
opacity,
|
|
23
|
+
featureId: featureIdProp,
|
|
24
|
+
color: colorProp,
|
|
25
|
+
grouping: groupingProp,
|
|
26
|
+
opacity: opacityProp,
|
|
26
27
|
excludeParts,
|
|
27
28
|
width,
|
|
28
29
|
height,
|
|
@@ -33,11 +34,26 @@
|
|
|
33
34
|
coordinates = {},
|
|
34
35
|
margins = $bindable(),
|
|
35
36
|
tooltip = undefined,
|
|
37
|
+
sliceContext = {},
|
|
36
38
|
pipeline
|
|
37
39
|
}: Props = $props();
|
|
38
40
|
|
|
39
41
|
const cfg = $derived(getConfiguration());
|
|
40
42
|
|
|
43
|
+
// The callbacks read a feature in the slice this core draws; the pipeline's phases call them with the feature alone.
|
|
44
|
+
const color = $derived(colorProp && ((f: GeoFeature<TProps>) => colorProp(f, sliceContext)));
|
|
45
|
+
const grouping = $derived(
|
|
46
|
+
groupingProp && ((f: GeoFeature<TProps>) => groupingProp(f, sliceContext))
|
|
47
|
+
);
|
|
48
|
+
const opacity = $derived(
|
|
49
|
+
opacityProp && ((f: GeoFeature<TProps>) => opacityProp(f, sliceContext))
|
|
50
|
+
);
|
|
51
|
+
const featureId = $derived(
|
|
52
|
+
typeof featureIdProp === 'function'
|
|
53
|
+
? (f: GeoFeature<TProps>) => featureIdProp(f, sliceContext)
|
|
54
|
+
: featureIdProp
|
|
55
|
+
);
|
|
56
|
+
|
|
41
57
|
// ── Resolve data: GeoJSON is synchronous, TopoJSON is converted async ────
|
|
42
58
|
const resolved = new GeoFeatures<TProps>(
|
|
43
59
|
() => data,
|
|
@@ -83,7 +99,7 @@
|
|
|
83
99
|
fillOpacity: styles.fillOpacity,
|
|
84
100
|
stroke: styles.stroke,
|
|
85
101
|
strokeWidth: styles.strokeWidth,
|
|
86
|
-
onFeatureClick: styles.onFeatureClick
|
|
102
|
+
onFeatureClick: styles.onFeatureClick && ((d, e) => styles.onFeatureClick!(d, e, sliceContext))
|
|
87
103
|
});
|
|
88
104
|
// The kind's pipeline, built once by its plot, over the state this core seeds.
|
|
89
105
|
const plotState = $derived(
|
|
@@ -98,6 +114,7 @@
|
|
|
98
114
|
segments,
|
|
99
115
|
scales,
|
|
100
116
|
authoredMarkers: markers,
|
|
117
|
+
slice: sliceContext,
|
|
101
118
|
tooltip,
|
|
102
119
|
width,
|
|
103
120
|
height,
|
|
@@ -130,7 +147,13 @@
|
|
|
130
147
|
markers: markersOut,
|
|
131
148
|
scales: layoutScales,
|
|
132
149
|
channels: {
|
|
133
|
-
|
|
150
|
+
// Its own ramps draw every colored feature: the plot's has nothing to show.
|
|
151
|
+
color: {
|
|
152
|
+
source:
|
|
153
|
+
plotState.colorRamps && !plotState.colorDomain
|
|
154
|
+
? undefined
|
|
155
|
+
: ChannelName.of(color, 'color')
|
|
156
|
+
},
|
|
134
157
|
opacity: { source: ChannelName.of(opacity, 'opacity') }
|
|
135
158
|
},
|
|
136
159
|
colorDomain: plotState.colorDomain,
|