@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
@@ -3,7 +3,8 @@
3
3
  import AbstractPlot from '../../../layout/plot/AbstractPlot.svelte';
4
4
  import { merge } from '../../../utils/merge';
5
5
  import { buildGeoDefaults } from './defaults';
6
- import { buildGeoSlicing } from './slices';
6
+ import { buildGeoSlicing, GeoSlices } from './slices';
7
+ import { Slice } from '../../../layout/plot/Slice';
7
8
  import { GeoFeatures } from './GeoFeatures.svelte';
8
9
  import type { GeoPlotProps, GeoPlotCoreProps } from '../../../types/plots/map/geo/props';
9
10
  import type { GeoFeature } from '../../../types/plots/data/geo';
@@ -19,9 +20,11 @@
19
20
  * Optional chrome (`title`, `subtitle`, `caption`, `header`, `footer`,
20
21
  * `legend`) frames it. `timeline` and `facet` slice its features and its
21
22
  * markers' own rows by a field (a feature's from its `properties`); what
22
- * lacks the field draws in every slice, and the color ramp spans every
23
- * slice. With `coordinates.projection.domain: 'data'`, each facet zooms to
24
- * its own features.
23
+ * lacks the field draws in every slice, the color ramp spans every
24
+ * slice, and the panels may come from `facet.values` alone (or `rowValues`, `colValues`,
25
+ * `timeline.values`), one layer of features serving them all. The callbacks that read a
26
+ * feature get the slice as their last argument (`GeoSliceContext`). With
27
+ * `coordinates.projection.domain: 'data'`, each facet zooms to its own features.
25
28
  */
26
29
  let {
27
30
  title,
@@ -54,13 +57,29 @@
54
57
  buildGeoSlicing({ rows: [...features, ...markerRows], timeline, facet })
55
58
  );
56
59
 
