@fundar/data-chart-telling 1.4.0 → 1.6.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 CHANGED
@@ -2,6 +2,22 @@
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.6.0] - 2026-11-02
6
+
7
+ ### Added
8
+
9
+ - Every legend section takes `enabled`, on unless set: `legend: { series: { enabled: false } }` leaves the series section off the automatic legend, and `color`, `opacity`, `r` and `symbol` (with a `match`, to pick one of several) do the same for theirs. A section added through `legend.sections` takes it too.
10
+
11
+ ## [1.5.0] - 2026-10-31
12
+
13
+ ### Changed
14
+
15
+ - Zooming or panning a map no longer re-projects every feature on each wheel tick or drag move. The features already drawn are carried to the new view with an SVG transform and the projection is moved, and the features redrawn, once the view has been still for 120 ms. A map of 6,000 features goes from about 340 ms to about 50 ms per wheel tick. Strokes and dots are briefly scaled while the view moves.
16
+
17
+ ### Fixed
18
+
19
+ - A series' catch-all segments that name different facets, or a facet next to the unscoped one, no longer log the warning for two catch-alls: they do not compete.
20
+
5
21
  ## [1.4.0] - 2026-10-30
6
22
 
7
23
  ### Added
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { getContext, setContext, type Snippet } from 'svelte';
2
+ import { getContext, setContext, untrack, type Snippet } from 'svelte';
3
3
  import { ZoomedProjection } from './ZoomedProjection';
4
4
  import type { GeoZoomTransform } from './zoom.svelte';
5
5
  import type { StreamingProjection } from '../../plots/utils/tiles';
@@ -10,31 +10,65 @@
10
10
  * positions, while dots, text and strokes keep their size. Works by handing
11
11
  * the marks below a view of svelteplot's plot state whose `scales.projection`
12
12
  * is the moved one.
13
+ *
14
+ * Re-projecting every mark is the cost of that, and a map with thousands of
15
+ * features pays it on each wheel tick. So while the view is moving, the marks
16
+ * already drawn are carried to it with an SVG transform (cheap, if briefly
17
+ * coarser: strokes and dots scale with it), and the projection is only moved,
18
+ * and the marks redrawn, once the view has been still for `settleDelay` ms.
13
19
  */
14
20
  let {
15
21
  inner,
16
22
  outer,
23
+ settleDelay = 120,
17
24
  children
18
- }: { inner: GeoZoomTransform; outer: GeoZoomTransform; children: Snippet } = $props();
25
+ }: {
26
+ inner: GeoZoomTransform;
27
+ outer: GeoZoomTransform;
28
+ /** Milliseconds the view must be still before the marks are re-projected */
29
+ settleDelay?: number;
30
+ children: Snippet;
31
+ } = $props();
32
+
33
+ const same = (a: GeoZoomTransform, b: GeoZoomTransform) =>
34
+ a.k === b.k && a.x === b.x && a.y === b.y;
35
+
36
+ // The view the marks are drawn for; the first one is the one they mount with.
37
+ let settled = $state.raw(untrack(() => ({ inner: { ...inner }, outer: { ...outer } })));
38
+
39
+ $effect(() => {
40
+ const next = { inner: { ...inner }, outer: { ...outer } };
41
+ if (same(next.inner, settled.inner) && same(next.outer, settled.outer)) return;
42
+ const timer = setTimeout(() => (settled = next), settleDelay);
43
+ return () => clearTimeout(timer);
44
+ });
45
+
46
+ const carry = $derived(
47
+ ZoomedProjection.between(
48
+ ZoomedProjection.compose(settled.inner, settled.outer),
49
+ ZoomedProjection.compose(inner, outer)
50
+ )
51
+ );
52
+ const carrying = $derived(carry.k !== 1 || carry.x !== 0 || carry.y !== 0);
19
53
 
20
54
  type Scales = { projection?: StreamingProjection & { aspectRatio?: number } } | undefined;
21
55
  type PlotState = { scales: Scales; publicState: object };
22
56
  type PlotContext = { getPlotState: () => PlotState } & Record<string, unknown>;
