@fundar/data-chart-telling 0.0.28 → 0.0.30

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 (49) hide show
  1. package/dist/charts/bar/Chart.svelte +2 -2
  2. package/dist/charts/bar/Chart.svelte.d.ts +2 -2
  3. package/dist/charts/line/Chart.svelte +2 -2
  4. package/dist/charts/line/Chart.svelte.d.ts +2 -2
  5. package/dist/charts/pyramid/Chart.svelte +2 -2
  6. package/dist/charts/pyramid/Chart.svelte.d.ts +3 -2
  7. package/dist/configuration/config.svelte.js +6 -0
  8. package/dist/configuration/themes/index.d.ts +168 -0
  9. package/dist/index.d.ts +4 -3
  10. package/dist/layout/coordinates/CoordinatesLayout.svelte +10 -5
  11. package/dist/layout/coordinates/CoordinatesLayout.svelte.d.ts +2 -0
  12. package/dist/layout/facet/FacetLayout.svelte +77 -20
  13. package/dist/layout/facet/FacetLayout.svelte.d.ts +2 -1
  14. package/dist/layout/plot/BasePlotLayout.svelte +19 -0
  15. package/dist/layout/plot/BasePlotLayout.svelte.d.ts +17 -0
  16. package/dist/plots/bar/Plot.svelte +13 -2
  17. package/dist/plots/bar/Plot.svelte.d.ts +2 -2
  18. package/dist/plots/bar/ValueLabels.svelte +5 -1
  19. package/dist/plots/bar/buildBarMarkers.d.ts +10 -3
  20. package/dist/plots/bar/buildBarMarkers.js +86 -55
  21. package/dist/plots/bar/hoverPoints.js +2 -1
  22. package/dist/plots/bar/layout.svelte.d.ts +2 -0
  23. package/dist/plots/bar/layout.svelte.js +6 -1
  24. package/dist/plots/heatmap/Plot.svelte +2 -2
  25. package/dist/plots/heatmap/Plot.svelte.d.ts +2 -2
  26. package/dist/plots/line/Plot.svelte +39 -15
  27. package/dist/plots/line/Plot.svelte.d.ts +2 -2
  28. package/dist/plots/line/ValueLabels.svelte +17 -2
  29. package/dist/plots/line/buildGapMarkers.d.ts +21 -0
  30. package/dist/plots/line/buildGapMarkers.js +68 -0
  31. package/dist/plots/pyramid/Plot.svelte +39 -14
  32. package/dist/plots/pyramid/Plot.svelte.d.ts +2 -2
  33. package/dist/plots/pyramid/ValueLabels.svelte +5 -1
  34. package/dist/plots/pyramid/buildPyramidBarMarkers.d.ts +8 -1
  35. package/dist/plots/pyramid/buildPyramidBarMarkers.js +43 -3
  36. package/dist/plots/scatter/Plot.svelte +2 -2
  37. package/dist/plots/scatter/Plot.svelte.d.ts +2 -2
  38. package/dist/plots/utils/gaps.d.ts +52 -0
  39. package/dist/plots/utils/gaps.js +156 -0
  40. package/dist/plots/utils/labelOverflow.svelte.d.ts +9 -0
  41. package/dist/plots/utils/labelOverflow.svelte.js +11 -0
  42. package/dist/types/charts/props.d.ts +11 -2
  43. package/dist/types/configuration/styling.d.ts +19 -0
  44. package/dist/types/layout/facet.d.ts +2 -0
  45. package/dist/types/layout/styles.d.ts +29 -11
  46. package/dist/types/plots/gaps.d.ts +54 -0
  47. package/dist/types/plots/gaps.js +1 -0
  48. package/dist/types/plots/props.d.ts +1 -1
  49. package/package.json +1 -1
@@ -13,6 +13,7 @@
13
13
  import { createLabelMarginTracker } from '../utils/labelOverflow.svelte';
14
14
  import { buildHoverPoints } from './hoverPoints';
15
15
  import { buildBarMarkers } from './buildBarMarkers';
16
+ import { resolveGapsForSeries, resolveDiscreteGapFills } from '../utils/gaps';
16
17
  import ValueLabels from './ValueLabels.svelte';
17
18
  import BasePlotLayout from '../../layout/plot/BasePlotLayout.svelte';
18
19
  import { hasHoverMarker, resolveHoverStrategy, resolveHoverSync } from '../../layout/tooltip/utils';
@@ -20,7 +21,7 @@
20
21
  import type { BasePlotProps } from '../../types/plots/props';
21
22
  import type { AxisScale, AxisBasedScalesConfig } from '../../types/layout/scales';
22
23
  import type { Series, SeriesProps, DataProps } from '../../types/plots/data/common';
23
- import type { BasePlotStylesConfig } from '../../types/layout/styles';
24
+ import type { GapsAwarePlotStylesConfig } from '../../types/layout/styles';
24
25
  import type { ValueAnchor } from '../../types/plots/constants';
25
26
  import type { BarSegmentStyle } from '../../types/plots/segments/common';
26
27
  import type { LegendDisabledStyle } from '../../types/layout/legend';
@@ -33,7 +34,7 @@
33
34
  type Props = BasePlotProps<
