@fundar/data-chart-telling 0.0.49 → 0.0.51

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 (40) hide show
  1. package/dist/configuration/config.svelte.js +7 -2
  2. package/dist/configuration/themes/index.d.ts +12 -0
  3. package/dist/index.d.ts +2 -2
  4. package/dist/index.js +1 -1
  5. package/dist/layout/legend/DiscreteBadgeItem.svelte +21 -8
  6. package/dist/layout/legend/DiscreteBadgeItem.svelte.d.ts +1 -0
  7. package/dist/layout/legend/DiscreteSection.svelte +22 -9
  8. package/dist/layout/legend/DiscreteSection.svelte.d.ts +2 -1
  9. package/dist/layout/legend/LegendLayout.svelte +7 -2
  10. package/dist/layout/scales/ScalesLayout.svelte +87 -10
  11. package/dist/layout/scales/buildScale.d.ts +18 -1
  12. package/dist/layout/scales/buildScale.js +49 -2
  13. package/dist/layout/scales/niceDomain.d.ts +6 -0
  14. package/dist/layout/scales/niceDomain.js +34 -0
  15. package/dist/layout/tooltip/controller.svelte.js +23 -11
  16. package/dist/layout/tooltip/utils.js +3 -0
  17. package/dist/markers/BarMarker.svelte +145 -84
  18. package/dist/markers/HoverMarker.svelte +41 -1
  19. package/dist/markers/HoverMarker.svelte.d.ts +7 -1
  20. package/dist/plots/bar/Plot.svelte +46 -20
  21. package/dist/plots/bar/buildBarMarkers.d.ts +4 -14
  22. package/dist/plots/bar/buildBarMarkers.js +73 -53
  23. package/dist/plots/bar/hoverPoints.d.ts +1 -1
  24. package/dist/plots/bar/hoverPoints.js +2 -3
  25. package/dist/plots/bar/layout.svelte.d.ts +4 -0
  26. package/dist/plots/bar/layout.svelte.js +62 -17
  27. package/dist/plots/line/Plot.svelte +29 -14
  28. package/dist/plots/line/buildLineMarkers.d.ts +1 -0
  29. package/dist/plots/line/buildLineMarkers.js +3 -4
  30. package/dist/plots/pyramid/Plot.svelte +10 -6
  31. package/dist/plots/scatter/Plot.svelte +25 -11
  32. package/dist/plots/scatter/buildScatterMarkers.d.ts +1 -0
  33. package/dist/plots/scatter/buildScatterMarkers.js +3 -4
  34. package/dist/plots/utils/legendDisabled.d.ts +13 -1
  35. package/dist/plots/utils/legendDisabled.js +27 -0
  36. package/dist/types/configuration/styling.d.ts +12 -2
  37. package/dist/types/layout/legend.d.ts +15 -10
  38. package/dist/types/layout/tooltip.d.ts +2 -1
  39. package/dist/types/markers/common.d.ts +14 -2
  40. package/package.json +1 -1
@@ -115,7 +115,12 @@ export const DEFAULT_CONFIG = {
115
115
  connectorWidth: 0.5
116
116
  }
117
117
  },
118
- bar: { gap: { fillOpacity: 0.5 } },
118
+ bar: {
119
+ gap: { fillOpacity: 0.5 },
120
+ seriesPadding: 0.2,
121
+ paddingInner: 0.15,
122
+ paddingOuter: 0.15
123
+ },
119
124
  pyramid: { gap: { fillOpacity: 0.5 } },
120
125
  scatter: { dotRadius: 5 },
121
126
  timeline: { axisColor: 'currentColor', activeColor: '#e6580d', thickness: 60 },
@@ -123,7 +128,7 @@ export const DEFAULT_CONFIG = {
123
128
  rule: { stroke: '#e6580d', strokeOpacity: 0.35, strokeWidth: 1.5, strokeDasharray: '4 4' },
124
129
  dot: { dotRadius: 5, dotFill: 'currentColor' },
125
130
  font: { fill: 'currentColor', fontSize: 12, dy: -18 },
126
- area: { stroke: '#e6580d', strokeWidth: 2, fillOpacity: 0 }
131
+ area: { fill: '#94a3b8', fillOpacity: 0.25 }
127
132
  },