57
- /** The color ramp's domain over every feature, so each slice colors on the same scale. */
60
+ /**
61
+ * The color ramp's domain over every feature as each slice reads it - a facet panel, a timeline step, each
62
+ * with its own context - so every slice colors on the same scale. Not under `scales: 'free'` (the
63
+ * facet's, or the timeline's when there is no facet): each slice then fits its own.
64
+ */
58
65
  const sharedColor = $derived.by((): GeoScalesConfig | undefined => {
59
66
  const value = core.color;
60
67
  if (!value) return undefined;
61
- const values = (resolved.features as GeoFeature<TProps>[])
62
- .map((f) => value(f))
63
- .filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
68
+ if ((facet ? facet.scales : timeline?.scales) === 'free') return undefined;
69
+ const all = resolved.features as GeoFeature<TProps>[];
70
+ const whole = new Slice({
71
+ rows: features,
72
+ timeline: slicing.timeline,
73
+ facet: slicing.facet,
74
+ filterMode: undefined
75
+ });
76
+ const reads = slicing.slices.length
77
+ ? slicing.slices.flatMap(({ place, context }) => {
78
+ const keep = whole.keep(place) as unknown as (f: GeoFeature<TProps>) => boolean;
79
+ return all.filter(keep).map((f) => value(f, context));
80
+ })
81
+ : all.map((f) => value(f, {}));
82
+ const values = reads.filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
64
83
  return values.length
65
84
  ? { color: { domain: [Math.min(...values), Math.max(...values)] } }
66
85
  : undefined;
@@ -69,8 +88,16 @@
69
88
  /** The core's props for one slice: the features and marker rows it keeps. */
70
89
  function sliceProps(slice: PlotCellContext<Row>): GeoPlotCoreProps<TProps> {
71
90
  const tip = slice.tooltip as TooltipConfig<GeoFeature<TProps>> | undefined;
91
+ const sliceContext = GeoSlices.contextOf(slice);
72
92
  if (!sliced)
73
- return { ...core, width: slice.width, height: slice.height, margins, tooltip: tip };
93
+ return {
94
+ ...core,
95
+ width: slice.width,
96
+ height: slice.height,
97
+ margins,
98
+ tooltip: tip,
99
+ sliceContext
100
+ };
74
101
  return {
75
102
  ...core,
76
103
  data: features.filter(slice.keep) as unknown as GeoFeature<TProps>[],
@@ -82,7 +109,8 @@
82
109
  height: slice.height,
83
110
  margins: slice.margins ?? margins,
84
111
  scales: merge(sharedColor, core.scales),
85
- tooltip: tip
112
+ tooltip: tip,
113
+ sliceContext
86
114
  } as GeoPlotCoreProps<TProps>;
87
115
  }
88
116
  </script>
@@ -1,6 +1,7 @@
1
1
  import type { GeoPlotState } from '../../../../types/plots/map/geo/state';
2
2
  /**
3
3
  * GeoPlot's "data" phase: every feature tagged with its resolved id/centroid,
4
- * then reduced by `excludeParts`. Reads no env.
4
+ * then reduced by `excludeParts`. Each carries the slice it is drawn in as its
5
+ * row context. Reads no env.
5
6
  */
6
7
  export declare function buildData<TProps extends Record<string, unknown>>(state: GeoPlotState<TProps>): GeoPlotState<TProps>;
@@ -1,12 +1,14 @@
1
1
  import { resolveAccessor } from '../../../../utils/accessors';
2
2
  import { GeoGeometry } from '../GeoGeometry';
3
3
  import { GeoFeatures } from '../GeoFeatures.svelte';
4
+ import { ROW_CONTEXT_KEY } from '../../../../phases/context/rowContextKey';
4
5
  /**
5
6
  * GeoPlot's "data" phase: every feature tagged with its resolved id/centroid,
6
- * then reduced by `excludeParts`. Reads no env.
7
+ * then reduced by `excludeParts`. Each carries the slice it is drawn in as its
8
+ * row context. Reads no env.
7
9
  */
8
10
  export function buildData(state) {
9
- const { rawFeatures, featureId, excludeParts } = state;
11
+ const { rawFeatures, featureId, excludeParts, slice = {} } = state;
10
12
  const getFeatureId = featureId
11
13
  ? resolveAccessor(featureId)
12
14
  : GeoFeatures.defaultId;
@@ -23,5 +25,9 @@ export function buildData(state) {
23
25
  const [cx, cy] = GeoGeometry.centroid(geometry);
24
26
  return [{ ...f, geometry, __cx: cx, __cy: cy }];
25
27
  });
28
+ // Kept as the row's context, which a copy of the feature carries along: a tooltip's `format` reads it back.
29
+ for (const f of new Set([...allFeatures, ...features])) {
30
+ f[ROW_CONTEXT_KEY] = slice;
31
+ }
26
32
  return { ...state, allFeatures, features };
27
33
  }
@@ -61,7 +61,9 @@ export function buildScales(state, env) {
61
61
  return colorScale ? colorScale(v) : undefined;
62
62
  }
63
63
  : undefined;
64
- // Every segment ramp is built up front, so the legend can draw each one.
64
+ // Every segment ramp is built up front, so the legend can draw each one. The plot's own ramp is left out of
65
+ // the legend when every colored feature has one of its own: nothing is drawn with it.
66
+ let usesPlotRamp = false;
65
67
  if (colorScale) {
66
68
  for (const f of features) {
67
69
  const featureId = f.__id ?? '';
@@ -69,6 +71,8 @@ export function buildScales(state, env) {
69
71
  const segScales = GeoSegments.scales(segments, featureId, groupingValue);
70
72
  if (segScales?.color)
71
73
  segmentColorScaleFor(groupingValue ?? featureId, segScales);
74
+ else if (color?.(f) != null)
75
+ usesPlotRamp = true;
72
76
  }
73
77
  }
74
78
  // Plot-level only - no segment-scoped opacity ramp (unlike `resolveColor`
@@ -90,7 +94,7 @@ export function buildScales(state, env) {
90
94
  : undefined;
91
95
  return {
92
96
  ...state,
93
- colorDomain: valueDomain ?? undefined,
97
+ colorDomain: (usesPlotRamp || !colorRamps.length ? valueDomain : undefined) ?? undefined,
94
98
  colorRamps: colorRamps.length ? colorRamps : undefined,
95
99
  opacityDomain: opacityDomain && typeof opacityDomain[0] === 'number'
96
100
  ? opacityDomain
@@ -1,10 +1,22 @@
1
1
  import type { FacetConfig } from '../../../types/layout/facet';
2
- import type { TimelineConfig } from '../../../types/layout/timeline';
2
+ import type { TimelineConfig, TimeValue } from '../../../types/layout/timeline';
3
+ import type { SlicePlace } from '../../../types/layout/plot';
4
+ import type { GeoSliceContext } from '../../../types/plots/data/geo';
3
5
  type Row = Record<string, unknown>;
6
+ /** One slice of a sliced map: where it sits, and the context its features are read in. */
7
+ export type GeoSlice = {
8
+ place: SlicePlace;
9
+ context: GeoSliceContext;
10
+ };
4
11
  /**
5
12
  * What a GeoPlot hands `Plot` to slice by: one stand-in row per value
6
- * combination found in the features and markers' rows, and timeline/facet
7
- * accessors that read a stand-in or a real row.
13
+ * combination, and timeline/facet accessors that read a stand-in or a real
14
+ * row. A dimension's values are the ones the timeline or facet names
15
+ * (`timeline.values`, `facet.values`, `rowValues`, `colValues`), in
16
+ * their order, then any more found in the features and markers' rows - so
17
+ * the slices exist even when no feature carries the field. Each combination
18
+ * is also returned as a `slices` entry: its place and the context its
19
+ * features are read in.
8
20
  */
9
21
  export declare function buildGeoSlicing(args: {
10
22
  rows: Row[];
@@ -12,7 +24,19 @@ export declare function buildGeoSlicing(args: {
12
24
  facet?: FacetConfig<Row>;
13
25
  }): {
14
26
  rows: Row[];
27
+ slices: GeoSlice[];
15
28
  timeline?: TimelineConfig<Row>;
16
29
  facet?: FacetConfig<Row>;
17
30
  };
31
+ export declare const GeoSlices: {
32
+ /** The context a callback reads a feature in, for the slice at `place`: empty when there is none. */
33
+ contextOf(place: {
34
+ time?: TimeValue;
35
+ facet?: {
36
+ facetValue: string;
37
+ row?: string;
38
+ col?: string;
39
+ };
40
+ }): GeoSliceContext;
41
+ };
18
42
  export {};
@@ -12,8 +12,13 @@ function readField(field, row) {
12
12
  }
13
13
  /**
14
14
  * What a GeoPlot hands `Plot` to slice by: one stand-in row per value
15
- * combination found in the features and markers' rows, and timeline/facet
16
- * accessors that read a stand-in or a real row.
15
+ * combination, and timeline/facet accessors that read a stand-in or a real
16
+ * row. A dimension's values are the ones the timeline or facet names
17
+ * (`timeline.values`, `facet.values`, `rowValues`, `colValues`), in
18
+ * their order, then any more found in the features and markers' rows - so
19
+ * the slices exist even when no feature carries the field. Each combination
20
+ * is also returned as a `slices` entry: its place and the context its
21
+ * features are read in.
17
22
  */
18
23
  export function buildGeoSlicing(args) {
19
24
  const { rows, timeline, facet } = args;
@@ -31,17 +36,47 @@ export function buildGeoSlicing(args) {
31
36
  const reader = (dimension) => (row) => SLICE in row
32
37
  ? row[SLICE][dimension]
33
38
  : readField(fields[dimension], row);
39
+ /** The values the timeline or facet names for a dimension, ahead of the ones found. */
40
+ const named = {
41
+ time: timeline?.values,
42
+ by: facet && isFacetWrap(facet) ? facet.values : undefined,
43
+ rows: facet && !isFacetWrap(facet) ? facet.rowValues : undefined,
44
+ cols: facet && !isFacetWrap(facet) ? facet.colValues : undefined
45
+ };
46
+ const time = new TimelineTime(timeline?.dateFormat);
34
47
  let combos = [{}];
35
48
  for (const dimension of Object.keys(fields)) {
36
49
  const read = rows.map((row) => readField(fields[dimension], row)).filter((v) => v != null);
37
50
  // Dates on the same step are one value.
38
- const time = new TimelineTime(timeline?.dateFormat);
39
- const keyOf = (value) => dimension === 'time' ? String(time.toTime(value) ?? value) : value;
40
- const values = [...new Map(read.map((value) => [keyOf(value), value])).values()];
51
+ const keyOf = (value) => dimension === 'time' ? String(time.toTime(value) ?? value) : String(value);
52
+ const values = [
53
+ ...new Map([...(named[dimension] ?? []), ...read].map((value) => [keyOf(value), value])).values()
54
+ ];
41
55
  combos = combos.flatMap((combo) => values.map((value) => ({ ...combo, [dimension]: value })));
42
56
  }
57
+ const slices = combos.map((combo) => {
58
+ const tick = combo.time === undefined ? undefined : time.toTime(combo.time);
59
+ const by = combo.by === undefined ? undefined : String(combo.by);
60
+ const row = combo.rows === undefined ? undefined : String(combo.rows);
61
+ const col = combo.cols === undefined ? undefined : String(combo.cols);
62
+ const value = by ?? (row !== undefined && col !== undefined ? `${row} · ${col}` : (row ?? col));
63
+ const place = {
64
+ ...(tick === undefined ? {} : { time: tick }),
65
+ ...(value === undefined
66
+ ? {}
67
+ : {
68
+ facet: {
69
+ facetValue: value,
70
+ ...(row === undefined ? {} : { row }),
71
+ ...(col === undefined ? {} : { col })
72
+ }
73
+ })
74
+ };
75
+ return { place, context: GeoSlices.contextOf(place) };
76
+ });
43
77
  return {
44
78
  rows: combos.map((combo) => ({ [SLICE]: combo })),
79
+ slices,
45
80
  timeline: timeline && {
46
81
  ...timeline,
47
82
  accessor: reader('time'),
@@ -57,3 +92,21 @@ export function buildGeoSlicing(args) {
57
92
  })
58
93
  };
59
94
  }
95
+ export const GeoSlices = {
96
+ /** The context a callback reads a feature in, for the slice at `place`: empty when there is none. */
97
+ contextOf(place) {
98
+ const { time, facet } = place;
99
+ return {
100
+ ...(facet
101
+ ? {
102
+ facet: {
103
+ value: facet.facetValue,
104
+ ...(facet.row === undefined ? {} : { row: facet.row }),
105
+ ...(facet.col === undefined ? {} : { col: facet.col })
106
+ }
107
+ }
108
+ : {}),
109
+ ...(time === undefined ? {} : { time })
110
+ };
111
+ }
112
+ };
@@ -57,11 +57,11 @@ hover, env) {
57
57
  data: [row],
58
58
  category: categoryOf(row),
59
59
  hover: hover.active && 'trigger',
60
- ...(slice
60
+ styles: (slice
61
61
  ? isHorizontal
62
62
  ? { insetTop: slice.start, insetBottom: slice.end }
63
63
  : { insetLeft: slice.start, insetRight: slice.end }
64
- : {})
64
+ : undefined)
65
65
  };
66
66
  });
67
67
  }
@@ -31,6 +31,8 @@ export type PlotCellContext<TRow extends Record<string, unknown>, TScales extend
31
31
  panel?: object;
32
32
  /** Where this slice sits among the facet panels - `undefined` when the plot has no facet. */
33
33
  facet?: NonNullable<SlicePlace['facet']>;
34
+ /** The timeline step this slice shows - `undefined` when the plot has no timeline. */
35
+ time?: TimeValue;
34
36
  /** Whether any row - the plot's own or a marker's - belongs in this slice; rows without a slicing field belong in all. */
35
37
  keep: (row: TRow) => boolean;
36
38
  };
@@ -1,4 +1,4 @@
1
- import type { GeoFeature } from '../../plots/data/geo';
1
+ import type { GeoFeature, GeoSliceContext } from '../../plots/data/geo';
2
2
  import type { FrameProp } from './frame';
3
3
  import type { BasePlotStylesConfig } from './base';
4
4
  export type GeoTileLayerConfig = {
@@ -26,8 +26,8 @@ export type GeoStylesConfig<TProps extends Record<string, unknown> = Record<stri
26
26
  strokeWidth?: number;
27
27
  /** Raster XYZ tile basemap under the vector geometry. Mercator-family projections only. */
28
28
  tileLayer?: GeoTileLayerConfig;
29
- /** Fired when a base-map feature is clicked. */
30
- onFeatureClick?: (feature: GeoFeature<TProps>, event: Event) => void;
29
+ /** Fired when a base-map feature is clicked, with the slice it was drawn in. */
30
+ onFeatureClick?: (feature: GeoFeature<TProps>, event: Event, slice: GeoSliceContext) => void;
31
31
  };
32
32
  /**
33
33
  * `GeoTileLayerConfig`, plus `features` - never author-facing (the public
@@ -1,4 +1,4 @@
1
- import type { Accessor } from './common';
1
+ import type { TimeValue } from '../../layout/timeline';
2
2
  /**
3
3
  * One GeoJSON feature, generically typed over its `properties`. Intersected
4
4
  * with `Record<string, unknown>` so it satisfies the same generic bound
@@ -7,6 +7,20 @@ import type { Accessor } from './common';
7
7
  * index signature on its own.
8
8
  */
9
9
  export type GeoFeature<TProps extends Record<string, unknown> = Record<string, unknown>> = GeoJSON.Feature<GeoJSON.Geometry, TProps> & Record<string, unknown>;
10
+ /**
11
+ * Which slice of a `timeline` and `facet` a feature is being read for: the callbacks of a sliced map get it as
12
+ * their last argument. Empty on a map without either.
13
+ */
14
+ export type GeoSliceContext = {
15
+ /** The facet panel: its label (a grid's `row · col`), and the `rows` and `cols` values it sits at. */
16
+ facet?: {
17
+ value: string;
18
+ row?: string;
19
+ col?: string;
20
+ };
21
+ /** The timeline step. */
22
+ time?: TimeValue;
23
+ };
10
24
  export type GeoJsonInput<TProps extends Record<string, unknown>> = GeoJSON.FeatureCollection<GeoJSON.Geometry, TProps> | GeoFeature<TProps>[];
11
25
  /**
12
26
  * Minimal structural shape of a TopoJSON `Topology` object. Deliberately not
@@ -46,14 +60,14 @@ export type GeoDataProps<TProps extends Record<string, unknown> = Record<string,
46
60
  * (e.g. `segments.argentina`) and for hover/click identification. Defaults
47
61
  * to `feature.id ?? feature.properties?.id ?? feature.properties?.name`.
48
62
  */
49
- featureId?: Accessor<GeoFeature<TProps>, string>;
63
+ featureId?: keyof GeoFeature<TProps> | ((feature: GeoFeature<TProps>, slice: GeoSliceContext) => string);
50
64
  /**
51
65
  * Optional continuous color-value accessor for choropleth coloring - the
52
66
  * channel driving the ramp (mirrors heatmap's `color`), configured via
53
67
  * `scales.color`. A feature with no `color` (or when omitted entirely)
54
68
  * falls back to `styles.fill`/`segments`.
55
69
  */
56
- color?: (feature: GeoFeature<TProps>) => number | undefined;
70
+ color?: (feature: GeoFeature<TProps>, slice: GeoSliceContext) => number | undefined;
57
71
  /**
58
72
  * Optional structural grouping key - splits features into named buckets
59
73
  * for `segments` to address by name (e.g. `segments.global_south`),
@@ -64,9 +78,9 @@ export type GeoDataProps<TProps extends Record<string, unknown> = Record<string,
64
78
  * ungrouped. A feature whose `grouping` value has no matching `segments`
65
79
  * key simply falls through to `segments.default`/the plot-level ramp.
66
80
  */
67
- grouping?: (feature: GeoFeature<TProps>) => string | undefined;
81
+ grouping?: (feature: GeoFeature<TProps>, slice: GeoSliceContext) => string | undefined;
68
82
  /** Continuous or categorical opacity-value accessor - configure the ramp via `scales.opacity`. */
69
- opacity?: (feature: GeoFeature<TProps>) => number | undefined;
83
+ opacity?: (feature: GeoFeature<TProps>, slice: GeoSliceContext) => number | undefined;
70
84
  /**
71
85
  * Excludes matching parts of a feature's geometry from the map's own
72
86
  * rendering and hover/click matching - e.g. so a separate `inset` marker
@@ -1,6 +1,7 @@
1
1
  import type { BasePlotProps, PlotCoreProps } from '../../props';
2
2
  import type { GeoScalesConfig, CoordinatesConfig } from '../../../layout/coordinates';
3
- import type { GeoFeature, GeoDataProps } from '../../data/geo';
3
+ import type { GeoFeature, GeoDataProps, GeoSliceContext } from '../../data/geo';
4
+ import type { PlotRowContext } from '../../../layout/context';
4
5
  import type { GeoStylesConfig } from '../../../layout/styles';
5
6
  import type { GeoSegmentStyle } from '../../segments/common';
6
7
  import type { GeoMarkersConfig } from '../../../markers/geo';
@@ -14,10 +15,15 @@ type OmitEach<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
14
15
  * marker's own `data` rows; a feature or row without the field is context,
15
16
  * drawn in every slice.
16
17
  */
17
- export type GeoPlotProps<TProps extends Record<string, unknown>> = OmitEach<BasePlotProps<GeoDataProps<TProps>, GeoFeature<TProps>, GeoStylesConfig<TProps>, GeoSegmentStyle, GeoMarkersConfig, GeoScalesConfig, CoordinatesConfig>, 'timeline' | 'facet'> & {
18
+ export type GeoPlotProps<TProps extends Record<string, unknown>> = OmitEach<BasePlotProps<GeoDataProps<TProps>, GeoFeature<TProps>, GeoStylesConfig<TProps>, GeoSegmentStyle, GeoMarkersConfig, GeoScalesConfig, CoordinatesConfig, GeoTooltipContext>, 'timeline' | 'facet'> & {
18
19
  timeline?: TimelineConfig<Record<string, unknown>>;
19
20
  facet?: FacetConfig<Record<string, unknown>>;
20
21
  };
22
+ /** What a tooltip's `format` knows of a hovered feature: the shared row context, and the slice it was drawn in. */
23
+ export type GeoTooltipContext = PlotRowContext & GeoSliceContext;
21
24
  /** What `GeoPlot`'s core draws from. */
22
- export type GeoPlotCoreProps<TProps extends Record<string, unknown>> = PlotCoreProps<GeoDataProps<TProps>, GeoFeature<TProps>, GeoStylesConfig<TProps>, GeoSegmentStyle, GeoMarkersConfig, GeoScalesConfig, CoordinatesConfig>;
25
+ export type GeoPlotCoreProps<TProps extends Record<string, unknown>> = PlotCoreProps<GeoDataProps<TProps>, GeoFeature<TProps>, GeoStylesConfig<TProps>, GeoSegmentStyle, GeoMarkersConfig, GeoScalesConfig, CoordinatesConfig> & {
26
+ /** The slice of the `timeline` and `facet` this core draws - what its callbacks read its features in. */
27
+ sliceContext?: GeoSliceContext;
28
+ };
23
29
  export {};
@@ -1,5 +1,5 @@
1
1
  import type { Accessor } from '../../data/common';
2
- import type { GeoFeature } from '../../data/geo';
2
+ import type { GeoFeature, GeoSliceContext } from '../../data/geo';
3
3
  import type { ColorScaleConfig } from '../../../layout/scales';
4
4
  import type { GeoScalesConfig } from '../../../layout/coordinates';
5
5
  import type { SegmentsConfig, MarginConfig, HoverConfig } from '../../props';
@@ -46,6 +46,8 @@ export type GeoPlotState<TProps extends Record<string, unknown>> = {
46
46
  }, feature: TaggedGeoFeature<TProps>) => boolean;
47
47
  segments: SegmentsConfig<GeoSegmentStyle>;
48
48
  scales: GeoScalesConfig;
49
+ /** The slice of the `timeline` and `facet` this core draws, kept on each of its features for a tooltip's `format` to read. */
50
+ slice?: GeoSliceContext;
49
51
  /** The plot's `tooltip`, which the "hover" phase reads. */
50
52
  tooltip?: TooltipProp<TaggedGeoFeature<TProps>>;
51
53
  /** The raw `markers` prop - GeoPlot interleaves it with the base map by draw order, not a plain append. */
@@ -0,0 +1,11 @@
1
+ /** Whether two configuration values hold the same, for a plot to tell a new configuration from one built again. */
2
+ export declare class ConfigEquality {
3
+ #private;
4
+ /** How many levels of objects and arrays it looks into; a value nested deeper only matches as the same object. */
5
+ static readonly MAX_DEPTH = 6;
6
+ /**
7
+ * Whether `a` and `b` are the same value, or plain objects and arrays alike all the way down, dates of one
8
+ * time; anything else - a function, a class instance - only if it is the same object.
9
+ */
10
+ static same(a: unknown, b: unknown, depth?: number): boolean;
11
+ }
@@ -0,0 +1,37 @@
1
+ /** Whether two configuration values hold the same, for a plot to tell a new configuration from one built again. */
2
+ export class ConfigEquality {
3
+ /** How many levels of objects and arrays it looks into; a value nested deeper only matches as the same object. */
4
+ static MAX_DEPTH = 6;
5
+ /**
6
+ * Whether `a` and `b` are the same value, or plain objects and arrays alike all the way down, dates of one
7
+ * time; anything else - a function, a class instance - only if it is the same object.
8
+ */
9
+ static same(a, b, depth = 0) {
10
+ if (Object.is(a, b))
11
+ return true;
12
+ if (depth >= ConfigEquality.MAX_DEPTH)
13
+ return false;
14
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
15
+ return false;
16
+ if (a instanceof Date || b instanceof Date) {
17
+ return a instanceof Date && b instanceof Date && +a === +b;
18
+ }
19
+ if (Array.isArray(a) || Array.isArray(b)) {
20
+ return (Array.isArray(a) &&
21
+ Array.isArray(b) &&
22
+ a.length === b.length &&
23
+ a.every((value, i) => ConfigEquality.same(value, b[i], depth + 1)));
24
+ }
25
+ if (!ConfigEquality.#isPlain(a) || !ConfigEquality.#isPlain(b))
26
+ return false;
27
+ const keys = Reflect.ownKeys(a);
28
+ if (keys.length !== Reflect.ownKeys(b).length)
29
+ return false;
30
+ return keys.every((key) => Object.prototype.hasOwnProperty.call(b, key) &&
31
+ ConfigEquality.same(a[key], b[key], depth + 1));
32
+ }
33
+ static #isPlain(value) {
34
+ const proto = Object.getPrototypeOf(value);
35
+ return proto === Object.prototype || proto === null;
36
+ }
37
+ }
package/docs/facets.md CHANGED
@@ -168,3 +168,15 @@ Panels line up: each plot reports the margins it settled on, and once all have,
168
168
  ## Maps
169
169
 
170
170
  `GeoPlot` takes `facet` and reads the field from each feature's `properties` (and from each marker row); features without the field are drawn in every panel.
171
+
172
+ The panels need not come from the features. `values` (or a grid's `rowValues` and `colValues`) name them, and they exist even when no feature carries the field: one layer of features serves every panel, with no copy of it per value. Each callback that reads a feature - `color`, `opacity`, `grouping`, `featureId`, `styles.onFeatureClick` - gets the slice it is drawn in as its last argument, `{ facet?: { value, row?, col? }, time? }`, and a tooltip's `format` finds the same `facet` and `time` in its context:
173
+
174
+ ```svelte
175
+ <GeoPlot
176
+ data={provinces}
177
+ facet={{ by: 'sex', values: ['Women', 'Men'] }}
178
+ color={(f, { facet }) => valueFor(f.properties.name, facet?.value)}
179
+ />
180
+ ```
181
+
182
+ The ramp spans every panel, each read in its own slice. See [Facets and timeline](geo.md#facets-and-timeline) on maps.
package/docs/geo.md CHANGED
@@ -19,13 +19,13 @@
19
19
 
20
20
  ## Data
21
21
 
22
- | Prop | Meaning |
23
- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
24
- | `data` | A GeoJSON `FeatureCollection`, an array of `Feature`s, or a TopoJSON `Topology` |
25
- | `object` | Required for a `Topology`: the key under `topology.objects` to draw. Needs the optional `topojson-client` package |
26
- | `featureId` | Accessor for each feature's stable id. Default: `feature.id ?? properties.id ?? properties.name`. It is the key of `segments`, and identifies features in hover and click |
27
- | `color` | `(feature) => number \| undefined` - the value of a choropleth. Features without one use `styles.fill` or their `segments` |
28
- | `excludeParts` | `(part, feature) => boolean`, where `part` is `{ centroid, geometry }` - drops geometry parts from drawing and hover (a distant territory drawn in an inset instead). `segments` still apply to what remains |
22
+ | Prop | Meaning |
23
+ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
24
+ | `data` | A GeoJSON `FeatureCollection`, an array of `Feature`s, or a TopoJSON `Topology` |
25
+ | `object` | Required for a `Topology`: the key under `topology.objects` to draw. Needs the optional `topojson-client` package |
26
+ | `featureId` | Accessor for each feature's stable id. Default: `feature.id ?? properties.id ?? properties.name`. It is the key of `segments`, and identifies features in hover and click |
27
+ | `color` | `(feature, slice) => number \| undefined` - the value of a choropleth. Features without one use `styles.fill` or their `segments`. `slice` is the [facet panel and timeline step](#facets-and-timeline) it is drawn in |
28
+ | `excludeParts` | `(part, feature) => boolean`, where `part` is `{ centroid, geometry }` - drops geometry parts from drawing and hover (a distant territory drawn in an inset instead). `segments` still apply to what remains |
29
29
 
30
30
  ## Styles
31
31
 
@@ -146,6 +146,34 @@ An `inset` draws part of the map (or other data) in its own small panel, such as
146
146
 
147
147
  `location` is one of the 17 anchors (`'top-left'`, `'bottom-right'`, `'middle-left'`, ...); `features` filters by id from the plot's `data` (or give the inset its own `data`); `featurePartsFilter` narrows to geometry parts; `width` and `height` are fractions of the plot; an inset always fits its own content.
148
148
 
149
+ ## Facets and timeline
150
+
151
+ `facet` and `timeline` slice the map by a field each feature carries in its `properties` (and each marker's own rows by the same field); what lacks the field is drawn in every slice. To draw one layer of features in every panel, with no field on them, name the panels instead: `facet.values` for `by`, `rowValues` and `colValues` for `rows` and `cols`, or `timeline.values` for the steps. A slice's values are the ones named, in that order, then any more found in the features.
152
+
153
+ Every callback that reads a feature gets the slice it is drawn in as its last argument - `color`, `opacity`, `grouping`, `featureId` and `styles.onFeatureClick` - and a tooltip's `format` has it in its context:
154
+
155
+ | Field | Meaning |
156
+ | ------------- | ------------------------------------------------------------------ |
157
+ | `facet.value` | The panel: its `by` value, or a grid's `row · col` |
158
+ | `facet.row` | The panel's `rows` value, on a grid |
159
+ | `facet.col` | The panel's `cols` value, on a grid |
160
+ | `time` | The timeline step; the context is empty without `facet`/`timeline` |
161
+
162
+ ```svelte
163
+ <GeoPlot
164
+ data={provinces}
165
+ facet={{ by: 'sex', values: ['Women', 'Men'] }}
166
+ color={(f, { facet }) => valueFor(f.properties.name, facet?.value)}
167
+ tooltip={{
168
+ sync: true,
169
+ format: (f, number, { facet }) => `${f.properties.name} · ${facet?.value}`
170
+ }}
171
+ coordinates={{ projection: { type: 'mercator', domain: 'data' } }}
172
+ />
173
+ ```
174
+
175
+ The color ramp spans every panel and step, each read in its own slice, so colors compare across them; `facet.scales: 'free'` (or `timeline.scales`, with no facet) fits each slice's own instead, for panels that show different variables. To give each panel its own scheme too, return the panel from `grouping` and set `scales.color` in a `segments` entry per panel. With `projection.domain: 'data'`, each panel fits its own features. See also [Facets](facets.md#maps).
176
+
149
177
  ## Tooltip and hover
150
178
 
151
179
  `tooltip` works as on other plots with the hovered feature as the row (`format: (feature) => string`, or a `content` snippet). Add `{ type: 'geo-hover' }` for an in-map highlight. Hover matches the browser's own SVG hit-testing, so a feature is hovered where it is drawn.
package/docs/legend.md CHANGED
@@ -86,6 +86,12 @@ A continuous section narrows with `interactive: { enabled: true, mode: 'filter'
86
86
 
87
87
  Series can be toggled from code too: `useFigure().legend` has `off`, `isOff(name)`, `toggle`, `turnOff`, `turnOn` (see [Reading plot state](state.md)).
88
88
 
89
+ The range of a continuous section can be set from code as well: `useFigure().legend.setRange([min, max])` does what dragging the handles does - they move, and the plots filter or rescale to it, in the mode the section was set up with (a second argument, `'filter'` or `'rescale'`, overrides it). `setRange(null)`, or a span that covers the whole ramp, resets it; `legend.range` reads it back. A mini legend per panel can use it to apply its own panel's range to the figure's legend on a click.
90
+
91
+ ```svelte
92
+ <button onclick={() => figure.legend?.setRange([panelMin, panelMax])}>Use this range</button>
93
+ ```
94
+
89
95
  ## Adjusting the automatic legend
90
96
 
91
97
  An object adjusts the automatic legend while keeping what the plots read:
@@ -53,7 +53,7 @@ Bars where one axis holds the categories and the other the values. Categories on
53
53
  ![Horizontal grouped bars](../../images/horizontal-bars.png)
54
54
 
55
55
  - Several series sharing a category sit side by side, each in its slice of the category band. The gap between them is `bar.seriesPadding` in the configuration.
56
- - `transforms={[{ type: 'stackY' }]}` stacks the series instead. Stacking is always `stackY`, also for horizontal bars, because the value channel is the one stacked.
56
+ - `transforms={[{ type: 'stackY' }]}` stacks the series instead of putting them side by side. The stack runs along the value axis: `stackY` for vertical bars (values on `y`), `stackX` for horizontal ones (values on `x`, categories on `y`). The other one turns the category axis into a numeric one and the bars come out empty.
57
57
  - `scales.x.paddingInner` / `paddingOuter` set the spacing between category bands.
58
58
  - `styles.values.show` labels each bar; `styles.values.anchor` places the label (`'outside'`, `'middle'`, `'top'`, `'bottom'`, `'left'`, `'right'`, a corner such as `'top-left'`, or `'tip'` and `'base'`, the ends of the bar away from zero and on it).
59
59
  - The tooltip triggers anywhere over a bar's whole band, not just over the visible bar.
package/docs/state.md CHANGED
@@ -7,7 +7,7 @@ A plot's state - its hover, legend, timeline, margins, scales, rows and size - c
7
7
  Works anywhere inside a figure, including the `header`, `footer` and `content` snippets of a plot or composer. It returns:
8
8
 
9
9
  - `hover` - `activeX`, `activeY`, `activeSeries`, `strategy`, `sync`, `source` (the id of the plot owning the hover).
10
- - `legend` (or `null`) - `entries`, `range`, `off` (names of hidden series), `isOff(name)`, `turnOff(name, action)`, `turnOn(name)`, `toggle(name, action)`. `action` is `{ mode: 'dim' | 'hide' | 'omit' | 'rescale' }`; `'omit'` removes the series and refits the axes, and `'rescale'` also makes the parts left of a whole (a stack, a treemap) the new 100%.
10
+ - `legend` (or `null`) - `entries`, `range`, `setRange([min, max] | null, mode?)` (narrows the continuous section as dragging it would, or resets it), `off` (names of hidden series), `isOff(name)`, `turnOff(name, action)`, `turnOn(name)`, `toggle(name, action)`. `action` is `{ mode: 'dim' | 'hide' | 'omit' | 'rescale' }`; `'omit'` removes the series and refits the axes, and `'rescale'` also makes the parts left of a whole (a stack, a treemap) the new 100%.
11
11
  - `timeline` (or `null`) - `enabled`, `steps`, `value`, `index`, `progress`, `playing`, `loop`, `speed`, `speeds`, `controls`, and `seek`, `seekIndex`, `seekValue`, `next`, `previous`, `play`, `pause`, `toggle`, `setLoop`, `setSpeed`, `cycleSpeed`. On a timeline of dates, `steps` and `value` are `Date`s and `seekValue` takes a `Date` or a date string.
12
12
 
13
13
  A footer that toggles series through the legend:
package/docs/timeline.md CHANGED
@@ -43,7 +43,7 @@
43
43
  | `values` | The steps, when not every distinct value of `accessor` - read like `accessor`'s values |
44
44
  | `filterMode` | `'exact'` shows the rows of the current step; `'upTo'` shows every row up to it (a cumulative reveal). `LinePlot` defaults to `'upTo'` |
45
45
  | `interpolate` | Tween values between steps so a scrub is smooth: `true`, or `{ key, value, create, progressEasing, segmentEasing }`. Line and bar plots default to interpolating their value channel |
46
- | `scales` | `'shared'` (default): axes span every step, so they hold still while scrubbing; `'free'` refits both to each step; `'free-x'` / `'free-y'` refit one and keep the other still |
46
+ | `scales` | `'shared'` (default): axes span every step (the widest any step reaches on its own rows), so they hold still while scrubbing; `'free'` refits both to each step; `'free-x'` / `'free-y'` refit one and keep the other still |
47
47
  | `onprogress`, `onvalue` | Called with the new position (`0`-`1`) and with the step it lands on - a `Date` on a timeline of dates, else the number |
48
48
  | `orientation`, `show`, `thickness` | A rule's direction (`'vertical'` default, beside the plot; `'horizontal'`), visibility and size |
49
49
 
@@ -72,7 +72,7 @@ Both derive rows from the data before drawing. A **transform** reshapes the rows
72
72
  | Filter | `filter` | Keep rows a test holds for |
73
73
  | Select | `select`, `selectFirst`, `selectLast`, `selectMinX`, `selectMaxX`, `selectMinY`, `selectMaxY` | Keep one row per group, such as each series' last point or peak |
74
74
 
75
- Bar stacking is always `stackY`, also for horizontal bars. A transform on a `HistogramPlot` is merged over the plot's default binning, so `{ type: 'binX', thresholds: 20 }` changes the bins and the rest stays.
75
+ A bar plot stacks along its value axis: `stackY` for vertical bars, `stackX` for horizontal ones. A transform on a `HistogramPlot` is merged over the plot's default binning, so `{ type: 'binX', thresholds: 20 }` changes the bins and the rest stays.
76
76
 
77
77
  ## Statistics
78
78