@fundar/data-chart-telling 0.0.52 → 0.0.54

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/dist/index.d.ts CHANGED
@@ -34,7 +34,7 @@ export { default as TextMarker } from './markers/TextMarker.svelte';
34
34
  export { setConfiguration, getConfiguration, resetConfiguration, configToCssVars, cssVarsStyle, DEFAULT_CONFIG } from './configuration/config.svelte';
35
35
  export { THEMES } from './configuration/themes';
36
36
  export type { ThemeName } from './configuration/themes';
37
- export { groupBy } from './utils/grouping';
37
+ export { groupBy, buildSeries } from './utils/grouping';
38
38
  export { makeColorScale, normalize, resolveCssColor, paletteColor, contrastTextColor, contrastRatio } from './utils/color';
39
39
  export { estimateCategoricalAxisMargin } from './plots/utils/categoricalAxisMargin';
40
40
  export { estimateLineValueLabelMargin } from './plots/line/valueLabelMargin';
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ export { default as TextMarker } from './markers/TextMarker.svelte';
41
41
  export { setConfiguration, getConfiguration, resetConfiguration, configToCssVars, cssVarsStyle, DEFAULT_CONFIG } from './configuration/config.svelte';
42
42
  export { THEMES } from './configuration/themes';
43
43
  // ── Utils ─────────────────────────────────────────────────────────────────────
44
- export { groupBy } from './utils/grouping';
44
+ export { groupBy, buildSeries } from './utils/grouping';
45
45
  export { makeColorScale, normalize, resolveCssColor, paletteColor, contrastTextColor, contrastRatio } from './utils/color';
46
46
  export { estimateCategoricalAxisMargin } from './plots/utils/categoricalAxisMargin';
47
47
  export { estimateLineValueLabelMargin } from './plots/line/valueLabelMargin';
@@ -23,7 +23,8 @@
23
23
  import type { Series } from '../../types/plots/data/common';
24
24
  import type { HoverMarkerConfig, CustomComponentMarkerConfig } from '../../types/markers/common';
25
25
  import type { MarkerConfig } from '../../types/markers/all';
26
- import type { HoverPoint, TooltipAnchorX, TooltipAnchorY } from '../../types/layout/tooltip';
26
+ import type { TooltipAnchorX, TooltipAnchorY } from '../../types/layout/tooltip';
27
+ import type { HoverController } from '../tooltip/controller.svelte';
27
28
  import type { ScopedMarkerGroup } from '../../types/layout/geometry';
28
29
  import type { GeoFeature } from '../../types/plots/data/geo';
29
30
 
@@ -49,10 +50,7 @@
49
50
  markers = [],
50
51
  seriesData = [],
51
52
  scopedGroups = [],
52
- matchedPoints,
53
- activePoint,
54
- impliesCrosshairX,
55
- impliesCrosshairY,
53
+ ctrl,
56
54
  numericMargins,
57
55
  data = [],
58
56
  geoAllFeatures = [],
@@ -68,11 +66,8 @@
68
66
  seriesData?: Series<TSeries>[];
69
67
  /** One entry per series that can own a `series`-scoped marker. Same `TSeries` as `seriesData`. */
70
68
  scopedGroups?: ScopedMarkerGroup<TSeries>[];
71
- matchedPoints: HoverPoint<TData>[];
72
- /** The specific matched point actually under the cursor. */
73
- activePoint: HoverPoint<TData> | null;
74
- impliesCrosshairX: boolean;
75
- impliesCrosshairY: boolean;
69
+ /** `BasePlotLayout`'s own hover controller. */
70
+ ctrl: HoverController<TData>;
76
71
  /** The plot's own resolved margins — `inset` marker only, to place its content box. */
77
72
  numericMargins?: { top: number; right: number; bottom: number; left: number };
78
73
  /** `BasePlotLayout`'s own rows — `contour` (clip) and unscoped `geo-component` fallback data. */
@@ -171,9 +166,16 @@
171
166
  {:else if marker.type === 'geo-component'}
172
167
  <marker.component {...marker.props} data={marker.data ?? data} />
173
168
  {:else if marker.type === 'hover'}
