@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.
- package/dist/configuration/config.svelte.js +7 -2
- package/dist/configuration/themes/index.d.ts +12 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/layout/legend/DiscreteBadgeItem.svelte +21 -8
- package/dist/layout/legend/DiscreteBadgeItem.svelte.d.ts +1 -0
- package/dist/layout/legend/DiscreteSection.svelte +22 -9
- package/dist/layout/legend/DiscreteSection.svelte.d.ts +2 -1
- package/dist/layout/legend/LegendLayout.svelte +7 -2
- package/dist/layout/scales/ScalesLayout.svelte +87 -10
- package/dist/layout/scales/buildScale.d.ts +18 -1
- package/dist/layout/scales/buildScale.js +49 -2
- package/dist/layout/scales/niceDomain.d.ts +6 -0
- package/dist/layout/scales/niceDomain.js +34 -0
- package/dist/layout/tooltip/controller.svelte.js +23 -11
- package/dist/layout/tooltip/utils.js +3 -0
- package/dist/markers/BarMarker.svelte +145 -84
- package/dist/markers/HoverMarker.svelte +41 -1
- package/dist/markers/HoverMarker.svelte.d.ts +7 -1
- package/dist/plots/bar/Plot.svelte +46 -20
- package/dist/plots/bar/buildBarMarkers.d.ts +4 -14
- package/dist/plots/bar/buildBarMarkers.js +73 -53
- package/dist/plots/bar/hoverPoints.d.ts +1 -1
- package/dist/plots/bar/hoverPoints.js +2 -3
- package/dist/plots/bar/layout.svelte.d.ts +4 -0
- package/dist/plots/bar/layout.svelte.js +62 -17
- package/dist/plots/line/Plot.svelte +29 -14
- package/dist/plots/line/buildLineMarkers.d.ts +1 -0
- package/dist/plots/line/buildLineMarkers.js +3 -4
- package/dist/plots/pyramid/Plot.svelte +10 -6
- package/dist/plots/scatter/Plot.svelte +25 -11
- package/dist/plots/scatter/buildScatterMarkers.d.ts +1 -0
- package/dist/plots/scatter/buildScatterMarkers.js +3 -4
- package/dist/plots/utils/legendDisabled.d.ts +13 -1
- package/dist/plots/utils/legendDisabled.js +27 -0
- package/dist/types/configuration/styling.d.ts +12 -2
- package/dist/types/layout/legend.d.ts +15 -10
- package/dist/types/layout/tooltip.d.ts +2 -1
- package/dist/types/markers/common.d.ts +14 -2
- package/package.json +1 -1
|
@@ -115,7 +115,12 @@ export const DEFAULT_CONFIG = {
|
|
|
115
115
|
connectorWidth: 0.5
|
|
116
116
|
}
|
|
117
117
|
},
|
|
118
|
-
bar: {
|
|
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: {
|
|
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
|
|
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
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
/>
|
|
@@ -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
|
-
|
|
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 ??
|
|
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
|
|
30
|
-
section.interactive?.
|
|
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,
|
|
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,
|
|
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
|
|
146
|
-
{@const isDisabled =
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
/**
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
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))
|
|
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 }) => ({
|
|
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
|
-
|
|
86
|
-
h.
|
|
87
|
-
h.
|
|
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 [];
|