@fundar/data-chart-telling 1.4.0 → 1.5.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,16 @@
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.5.0] - 2026-10-31
6
+
7
+ ### Changed
8
+
9
+ - 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.
10
+
11
+ ### Fixed
12
+
13
+ - 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.
14
+
5
15
  ## [1.4.0] - 2026-10-30
6
16
 
7
17
  ### 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({
@@ -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.`);
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.5.0",
4
4
  "description": "Declarative charts, maps and small multiples for Svelte, built on svelteplot.",
5
5
  "license": "MIT",
6
6
  "author": "Ezequiel Puerta",