174
- <HoverMarker {marker} data={matchedPoints} {activePoint} {impliesCrosshairX} {impliesCrosshairY} />
169
+ <HoverMarker
170
+ {marker}
171
+ data={ctrl.points}
172
+ activePoint={ctrl.activePoint}
173
+ impliesCrosshairX={ctrl.impliesCrosshairX}
174
+ impliesCrosshairY={ctrl.impliesCrosshairY}
175
+ onActivePointPixel={ctrl.reportActivePointPixel}
176
+ />
175
177
  {:else if marker.type === 'geo-hover'}
176
- <GeoHoverMarker {marker} data={matchedPoints} />
178
+ <GeoHoverMarker {marker} data={ctrl.points} />
177
179
  {:else if marker.type === 'delta'}
178
180
  <DeltaMarker {marker} data={seriesData} />
179
181
  {/if}
@@ -182,7 +184,7 @@
182
184
  {#each scopedGroups as group (group.name)}
183
185
  {#each scopedMarkersByName.get(group.name) ?? [] as marker, i (`${group.name}-${marker.type}-${i}`)}
184
186
  {#if marker.type === 'hover'}
185
- {@const seriesPoints = matchedPoints.filter((p) => p.series === group.name)}
187
+ {@const seriesPoints = ctrl.points.filter((p) => p.series === group.name)}
186
188
  {#if marker.scope === 'segment'}
187
189
  {#each group.segments as seg, segIdx (`${group.name}-hover-segment-${segIdx}`)}
188
190
  <!-- p.row (TData) is always a superset of TSeries in practice, not provable across two independent generics. -->
@@ -192,8 +194,8 @@
192
194
  {marker}
193
195
  data={segPoints}
194
196
  activePoint={segPoints[0] ?? null}
195
- {impliesCrosshairX}
196
- {impliesCrosshairY}
197
+ impliesCrosshairX={ctrl.impliesCrosshairX}
198
+ impliesCrosshairY={ctrl.impliesCrosshairY}
197
199
  seriesColor={seg.color}
198
200
  />
199
201
  {/if}
@@ -203,8 +205,8 @@
203
205
  {marker}
204
206
  data={seriesPoints}
205
207
  activePoint={seriesPoints[0] ?? null}
206
- {impliesCrosshairX}
207
- {impliesCrosshairY}
208
+ impliesCrosshairX={ctrl.impliesCrosshairX}
209
+ impliesCrosshairY={ctrl.impliesCrosshairY}
208
210
  seriesColor={group.segments[0]?.color ?? group.color}
209
211
  />
210
212
  {/if}
@@ -1,7 +1,8 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { Series } from '../../types/plots/data/common';
3
3
  import type { MarkerConfig } from '../../types/markers/all';
4
- import type { HoverPoint, TooltipAnchorX, TooltipAnchorY } from '../../types/layout/tooltip';
4
+ import type { TooltipAnchorX, TooltipAnchorY } from '../../types/layout/tooltip';
5
+ import type { HoverController } from '../tooltip/controller.svelte';
5
6
  import type { ScopedMarkerGroup } from '../../types/layout/geometry';
6
7
  import type { GeoFeature } from '../../types/plots/data/geo';
7
8
  declare function $$render<TData extends Record<string, unknown>, TSeries extends Record<string, unknown> = TData>(): {
@@ -14,11 +15,8 @@ declare function $$render<TData extends Record<string, unknown>, TSeries extends
14
15
  seriesData?: Series<TSeries>[];
15
16
  /** One entry per series that can own a `series`-scoped marker. Same `TSeries` as `seriesData`. */
16
17
  scopedGroups?: ScopedMarkerGroup<TSeries>[];
17
- matchedPoints: HoverPoint<TData>[];
18
- /** The specific matched point actually under the cursor. */
19
- activePoint: HoverPoint<TData> | null;
20
- impliesCrosshairX: boolean;
21
- impliesCrosshairY: boolean;
18
+ /** `BasePlotLayout`'s own hover controller. */
19
+ ctrl: HoverController<TData>;
22
20
  /** The plot's own resolved margins — `inset` marker only, to place its content box. */
23
21
  numericMargins?: {
24
22
  top: number;
@@ -227,7 +227,8 @@
227
227
  label: () => hover.label,
228
228
  seriesFilter: () => hover.tooltip?.series,
229
229
  width: () => width,
230
- height: () => height
230
+ height: () => height,
231
+ crosshair: () => hover.crosshair
231
232
  });
232
233
 
233
234
  function onMove(e: PointerEvent) {
@@ -243,14 +244,6 @@
243
244
  ? markers({ setHoverMatch: ctrl.onPointer, clearHoverMatch: ctrl.onLeave })
244
245
  : markers
245
246
  );
246
-
247
- /** `hover.crosshair === false` forces both off, overriding the strategy-implied default — see {@link HoverConfig.crosshair}. */
248
- const effectiveImpliesCrosshairX = $derived(
249
- hover.crosshair === false ? false : ctrl.impliesCrosshairX
250
- );
251
- const effectiveImpliesCrosshairY = $derived(
252
- hover.crosshair === false ? false : ctrl.impliesCrosshairY
253
- );
254
247
  </script>
255
248
 
256
249
  <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -281,10 +274,7 @@
281
274
  markers={resolvedMarkers}
282
275
  seriesData={seriesData ?? []}
283
276
  scopedGroups={scopedGroups ?? []}
284
- matchedPoints={ctrl.points}
285
- activePoint={ctrl.activePoint}
286
- impliesCrosshairX={effectiveImpliesCrosshairX}
287
- impliesCrosshairY={effectiveImpliesCrosshairY}
277
+ {ctrl}
288
278
  {width}
289
279
  {height}
290
280
  {numericMargins}
@@ -296,8 +286,8 @@
296
286
  {@render children?.({
297
287
  matchedPoints: ctrl.points,
298
288
  activePoint: ctrl.activePoint,
299
- impliesCrosshairX: effectiveImpliesCrosshairX,
300
- impliesCrosshairY: effectiveImpliesCrosshairY,
289
+ impliesCrosshairX: ctrl.impliesCrosshairX,
290
+ impliesCrosshairY: ctrl.impliesCrosshairY,
301
291
  numericMargins,
302
292
  setHoverMatch: ctrl.onPointer,
303
293
  clearHoverMatch: ctrl.onLeave
@@ -1,5 +1,7 @@
1
1
  import type { HoverStore, HoverPoint } from '../../types/layout/tooltip';
2
2
  import type { AxisValue } from '../../types/layout/scales';
3
+ /** Return type of {@link createHoverController}. */
4
+ export type HoverController<T extends Record<string, unknown>> = ReturnType<typeof createHoverController<T>>;
3
5
  /**
4
6
  * The plot-agnostic hover state machine shared by every plot. Given reactive
5
7
  * accessors for the current hover store, this facet's id, and the local points,
@@ -23,10 +25,16 @@ export declare function createHoverController<T extends Record<string, unknown>>
23
25
  /** This facet's own box, used to convert the cursor to/from a sync-broadcast fraction. */
24
26
  width: () => number;
25
27
  height: () => number;
28
+ /** Forces the crosshair rule(s) off entirely — see {@link import('../../types/plots/props').HoverConfig.crosshair}. */
29
+ crosshair?: () => boolean | undefined;
26
30
  }): {
27
31
  onPointer: (data: T[]) => void;
28
32
  onMove: (e: PointerEvent) => void;
29
33
  onLeave: () => void;
34
+ reportActivePointPixel(pos: {
35
+ x: number;
36
+ y: number;
37
+ } | null): void;
30
38
  readonly points: {
31
39
  x: AxisValue;
32
40
  y: AxisValue;
@@ -11,6 +11,8 @@ import { eq, matchByStrategy, impliesCrosshairX, impliesCrosshairY, filterBySeri
11
11
  */
12
12
  export function createHoverController(args) {
13
13
  let cursor = $state(null);
14
+ // This facet's own `activePoint`, as a pixel position reported by `HoverMarker`.
15
+ let activePointPixel = $state(null);
14
16
  // Finds the matching hoverPoint for a hit row — by identity first, then
15
17
  // by x/y value (for svelteplot's `Pointer`, which hands back clones).
16
18
  const pointForRow = (row) => {
@@ -79,9 +81,9 @@ export function createHoverController(args) {
79
81
  return candidates[0];
80
82
  return candidates.find((p) => eq(p.x, activeX) && eq(p.y, activeY)) ?? candidates[0];
81
83
  });
82
- // Whether the current strategy implies a crosshair rule on each axis by default.
83
- const _impliesCrosshairX = $derived(impliesCrosshairX(args.hover().strategy));
84
- const _impliesCrosshairY = $derived(impliesCrosshairY(args.hover().strategy));
84
+ // Whether the current strategy implies a crosshair rule on each axis, unless `crosshair: false`.
85
+ const _impliesCrosshairX = $derived(args.crosshair?.() === false ? false : impliesCrosshairX(args.hover().strategy));
86
+ const _impliesCrosshairY = $derived(args.crosshair?.() === false ? false : impliesCrosshairY(args.hover().strategy));
85
87
  const showTooltip = $derived.by(() => {
86
88
  if (matchedRows.length === 0)
87
89
  return false;
@@ -127,9 +129,12 @@ export function createHoverController(args) {
127
129
  h.source = null;
128
130
  }
129
131
  }
132
+ /** This facet's tooltip position: the cursor, else `activePointPixel`, else the source facet's cursor fraction reprojected onto this box. */
130
133
  const tooltipPosition = $derived.by(() => {
131
134
  if (cursor)
132
135
  return cursor;
136
+ if (activePointPixel)
137
+ return activePointPixel;
133
138
  const frac = args.hover().cursorFrac;
134
139
  return frac ? { x: frac.x * args.width(), y: frac.y * args.height() } : null;
135
140
  });
@@ -137,6 +142,9 @@ export function createHoverController(args) {
137
142
  onPointer,
138
143
  onMove,
139
144
  onLeave,
145
+ reportActivePointPixel(pos) {
146
+ activePointPixel = pos;
147
+ },
140
148
  get points() {
141
149
  return highlight;
142
150
  },
@@ -78,14 +78,14 @@
78
78
  {#if categoryScale?.type === 'band'}
79
79
  {@const bandwidth = categoryScale.fn.bandwidth()}
80
80
  {@const bandStart = Number(categoryScale.fn(marker.category as never) ?? 0)}
81
+ {@const insetLeft = (marker.insetLeft ?? 0) * bandwidth}
82
+ {@const insetRight = (marker.insetRight ?? 0) * bandwidth}
83
+ {@const insetTop = (marker.insetTop ?? 0) * bandwidth}
84
+ {@const insetBottom = (marker.insetBottom ?? 0) * bandwidth}
81
85
  {#if marker.role === 'hitArea'}
82
86
  {@const [rangeMin, rangeMax] = marker.isHorizontal
83
87
  ? hitAreaValueRangeFor(marker.isHorizontal, marker.x1, marker.x2)
84
88
  : hitAreaValueRangeFor(marker.isHorizontal, marker.y1, marker.y2)}
85
- {@const insetLeft = (marker.insetLeft ?? 0) * bandwidth}
86
- {@const insetRight = (marker.insetRight ?? 0) * bandwidth}
87
- {@const insetTop = (marker.insetTop ?? 0) * bandwidth}
88
- {@const insetBottom = (marker.insetBottom ?? 0) * bandwidth}
89
89
  {#if marker.isHorizontal}
90
90
  <!-- svelte-ignore a11y_no_static_element_interactions -->
91
91
  <rect
@@ -113,9 +113,9 @@
113
113
  {/if}
114
114
  {:else}
115
115
  {@const [valueMin, valueMax] = valueExtentFor(marker.isHorizontal)}
116
- {@const thickness = bandwidth + 2}
117
- {@const center = bandStart + bandwidth / 2}
118
116
  {#if marker.isHorizontal}
117
+ {@const thickness = Math.max(0, bandwidth - insetTop - insetBottom) + 2}
118
+ {@const center = bandStart + insetTop + (bandwidth - insetTop - insetBottom) / 2}
119
119
  <rect
120
120
  class="hover-area"
121
121
  x={valueMin}
@@ -132,6 +132,8 @@
132
132
  pointer-events="none"
133
133
  />
134
134
  {:else}
135
+ {@const thickness = Math.max(0, bandwidth - insetLeft - insetRight) + 2}
136
+ {@const center = bandStart + insetLeft + (bandwidth - insetLeft - insetRight) / 2}
135
137
  <rect
136
138
  class="hover-area"
137
139
  x={center - thickness / 2}
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { usePlot } from 'svelteplot';
2
3
  import DotMarker from './DotMarker.svelte';
3
4
  import TextMarker from './TextMarker.svelte';
4
5
  import RuleMarker from './RuleMarker.svelte';
@@ -39,6 +40,8 @@
39
40
  activePoint?: HoverDisplayPoint | null;
40
41
  impliesCrosshairX?: boolean;
41
42
  impliesCrosshairY?: boolean;
43
+ /** Reports `activePoint`'s real pixel position within this facet's own box, or `null` when nothing's matched. */
44
+ onActivePointPixel?: (pos: { x: number; y: number } | null) => void;
42
45
  }
43
46
 
44
47
  let {
@@ -48,9 +51,35 @@
48
51
  impliesCrosshairX = false,
49
52
  impliesCrosshairY = false,
50
53
  seriesColor,
54
+ onActivePointPixel,
51
55
  }: Props = $props();
52
56
 
53
57
  const cfg = $derived(getConfiguration());
58
+ const plot = usePlot();
59
+
60
+ /** Scaled pixel position of `value`, centered within its band for a band scale. */
61
+ function scaledPixel(scale: { type?: string; fn?: (v: never) => unknown } | undefined, value: unknown): number | null {
62
+ if (!scale?.fn || value == null) return null;
63
+ const raw = Number(scale.fn(value as never));
64
+ if (!Number.isFinite(raw)) return null;
65
+ if (scale.type === 'band') {
66
+ const bandwidth = (scale.fn as { bandwidth?: () => number }).bandwidth?.() ?? 0;
67
+ return raw + bandwidth / 2;
68
+ }
69
+ return raw;
70
+ }
71
+
72
+ const activePixel = $derived.by((): { x: number; y: number } | null => {
73
+ if (!activePoint) return null;
74
+ const x = scaledPixel(plot.scales.x, activePoint.x);
75
+ const y = scaledPixel(plot.scales.y, activePoint.y);
76
+ if (x == null || y == null) return null;
77
+ return { x: x + (activePoint.dx ?? 0), y: y + (activePoint.dy ?? 0) };
78
+ });
79
+
80
+ $effect(() => {
81
+ onActivePointPixel?.(activePixel);
82
+ });
54
83
 
55
84
  const showCrosshairX = $derived(marker.ruleX !== false && (impliesCrosshairX || marker.ruleX === true));
56
85
  const showCrosshairY = $derived(marker.ruleY !== false && (impliesCrosshairY || marker.ruleY === true));
@@ -115,6 +144,8 @@
115
144
  isHorizontal: false,
116
145
  data: [],
117
146
  category: areaX,
147
+ insetLeft: activePoint?.areaInset?.start,
148
+ insetRight: activePoint?.areaInset?.end,
118
149
  style: areaStyle,
119
150
  }}
120
151
  />
@@ -128,17 +159,23 @@
128
159
  isHorizontal: true,
129
160
  data: [],
130
161
  category: areaY,
162
+ insetTop: activePoint?.areaInset?.start,
163
+ insetBottom: activePoint?.areaInset?.end,
131
164
  style: areaStyle,
132
165
  }}
133
166
  />
134
167
  {/if}
135
168
 
136
169
  {#if ruleX != null}
137
- <RuleMarker axis="x" value={ruleX} style={{ ...cfg.hover.rule, ...ruleStyle }} />
170
+ <g transform="translate({activePoint?.dx ?? 0}, {activePoint?.dy ?? 0})">
171
+ <RuleMarker axis="x" value={ruleX} style={{ ...cfg.hover.rule, ...ruleStyle }} />
172
+ </g>
138
173
  {/if}
139
174
 
140
175
  {#if ruleY != null}
141
- <RuleMarker axis="y" value={ruleY} style={{ ...cfg.hover.rule, ...ruleStyle }} />
176
+ <g transform="translate({activePoint?.dx ?? 0}, {activePoint?.dy ?? 0})">
177
+ <RuleMarker axis="y" value={ruleY} style={{ ...cfg.hover.rule, ...ruleStyle }} />
178
+ </g>
142
179
  {/if}
143
180
 
144
181
  {#if data.length > 0}
@@ -29,6 +29,11 @@ interface Props extends MarkerProps<HoverDisplayPoint> {
29
29
  activePoint?: HoverDisplayPoint | null;
30
30
  impliesCrosshairX?: boolean;
31
31
  impliesCrosshairY?: boolean;
32
+ /** Reports `activePoint`'s real pixel position within this facet's own box, or `null` when nothing's matched. */
33
+ onActivePointPixel?: (pos: {
34
+ x: number;
35
+ y: number;
36
+ } | null) => void;
32
37
  }
33
38
  declare const HoverMarker: import("svelte").Component<Props, {}, "">;
34
39
  type HoverMarker = ReturnType<typeof HoverMarker>;
@@ -12,4 +12,4 @@ import type { BarLayout } from './layout.svelte';
12
12
  * height instead of `dx`/`dy`, since that offset is representable as a
13
13
  * regular data value (unlike 'grouped''s pixel-only slice offset).
14
14
  */
15
- export declare function buildHoverPoints<TData extends Record<string, unknown>>(resolvedSeries: Series<TData>[], isHorizontal: boolean, geometry: Pick<BarLayout<TData>, 'layout' | 'stackedBaseline' | 'groupOffset'>, colorFor: (seriesName: string) => string): HoverPoint<TData>[];
15
+ export declare function buildHoverPoints<TData extends Record<string, unknown>>(resolvedSeries: Series<TData>[], isHorizontal: boolean, geometry: Pick<BarLayout<TData>, 'layout' | 'stackedBaseline' | 'groupOffset' | 'groupInsetFraction'>, colorFor: (seriesName: string) => string): HoverPoint<TData>[];
@@ -15,6 +15,7 @@ export function buildHoverPoints(resolvedSeries, isHorizontal, geometry, colorFo
15
15
  return resolvedSeries.flatMap((s, idx) => {
16
16
  const sx = resolveAccessor(s.x);
17
17
  const sy = resolveAccessor(s.y);
18
+ const areaInset = geometry.layout === 'grouped' ? geometry.groupInsetFraction(idx) : undefined;
18
19
  return s.data.filter((d) => !isGapValue(sy(d))).map((d) => {
19
20
  const category = sx(d);
20
21
  const rawValue = Number(sy(d));
@@ -25,6 +26,7 @@ export function buildHoverPoints(resolvedSeries, isHorizontal, geometry, colorFo
25
26
  y: isHorizontal ? category : value,
26
27
  dx,
27
28
  dy,
29
+ areaInset,
28
30
  row: d,
29
31
  label: String(sy(d)),
30
32
  series: s.name,
@@ -96,6 +96,11 @@ export type HoverDisplayPoint = {
96
96
  */
97
97
  dx?: number;
98
98
  dy?: number;
99
+ /** A 'grouped' series' own slice of the category band, as start/end fractions (0–1) of the whole band. `undefined` means the whole band. */
100
+ areaInset?: {
101
+ start: number;
102
+ end: number;
103
+ };
99
104
  };
100
105
  /** A candidate point for hover matching/highlighting — also carries its source row. */
101
106
  export type HoverPoint<T> = HoverDisplayPoint & {
@@ -52,7 +52,8 @@ export type LineMarkerConfig = {
52
52
  * for hover, resolved against the live scale at render time; `role:
53
53
  * 'highlight'` is `HoverMarkerConfig`'s `areaX`/`areaY` highlight — a
54
54
  * visible, non-interactive rect centered on `category`, spanning the full
55
- * opposite axis, sized to the category band's own width plus 2px.
55
+ * opposite axis, sized to the category band's own width plus 2px (or a
56
+ * single series' own slice, via `insetTop`/etc.).
56
57
  */
57
58
  export type BarMarkerConfig = {
58
59
  type: 'bar';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.52",
3
+ "version": "0.0.54",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"