@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/index.d.ts +1 -1
  3. package/dist/layout/facet/FacetLayout.svelte +54 -41
  4. package/dist/layout/legend/ContinuousSection.svelte +10 -0
  5. package/dist/layout/legend/LegendState.svelte.d.ts +13 -1
  6. package/dist/layout/legend/LegendState.svelte.js +32 -0
  7. package/dist/layout/plot/AbstractPlot.svelte +83 -6
  8. package/dist/layout/plot/AbstractPlot.svelte.d.ts +1 -1
  9. package/dist/layout/plot/PublicPlotContext.d.ts +5 -0
  10. package/dist/layout/plot/PublicPlotContext.js +7 -0
  11. package/dist/markers/band/BandMarker.svelte +2 -1
  12. package/dist/markers/geo/GeoHoverMarker.svelte +14 -11
  13. package/dist/phases/context/RowContext.js +2 -2
  14. package/dist/phases/context/rowContextKey.d.ts +2 -0
  15. package/dist/phases/context/rowContextKey.js +2 -0
  16. package/dist/phases/sharedScales/SharedStepScales.d.ts +10 -0
  17. package/dist/phases/sharedScales/SharedStepScales.js +43 -0
  18. package/dist/plots/map/geo/Core.svelte +29 -6
  19. package/dist/plots/map/geo/Plot.svelte +38 -10
  20. package/dist/plots/map/geo/phases/data.d.ts +2 -1
  21. package/dist/plots/map/geo/phases/data.js +8 -2
  22. package/dist/plots/map/geo/phases/scales.js +6 -2
  23. package/dist/plots/map/geo/slices.d.ts +27 -3
  24. package/dist/plots/map/geo/slices.js +58 -5
  25. package/dist/plots/ranking/bar/markers.js +2 -2
  26. package/dist/types/layout/plot.d.ts +2 -0
  27. package/dist/types/layout/styles/geo.d.ts +3 -3
  28. package/dist/types/plots/data/geo.d.ts +19 -5
  29. package/dist/types/plots/map/geo/props.d.ts +9 -3
  30. package/dist/types/plots/map/geo/state.d.ts +3 -1
  31. package/dist/utils/ConfigEquality.d.ts +11 -0
  32. package/dist/utils/ConfigEquality.js +37 -0
  33. package/docs/facets.md +12 -0
  34. package/docs/geo.md +35 -7
  35. package/docs/legend.md +6 -0
  36. package/docs/plots/ranking/bar.md +1 -1
  37. package/docs/state.md +1 -1
  38. package/docs/timeline.md +1 -1
  39. package/docs/transforms-and-statistics.md +1 -1
  40. package/llms-full.txt +3 -3
  41. 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 pageEntries = $derived(
160
+ const pageKeys = $derived(
154
161
  resolvedMode === 'paginated'
155
- ? wrapEntries.slice(page * resolvedPageSize, page * resolvedPageSize + resolvedPageSize)
156
- : wrapEntries
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, pageEntries.length || 1)));
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(entries: Entry[], columnCount: number)}
259
- {#each entries as entry, i (entry.key)}
260
- <GridCell
261
- row={entry.r ?? Math.floor(i / columnCount)}
262
- col={entry.c ?? i % columnCount}
263
- label={wrap ? entry.label : undefined}
264
- data-row={entry.row}
265
- data-col={entry.col}
266
- >
267
- {#snippet children(box)}
268
- {@render cell({
269
- facetValue: entry.label,
270
- row: entry.row,
271
- col: entry.col,
272
- data: entry.data,
273
- width: box.width,
274
- height: box.height,
275
- margins: box.margins,
276
- edges: box.edges
277
- })}
278
- {/snippet}
279
- </GridCell>
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(matrixEntries, colKeys.length)}
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 wrapEntries as entry (entry.key)}
314
- <div class="dct-facet-carousel__cell" style:width="{carouselCellWidth}px">
315
- <div class="dct-facet-carousel__title" style:height="{TITLE_H}px">{entry.label}</div>
316
- {#if carouselCellWidth > 0 && carouselCellHeight > 0}
317
- {@render cell({
318
- facetValue: entry.label,
319
- data: entry.data,
320
- width: carouselCellWidth,
321
- height: carouselCellHeight,
322
- edges: CAROUSEL_EDGES
323
- })}
324
- {/if}
325
- </div>
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(pageEntries, pageCols)}
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 { Component } from 'svelte';
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
- // Kept while its inputs are the same objects: a timeline step re-reads them without changing them.
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 = [dataRun, facet, rows];
137
- if (frame?.inputs.every((input, i) => input === inputs[i])) return frame.state;
138
- const state = dataRun && rows ? dataRun(rows, facet, null) : undefined;
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 { Component } from 'svelte';
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
- const from = start + band.slice.start * bandwidth;
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
- <Geo
19
- data={data.map((p) => p.row)}
20
- fill={style.fill}
21
- fillOpacity={style.fillOpacity}
22
- stroke={style.stroke}
23
- strokeWidth={style.strokeWidth}
24
- strokeOpacity={style.strokeOpacity}
25
- strokeDasharray={style.strokeDasharray}
26
- style="vector-effect: non-scaling-stroke; pointer-events: none;"
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
- /** Where a row's context is kept on it: under a symbol, which survives the copies svelteplot makes of a row. */
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,2 @@
1
+ /** Where a row's context is kept on it: under a symbol, which survives the copies svelteplot makes of a row. */
2
+ export declare const ROW_CONTEXT_KEY: symbol;
@@ -0,0 +1,2 @@
1
+ /** Where a row's context is kept on it: under a symbol, which survives the copies svelteplot makes of a row. */
2
+ export const ROW_CONTEXT_KEY = Symbol('dct-row-context');
@@ -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
- color: { source: ChannelName.of(color, 'color') },
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,