34
35
  SeriesProps<TData> | DataProps<TData>,
35
36
  TData,
36
- BasePlotStylesConfig,
37
+ GapsAwarePlotStylesConfig<BarSegmentStyle>,
37
38
  BarSegmentStyle,
38
39
  AxisBasedMarkersConfig,
39
40
  AxisBasedScalesConfig<TData>
@@ -68,6 +69,14 @@
68
69
  const formatValue = $derived(styles.values?.format ?? ((v: number) => `${v}`));
69
70
  const valAnchor = $derived<ValueAnchor>(styles.values?.anchor ?? 'outside');
70
71
 
72
+ // Fills leading/internal/trailing missing-data holes with an
73
+ // interpolated, distinctly-styled bar, resolved per series like
74
+ // `segments` — see `GapsAwarePlotStylesConfig` (`types/layout/styles.ts`).
75
+ const gaps = $derived(styles.gaps ?? {});
76
+ const gapFillMaps = $derived(
77
+ new Map(resolvedSeries.map((s) => [s.name, resolveDiscreteGapFills(s, resolveGapsForSeries(gaps, s.name))])),
78
+ );
79
+
71
80
  // `xAcc`/`yAcc` are always data-semantic (category/value) — independent of
72
81
  // which *visual* axis ends up drawing them, decided by `categoricalAxis`.
73
82
  const xAcc = $derived(resolveAccessor(resolvedSeries[0]?.x ?? ((d: TData) => d)));
@@ -148,6 +157,7 @@
148
157
  flat: () => flat,
149
158
  xAcc: () => xAcc,
150
159
  groupedSeries: () => groupedSeries,
160
+ gapFillMaps: () => gapFillMaps,
151
161
  });
152
162
 
153
163
  // `x`/`y` here are visual (matched against BasePlotLayout's own getX/getY,
@@ -228,6 +238,7 @@
228
238
  active: hover.active,
229
239
  setHoverMatch,
230
240
  clearHoverMatch,
241
+ gapFillMaps,
231
242
  }),
232
243
  ...markers,
233
244
  ]}
@@ -2,9 +2,9 @@ import type { AxisBasedMarkersConfig } from '../../types/markers/common';
2
2
  import type { BasePlotProps } from '../../types/plots/props';
3
3
  import type { AxisBasedScalesConfig } from '../../types/layout/scales';
4
4
  import type { SeriesProps, DataProps } from '../../types/plots/data/common';