128
133
  facet: {
129
134
  indicator: {
@@ -147,6 +147,9 @@ export declare const THEMES: {
147
147
  strokeDasharray?: (string | import("../..").LineStyle) | undefined;
148
148
  borderRadius?: number | undefined;
149
149
  } | undefined;
150
+ seriesPadding?: number | undefined;
151
+ paddingInner?: number | undefined;
152
+ paddingOuter?: number | undefined;
150
153
  } | undefined;
151
154
  pyramid?: {
152
155
  gap?: {
@@ -372,6 +375,9 @@ export declare const THEMES: {
372
375
  strokeDasharray?: (string | import("../..").LineStyle) | undefined;
373
376
  borderRadius?: number | undefined;
374
377
  } | undefined;
378
+ seriesPadding?: number | undefined;
379
+ paddingInner?: number | undefined;
380
+ paddingOuter?: number | undefined;
375
381
  } | undefined;
376
382
  pyramid?: {
377
383
  gap?: {
@@ -597,6 +603,9 @@ export declare const THEMES: {
597
603
  strokeDasharray?: (string | import("../..").LineStyle) | undefined;
598
604
  borderRadius?: number | undefined;
599
605
  } | undefined;
606
+ seriesPadding?: number | undefined;
607
+ paddingInner?: number | undefined;
608
+ paddingOuter?: number | undefined;
600
609
  } | undefined;
601
610
  pyramid?: {
602
611
  gap?: {
@@ -822,6 +831,9 @@ export declare const THEMES: {
822
831
  strokeDasharray?: (string | import("../..").LineStyle) | undefined;
823
832
  borderRadius?: number | undefined;
824
833
  } | undefined;
834
+ seriesPadding?: number | undefined;
835
+ paddingInner?: number | undefined;
836
+ paddingOuter?: number | undefined;
825
837
  } | undefined;
826
838
  pyramid?: {
827
839
  gap?: {
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, buildSeries } from './utils/grouping';
37
+ export { groupBy } 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';
@@ -58,7 +58,7 @@ export type { GeoMarkersConfig, GeoInsetMarkerConfig, GeoInsetTooltipConfig, Geo
58
58
  export type { ChartPlotContext, ChartPlotSnippet } from './types/charts/common';
59
59
  export type { TimeValue, Orientation, TimelineConfig } from './types/layout/timeline';
60
60
  export type { Responsive, FacetColumns, FacetMode, FacetNavLayout, FacetConfig, FacetIndicatorContext, FacetIndicatorSnippet, FacetNavDirection, FacetNavContext, FacetNavSnippet } from './types/layout/facet';
61
- export type { LegendSection, DiscreteLegendSection, ContinuousLegendSection, LegendItem, LegendDisabledStyle, LegendItemsLayout, LegendInteractiveStyle, DiscreteInteractiveConfig, ContinuousInteractiveConfig, LegendNavDirection, LegendNavContext, LegendNavSnippet, LegendInteractionStore, LegendPlotContext } from './types/layout/legend';
61
+ export type { LegendSection, DiscreteLegendSection, ContinuousLegendSection, LegendItem, LegendDisabledStyle, LegendDisabledAction, LegendItemsLayout, LegendInteractiveStyle, DiscreteInteractiveConfig, ContinuousInteractiveConfig, LegendNavDirection, LegendNavContext, LegendNavSnippet, LegendInteractionStore, LegendPlotContext } from './types/layout/legend';
62
62
  export type { HoverStrategy, TooltipOptions, TooltipProp, TooltipAnchorX, TooltipAnchorY } from './types/layout/tooltip';
63
63
  export type { ChartConfig, ChartConfigInput, TextStyle } from './types/configuration/styling';
64
64
  export type { ChartProps, GapsAwareChartProps } from './types/charts/props';
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, buildSeries } from './utils/grouping';
44
+ export { groupBy } 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';
@@ -4,18 +4,23 @@
4
4
  import type { LegendItem } from '../../types/layout/legend';
5
5
 
6
6
  /**
7
- * The `interactive.legendStyle: 'badge'` item look: a pill filled with the
8
- * series color, its symbol icon (unless `showIcon` is off) and label
9
- * auto-contrasted against that fill, and an eye icon that's crossed out
10
- * while `disabled`. The toggled-off dim itself is applied by the parent
11
- * `.dct-discrete__item`.
7
+ * The `interactive.legendStyle: 'badge'` item look: a pill with the series
8
+ * color, symbol icon, label, and a toggle icon — an eye (crossed out when
9
+ * `disabled`) for `'style'` mode, or a plus/cross pair for `'omit'`.
12
10
  */
13
11
  let {
14
12
  item,
15
13
  color,
16
14
  disabled,
17
- showIcon = true
18
- }: { item: LegendItem; color: string; disabled: boolean; showIcon?: boolean } = $props();
15
+ showIcon = true,
16
+ omitMode = false
17
+ }: {
18
+ item: LegendItem;
19
+ color: string;
20
+ disabled: boolean;
21
+ showIcon?: boolean;
22
+ omitMode?: boolean;
23
+ } = $props();
19
24
  const textColor = $derived(contrastTextColor(color));
20
25
  </script>
21
26
 
@@ -25,7 +30,15 @@
25
30
  {/if}
26
31
  <span class="dct-discrete__label">{item.label || item.name}</span>
27
32
  <svg class="dct-discrete__eye" viewBox="0 0 24 24" aria-hidden="true">
28
- {#if disabled}
33
+ {#if omitMode}
34
+ {#if disabled}
35
+ <line x1="12" y1="5" x2="12" y2="19" />
36
+ <line x1="5" y1="12" x2="19" y2="12" />
37
+ {:else}
38
+ <line x1="6" y1="6" x2="18" y2="18" />
39
+ <line x1="6" y1="18" x2="18" y2="6" />
40
+ {/if}
41
+ {:else if disabled}
29
42
  <path
30
43
  d="M17.94 17.94A10.94 10.94 0 0 1 12 20c-7 0-11-8-11-8a18.5 18.5 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
31
44
  />
@@ -4,6 +4,7 @@ type $$ComponentProps = {
4
4
  color: string;
5
5
  disabled: boolean;
6
6
  showIcon?: boolean;
7
+ omitMode?: boolean;
7
8
  };
8
9
  declare const DiscreteBadgeItem: import("svelte").Component<$$ComponentProps, {}, "">;
9
10
  type DiscreteBadgeItem = ReturnType<typeof DiscreteBadgeItem>;
@@ -6,18 +6,25 @@
6
6
  getLegendInteraction,
7
7
  createLegendInteraction
8
8
  } from './interaction.svelte';
9
+ import { resolveDisabledStyle } from '../../plots/utils/legendDisabled';
9
10
  import DiscreteBadgeItem from './DiscreteBadgeItem.svelte';
10
11
  import DiscreteSwatchItem from './DiscreteSwatchItem.svelte';
11
12
  import type {
12
13
  DiscreteLegendSection,
14
+ LegendDisabledAction,
13
15
  LegendItem,
16
+ LegendItemsLayout,
14
17
  LegendNavDirection
15
18
  } from '../../types/layout/legend';
16
19
 
17
- let { section }: { section: DiscreteLegendSection } = $props();
20
+ /** `defaultItemsLayout` is the fallback used when `section.layout` isn't set. */
21
+ let {
22
+ section,
23
+ defaultItemsLayout = { type: 'vertical' }
24
+ }: { section: DiscreteLegendSection; defaultItemsLayout?: LegendItemsLayout } = $props();
18
25
 
19
26
  const cfg = $derived(getConfiguration());
20
- const layout = $derived(section.layout ?? { type: 'vertical' as const });
27
+ const layout = $derived(section.layout ?? defaultItemsLayout);
21
28
  const isHorizontal = $derived(layout.type === 'horizontal');
22
29
  const maxRows = $derived(layout.type === 'horizontal' ? Math.max(1, layout.maxRows ?? 1) : 1);
23
30
  const columns = $derived(section.columns ?? 1);
@@ -26,8 +33,8 @@
26
33
  isInteractive && (section.interactive?.legendStyle ?? 'badge') === 'badge'
27
34
  );
28
35
  const showIcon = $derived(section.interactive?.showIcon ?? true);
29
- const disabledStyle = $derived(
30
- section.interactive?.disabledStyle ?? { opacity: cfg.legend.disabledOpacity }
36
+ const disabledAction = $derived<LegendDisabledAction>(
37
+ section.interactive?.disabled ?? { mode: 'style' }
31
38
  );
32
39
  const interaction = getLegendInteraction() ?? createLegendInteraction();
33
40
 
@@ -37,7 +44,7 @@
37
44
  if (seeded.has(item.name)) continue;
38
45
  seeded.add(item.name);
39
46
  if (item.disabled) {
40
- interaction.disabledSeries.set(item.name, disabledStyle);
47
+ interaction.disabledSeries.set(item.name, disabledAction);
41
48
  }
42
49
  }
43
50
  });
@@ -47,7 +54,7 @@
47
54
  if (interaction.disabledSeries.has(item.name)) {
48
55
  interaction.disabledSeries.delete(item.name);
49
56
  } else {
50
- interaction.disabledSeries.set(item.name, disabledStyle);
57
+ interaction.disabledSeries.set(item.name, disabledAction);
51
58
  }
52
59
  }
53
60
 
@@ -142,8 +149,14 @@
142
149
  </script>
143
150
 
144
151
  {#snippet discreteItem(item: LegendItem, i: number)}
145
- {@const disabledStyle = interaction.disabledSeries.get(item.name)}
146
- {@const isDisabled = disabledStyle != null}
152
+ {@const action = interaction.disabledSeries.get(item.name)}
153
+ {@const isDisabled = action != null}
154
+ {@const omitMode = disabledAction.mode === 'omit'}
155
+ {@const disabledStyle = isDisabled
156
+ ? omitMode
157
+ ? { opacity: cfg.legend.disabledOpacity }
158
+ : resolveDisabledStyle(action, cfg.legend.disabledOpacity)
159
+ : undefined}
147
160
  {@const color =
148
161
  disabledStyle?.fill ??
149
162
  disabledStyle?.stroke?.stroke ??
@@ -167,7 +180,7 @@
167
180
  : {}}
168
181
  >
169
182
  {#if useBadge}
170
- <DiscreteBadgeItem {item} {color} disabled={isDisabled} {showIcon} />
183
+ <DiscreteBadgeItem {item} {color} disabled={isDisabled} {showIcon} {omitMode} />
171
184
  {:else}
172
185
  <DiscreteSwatchItem {item} {color} />
173
186
  {/if}
@@ -1,6 +1,7 @@
1
- import type { DiscreteLegendSection } from '../../types/layout/legend';
1
+ import type { DiscreteLegendSection, LegendItemsLayout } from '../../types/layout/legend';
2
2
  type $$ComponentProps = {
3
3
  section: DiscreteLegendSection;
4
+ defaultItemsLayout?: LegendItemsLayout;
4
5
  };
5
6
  declare const DiscreteSection: import("svelte").Component<$$ComponentProps, {}, "">;
6
7
  type DiscreteSection = ReturnType<typeof DiscreteSection>;
@@ -1,13 +1,18 @@
1
1
  <script lang="ts">
2
- import type { LegendSection } from '../../types/layout/legend';
2
+ import type { LegendItemsLayout, LegendSection } from '../../types/layout/legend';
3
3
  import DiscreteSection from './DiscreteSection.svelte';
4
4
  import ContinuousSection from './ContinuousSection.svelte';
5
5
 
6
6
  /**
7
7
  * Renders one or more legend sections side by side, dispatching each to the
8
8
  * matching section renderer. Discrete and continuous sections can be mixed.
9
+ * A lone discrete section's items default to horizontal flow; vertical once
10
+ * a second section joins. A section's own `layout` always wins.
9
11
  */
10
12
  let { sections }: { sections: LegendSection[] } = $props();
13
+ const defaultItemsLayout = $derived<LegendItemsLayout>(
14
+ sections.length === 1 ? { type: 'horizontal' } : { type: 'vertical' }
15
+ );
11
16
  </script>
12
17
 
13
18
  <div class="dct-legend">
@@ -17,7 +22,7 @@
17
22
  <div class="dct-legend__title">{section.title}</div>
18
23
  {/if}
19
24
  {#if section.type === 'discrete'}
20
- <DiscreteSection {section} />
25
+ <DiscreteSection {section} {defaultItemsLayout} />
21
26
  {:else if section.type === 'continuous'}
22
27
  <ContinuousSection {section} />
23
28
  {/if}
@@ -1,6 +1,7 @@
1
1
  <script lang="ts" generics="TData extends Record<string, unknown>">
2
- import { AxisX, AxisY, GridX, GridY } from 'svelteplot';
2
+ import { AxisX, AxisY, GridX, GridY, usePlot } from 'svelteplot';
3
3
  import { getConfiguration } from '../../configuration/config.svelte';
4
+ import { tickStep, ticksInRange, naturalTickCount } from './niceDomain';
4
5
  import type { AxisValue, AxisBasedScalesConfig } from '../../types/layout/scales';
5
6
 
6
7
  /**
@@ -28,15 +29,25 @@
28
29
  } = $props();
29
30
 
30
31
  const cfg = $derived(getConfiguration());
32
+ const plot = usePlot();
31
33
 
32
34
  const xValues = $derived(data.map(getX));
33
35
  const yValues = $derived(data.map(getY));
34
36
 
35
- function naturalTickCount(span: number, spacing: number, min: number): number {
36
- return Math.max(min, Math.round(span / spacing));
37
- }
38
-
39
- /** `[min, max]` of a pinned `scale.domain` (if numeric), else the extent of `values`. */
37
+ /**
38
+ * `[min, max]` of a pinned `scale.domain` (if numeric), else the extent of
39
+ * `values`. Deliberately *not* the live, fully-resolved scale off
40
+ * `usePlot()` (e.g. `plot.scales.x.domain`), even though that would be
41
+ * more accurate for a bar's implicit zero baseline or a stacked series'
42
+ * cumulative total: reading it reactively here — inside a `$derived` that
43
+ * itself feeds back into `<AxisX>`'s rendered tick content, which
44
+ * `BasePlotLayout`'s own auto-margin sizing measures — closes a
45
+ * live-scale ⇄ rendered-axis feedback loop that was observed to perturb
46
+ * margin *settling* (a captured-and-frozen `bind:margins` value gained a
47
+ * stray extra `left` entry it wasn't tracking before). `edgeInclusiveTicks`
48
+ * instead sidesteps needing this axis's own real extent at all, by only
49
+ * running when the *opposite* axis's resolved type says it's safe to.
50
+ */
40
51
  function axisExtent(scale: AxisBasedScalesConfig<TData>['x'], values: AxisValue[]): [number, number] | undefined {
41
52
  const domainNumbers = scale?.domain?.filter((d): d is number => typeof d === 'number');
42
53
  const source = domainNumbers && domainNumbers.length >= 2 ? domainNumbers : values.filter((v): v is number => typeof v === 'number');
@@ -46,9 +57,30 @@
46
57
  return hi > lo ? [lo, hi] : undefined;
47
58
  }
48
59
 
49
- /** Caps tick count to the axis's distinct value count, never above what fits the pixel span. Skipped once the caller sets tick density directly, or pins the domain. */
50
- function autoTickCount(scale: AxisBasedScalesConfig<TData>['x'], values: AxisValue[], span: number, spacing: number, min: number): number | undefined {
60
+ /** Resolved scale types with their own discrete tick semantics an axis paired with one of these plays the "index" role for the pair, so *this* axis is the plain magnitude/value one (see `autoTickCount`, `edgeInclusiveTicks`). */
61
+ const DISCRETE_SCALE_TYPES = new Set(['band', 'categorical', 'point', 'ordinal']);
62
+
63
+ /**
64
+ * Caps tick count to the axis's distinct value count, never above what
65
+ * fits the pixel span — meaningful for an index-like axis (don't show more
66
+ * ticks than there are actual data points), but not for a magnitude/value
67
+ * one, where "how many bars there are" has nothing to do with how many
68
+ * round numbers should label the axis (and can otherwise stop tick
69
+ * generation short of `nice`'s own widened domain edge — the same
70
+ * "opposite axis is discrete ⇒ this one's a value axis" signal
71
+ * `edgeInclusiveTicks` uses). Skipped once the caller sets tick density
72
+ * directly, or pins the domain.
73
+ */
74
+ function autoTickCount(
75
+ oppositeType: string | undefined,
76
+ scale: AxisBasedScalesConfig<TData>['x'],
77
+ values: AxisValue[],
78
+ span: number,
79
+ spacing: number,
80
+ min: number
81
+ ): number | undefined {
51
82
  if (scale?.ticks != null || scale?.tickSpacing != null || scale?.interval != null || scale?.domain != null) return undefined;
83
+ if (oppositeType != null && DISCRETE_SCALE_TYPES.has(oppositeType)) return undefined;
52
84
  const uniqueCount = new Set(values.map(String)).size;
53
85
  const natural = naturalTickCount(span, spacing, min);
54
86
  return uniqueCount >= 2 && uniqueCount < natural ? uniqueCount : undefined;
@@ -74,12 +106,56 @@
74
106
  const xSpan = $derived(Math.max(0, width - numericMargins.left - numericMargins.right));
75
107
  const ySpan = $derived(Math.max(0, height - numericMargins.top - numericMargins.bottom));
76
108
 
77
- const xTickCount = $derived(autoTickCount(scales?.x, xValues, xSpan, 80, 3));
78
- const yTickCount = $derived(autoTickCount(scales?.y, yValues, ySpan, 50, 2));
109
+ const xTickCount = $derived(autoTickCount(plot.scales.y.type, scales?.x, xValues, xSpan, 80, 3));
110
+ const yTickCount = $derived(autoTickCount(plot.scales.x.type, scales?.y, yValues, ySpan, 50, 2));
79
111
 
80
112
  const xTickInterval = $derived(integerTickInterval(scales?.x, xValues, xSpan, 80, 3));
81
113
  const yTickInterval = $derived(integerTickInterval(scales?.y, yValues, ySpan, 50, 2));
82
114
 
115
+ /**
116
+ * An axis's own natural "nice" ticks, plus the domain's exact endpoints
117
+ * whenever a natural tick doesn't already land on them — so the axis
118
+ * always labels the actual start/end of its real range, not just
119
+ * whichever round numbers happen to fall inside it (which, per
120
+ * `axisExtent`, may extend past the raw data — e.g. a bar's implicit zero
121
+ * baseline).
122
+ *
123
+ * Only meaningful for an axis that plays the "series ran from here to
124
+ * here" role — a time series' x, say — which is exactly the case where the
125
+ * *opposite* axis is itself continuous (both this codebase's line/scatter
126
+ * charts). When the opposite axis is discrete instead (a bar/pyramid's
127
+ * category axis), *this* axis is really the plain magnitude/value one —
128
+ * `84` isn't meaningfully "where the series ends" the way a last year is,
129
+ * so forcing it onto the axis just crowds a round `80`/`90` tick for no
130
+ * benefit. Skipped once the caller sets tick density/domain directly, same
131
+ * guard as `autoTickCount`, or the axis isn't a plain continuous numeric
132
+ * one (`integerTickInterval`'s domain check covers the same ground
133
+ * `axisExtent` needs here).
134
+ */
135
+ function edgeInclusiveTicks(
136
+ oppositeType: string | undefined,
137
+ scale: AxisBasedScalesConfig<TData>['x'],
138
+ values: AxisValue[],
139
+ span: number,
140
+ spacing: number,
141
+ min: number
142
+ ): number[] | undefined {
143
+ if (scale?.ticks != null || scale?.tickSpacing != null || scale?.interval != null || scale?.domain != null) return undefined;
144
+ if (oppositeType != null && DISCRETE_SCALE_TYPES.has(oppositeType)) return undefined;
145
+ if (scale?.type != null && scale.type !== 'linear' && scale.type !== 'auto') return undefined;
146
+ const extent = axisExtent(scale, values);
147
+ if (!extent) return undefined;
148
+ const [lo, hi] = extent;
149
+ const count = naturalTickCount(span, spacing, min);
150
+ const step = tickStep(lo, hi, count);
151
+ const base = ticksInRange(lo, hi, step);
152
+ const eps = step * 1e-6;
153
+ const withLo = base.length === 0 || base[0] - lo > eps ? [lo, ...base] : base;
154
+ return withLo.length === 0 || hi - withLo[withLo.length - 1] > eps ? [...withLo, hi] : withLo;
155
+ }
156
+
157
+ const xTicksExplicit = $derived(edgeInclusiveTicks(plot.scales.y.type, scales?.x, xValues, xSpan, 80, 3));
158
+
83
159
  /** Vertical Y-axis title placement, when `scales.y.labelOrientation === 'vertical'`. */
84
160
  const verticalYLabel = $derived.by(() => {
85
161
  if (!scales?.y?.label || scales.y.labelOrientation !== 'vertical') return null;
@@ -93,6 +169,7 @@
93
169
  </script>
94
170
 
95
171
  <AxisX
172
+ ticks={xTicksExplicit}
96
173
  tickCount={xTickCount}
97
174
  interval={xTickInterval}
98
175
  tickSize={scales?.x?.tickSize ?? cfg.axis.xTickSize}
@@ -3,6 +3,23 @@ import type { AxisValue, AxisScale } from '../../types/layout/scales';
3
3
  * Resolves an author-facing {@link AxisScale} into what svelteplot's
4
4
  * `<Plot x>`/`<Plot y>` accepts — strips `grid`/`seriesLayout`/
5
5
  * `seriesPadding`/a vertical `label`, since `ScalesLayout` renders those
6
- * itself.
6
+ * itself. Also defaults `nice: true` whenever the caller hasn't pinned a
7
+ * `domain`/`ticks`/`nice`/non-numeric `type` of their own and the axis's own
8
+ * values are plainly numeric ({@link isNumericAxis}), so an unpinned
9
+ * continuous axis rounds its domain outward to whole tick steps instead of
10
+ * stopping exactly at the data's own min/max.
11
+ *
12
+ * Deliberately *not* our own `[min, max]` padding: svelteplot computes the
13
+ * real domain from every mark's own channels — e.g. a bar's implicit zero
14
+ * baseline, or a stacked series' cumulative total — not just this axis's raw
15
+ * per-row values, which is all this function has to work with. `nice`'s
16
+ * domain-widening runs *after* that real domain is resolved, inside
17
+ * svelteplot itself, so it stays correct for every plot kind for free — at
18
+ * the cost of only widening up to the nearest round tick step, which can
19
+ * still land exactly on the data's own min/max rather than strictly beyond
20
+ * it (e.g. a value of exactly `52` needs no widening to reach the nice tick
21
+ * `52`). Recomputing the domain ourselves to close that gap would need each
22
+ * axis's true rendered extent (baseline/stack included), which only the
23
+ * calling plot kind knows — not attempted here.
7
24
  */
8
25
  export declare function buildScale<TData>(scale: AxisScale<TData> | undefined, rows: TData[], getAxisValue: (d: TData) => AxisValue): AxisScale<TData> | undefined;
@@ -23,15 +23,60 @@ function sortedScale(scale, rows, getAxisValue) {
23
23
  const domain = [...reps].sort(sort).map(getAxisValue);
24
24
  return { ...scale, domain };
25
25
  }
26
+ /** Scale types {@link buildScale}'s default `nice` only applies to — every other type has its own tick semantics (or isn't numeric at all). */
27
+ const CONTINUOUS_NUMERIC_TYPES = new Set(['auto', 'linear', 'pow', 'sqrt', 'log', 'symlog']);
28
+ /**
29
+ * Whether this axis's own values are plainly numeric — required before
30
+ * defaulting `nice: true`. Not just an optimization: svelteplot's own type
31
+ * *inference* treats a `nice` option as a hint to resolve the scale as
32
+ * `'linear'` (see its `autoScales.js`), so setting it on a scale whose real
33
+ * values are strings (a bar/pyramid's category axis) would force a linear
34
+ * scale onto categorical data and break the mark entirely.
35
+ */
36
+ function isNumericAxis(rows, getAxisValue) {
37
+ let numericSeen = 0;
38
+ for (const row of rows) {
39
+ const v = getAxisValue(row);
40
+ if (v == null)
41
+ continue;
42
+ if (typeof v !== 'number')
43
+ return false;
44
+ if (++numericSeen >= 2)
45
+ return true;
46
+ }
47
+ return numericSeen > 0;
48
+ }
26
49
  /**
27
50
  * Resolves an author-facing {@link AxisScale} into what svelteplot's
28
51
  * `<Plot x>`/`<Plot y>` accepts — strips `grid`/`seriesLayout`/
29
52
  * `seriesPadding`/a vertical `label`, since `ScalesLayout` renders those
30
- * itself.
53
+ * itself. Also defaults `nice: true` whenever the caller hasn't pinned a
54
+ * `domain`/`ticks`/`nice`/non-numeric `type` of their own and the axis's own
55
+ * values are plainly numeric ({@link isNumericAxis}), so an unpinned
56
+ * continuous axis rounds its domain outward to whole tick steps instead of
57
+ * stopping exactly at the data's own min/max.
58
+ *
59
+ * Deliberately *not* our own `[min, max]` padding: svelteplot computes the
60
+ * real domain from every mark's own channels — e.g. a bar's implicit zero
61
+ * baseline, or a stacked series' cumulative total — not just this axis's raw
62
+ * per-row values, which is all this function has to work with. `nice`'s
63
+ * domain-widening runs *after* that real domain is resolved, inside
64
+ * svelteplot itself, so it stays correct for every plot kind for free — at
65
+ * the cost of only widening up to the nearest round tick step, which can
66
+ * still land exactly on the data's own min/max rather than strictly beyond
67
+ * it (e.g. a value of exactly `52` needs no widening to reach the nice tick
68
+ * `52`). Recomputing the domain ourselves to close that gap would need each
69
+ * axis's true rendered extent (baseline/stack included), which only the
70
+ * calling plot kind knows — not attempted here.
31
71
  */
32
72
  export function buildScale(scale, rows, getAxisValue) {
33
73
  const sorted = sortedScale(scale, rows, getAxisValue);
34
- if (!sorted)
74
+ const wantsNice = scale?.domain == null &&
75
+ scale?.ticks == null &&
76
+ scale?.nice == null &&
77
+ (scale?.type == null || CONTINUOUS_NUMERIC_TYPES.has(scale.type)) &&
78
+ isNumericAxis(rows, getAxisValue);
79
+ if (!sorted && !wantsNice)
35
80
  return sorted;
36
81
  const out = { ...sorted };
37
82
  delete out.grid;
@@ -39,5 +84,7 @@ export function buildScale(scale, rows, getAxisValue) {
39
84
  delete out.seriesPadding;
40
85
  if (scale?.labelOrientation === 'vertical')
41
86
  delete out.label;
87
+ if (wantsNice)
88
+ out.nice = true;
42
89
  return out;
43
90
  }
@@ -0,0 +1,6 @@
1
+ /** Step between "nice" (1/2/5×10ⁿ) tick values for an approximate `count` over `[start, stop]`. */
2
+ export declare function tickStep(start: number, stop: number, count: number): number;
3
+ /** Multiples of `step` covering `[start, stop]`, inclusive of either endpoint when it lands exactly on one. */
4
+ export declare function ticksInRange(start: number, stop: number, step: number): number[];
5
+ /** Ticks-per-axis heuristic — used by `ScalesLayout` to pick a target count from an axis's actual pixel span. */
6
+ export declare function naturalTickCount(span: number, spacing: number, min: number): number;
@@ -0,0 +1,34 @@
1
+ // d3-array-compatible "nice" tick step/generation, reimplemented locally to
2
+ // avoid depending on internals svelteplot itself pulls in (not one of our
3
+ // declared dependencies) — see https://d3js.org/d3-array/ticks.
4
+ /** Step between "nice" (1/2/5×10ⁿ) tick values for an approximate `count` over `[start, stop]`. */
5
+ export function tickStep(start, stop, count) {
6
+ const e10 = Math.sqrt(50);
7
+ const e5 = Math.sqrt(10);
8
+ const e2 = Math.sqrt(2);
9
+ const step0 = Math.abs(stop - start) / Math.max(0, count);
10
+ let step1 = 10 ** Math.floor(Math.log10(step0));
11
+ const error = step0 / step1;
12
+ if (error >= e10)
13
+ step1 *= 10;
14
+ else if (error >= e5)
15
+ step1 *= 5;
16
+ else if (error >= e2)
17
+ step1 *= 2;
18
+ return step1;
19
+ }
20
+ /** Multiples of `step` covering `[start, stop]`, inclusive of either endpoint when it lands exactly on one. */
21
+ export function ticksInRange(start, stop, step) {
22
+ if (!(step > 0) || !Number.isFinite(step))
23
+ return [];
24
+ const lo = Math.ceil(start / step);
25
+ const hi = Math.floor(stop / step);
26
+ const out = [];
27
+ for (let i = lo; i <= hi; i++)
28
+ out.push(i * step);
29
+ return out;
30
+ }
31
+ /** Ticks-per-axis heuristic — used by `ScalesLayout` to pick a target count from an axis's actual pixel span. */
32
+ export function naturalTickCount(span, spacing, min) {
33
+ return Math.max(min, Math.round(span / spacing));
34
+ }
@@ -1,4 +1,4 @@
1
- import { eq, matchByStrategy, impliesCrosshairX, impliesCrosshairY, filterBySeries, } from './utils';
1
+ import { eq, matchByStrategy, impliesCrosshairX, impliesCrosshairY, filterBySeries } from './utils';
2
2
  /**
3
3
  * The plot-agnostic hover state machine shared by every plot. Given reactive
4
4
  * accessors for the current hover store, this facet's id, and the local points,
@@ -11,13 +11,15 @@ import { eq, matchByStrategy, impliesCrosshairX, impliesCrosshairY, filterBySeri
11
11
  */
12
12
  export function createHoverController(args) {
13
13
  let cursor = $state(null);
14
- // svelteplot's `Pointer` hands back cloned rows (not the original
15
- // references), so a row's series can't be looked up by identity — find it
16
- // by value against the locally-known, already-tagged points instead.
17
- const seriesForRow = (row) => {
14
+ // Finds the matching hoverPoint for a hit row — by identity first, then
15
+ // by x/y value (for svelteplot's `Pointer`, which hands back clones).
16
+ const pointForRow = (row) => {
17
+ const byIdentity = args.points().find((p) => p.row === row);
18
+ if (byIdentity)
19
+ return byIdentity;
18
20
  const x = args.getX()(row);
19
21
  const y = args.getY()(row);
20
- return args.points().find((p) => eq(p.x, x) && eq(p.y, y))?.series;
22
+ return args.points().find((p) => eq(p.x, x) && eq(p.y, y));
21
23
  };
22
24
  // Points matched in THIS facet. Non-source facets only react when sync is
23
25
  // enabled. The strategy in the shared store determines how points are found.
@@ -34,7 +36,16 @@ export function createHoverController(args) {
34
36
  // Keeps `row` (not just the display fields) so plot-kind templates can
35
37
  // resolve a semantic value off the source row directly — e.g. a bar
36
38
  // plot's category, regardless of which visual axis it's drawn on.
37
- const highlight = $derived(matched.map(({ x, y, label, series, color, dx, dy, row }) => ({ x, y, label, series, color, dx, dy, row })));
39
+ const highlight = $derived(matched.map(({ x, y, label, series, color, dx, dy, row }) => ({
40
+ x,
41
+ y,
42
+ label,
43
+ series,
44
+ color,
45
+ dx,
46
+ dy,
47
+ row
48
+ })));
38
49
  /**
39
50
  * The specific matched point that was actually hovered — found by the
40
51
  * shared store's `activeSeries` (set from whichever row was hit), falling
@@ -82,9 +93,10 @@ export function createHoverController(args) {
82
93
  function onPointer(data) {
83
94
  const h = args.hover();
84
95
  if (data.length) {
85
- h.activeX = args.getX()(data[0]);
86
- h.activeY = args.getY()(data[0]);
87
- h.activeSeries = seriesForRow(data[0]) ?? null;
96
+ const point = pointForRow(data[0]);
97
+ h.activeX = point?.x ?? args.getX()(data[0]);
98
+ h.activeY = point?.y ?? args.getY()(data[0]);
99
+ h.activeSeries = point?.series ?? null;
88
100
  h.source = args.facetId();
89
101
  }
90
102
  else if (h.source === args.facetId()) {
@@ -148,6 +160,6 @@ export function createHoverController(args) {
148
160
  },
149
161
  get cursor() {
150
162
  return cursor;
151
- },
163
+ }
152
164
  };
153
165
  }
@@ -16,6 +16,9 @@ export function matchByStrategy(points, hover) {
16
16
  if (strategy === 'y') {
17
17
  return activeY == null ? [] : points.filter((p) => eq(p.y, activeY));
18
18
  }
19
+ if (strategy === 'series') {
20
+ return activeSeries == null ? [] : points.filter((p) => p.series === activeSeries);
21
+ }
19
22
  // 'punctual': exact XY match, respecting series identity when both sides carry it
20
23
  if (activeX == null || activeY == null)
21
24
  return [];