23
57
 
24
58
  const context = getContext<PlotContext>('svelteplot');
25
- const state = context.getPlotState();
59
+ const plot = context.getPlotState();
26
60
 
27
61
  const scales = $derived.by((): Scales => {
28
- const own = state.scales;
62
+ const own = plot.scales;
29
63
  return own?.projection
30
- ? { ...own, projection: ZoomedProjection.of(own.projection, inner, outer) }
64
+ ? { ...own, projection: ZoomedProjection.of(own.projection, settled.inner, settled.outer) }
31
65
  : own;
32
66
  });
33
67
 
34
- const publicState = new Proxy(state.publicState, {
68
+ const publicState = new Proxy(plot.publicState, {
35
69
  get: (target, key) => (key === 'scales' ? scales : Reflect.get(target, key, target))
36
70
  });
37
- const movedState = new Proxy(state, {
71
+ const movedState = new Proxy(plot, {
38
72
  get: (target, key) => {
39
73
  if (key === 'scales') return scales;
40
74
  if (key === 'publicState') return publicState;
@@ -45,4 +79,6 @@
45
79
  setContext('svelteplot', { ...context, getPlotState: () => movedState });
46
80
  </script>
47
81
 
48
- {@render children()}
82
+ <g transform={carrying ? `translate(${carry.x} ${carry.y}) scale(${carry.k})` : undefined}>
83
+ {@render children()}
84
+ </g>
@@ -3,6 +3,8 @@ import type { GeoZoomTransform } from './zoom.svelte';
3
3
  type $$ComponentProps = {
4
4
  inner: GeoZoomTransform;
5
5
  outer: GeoZoomTransform;
6
+ /** Milliseconds the view must be still before the marks are re-projected */
7
+ settleDelay?: number;
6
8
  children: Snippet;
7
9
  };
8
10
  declare const ProjectionZoom: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -2,6 +2,10 @@ import type { GeoZoomTransform } from './zoom.svelte';
2
2
  import type { StreamingProjection } from '../../plots/utils/tiles';
3
3
  /** A fitted projection with a pan and scale applied to where it puts things, so positions move and zoom while what is drawn at them keeps its size. */
4
4
  export declare class ZoomedProjection {
5
+ /** `outer` over `inner` as one pan and scale of the pixels. */
6
+ static compose(inner: GeoZoomTransform, outer: GeoZoomTransform): GeoZoomTransform;
7
+ /** The pan and scale that carries what is drawn through `settled` to where `live` puts it. */
8
+ static between(settled: GeoZoomTransform, live: GeoZoomTransform): GeoZoomTransform;
5
9
  /** `projection`, then `outer` over `inner` (each a pan and scale) as one move in pixels. */
6
10
  static of(projection: StreamingProjection & {
7
11
  aspectRatio?: number;
@@ -1,11 +1,18 @@
1
1
  import { geoTransform } from 'd3-geo';
2
2
  /** A fitted projection with a pan and scale applied to where it puts things, so positions move and zoom while what is drawn at them keeps its size. */
3
3
  export class ZoomedProjection {
4
+ /** `outer` over `inner` as one pan and scale of the pixels. */
5
+ static compose(inner, outer) {
6
+ return { k: inner.k * outer.k, x: outer.k * inner.x + outer.x, y: outer.k * inner.y + outer.y };
7
+ }
8
+ /** The pan and scale that carries what is drawn through `settled` to where `live` puts it. */
9
+ static between(settled, live) {
10
+ const k = live.k / settled.k;
11
+ return { k, x: live.x - settled.x * k, y: live.y - settled.y * k };
12
+ }
4
13
  /** `projection`, then `outer` over `inner` (each a pan and scale) as one move in pixels. */
5
14
  static of(projection, inner, outer) {
6
- const k = inner.k * outer.k;
7
- const x = outer.k * inner.x + outer.x;
8
- const y = outer.k * inner.y + outer.y;
15
+ const { k, x, y } = ZoomedProjection.compose(inner, outer);
9
16
  if (k === 1 && x === 0 && y === 0)
10
17
  return projection;
11
18
  const move = geoTransform({
@@ -77,7 +77,8 @@ export declare class LegendState {
77
77
  * scale, each titled by its field name (see `legend.pretty`);
78
78
  * then `extension.sections`. A lone series, with nothing else to tell it
79
79
  * from, shows no discrete section; with other sections beside it, the
80
- * discrete one is titled `legend.seriesTitle`.
80
+ * discrete one is titled `legend.seriesTitle`. A section whose adjustment sets
81
+ * `enabled: false` is left out.
81
82
  */
82
83
  sections(extension?: LegendExtension): LegendSection[];
83
84
  /**
@@ -195,7 +195,8 @@ export class LegendState {
195
195
  * scale, each titled by its field name (see `legend.pretty`);
196
196
  * then `extension.sections`. A lone series, with nothing else to tell it
197
197
  * from, shows no discrete section; with other sections beside it, the
198
- * discrete one is titled `legend.seriesTitle`.
198
+ * discrete one is titled `legend.seriesTitle`. A section whose adjustment sets
199
+ * `enabled: false` is left out.
199
200
  */
200
201
  sections(extension) {
201
202
  const entries = this.entries;
@@ -286,7 +287,7 @@ export class LegendState {
286
287
  }
287
288
  ]
288
289
  : [];
289
- return [...discrete, ...others];
290
+ return [...discrete, ...others].filter((section) => section.enabled !== false);
290
291
  }
291
292
  /** What `override` changes in the section titled `title`: the entries that match it, in order, merged. */
292
293
  static #adjustment(override, title, format) {
@@ -17,8 +17,11 @@ export declare function resolveSegmentsForSeries<TStyle>(segments: SegmentsConfi
17
17
  export declare function matchesSegmentX<TStyle>(xVal: AxisValue, seg: Segment<TStyle>): boolean;
18
18
  /**
19
19
  * Warns about obvious configuration mistakes: multiple catch-all segments
20
- * (no `areas`) in the same series list. Since catch-alls are evaluated last
21
- * and in order, only the first one can ever match; later ones are dead code.
20
+ * (no `areas`) in the same series list that reach the same facet panels.
21
+ * Since catch-alls are evaluated last and in order, only the first one can
22
+ * ever match; later ones are dead code. A catch-all that names a `facet`
23
+ * does not compete with one for another panel, nor with the unscoped one,
24
+ * which it overrides in its own panels.
22
25
  */
23
26
  export declare function validateSegments<TStyle>(segments: SegmentsConfig<TStyle>): void;
24
27
  export declare function buildGroupedSeries<S extends Series<any>, TStyle = LineSegmentStyle>(seriesList: S[], segments?: SegmentsConfig<TStyle>, connect?: boolean): VisualGroup<S, TStyle>[];
@@ -49,12 +49,22 @@ function areaMatchesXY(xVal, yVal, area) {
49
49
  /* eslint-enable @typescript-eslint/no-explicit-any */
50
50
  /**
51
51
  * Warns about obvious configuration mistakes: multiple catch-all segments
52
- * (no `areas`) in the same series list. Since catch-alls are evaluated last
53
- * and in order, only the first one can ever match; later ones are dead code.
52
+ * (no `areas`) in the same series list that reach the same facet panels.
53
+ * Since catch-alls are evaluated last and in order, only the first one can
54
+ * ever match; later ones are dead code. A catch-all that names a `facet`
55
+ * does not compete with one for another panel, nor with the unscoped one,
56
+ * which it overrides in its own panels.
54
57
  */
55
58
  export function validateSegments(segments) {
56
59
  const check = (label, segs) => {
57
- const catchAlls = segs.filter((s) => !s.areas || s.areas.length === 0).length;
60
+ const perScope = new Map();
61
+ for (const seg of segs) {
62
+ if (seg.areas && seg.areas.length > 0)
63
+ continue;
64
+ const scope = JSON.stringify(seg.facet ?? null);
65
+ perScope.set(scope, (perScope.get(scope) ?? 0) + 1);
66
+ }
67
+ const catchAlls = Math.max(0, ...perScope.values());
58
68
  if (catchAlls > 1) {
59
69
  console.warn(`segments ${label}: ${catchAlls} entries have no areas - ` +
60
70
  `only the first catch-all's style will be used.`);
@@ -91,6 +91,7 @@ export type DiscreteInteractiveConfig = {
91
91
  };
92
92
  export type DiscreteLegendSection = {
93
93
  type: 'discrete';
94
+ enabled?: boolean;
94
95
  title?: string;
95
96
  items: LegendItem[];
96
97
  columns?: number;
@@ -112,6 +113,7 @@ export type ContinuousInteractiveConfig = {
112
113
  };
113
114
  export type ContinuousLegendSection = {
114
115
  type: 'continuous';
116
+ enabled?: boolean;
115
117
  title?: string;
116
118
  /** The channel the section reads, which tells apart automatic sections that share a title. */
117
119
  channel?: Exclude<LegendChannel, 'series'>;
@@ -138,6 +140,7 @@ export type ContinuousLegendSection = {
138
140
  */
139
141
  export type SizeLegendSection = {
140
142
  type: 'size';
143
+ enabled?: boolean;
141
144
  title?: string;
142
145
  /** The channel the section reads, which tells apart automatic sections that share a title. */
143
146
  channel?: Exclude<LegendChannel, 'series'>;
@@ -162,6 +165,7 @@ export type SizeLegendSection = {
162
165
  */
163
166
  export type SymbolLegendSection = {
164
167
  type: 'symbol';
168
+ enabled?: boolean;
165
169
  title?: string;
166
170
  /** The channel the section reads, which tells apart automatic sections that share a title. */
167
171
  channel?: Exclude<LegendChannel, 'series'>;
@@ -276,7 +280,7 @@ export type LegendOverride<TSection extends {
276
280
  * takes what both sections share, plus each one's own - what a section lacks
277
281
  * is ignored. `interactive` takes either section's options.
278
282
  */
279
- export type ColorOverride = Omit<ContinuousLegendSection, 'type' | 'min' | 'max' | 'colorMin' | 'colorMax' | 'scheme' | 'pivot' | 'opacity' | 'interactive'> & Pick<DiscreteLegendSection, 'layout' | 'navButton'> & {
283
+ export type ColorOverride = Omit<ContinuousLegendSection, 'type' | 'min' | 'max' | 'colorMin' | 'colorMax' | 'scheme' | 'pivot' | 'opacity' | 'interactive'> & Pick<DiscreteLegendSection, 'layout' | 'navButton' | 'enabled'> & {
280
284
  interactive?: ContinuousInteractiveConfig & DiscreteInteractiveConfig;
281
285
  /** Adjusts only the continuous section titled so. */
282
286
  match?: string;
package/docs/legend.md CHANGED
@@ -101,6 +101,15 @@ legend={{
101
101
 
102
102
  Each key is the name of the channel (the plot's own prop: `r` for the size section) and adjusts the section that channel draws: any section field except what the plots compute (items, ranges, domains). An entry with `match` adjusts only the section whose title matches; a list of entries adjusts several.
103
103
 
104
+ ### Leaving a section disabled
105
+
106
+ Every section takes `enabled`, on unless set. `enabled: false` leaves that section off the legend, while the others stay:
107
+
108
+ ```ts
109
+ legend={{ series: { enabled: false } }} // no series section
110
+ legend={{ color: { match: 'temperature', enabled: false } }} // not that ramp
111
+ ```
112
+
104
113
  ### Turning series off from code
105
114
 
106
115
  `off` turns series off as if the reader had clicked them, without a click: a list of names (each dimmed, to `legend.disabledOpacity`), or a name → opacity or name → action map, where an action is the same `{ mode: 'dim' | 'hide' | 'omit' }` a toggle does. `highlight` is its inverse: it keeps the series it names on and dims all the others. An `off` entry for a series wins over `highlight`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Declarative charts, maps and small multiples for Svelte, built on svelteplot.",
5
5
  "license": "MIT",
6
6
  "author": "Ezequiel Puerta",