5
- import type { BasePlotStylesConfig } from '../../types/layout/styles';
5
+ import type { GapsAwarePlotStylesConfig } from '../../types/layout/styles';
6
6
  declare function $$render<TData extends Record<string, unknown>>(): {
7
- props: BasePlotProps<SeriesProps<TData> | DataProps<TData>, TData, BasePlotStylesConfig, import("../..").AreaStyle, AxisBasedMarkersConfig, AxisBasedScalesConfig<TData>>;
7
+ props: BasePlotProps<SeriesProps<TData> | DataProps<TData>, TData, GapsAwarePlotStylesConfig<import("../..").AreaStyle>, import("../..").AreaStyle, AxisBasedMarkersConfig, AxisBasedScalesConfig<TData>>;
8
8
  exports: {};
9
9
  bindings: "";
10
10
  slots: {};
@@ -3,6 +3,7 @@
3
3
  import { resolveAccessor } from '../utils/accessors';
4
4
  import { disabledFillOverride } from '../utils/legendDisabled';
5
5
  import { watchLabelOverflow } from '../utils/labelOverflow.svelte';
6
+ import { isGapValue } from '../utils/gaps';
6
7
  import type { Series } from '../../types/plots/data/common';
7
8
  import type { ValuesStyle } from '../../types/layout/styles';
8
9
  import type { ValueAnchor } from '../../types/plots/constants';
@@ -77,6 +78,9 @@
77
78
 
78
79
  const xFn = $derived(resolveAccessor(group.series.x));
79
80
  const yFn = $derived(resolveAccessor(group.series.y));
81
+ // A gap row — missing data, whether or not `styles.gaps` fills it with a
82
+ // synthetic bar — never gets a value label, same as LinePlot's `lastPoints`.
83
+ const labelData = $derived(group.series.data.filter((d) => !isGapValue(yFn(d))));
80
84
 
81
85
  const valueTextFn = $derived(
82
86
  barLayout.layout === 'stacked'
@@ -113,7 +117,7 @@
113
117
  boundary crossing instead (pointerenter/leave fire on every ancestor). -->
114
118
  <g bind:this={groupEl} role="presentation" onpointerenter={onHoverSeries} onpointerleave={onHoverEnd}>
115
119
  <Text
116
- data={group.series.data}
120
+ data={labelData}
117
121
  {...isHorizontal ? { y: xFn, x: valueTextFn } : { x: xFn, y: valueTextFn }}
118
122
  text={(d: TData) => formatValue(Number(yFn(d)), group.series.name)}
119
123
  dx={font?.dx ?? (isHorizontal ? defaultOffset : groupOffset.dx)}
@@ -4,11 +4,17 @@ import type { VisualGroup } from '../../types/plots/segments/config';
4
4
  import type { HoverPoint } from '../../types/layout/tooltip';
5
5
  import type { BarMarkerConfig } from '../../types/markers/common';
6
6
  import type { BarLayout } from './layout.svelte';
7
+ import type { DiscreteGapFill } from '../utils/gaps';
7
8
  /**
8
9
  * Every `'bar'` marker for one BarPlot render: one `role: 'segment'` marker
9
- * per series (stacked) or per visual segment (grouped/overlap), plus one
10
- * `role: 'hitArea'` marker per category, so a chart never gets segment
11
- * markers without their hit areas.
10
+ * per series (stacked) or per visual segment (grouped/overlap), one
11
+ * additional `role: 'segment'` marker per series per gap zone kind for any
12
+ * row `styles.gaps` fills (distinctly styled, drawn at its interpolated
13
+ * value — see `resolveDiscreteGapFills`), plus one `role: 'hitArea'` marker
14
+ * per category, so a chart never gets segment markers without their hit
15
+ * areas. A row whose value is missing and *not* filled by a gap zone is
16
+ * skipped entirely — no bar drawn, rather than the broken zero/`NaN`
17
+ * geometry a raw missing value would otherwise produce.
12
18
  *
13
19
  * `'hitArea'` geometry is left unresolved (just `category` + `data`) —
14
20
  * resolving it to pixels needs svelteplot's live scale, only available
@@ -27,4 +33,5 @@ export declare function buildBarMarkers<TData extends Record<string, unknown>>(a
27
33
  active: boolean;
28
34
  setHoverMatch: (rows: TData[]) => void;
29
35
  clearHoverMatch: () => void;
36
+ gapFillMaps: Map<string, Map<TData, DiscreteGapFill<BarSegmentStyle>>>;
30
37
  }): BarMarkerConfig[];
@@ -1,81 +1,112 @@
1
1
  import { resolveAccessor } from '../utils/accessors';
2
2
  import { paletteColor } from '../../utils/color';
3
+ import { getConfiguration } from '../../configuration/config.svelte';
4
+ import { isGapValue, resolveGapAreaStyle } from '../utils/gaps';
5
+ const GAP_KINDS = ['start', 'middle', 'end'];
3
6
  /**
4
7
  * Every `'bar'` marker for one BarPlot render: one `role: 'segment'` marker
5
- * per series (stacked) or per visual segment (grouped/overlap), plus one
6
- * `role: 'hitArea'` marker per category, so a chart never gets segment
7
- * markers without their hit areas.
8
+ * per series (stacked) or per visual segment (grouped/overlap), one
9
+ * additional `role: 'segment'` marker per series per gap zone kind for any
10
+ * row `styles.gaps` fills (distinctly styled, drawn at its interpolated
11
+ * value — see `resolveDiscreteGapFills`), plus one `role: 'hitArea'` marker
12
+ * per category, so a chart never gets segment markers without their hit
13
+ * areas. A row whose value is missing and *not* filled by a gap zone is
14
+ * skipped entirely — no bar drawn, rather than the broken zero/`NaN`
15
+ * geometry a raw missing value would otherwise produce.
8
16
  *
9
17
  * `'hitArea'` geometry is left unresolved (just `category` + `data`) —
10
18
  * resolving it to pixels needs svelteplot's live scale, only available
11
19
  * where the marker actually renders.
12
20
  */
13
21
  export function buildBarMarkers(args) {
14
- const { groupedSeries, barLayout, isHorizontal, hoverPoints, palette, disabledFillFor, active, setHoverMatch, clearHoverMatch } = args;
22
+ const { groupedSeries, barLayout, isHorizontal, hoverPoints, palette, disabledFillFor, active, setHoverMatch, clearHoverMatch, gapFillMaps } = args;
23
+ const gapDefaults = getConfiguration().bar.gap;
15
24
  const segmentMarkers = [];
16
25
  groupedSeries.forEach((group, seriesIndex) => {
17
26
  const xFn = resolveAccessor(group.series.x);
18
27
  const yFn = resolveAccessor(group.series.y);
19
28
  const defaultColor = paletteColor(seriesIndex, palette);
20
29
  const disabledFill = disabledFillFor(group.series.name);
21
- if (barLayout.layout === 'stacked') {
22
- const stackedBaselineFn = (d) => barLayout.stackedBaseline(xFn(d), seriesIndex);
23
- const stackedTopFn = (d) => barLayout.stackedBaseline(xFn(d), seriesIndex) + Number(yFn(d));
24
- const marker = {
25
- type: 'bar',
26
- role: 'segment',
27
- isHorizontal,
28
- data: group.series.data,
29
- style: {
30
- fill: disabledFill?.fill ?? defaultColor,
31
- fillOpacity: disabledFill?.fillOpacity ?? 1,
32
- },
33
- };
34
- if (isHorizontal) {
35
- marker.y = xFn;
36
- marker.x1 = stackedBaselineFn;
37
- marker.x2 = stackedTopFn;
38
- }
39
- else {
40
- marker.x = xFn;
41
- marker.y1 = stackedBaselineFn;
42
- marker.y2 = stackedTopFn;
43
- }
44
- segmentMarkers.push(marker);
45
- return;
46
- }
47
- for (const seg of group.visualSegments) {
48
- const marker = {
49
- type: 'bar',
50
- role: 'segment',
51
- isHorizontal,
52
- data: seg.data,
53
- style: {
54
- fill: disabledFill?.fill ?? seg.style.fill ?? defaultColor,
55
- fillOpacity: disabledFill?.fillOpacity ?? seg.style.fillOpacity ?? 1,
56
- },
57
- };
58
- if (isHorizontal) {
59
- marker.y = xFn;
60
- marker.x1 = barLayout.valueBaseline;
61
- marker.x2 = yFn;
30
+ // Places one series' bar(s) — real or gap-fill — using whichever
31
+ // layout mode (stacked baseline, or flat baseline + grouped insets) is
32
+ // active, so a gap-fill bar always lands in exactly the same slot a
33
+ // real bar for that row would.
34
+ function makeMarker(data, valueFn, style) {
35
+ if (data.length === 0)
36
+ return null;
37
+ const marker = { type: 'bar', role: 'segment', isHorizontal, data, style };
38
+ if (barLayout.layout === 'stacked') {
39
+ const baselineFn = (d) => barLayout.stackedBaseline(xFn(d), seriesIndex);
40
+ const topFn = (d) => baselineFn(d) + valueFn(d);
41
+ if (isHorizontal) {
42
+ marker.y = xFn;
43
+ marker.x1 = baselineFn;
44
+ marker.x2 = topFn;
45
+ }
46
+ else {
47
+ marker.x = xFn;
48
+ marker.y1 = baselineFn;
49
+ marker.y2 = topFn;
50
+ }
62
51
  }
63
52
  else {
64
- marker.x = xFn;
65
- marker.y1 = barLayout.valueBaseline;
66
- marker.y2 = yFn;
67
- }
68
- if (barLayout.layout === 'grouped') {
69
53
  if (isHorizontal) {
70
- marker.insetTop = seriesIndex * barLayout.seriesStep + barLayout.seriesGap / 2;
71
- marker.insetBottom = (barLayout.seriesCount - 1 - seriesIndex) * barLayout.seriesStep + barLayout.seriesGap / 2;
54
+ marker.y = xFn;
55
+ marker.x1 = barLayout.valueBaseline;
56
+ marker.x2 = valueFn;
72
57
  }
73
58
  else {
74
- marker.insetLeft = seriesIndex * barLayout.seriesStep + barLayout.seriesGap / 2;
75
- marker.insetRight = (barLayout.seriesCount - 1 - seriesIndex) * barLayout.seriesStep + barLayout.seriesGap / 2;
59
+ marker.x = xFn;
60
+ marker.y1 = barLayout.valueBaseline;
61
+ marker.y2 = valueFn;
62
+ }
63
+ if (barLayout.layout === 'grouped') {
64
+ if (isHorizontal) {
65
+ marker.insetTop = seriesIndex * barLayout.seriesStep + barLayout.seriesGap / 2;
66
+ marker.insetBottom = (barLayout.seriesCount - 1 - seriesIndex) * barLayout.seriesStep + barLayout.seriesGap / 2;
67
+ }
68
+ else {
69
+ marker.insetLeft = seriesIndex * barLayout.seriesStep + barLayout.seriesGap / 2;
70
+ marker.insetRight = (barLayout.seriesCount - 1 - seriesIndex) * barLayout.seriesStep + barLayout.seriesGap / 2;
71
+ }
76
72
  }
77
73
  }
78
- segmentMarkers.push(marker);
74
+ return marker;
75
+ }
76
+ // Mirrors the pre-existing behavior for both branches: a series-scoped
77
+ // `segments` catch-all style (e.g. an explicit `fill`) wins over the
78
+ // index-derived `defaultColor`.
79
+ for (const seg of group.visualSegments) {
80
+ const realData = seg.data.filter((d) => !isGapValue(yFn(d)));
81
+ const style = {
82
+ fill: disabledFill?.fill ?? seg.style.fill ?? defaultColor,
83
+ fillOpacity: disabledFill?.fillOpacity ?? seg.style.fillOpacity ?? 1,
84
+ };
85
+ const valueFn = barLayout.layout === 'stacked' ? (d) => Number(yFn(d)) : yFn;
86
+ const marker = makeMarker(realData, valueFn, style);
87
+ if (marker)
88
+ segmentMarkers.push(marker);
89
+ }
90
+ const fillMap = gapFillMaps.get(group.series.name) ?? new Map();
91
+ for (const kind of GAP_KINDS) {
92
+ const filledRows = group.series.data.filter((d) => fillMap.get(d)?.kind === kind);
93
+ if (filledRows.length === 0)
94
+ continue;
95
+ const base = resolveGapAreaStyle(fillMap.get(filledRows[0]).style, defaultColor, gapDefaults);
96
+ const gapStyle = {
97
+ ...base,
98
+ fill: disabledFill?.fill ?? base.fill,
99
+ fillOpacity: disabledFill?.fillOpacity ?? base.fillOpacity,
100
+ };
101
+ // svelteplot shallow-clones each row before invoking a mark's own
102
+ // geometry accessors (same reason PyramidPlot's `flat` tags rows
103
+ // instead of relying on identity) — a `fillMap.get(d)` lookup inside
104
+ // `valueFn` would see a clone, not the original key. Tagging the
105
+ // resolved value onto the row as a plain field survives that clone.
106
+ const taggedData = filledRows.map((d) => ({ ...d, __gapValue: fillMap.get(d).value }));
107
+ const marker = makeMarker(taggedData, (d) => d.__gapValue, gapStyle);
108
+ if (marker)
109
+ segmentMarkers.push(marker);
79
110
  }
80
111
  });
81
112
  const byCategory = new Map();
@@ -1,5 +1,6 @@
1
1
  import { resolveAccessor } from '../utils/accessors';
2
2
  import { paletteColor } from '../../utils/color';
3
+ import { isGapValue } from '../utils/gaps';
3
4
  /**
4
5
  * Builds hover-marker candidate points for every row across every series.
5
6
  *
@@ -15,7 +16,7 @@ export function buildHoverPoints(resolvedSeries, isHorizontal, geometry, palette
15
16
  return resolvedSeries.flatMap((s, idx) => {
16
17
  const sx = resolveAccessor(s.x);
17
18
  const sy = resolveAccessor(s.y);
18
- return s.data.map((d) => {
19
+ return s.data.filter((d) => !isGapValue(sy(d))).map((d) => {
19
20
  const category = sx(d);
20
21
  const rawValue = Number(sy(d));
21
22
  const value = geometry.layout === 'stacked' ? geometry.stackedBaseline(category, idx) + rawValue : rawValue;
@@ -3,6 +3,7 @@ import type { Series } from '../../types/plots/data/common';
3
3
  import type { BarSegmentStyle } from '../../types/plots/segments/common';
4
4
  import type { VisualGroup } from '../../types/plots/segments/config';
5
5
  import type { MarginConfig } from '../../types/plots/props';
6
+ import type { DiscreteGapFill } from '../utils/gaps';
6
7
  export type BarSeriesLayout = 'overlap' | 'grouped' | 'stacked';
7
8
  /**
8
9
  * Reactive geometry for BarPlot's series-layout modes — the single source of
@@ -20,6 +21,7 @@ export declare function createBarLayout<TData extends Record<string, unknown>>(a
20
21
  flat: () => TData[];
21
22
  xAcc: () => (d: TData) => AxisValue;
22
23
  groupedSeries: () => VisualGroup<Series<TData>, BarSegmentStyle>[];
24
+ gapFillMaps: () => Map<string, Map<TData, DiscreteGapFill<BarSegmentStyle>>>;
23
25
  }): {
24
26
  readonly layout: BarSeriesLayout;
25
27
  readonly valueBaseline: number;
@@ -2,6 +2,7 @@ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
2
2
  import { getConfiguration } from '../../configuration/config.svelte';
3
3
  import { resolveAccessor } from '../utils/accessors';
4
4
  import { numericMargin } from '../../layout/plot/margins';
5
+ import { isGapValue } from '../utils/gaps';
5
6
  /**
6
7
  * Reactive geometry for BarPlot's series-layout modes — the single source of
7
8
  * truth for "where does series N's bar actually sit," shared by bar-segment
@@ -98,7 +99,11 @@ export function createBarLayout(args) {
98
99
  const seriesXFn = resolveAccessor(group.series.x);
99
100
  const seriesYFn = resolveAccessor(group.series.y);
100
101
  const row = group.series.data.find((d) => seriesXFn(d) === category);
101
- running += row ? Number(seriesYFn(row)) : 0;
102
+ if (row) {
103
+ const raw = seriesYFn(row);
104
+ const fill = args.gapFillMaps().get(group.series.name)?.get(row);
105
+ running += fill ? fill.value : isGapValue(raw) ? 0 : Number(raw);
106
+ }
102
107
  }
103
108
  sums.set(category, prefix);
104
109
  }
@@ -13,7 +13,7 @@
13
13
  import type { BasePlotProps } from '../../types/plots/props';
14
14
  import type { AxisValue, AxisBasedScalesConfig } from '../../types/layout/scales';
15
15
  import type { SeriesProps, DataProps } from '../../types/plots/data/common';
16
- import type { BasePlotStylesConfig } from '../../types/layout/styles';
16
+ import type { AxisBasedStylesConfig } from '../../types/layout/styles';
17
17
  import type { CellSegmentStyle } from '../../types/plots/segments/common';
18
18
  import type { AxisBasedMarkersConfig } from '../../types/markers/common';
19
19
 
@@ -29,7 +29,7 @@
29
29
  type Props = BasePlotProps<
30
30
  SeriesProps<TData> | DataProps<TData>,
31
31
  TData,
32
- BasePlotStylesConfig,
32
+ AxisBasedStylesConfig,
33
33
  CellSegmentStyle,
34
34
  AxisBasedMarkersConfig,
35
35
  AxisBasedScalesConfig<TData>
@@ -1,10 +1,10 @@
1
1
  import type { BasePlotProps } from '../../types/plots/props';
2
2
  import type { AxisBasedScalesConfig } from '../../types/layout/scales';
3
3
  import type { SeriesProps, DataProps } from '../../types/plots/data/common';
4
- import type { BasePlotStylesConfig } from '../../types/layout/styles';
4
+ import type { AxisBasedStylesConfig } from '../../types/layout/styles';
5
5
  import type { AxisBasedMarkersConfig } from '../../types/markers/common';
6
6
  declare function $$render<TData extends Record<string, unknown>>(): {
7
- props: BasePlotProps<SeriesProps<TData> | DataProps<TData>, TData, BasePlotStylesConfig, import("../..").AreaStyle, AxisBasedMarkersConfig, AxisBasedScalesConfig<TData>>;
7
+ props: BasePlotProps<SeriesProps<TData> | DataProps<TData>, TData, AxisBasedStylesConfig, import("../..").AreaStyle, AxisBasedMarkersConfig, AxisBasedScalesConfig<TData>>;
8
8
  exports: {};
9
9
  bindings: "";
10
10
  slots: {};
@@ -9,6 +9,8 @@
9
9
  import { getLegendInteraction } from '../../layout/legend/interaction.svelte';
10
10
  import { disabledStrokeOverride, disabledDotsOverride } from '../utils/legendDisabled';
11
11
  import { buildLineMarkers } from './buildLineMarkers';
12
+ import { buildGapMarkers } from './buildGapMarkers';
13
+ import { isGapValue } from '../utils/gaps';
12
14
  import { createLabelMarginTracker } from '../utils/labelOverflow.svelte';
13
15
  import BasePlotLayout from '../../layout/plot/BasePlotLayout.svelte';
14
16
  import ValueLabels from './ValueLabels.svelte';
@@ -17,7 +19,7 @@
17
19
  import type { BasePlotProps } from '../../types/plots/props';
18
20
  import type { AxisValue, AxisBasedScalesConfig } from '../../types/layout/scales';
19
21
  import type { Series, SeriesProps, DataProps } from '../../types/plots/data/common';
20
- import type { BasePlotStylesConfig } from '../../types/layout/styles';
22
+ import type { GapsAwarePlotStylesConfig } from '../../types/layout/styles';
21
23
  import type { LineSegmentStyle } from '../../types/plots/segments/common';
22
24
  import type { LineMarkersConfig } from '../../types/markers/line';
23
25
  import type { LegendDisabledStyle } from '../../types/layout/legend';
@@ -25,7 +27,7 @@
25
27
  type Props = BasePlotProps<
26
28
  SeriesProps<TData> | DataProps<TData>,
27
29
  TData,
28
- BasePlotStylesConfig,
30
+ GapsAwarePlotStylesConfig<LineSegmentStyle>,
29
31
  LineSegmentStyle,
30
32
  LineMarkersConfig,
31
33
  AxisBasedScalesConfig<TData>
@@ -47,6 +49,11 @@
47
49
  tooltip = undefined,
48
50
  }: Props = $props();
49
51
 
52
+ // Fills leading/internal/trailing missing-data holes with an
53
+ // interpolated, distinctly-styled bridge, resolved per series like
54
+ // `segments` — see `GapsAwarePlotStylesConfig` (`types/layout/styles.ts`).
55
+ const gaps = $derived(styles.gaps ?? {});
56
+
50
57
  const resolvedSeries = $derived(series ?? buildSeries(data!, x!, y!, z));
51
58
  const legendInteraction = $derived(getLegendInteraction());
52
59
 
@@ -80,27 +87,36 @@
80
87
  const flat = $derived(resolvedSeries.flatMap((s) => s.data));
81
88
  // A legend-disabled series is dimmed on the mark itself, but never surfaces
82
89
  // in hover/tooltip — its points are simply absent from the candidate list.
90
+ // A gap row (see `gaps`) is excluded the same way — it carries no real
91
+ // value to hover.
83
92
  const hoverPoints = $derived(
84
93
  resolvedSeries.flatMap((s, idx) => {
85
94
  if (legendInteraction?.disabledSeries.has(s.name)) return [];
86
95
  const sx = resolveAccessor(s.x);
87
96
  const sy = resolveAccessor(s.y);
88
- return s.data.map((d) => ({
89
- x: sx(d) as AxisValue,
90
- y: Number(sy(d)),
91
- row: d,
92
- label: String(sy(d)),
93
- series: s.name,
94
- color: paletteColor(idx, cfg.palette),
95
- }));
97
+ return s.data
98
+ .filter((d) => !isGapValue(sy(d)))
99
+ .map((d) => ({
100
+ x: sx(d) as AxisValue,
101
+ y: Number(sy(d)),
102
+ row: d,
103
+ label: String(sy(d)),
104
+ series: s.name,
105
+ color: paletteColor(idx, cfg.palette),
106
+ }));
96
107
  }),
97
108
  );
98
109
 
99
- // Last data point per series, with its palette index for colour.
110
+ // Last *real* data point per series (skipping any trailing gap row), with
111
+ // its palette index for colour.
100
112
  const lastPoints = $derived(
101
113
  resolvedSeries
102
- .map((s, idx) => ({ series: s, lastRow: s.data[s.data.length - 1], seriesIndex: idx }))
103
- .filter(({ lastRow }) => lastRow !== undefined),
114
+ .map((s, idx) => {
115
+ const sy = resolveAccessor(s.y);
116
+ const lastRow = [...s.data].reverse().find((d) => !isGapValue(sy(d)));
117
+ return { series: s, lastRow, seriesIndex: idx };
118
+ })
119
+ .filter((p): p is { series: Series<TData>; lastRow: TData; seriesIndex: number } => p.lastRow !== undefined),
104
120
  );
105
121
 
106
122
  const hover = $derived({
@@ -120,6 +136,14 @@
120
136
  }),
121
137
  );
122
138
 
139
+ const gapMarkers = $derived(
140
+ buildGapMarkers<TData>({
141
+ seriesList: resolvedSeries,
142
+ gaps,
143
+ colorFor: (seriesIndex) => paletteColor(seriesIndex, cfg.palette),
144
+ }),
145
+ );
146
+
123
147
  const scopedGroups = $derived(
124
148
  buildScopedMarkerGroups<TData, Series<TData>, LineSegmentStyle>({
125
149
  groupedSeries,
@@ -148,9 +172,9 @@
148
172
  getY={(d) => Number(yAcc(d))}
149
173
  {hover}
150
174
  {scales}
151
- margins={labelMargin.resolvedMargins}
175
+ {...labelMargin.plotProps}
152
176
  xTickRotate={scales?.x?.tickRotate}
153
- markers={[...lineMarkers, ...markers]}
177
+ markers={[...gapMarkers, ...lineMarkers, ...markers]}
154
178
  seriesData={resolvedSeries}
155
179
  {scopedGroups}
156
180
  >
@@ -1,11 +1,11 @@
1
1
  import type { BasePlotProps } from '../../types/plots/props';
2
2
  import type { AxisBasedScalesConfig } from '../../types/layout/scales';
3
3
  import type { SeriesProps, DataProps } from '../../types/plots/data/common';
4
- import type { BasePlotStylesConfig } from '../../types/layout/styles';
4
+ import type { GapsAwarePlotStylesConfig } from '../../types/layout/styles';
5
5
  import type { LineSegmentStyle } from '../../types/plots/segments/common';
6
6
  import type { LineMarkersConfig } from '../../types/markers/line';
7
7
  declare function $$render<TData extends Record<string, unknown>>(): {
8
- props: BasePlotProps<SeriesProps<TData> | DataProps<TData>, TData, BasePlotStylesConfig, LineSegmentStyle, LineMarkersConfig, AxisBasedScalesConfig<TData>>;
8
+ props: BasePlotProps<SeriesProps<TData> | DataProps<TData>, TData, GapsAwarePlotStylesConfig<LineSegmentStyle>, LineSegmentStyle, LineMarkersConfig, AxisBasedScalesConfig<TData>>;
9
9
  exports: {};
10
10
  bindings: "";
11
11
  slots: {};
@@ -1,4 +1,5 @@
1
1
  <script lang="ts" generics="TData extends Record<string, unknown>">
2
+ import { untrack } from 'svelte';
2
3
  import { SvelteMap } from 'svelte/reactivity';
3
4
  import { Text, usePlot } from 'svelteplot';
4
5
  import { resolveAccessor } from '../utils/accessors';
@@ -97,8 +98,22 @@
97
98
  // changes the rendered label layout so this re-measures.
98
99
  void lastPoints;
99
100
  void values;
100
- const xRange = plot.scales.x?.fn?.range?.()?.map(Number);
101
- const yRange = plot.scales.y?.fn?.range?.()?.map(Number);
101
+ // The scale range itself is read `untrack`ed on purpose: this margin
102
+ // watcher's own report (see labelOverflow.svelte.ts) grows the plot's
103
+ // margin to fit the labels, which feeds back into svelteplot's own
104
+ // auto-margin sizing and nudges this same range — occasionally by a
105
+ // permanent, never-settling ±1px each render. Treating the range as a
106
+ // tracked dependency here reran the whole watcher every single time it
107
+ // twitched, which (when it never settles) never let the watcher's own
108
+ // "poll until stable" loop finish — an infinite effect-restart loop
109
+ // that tripped Svelte's effect_update_depth_exceeded guard. Reading it
110
+ // untracked still measures against the *current* range on every
111
+ // re-run triggered by `lastPoints`/`values` above; it just stops the
112
+ // range's own fluctuations from being what triggers those re-runs.
113
+ const { xRange, yRange } = untrack(() => ({
114
+ xRange: plot.scales.x?.fn?.range?.()?.map(Number),
115
+ yRange: plot.scales.y?.fn?.range?.()?.map(Number),
116
+ }));
102
117
  if (!xRange || !yRange) return undefined;
103
118
  return {
104
119
  left: Math.min(xRange[0], xRange[1]),
@@ -0,0 +1,21 @@
1
+ import type { Series } from '../../types/plots/data/common';
2
+ import type { GapsConfig } from '../../types/plots/gaps';
3
+ import type { LineSegmentStyle } from '../../types/plots/segments/common';
4
+ import type { LineMarkerConfig, DotMarkerConfig } from '../../types/markers/common';
5
+ /**
6
+ * One `'line'` (+ `'dot'`, when its zone sets `style.dots`) marker per
7
+ * detected, configured gap run across every series — the interpolated
8
+ * bridge drawn over a missing-data hole. `gaps` is resolved per series the
9
+ * same way `segments` is (a named entry, falling back to `'default'` at
10
+ * zone granularity — see `resolveGapsForSeries`). Defaults to the series'
11
+ * own colour with `cfg.line.gap`'s stroke/dot styling (dashed and slightly
12
+ * muted out of the box), so a gap-fill reads as estimated rather than real
13
+ * data unless a zone or the global configuration overrides it. Dots (when
14
+ * requested) are drawn only at a run's two endpoints, not along every
15
+ * sampled bridge point.
16
+ */
17
+ export declare function buildGapMarkers<TData extends Record<string, unknown>>(args: {
18
+ seriesList: Series<TData>[];
19
+ gaps: GapsConfig<LineSegmentStyle>;
20
+ colorFor: (seriesIndex: number) => string;
21
+ }): (LineMarkerConfig | DotMarkerConfig)[];
@@ -0,0 +1,68 @@
1
+ import { resolveGapRuns, resolveGapsForSeries } from '../utils/gaps';
2
+ import { getConfiguration } from '../../configuration/config.svelte';
3
+ const gx = (p) => p.x;
4
+ const gy = (p) => p.y;
5
+ /**
6
+ * One `'line'` (+ `'dot'`, when its zone sets `style.dots`) marker per
7
+ * detected, configured gap run across every series — the interpolated
8
+ * bridge drawn over a missing-data hole. `gaps` is resolved per series the
9
+ * same way `segments` is (a named entry, falling back to `'default'` at
10
+ * zone granularity — see `resolveGapsForSeries`). Defaults to the series'
11
+ * own colour with `cfg.line.gap`'s stroke/dot styling (dashed and slightly
12
+ * muted out of the box), so a gap-fill reads as estimated rather than real
13
+ * data unless a zone or the global configuration overrides it. Dots (when
14
+ * requested) are drawn only at a run's two endpoints, not along every
15
+ * sampled bridge point.
16
+ */
17
+ export function buildGapMarkers(args) {
18
+ const { seriesList, gaps, colorFor } = args;
19
+ const cfg = getConfiguration();
20
+ const gapDefaults = cfg.line.gap;
21
+ const markers = [];
22
+ seriesList.forEach((series, seriesIndex) => {
23
+ const zones = resolveGapsForSeries(gaps, series.name);
24
+ const runs = resolveGapRuns(series, zones);
25
+ if (runs.length === 0)
26
+ return;
27
+ const defaultColor = colorFor(seriesIndex);
28
+ for (const run of runs) {
29
+ const strokeStyle = run.zone.style?.stroke;
30
+ const dotStyle = run.zone.style?.dots;
31
+ markers.push({
32
+ type: 'line',
33
+ data: run.points,
34
+ x: gx,
35
+ y: gy,
36
+ style: {
37
+ stroke: strokeStyle?.stroke ?? defaultColor,
38
+ strokeWidth: strokeStyle?.strokeWidth ?? gapDefaults.stroke.strokeWidth ?? cfg.line.strokeWidth,
39
+ strokeOpacity: strokeStyle?.strokeOpacity ?? gapDefaults.stroke.strokeOpacity,
40
+ strokeDasharray: strokeStyle?.strokeDasharray ?? gapDefaults.stroke.strokeDasharray,
41
+ strokeLinejoin: strokeStyle?.strokeLinejoin ?? gapDefaults.stroke.strokeLinejoin,
42
+ strokeLinecap: strokeStyle?.strokeLinecap ?? gapDefaults.stroke.strokeLinecap,
43
+ },
44
+ });
45
+ if (!dotStyle)
46
+ continue;
47
+ const dotDefaults = gapDefaults.dots;
48
+ const endpoints = [run.points[0], run.points[run.points.length - 1]];
49
+ markers.push({
50
+ type: 'dot',
51
+ data: endpoints,
52
+ x: gx,
53
+ y: gy,
54
+ style: {
55
+ dotRadius: dotStyle.dotRadius ?? dotDefaults.dotRadius ?? cfg.line.dotRadius,
56
+ dotFill: dotStyle.dotFill ?? strokeStyle?.stroke ?? dotDefaults.dotFill ?? defaultColor,
57
+ dotSymbol: dotStyle.dotSymbol ?? dotDefaults.dotSymbol ?? 'circle',
58
+ dotStroke: dotStyle.dotStroke ?? strokeStyle?.stroke ?? dotDefaults.dotStroke ?? defaultColor,
59
+ dotStrokeWidth: dotStyle.dotStrokeWidth ?? dotDefaults.dotStrokeWidth ?? 1,
60
+ dotFillOpacity: dotStyle.dotFillOpacity ?? dotDefaults.dotFillOpacity ?? 1,
61
+ dotStrokeOpacity: dotStyle.dotStrokeOpacity ?? dotDefaults.dotStrokeOpacity ?? 1,
62
+ dotStrokeDasharray: dotStyle.dotStrokeDasharray ?? dotDefaults.dotStrokeDasharray,
63
+ },
64
+ });
65
+ }
66
+ });
67
+ return markers;
68
+ }