@fundar/data-chart-telling 0.0.15 → 0.0.17
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/charts/BaseChart.svelte +2 -0
- package/dist/configuration/config.svelte.js +1 -0
- package/dist/configuration/themes/index.d.ts +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/layout/legend/ContinuousSection.svelte +173 -17
- package/dist/layout/legend/DiscreteSection.svelte +55 -3
- package/dist/layout/legend/interaction.svelte.d.ts +7 -0
- package/dist/layout/legend/interaction.svelte.js +24 -0
- package/dist/plots/bar/BarSegments.svelte +6 -3
- package/dist/plots/bar/BarSegments.svelte.d.ts +4 -0
- package/dist/plots/bar/Plot.svelte +12 -2
- package/dist/plots/geo/Plot.svelte +32 -3
- package/dist/plots/heatmap/Plot.svelte +42 -11
- package/dist/plots/line/Plot.svelte +23 -14
- package/dist/plots/pyramid/Plot.svelte +18 -10
- package/dist/plots/utils/legendDisabled.d.ts +15 -0
- package/dist/plots/utils/legendDisabled.js +41 -0
- package/dist/types/charts/legend.d.ts +31 -0
- package/dist/types/configuration/styling.d.ts +2 -0
- package/dist/types/layout/legend.d.ts +26 -0
- package/dist/types/layout/legend.js +1 -0
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import LegendLayout from '../layout/legend/LegendLayout.svelte';
|
|
6
6
|
import { getConfiguration } from '../configuration/config.svelte';
|
|
7
7
|
import { provideHover } from '../layout/tooltip/hover.svelte';
|
|
8
|
+
import { provideLegendInteraction } from '../layout/legend/interaction.svelte';
|
|
8
9
|
import type { Snippet } from 'svelte';
|
|
9
10
|
import type { LegendSection } from '../types/charts/legend';
|
|
10
11
|
import type { HoverStrategy } from '../types/layout/tooltip';
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
const cfg = $derived(getConfiguration());
|
|
60
61
|
|
|
61
62
|
const hoverStore = provideHover();
|
|
63
|
+
provideLegendInteraction();
|
|
62
64
|
$effect(() => {
|
|
63
65
|
hoverStore.strategy = hover?.strategy ?? 'x';
|
|
64
66
|
hoverStore.sync = hover?.sync ?? false;
|
|
@@ -48,6 +48,7 @@ export declare const THEMES: {
|
|
|
48
48
|
titleWeight?: string | number | undefined;
|
|
49
49
|
gap?: string | undefined;
|
|
50
50
|
swatchSize?: string | undefined;
|
|
51
|
+
disabledOpacity?: number | undefined;
|
|
51
52
|
} | undefined;
|
|
52
53
|
axis?: {
|
|
53
54
|
color?: string | undefined;
|
|
@@ -170,6 +171,7 @@ export declare const THEMES: {
|
|
|
170
171
|
titleWeight?: string | number | undefined;
|
|
171
172
|
gap?: string | undefined;
|
|
172
173
|
swatchSize?: string | undefined;
|
|
174
|
+
disabledOpacity?: number | undefined;
|
|
173
175
|
} | undefined;
|
|
174
176
|
axis?: {
|
|
175
177
|
color?: string | undefined;
|
|
@@ -292,6 +294,7 @@ export declare const THEMES: {
|
|
|
292
294
|
titleWeight?: string | number | undefined;
|
|
293
295
|
gap?: string | undefined;
|
|
294
296
|
swatchSize?: string | undefined;
|
|
297
|
+
disabledOpacity?: number | undefined;
|
|
295
298
|
} | undefined;
|
|
296
299
|
axis?: {
|
|
297
300
|
color?: string | undefined;
|
|
@@ -414,6 +417,7 @@ export declare const THEMES: {
|
|
|
414
417
|
titleWeight?: string | number | undefined;
|
|
415
418
|
gap?: string | undefined;
|
|
416
419
|
swatchSize?: string | undefined;
|
|
420
|
+
disabledOpacity?: number | undefined;
|
|
417
421
|
} | undefined;
|
|
418
422
|
axis?: {
|
|
419
423
|
color?: string | undefined;
|
package/dist/index.d.ts
CHANGED
|
@@ -44,7 +44,7 @@ export type { GeoProjectionName, GeoProjectionConfig, GeoCustomProjection, GeoCu
|
|
|
44
44
|
export type { GeoStyles, GeoTileLayerConfig, GeoZoomConfig } from './types/plots/styles/geo';
|
|
45
45
|
export type { GeoMarker, GeoInsetMarker, GeoInsetLocation, GeoInsetProjectionName, GeoInsetProjectionConfig, } from './types/markers/geo';
|
|
46
46
|
export type { TimeValue, Orientation, FacetColumns, FacetConfig, TimelineConfig, ChartPlotContext, ChartPlotSnippet, } from './types/charts/common';
|
|
47
|
-
export type { LegendSection, DiscreteLegendSection, ContinuousLegendSection, LegendItem, } from './types/charts/legend';
|
|
47
|
+
export type { LegendSection, DiscreteLegendSection, ContinuousLegendSection, LegendItem, LegendDisabledStyle, } from './types/charts/legend';
|
|
48
48
|
export type { HoverStrategy, TooltipOptions, TooltipProp, } from './types/layout/tooltip';
|
|
49
49
|
export type { ChartConfig, ChartConfigInput, TextStyle } from './types/configuration/styling';
|
|
50
50
|
export type { ChartProps } from './types/charts/props';
|
|
@@ -1,32 +1,69 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
2
3
|
import type { ContinuousLegendSection } from '../../types/charts/legend';
|
|
3
4
|
import { makeColorScale } from '../../utils/color';
|
|
4
5
|
import { getConfiguration } from '../../configuration/config.svelte';
|
|
6
|
+
import { getLegendInteraction, createLegendInteraction } from './interaction.svelte';
|
|
5
7
|
|
|
6
8
|
let { section }: { section: ContinuousLegendSection } = $props();
|
|
7
9
|
|
|
8
10
|
const cfg = $derived(getConfiguration());
|
|
9
11
|
const gradientId = `dct-grad-${Math.random().toString(36).slice(2)}`;
|
|
10
12
|
const barWidth = $derived(section.width ?? 170);
|
|
13
|
+
|
|
14
|
+
const fmt = $derived(section.format ?? ((v: number) => String(v)));
|
|
15
|
+
|
|
16
|
+
const tickPositions = $derived(
|
|
17
|
+
section.interactive
|
|
18
|
+
? []
|
|
19
|
+
: (section.ticks ?? []).map((v) => ({
|
|
20
|
+
v,
|
|
21
|
+
t: (v - section.min) / (section.max - section.min || 1),
|
|
22
|
+
label: fmt(v),
|
|
23
|
+
})),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// ── Drag-to-narrow-margins ────────────────────────────────────────────────
|
|
27
|
+
const interaction = getLegendInteraction() ?? createLegendInteraction();
|
|
28
|
+
|
|
29
|
+
// Seeded once from the section's own bounds (deliberately untracked — a
|
|
30
|
+
// later `section.min`/`max` change re-clamps the existing lo/hi instead of
|
|
31
|
+
// resetting them back out to the new full range, see below).
|
|
32
|
+
let lo = $state(untrack(() => section.min));
|
|
33
|
+
let hi = $state(untrack(() => section.max));
|
|
34
|
+
|
|
35
|
+
// `section.min`/`max` can shift under an already-active selection — e.g. a
|
|
36
|
+
// consumer swaps `data` for a different year on its own timeline, and the
|
|
37
|
+
// value that used to be the max is no longer present. Re-clamping (instead
|
|
38
|
+
// of leaving lo/hi at their old absolute values) keeps the handles inside
|
|
39
|
+
// whatever range now actually exists: the lower handle can only move up to
|
|
40
|
+
// meet a risen min, the upper handle can only move down to meet a fallen
|
|
41
|
+
// max. Clamping each bound against *both* of the new min/max first (not
|
|
42
|
+
// just its own side) keeps lo/hi from inverting if the domain shifts
|
|
43
|
+
// entirely past the old selection.
|
|
44
|
+
$effect(() => {
|
|
45
|
+
lo = Math.min(Math.max(lo, section.min), section.max);
|
|
46
|
+
hi = Math.max(Math.min(hi, section.max), section.min);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function toT(v: number): number {
|
|
50
|
+
return (v - section.min) / (section.max - section.min || 1);
|
|
51
|
+
}
|
|
52
|
+
const loT = $derived(toT(lo));
|
|
53
|
+
const hiT = $derived(toT(hi));
|
|
54
|
+
|
|
55
|
+
const colorDomain = $derived.by((): [number, number] =>
|
|
56
|
+
section.interactive && (section.mode ?? 'filter') === 'rescale' ? [lo, hi] : [section.min, section.max],
|
|
57
|
+
);
|
|
11
58
|
const colorScale = $derived(
|
|
12
59
|
makeColorScale(
|
|
13
|
-
|
|
14
|
-
|
|
60
|
+
colorDomain[0],
|
|
61
|
+
colorDomain[1],
|
|
15
62
|
section.colorMin ?? cfg.continuous.min,
|
|
16
63
|
section.colorMax ?? cfg.continuous.max,
|
|
17
64
|
),
|
|
18
65
|
);
|
|
19
66
|
|
|
20
|
-
const fmt = $derived(section.format ?? ((v: number) => String(v)));
|
|
21
|
-
|
|
22
|
-
const tickPositions = $derived(
|
|
23
|
-
(section.ticks ?? []).map((v) => ({
|
|
24
|
-
v,
|
|
25
|
-
t: (v - section.min) / (section.max - section.min || 1),
|
|
26
|
-
label: fmt(v),
|
|
27
|
-
})),
|
|
28
|
-
);
|
|
29
|
-
|
|
30
67
|
const stops = $derived(
|
|
31
68
|
Array.from({ length: 20 }, (_, i) => {
|
|
32
69
|
const t = i / 19;
|
|
@@ -34,6 +71,56 @@
|
|
|
34
71
|
return { offset: t * 100, color: colorScale(v) };
|
|
35
72
|
}),
|
|
36
73
|
);
|
|
74
|
+
|
|
75
|
+
$effect(() => {
|
|
76
|
+
if (!section.interactive) return;
|
|
77
|
+
interaction.continuousRange =
|
|
78
|
+
lo <= section.min && hi >= section.max
|
|
79
|
+
? null
|
|
80
|
+
: {
|
|
81
|
+
min: lo,
|
|
82
|
+
max: hi,
|
|
83
|
+
mode: section.mode ?? 'filter',
|
|
84
|
+
disabledStyle: section.disabledStyle ?? { opacity: cfg.legend.disabledOpacity },
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function startDrag(which: 'lo' | 'hi') {
|
|
89
|
+
return (event: PointerEvent) => {
|
|
90
|
+
if (!section.interactive) return;
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
const target = event.currentTarget as SVGElement;
|
|
93
|
+
target.setPointerCapture(event.pointerId);
|
|
94
|
+
const startX = event.clientX;
|
|
95
|
+
const startValue = which === 'lo' ? lo : hi;
|
|
96
|
+
const valuePerPixel = (section.max - section.min) / barWidth;
|
|
97
|
+
|
|
98
|
+
function onMove(e: PointerEvent): void {
|
|
99
|
+
const next = startValue + (e.clientX - startX) * valuePerPixel;
|
|
100
|
+
if (which === 'lo') {
|
|
101
|
+
lo = Math.max(section.min, Math.min(next, hi));
|
|
102
|
+
} else {
|
|
103
|
+
hi = Math.min(section.max, Math.max(next, lo));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function onUp(e: PointerEvent): void {
|
|
107
|
+
target.releasePointerCapture(e.pointerId);
|
|
108
|
+
target.removeEventListener('pointermove', onMove);
|
|
109
|
+
target.removeEventListener('pointerup', onUp);
|
|
110
|
+
target.removeEventListener('pointercancel', onUp);
|
|
111
|
+
}
|
|
112
|
+
target.addEventListener('pointermove', onMove);
|
|
113
|
+
target.addEventListener('pointerup', onUp);
|
|
114
|
+
target.addEventListener('pointercancel', onUp);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function resetHandle(which: 'lo' | 'hi') {
|
|
119
|
+
return () => {
|
|
120
|
+
if (which === 'lo') lo = section.min;
|
|
121
|
+
else hi = section.max;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
37
124
|
</script>
|
|
38
125
|
|
|
39
126
|
<div class="dct-continuous" style:width="{barWidth}px">
|
|
@@ -56,14 +143,56 @@
|
|
|
56
143
|
stroke-width="1"
|
|
57
144
|
/>
|
|
58
145
|
{/each}
|
|
146
|
+
|
|
147
|
+
{#if section.interactive}
|
|
148
|
+
{#if loT > 0}
|
|
149
|
+
<rect x="0" y="0" width={loT * barWidth} height="12" rx="2" fill="currentColor" fill-opacity="0.35" />
|
|
150
|
+
{/if}
|
|
151
|
+
{#if hiT < 1}
|
|
152
|
+
<rect x={hiT * barWidth} y="0" width={barWidth - hiT * barWidth} height="12" rx="2" fill="currentColor" fill-opacity="0.35" />
|
|
153
|
+
{/if}
|
|
154
|
+
<circle
|
|
155
|
+
class="dct-continuous__handle"
|
|
156
|
+
cx={loT * barWidth}
|
|
157
|
+
cy="6"
|
|
158
|
+
r="6"
|
|
159
|
+
onpointerdown={startDrag('lo')}
|
|
160
|
+
ondblclick={resetHandle('lo')}
|
|
161
|
+
role="slider"
|
|
162
|
+
aria-label="Margen inferior"
|
|
163
|
+
aria-valuemin={section.min}
|
|
164
|
+
aria-valuemax={hi}
|
|
165
|
+
aria-valuenow={lo}
|
|
166
|
+
tabindex="0"
|
|
167
|
+
/>
|
|
168
|
+
<circle
|
|
169
|
+
class="dct-continuous__handle"
|
|
170
|
+
cx={hiT * barWidth}
|
|
171
|
+
cy="6"
|
|
172
|
+
r="6"
|
|
173
|
+
onpointerdown={startDrag('hi')}
|
|
174
|
+
ondblclick={resetHandle('hi')}
|
|
175
|
+
role="slider"
|
|
176
|
+
aria-label="Margen superior"
|
|
177
|
+
aria-valuemin={lo}
|
|
178
|
+
aria-valuemax={section.max}
|
|
179
|
+
aria-valuenow={hi}
|
|
180
|
+
tabindex="0"
|
|
181
|
+
/>
|
|
182
|
+
{/if}
|
|
59
183
|
</svg>
|
|
60
184
|
|
|
61
185
|
<div class="dct-continuous__labels" style:width="{barWidth}px">
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
<span class="dct-continuous__tick" style:left="{
|
|
65
|
-
{
|
|
66
|
-
|
|
186
|
+
{#if section.interactive}
|
|
187
|
+
<span class="dct-continuous__tick dct-continuous__tick--handle-lo" style:left="{loT * barWidth}px">{fmt(lo)}</span>
|
|
188
|
+
<span class="dct-continuous__tick dct-continuous__tick--handle-hi" style:left="{hiT * barWidth}px">{fmt(hi)}</span>
|
|
189
|
+
{:else}
|
|
190
|
+
<span class="dct-continuous__edge dct-continuous__edge--start">{fmt(section.min)}</span>
|
|
191
|
+
{#each tickPositions as tick (tick.v)}
|
|
192
|
+
<span class="dct-continuous__tick" style:left="{tick.t * barWidth}px">{tick.label}</span>
|
|
193
|
+
{/each}
|
|
194
|
+
<span class="dct-continuous__edge dct-continuous__edge--end">{fmt(section.max)}</span>
|
|
195
|
+
{/if}
|
|
67
196
|
</div>
|
|
68
197
|
</div>
|
|
69
198
|
|
|
@@ -92,4 +221,31 @@
|
|
|
92
221
|
position: absolute;
|
|
93
222
|
transform: translateX(-50%);
|
|
94
223
|
}
|
|
224
|
+
/*
|
|
225
|
+
* Anchored outward (not centered, unlike a plain tick) so the label always
|
|
226
|
+
* grows into the bar instead of over its own edge — a centered label at
|
|
227
|
+
* lo=min or hi=max would overflow past `.dct-chart`'s `overflow: hidden`
|
|
228
|
+
* and get clipped, exactly like the old fixed min/max edge labels never did.
|
|
229
|
+
*/
|
|
230
|
+
.dct-continuous__tick--handle-lo,
|
|
231
|
+
.dct-continuous__tick--handle-hi {
|
|
232
|
+
font-weight: 600;
|
|
233
|
+
}
|
|
234
|
+
.dct-continuous__tick--handle-lo {
|
|
235
|
+
transform: none;
|
|
236
|
+
}
|
|
237
|
+
.dct-continuous__tick--handle-hi {
|
|
238
|
+
transform: translateX(-100%);
|
|
239
|
+
}
|
|
240
|
+
.dct-continuous__handle {
|
|
241
|
+
fill: currentColor;
|
|
242
|
+
stroke: var(--dct-bg, #fff);
|
|
243
|
+
stroke-width: 1.5;
|
|
244
|
+
cursor: ew-resize;
|
|
245
|
+
touch-action: none;
|
|
246
|
+
}
|
|
247
|
+
.dct-continuous__handle:focus-visible {
|
|
248
|
+
outline: 2px solid currentColor;
|
|
249
|
+
outline-offset: 2px;
|
|
250
|
+
}
|
|
95
251
|
</style>
|
|
@@ -1,13 +1,42 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
2
3
|
import { getConfiguration } from '../../configuration/config.svelte';
|
|
3
4
|
import { paletteColor } from '../../utils/color';
|
|
4
|
-
import
|
|
5
|
+
import { getLegendInteraction, createLegendInteraction } from './interaction.svelte';
|
|
6
|
+
import type { DiscreteLegendSection, LegendItem } from '../../types/charts/legend';
|
|
5
7
|
import type { LineStyle } from '../../types/plots/constants';
|
|
6
8
|
|
|
7
9
|
let { section }: { section: DiscreteLegendSection } = $props();
|
|
8
10
|
|
|
9
11
|
const cfg = $derived(getConfiguration());
|
|
10
12
|
const columns = $derived(section.columns ?? 1);
|
|
13
|
+
const interaction = getLegendInteraction() ?? createLegendInteraction();
|
|
14
|
+
|
|
15
|
+
const seeded = new SvelteSet<string>();
|
|
16
|
+
$effect(() => {
|
|
17
|
+
for (const item of section.items) {
|
|
18
|
+
if (seeded.has(item.name)) continue;
|
|
19
|
+
seeded.add(item.name);
|
|
20
|
+
if (item.disabled) {
|
|
21
|
+
interaction.disabledSeries.set(item.name, section.disabledStyle ?? { opacity: cfg.legend.disabledOpacity });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function toggle(item: LegendItem): void {
|
|
27
|
+
if (!section.interactive) return;
|
|
28
|
+
if (interaction.disabledSeries.has(item.name)) {
|
|
29
|
+
interaction.disabledSeries.delete(item.name);
|
|
30
|
+
} else {
|
|
31
|
+
interaction.disabledSeries.set(item.name, section.disabledStyle ?? { opacity: cfg.legend.disabledOpacity });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function onKeydown(event: KeyboardEvent, item: LegendItem): void {
|
|
36
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
toggle(item);
|
|
39
|
+
}
|
|
11
40
|
|
|
12
41
|
function dashArray(style?: LineStyle): string | undefined {
|
|
13
42
|
switch (style) {
|
|
@@ -25,8 +54,23 @@
|
|
|
25
54
|
|
|
26
55
|
<div class="dct-discrete" style:grid-template-columns="repeat({columns}, max-content)">
|
|
27
56
|
{#each section.items as item, i (item.name)}
|
|
28
|
-
{@const
|
|
29
|
-
|
|
57
|
+
{@const disabledStyle = interaction.disabledSeries.get(item.name)}
|
|
58
|
+
{@const color = disabledStyle?.fill ?? disabledStyle?.stroke?.stroke ?? item.color ?? paletteColor(i, cfg.palette)}
|
|
59
|
+
{@const itemOpacity = disabledStyle ? (disabledStyle.opacity ?? cfg.legend.disabledOpacity) : (item.opacity ?? 1)}
|
|
60
|
+
<div
|
|
61
|
+
class="dct-discrete__item"
|
|
62
|
+
class:dct-discrete__item--interactive={section.interactive}
|
|
63
|
+
style:opacity={itemOpacity}
|
|
64
|
+
{...section.interactive
|
|
65
|
+
? {
|
|
66
|
+
role: 'button',
|
|
67
|
+
tabindex: 0,
|
|
68
|
+
'aria-pressed': disabledStyle != null,
|
|
69
|
+
onclick: () => toggle(item),
|
|
70
|
+
onkeydown: (e: KeyboardEvent) => onKeydown(e, item),
|
|
71
|
+
}
|
|
72
|
+
: {}}
|
|
73
|
+
>
|
|
30
74
|
<svg class="dct-discrete__swatch" viewBox="0 0 15 10">
|
|
31
75
|
{#if item.symbol === 'circle'}
|
|
32
76
|
<circle cx="5" cy="5" r="4" fill={color} />
|
|
@@ -70,6 +114,14 @@
|
|
|
70
114
|
align-items: center;
|
|
71
115
|
gap: 0.35rem;
|
|
72
116
|
min-width: 0;
|
|
117
|
+
transition: opacity 0.15s ease;
|
|
118
|
+
}
|
|
119
|
+
.dct-discrete__item--interactive {
|
|
120
|
+
cursor: pointer;
|
|
121
|
+
}
|
|
122
|
+
.dct-discrete__item--interactive:focus-visible {
|
|
123
|
+
outline: 2px solid currentColor;
|
|
124
|
+
outline-offset: 2px;
|
|
73
125
|
}
|
|
74
126
|
.dct-discrete__swatch {
|
|
75
127
|
width: var(--dct-legend-swatch, 1.25rem);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { LegendInteractionStore } from '../../types/layout/legend';
|
|
2
|
+
/** Create + register the shared legend-interaction store. Call during component init. */
|
|
3
|
+
export declare function provideLegendInteraction(): LegendInteractionStore;
|
|
4
|
+
/** Read the shared legend-interaction store, or `null` when there is no provider. */
|
|
5
|
+
export declare function getLegendInteraction(): LegendInteractionStore | null;
|
|
6
|
+
/** A private legend-interaction store for a legend/plot used outside of a {@link BaseChart}. */
|
|
7
|
+
export declare function createLegendInteraction(): LegendInteractionStore;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
3
|
+
const LEGEND_INTERACTION_KEY = Symbol('dct-legend-interaction');
|
|
4
|
+
function blank() {
|
|
5
|
+
return {
|
|
6
|
+
disabledSeries: new SvelteMap(),
|
|
7
|
+
continuousRange: null,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/** Create + register the shared legend-interaction store. Call during component init. */
|
|
11
|
+
export function provideLegendInteraction() {
|
|
12
|
+
const store = $state(blank());
|
|
13
|
+
setContext(LEGEND_INTERACTION_KEY, store);
|
|
14
|
+
return store;
|
|
15
|
+
}
|
|
16
|
+
/** Read the shared legend-interaction store, or `null` when there is no provider. */
|
|
17
|
+
export function getLegendInteraction() {
|
|
18
|
+
return getContext(LEGEND_INTERACTION_KEY) ?? null;
|
|
19
|
+
}
|
|
20
|
+
/** A private legend-interaction store for a legend/plot used outside of a {@link BaseChart}. */
|
|
21
|
+
export function createLegendInteraction() {
|
|
22
|
+
const store = $state(blank());
|
|
23
|
+
return store;
|
|
24
|
+
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
isHorizontal,
|
|
19
19
|
BarMark,
|
|
20
20
|
defaultColor,
|
|
21
|
+
disabledFill,
|
|
21
22
|
}: {
|
|
22
23
|
group: VisualGroup<Series<TData>, BarSegmentStyle>;
|
|
23
24
|
seriesIndex: number;
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
isHorizontal: boolean;
|
|
26
27
|
BarMark: typeof BarX | typeof BarY;
|
|
27
28
|
defaultColor: string;
|
|
29
|
+
disabledFill?: { fill?: string; fillOpacity?: number };
|
|
28
30
|
} = $props();
|
|
29
31
|
|
|
30
32
|
const xFn = $derived(resolveAccessor(group.series.x));
|
|
@@ -39,7 +41,8 @@
|
|
|
39
41
|
{...isHorizontal
|
|
40
42
|
? { y: xFn, x1: stackedBaselineFn, x2: stackedTopFn }
|
|
41
43
|
: { x: xFn, y1: stackedBaselineFn, y2: stackedTopFn }}
|
|
42
|
-
fill={defaultColor}
|
|
44
|
+
fill={disabledFill?.fill ?? defaultColor}
|
|
45
|
+
fillOpacity={disabledFill?.fillOpacity ?? 1}
|
|
43
46
|
/>
|
|
44
47
|
{:else}
|
|
45
48
|
{#each group.visualSegments as seg, i (`${group.series.name}-${i}`)}
|
|
@@ -48,8 +51,8 @@
|
|
|
48
51
|
{...isHorizontal
|
|
49
52
|
? { y: xFn, x1: barLayout.valueBaseline, x2: yFn }
|
|
50
53
|
: { x: xFn, y1: barLayout.valueBaseline, y2: yFn }}
|
|
51
|
-
fill={seg.style.fill ?? defaultColor}
|
|
52
|
-
fillOpacity={seg.style.fillOpacity ?? 1}
|
|
54
|
+
fill={disabledFill?.fill ?? seg.style.fill ?? defaultColor}
|
|
55
|
+
fillOpacity={disabledFill?.fillOpacity ?? seg.style.fillOpacity ?? 1}
|
|
53
56
|
{...barLayout.layout === 'grouped'
|
|
54
57
|
? isHorizontal
|
|
55
58
|
? {
|
|
@@ -11,6 +11,10 @@ declare function $$render<TData extends Record<string, unknown>>(): {
|
|
|
11
11
|
isHorizontal: boolean;
|
|
12
12
|
BarMark: typeof BarX | typeof BarY;
|
|
13
13
|
defaultColor: string;
|
|
14
|
+
disabledFill?: {
|
|
15
|
+
fill?: string;
|
|
16
|
+
fillOpacity?: number;
|
|
17
|
+
};
|
|
14
18
|
};
|
|
15
19
|
exports: {};
|
|
16
20
|
bindings: "";
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import { SvelteMap } from 'svelte/reactivity';
|
|
6
6
|
import { paletteColor } from '../../utils/color';
|
|
7
7
|
import { buildSeries } from '../../utils/grouping';
|
|
8
|
+
import { getLegendInteraction } from '../../layout/legend/interaction.svelte';
|
|
9
|
+
import { disabledFillOverride } from '../utils/legendDisabled';
|
|
8
10
|
import { BarX, BarY } from 'svelteplot';
|
|
9
11
|
import { resolveAccessor } from '../utils/accessors';
|
|
10
12
|
import { getConfiguration } from '../../configuration/config.svelte';
|
|
@@ -160,7 +162,14 @@
|
|
|
160
162
|
// which drive tick/domain computation and the Pointer's hit-testing) —
|
|
161
163
|
// `row` (see BasePlotLayout/HoverPoint) is what lets the template recover the
|
|
162
164
|
// semantic category regardless of which axis it landed on.
|
|
163
|
-
const
|
|
165
|
+
const legendInteraction = $derived(getLegendInteraction());
|
|
166
|
+
// A legend-disabled series is dimmed on the bar itself, but never surfaces
|
|
167
|
+
// in hover/tooltip — its points are simply absent from the candidate list.
|
|
168
|
+
const hoverPoints = $derived(
|
|
169
|
+
buildHoverPoints(resolvedSeries, isHorizontal, barLayout, cfg.palette).filter(
|
|
170
|
+
(p) => !legendInteraction?.disabledSeries.has(p.series!),
|
|
171
|
+
),
|
|
172
|
+
);
|
|
164
173
|
|
|
165
174
|
const hoverActive = $derived(tooltip != null || hasHoverMarker(markers));
|
|
166
175
|
const hoverStrategy = $derived(resolveHoverStrategy(tooltip?.strategy, markers, categoricalAxis));
|
|
@@ -189,7 +198,8 @@
|
|
|
189
198
|
<RuleLayout {markers} seriesData={resolvedSeries} {matchedPoints} {ruleX} {ruleY} {impliesRuleX} {impliesRuleY}>
|
|
190
199
|
{#each groupedSeries as group, seriesIndex (group.series.name)}
|
|
191
200
|
{@const defaultColor = paletteColor(seriesIndex, cfg.palette)}
|
|
192
|
-
|
|
201
|
+
{@const disabledFill = disabledFillOverride(legendInteraction?.disabledSeries.get(group.series.name))}
|
|
202
|
+
<BarSegments {group} {seriesIndex} {barLayout} {isHorizontal} {BarMark} {defaultColor} {disabledFill} />
|
|
193
203
|
|
|
194
204
|
{#if showVals}
|
|
195
205
|
<ValueLabels {group} {seriesIndex} {barLayout} {isHorizontal} {valAnchor} {fmtVal} values={styles.values} axisColor={cfg.axis.color} />
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
import { isTopology, topologyToGeoJson } from '../utils/topojson';
|
|
14
14
|
import { makeColorScale } from '../../utils/color';
|
|
15
15
|
import { getConfiguration } from '../../configuration/config.svelte';
|
|
16
|
+
import { getLegendInteraction } from '../../layout/legend/interaction.svelte';
|
|
17
|
+
import { disabledFillOverride } from '../utils/legendDisabled';
|
|
16
18
|
import { GEO_MARGIN_ESTIMATE } from '../../layout/plot/margins';
|
|
17
19
|
import BasePlotLayout from '../../layout/plot/BasePlotLayout.svelte';
|
|
18
20
|
import GeoLayout from '../../layout/geo/GeoLayout.svelte';
|
|
@@ -165,8 +167,16 @@
|
|
|
165
167
|
return vals.length ? [Math.min(...vals), Math.max(...vals)] : null;
|
|
166
168
|
});
|
|
167
169
|
|
|
170
|
+
// ── Legend-driven range filter (mirrors HeatmapPlot's own) ────────────────
|
|
171
|
+
const legendInteraction = $derived(getLegendInteraction());
|
|
172
|
+
const range = $derived(legendInteraction?.continuousRange);
|
|
173
|
+
const effectiveDomain = $derived.by((): [number, number] | null => {
|
|
174
|
+
if (!valueDomain) return null;
|
|
175
|
+
return range && range.mode === 'rescale' ? [range.min, range.max] : valueDomain;
|
|
176
|
+
});
|
|
177
|
+
|
|
168
178
|
const colorScale = $derived(
|
|
169
|
-
|
|
179
|
+
effectiveDomain ? makeColorScale(effectiveDomain[0], effectiveDomain[1], minColor, maxColor) : null,
|
|
170
180
|
);
|
|
171
181
|
|
|
172
182
|
function resolveBaseFill(f: TaggedFeature): string {
|
|
@@ -177,6 +187,14 @@
|
|
|
177
187
|
return styles.fill ?? cfg.palette[0];
|
|
178
188
|
}
|
|
179
189
|
|
|
190
|
+
function rangeFilteredStyle(base: GeoSegmentStyle, f: TaggedFeature): GeoSegmentStyle {
|
|
191
|
+
const r = range;
|
|
192
|
+
if (r == null || !value) return base;
|
|
193
|
+
const v = value(f);
|
|
194
|
+
if (v == null || (v >= r.min && v <= r.max)) return base;
|
|
195
|
+
return { ...base, ...disabledFillOverride(r.disabledStyle) };
|
|
196
|
+
}
|
|
197
|
+
|
|
180
198
|
/** Per-marker color ramp for a `dot` marker's `value` channel — independent of the base map's own choropleth ramp. */
|
|
181
199
|
function makeDotColorScale(marker: GeoDotMarker): ((v: number) => string) | null {
|
|
182
200
|
if (!marker.value) return null;
|
|
@@ -197,7 +215,7 @@
|
|
|
197
215
|
// shared numeric-scale machinery collapses a per-row fillOpacity/strokeWidth
|
|
198
216
|
// *accessor* to a single value, so literal per-call constants are required.)
|
|
199
217
|
function getFeatureStyle(f: TaggedFeature): GeoSegmentStyle {
|
|
200
|
-
return resolveFeatureStyle<GeoSegmentStyle>(segments, f.__id);
|
|
218
|
+
return rangeFilteredStyle(resolveFeatureStyle<GeoSegmentStyle>(segments, f.__id), f);
|
|
201
219
|
}
|
|
202
220
|
|
|
203
221
|
type StyleGroup = { style: GeoSegmentStyle; data: TaggedFeature[] };
|
|
@@ -228,7 +246,18 @@
|
|
|
228
246
|
features.flatMap((f) => geometryPartCentroids(f.geometry).map(([cx, cy]) => ({ ...f, __cx: cx, __cy: cy }))),
|
|
229
247
|
);
|
|
230
248
|
|
|
231
|
-
|
|
249
|
+
// A feature outside the active range is dimmed in place, but never
|
|
250
|
+
// surfaces in hover/tooltip — it's simply absent from the candidate list.
|
|
251
|
+
const hoverPoints = $derived(
|
|
252
|
+
hoverCandidates
|
|
253
|
+
.filter((f) => {
|
|
254
|
+
const r = range;
|
|
255
|
+
if (r == null || !value) return true;
|
|
256
|
+
const v = value(f);
|
|
257
|
+
return v == null || (v >= r.min && v <= r.max);
|
|
258
|
+
})
|
|
259
|
+
.map((f) => ({ x: f.__cx, y: f.__cy, row: f, label: f.__id })),
|
|
260
|
+
);
|
|
232
261
|
const hoverGetX = (f: TaggedFeature) => f.__cx;
|
|
233
262
|
const hoverGetY = (f: TaggedFeature) => f.__cy;
|
|
234
263
|
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import { buildSeries } from '../../utils/grouping';
|
|
6
6
|
import { makeColorScale } from '../../utils/color';
|
|
7
7
|
import { getConfiguration } from '../../configuration/config.svelte';
|
|
8
|
+
import { getLegendInteraction } from '../../layout/legend/interaction.svelte';
|
|
9
|
+
import { disabledFillOverride } from '../utils/legendDisabled';
|
|
8
10
|
import BasePlotLayout from '../../layout/plot/BasePlotLayout.svelte';
|
|
9
11
|
import AxisLayout from '../../layout/plot/AxisLayout.svelte';
|
|
10
12
|
import GridLayout from '../../layout/plot/GridLayout.svelte';
|
|
@@ -86,15 +88,6 @@
|
|
|
86
88
|
const getY = $derived(resolveAccessor<TData>(resolvedSeries[0]?.y ?? y!));
|
|
87
89
|
const getZ = $derived(resolveAccessor<TData>(resolvedSeries[0]?.z ?? z!));
|
|
88
90
|
|
|
89
|
-
const hoverPoints = $derived(
|
|
90
|
-
flat.map((d) => ({
|
|
91
|
-
x: getX(d) as AxisValue,
|
|
92
|
-
y: getY(d) as AxisValue,
|
|
93
|
-
row: d,
|
|
94
|
-
label: fmtVal(Number(getZ(d)), ''),
|
|
95
|
-
})),
|
|
96
|
-
);
|
|
97
|
-
|
|
98
91
|
const xDomain = $derived([...new Set(flat.map((d) => getX(d) as AxisValue))]);
|
|
99
92
|
const yDomain = $derived([...new Set(flat.map((d) => getY(d) as AxisValue))]);
|
|
100
93
|
|
|
@@ -106,8 +99,42 @@
|
|
|
106
99
|
return vals.length ? [Math.min(...vals, 0), Math.max(...vals)] : [0, 1];
|
|
107
100
|
});
|
|
108
101
|
|
|
102
|
+
// ── Legend-driven range filter ────────────────────────────────────────────
|
|
103
|
+
const legendInteraction = $derived(getLegendInteraction());
|
|
104
|
+
const range = $derived(legendInteraction?.continuousRange);
|
|
105
|
+
// 'rescale' reprojects the ramp onto the narrowed range; 'filter' (and no
|
|
106
|
+
// active range) keeps the original domain — only which cells are hidden
|
|
107
|
+
// changes.
|
|
108
|
+
const effectiveDomain = $derived.by((): [number, number] =>
|
|
109
|
+
range && range.mode === 'rescale' ? [range.min, range.max] : valueDomain,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
function rangeFilteredStyle(base: CellSegmentStyle, value: number): CellSegmentStyle {
|
|
113
|
+
const r = range;
|
|
114
|
+
if (r == null || (value >= r.min && value <= r.max)) return base;
|
|
115
|
+
return { ...base, ...disabledFillOverride(r.disabledStyle) };
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
const colorScale = $derived(
|
|
110
|
-
makeColorScale(
|
|
119
|
+
makeColorScale(effectiveDomain[0], effectiveDomain[1], minColor, maxColor),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// A cell outside the active range is dimmed in place, but never surfaces
|
|
123
|
+
// in hover/tooltip — it's simply absent from the candidate list.
|
|
124
|
+
const hoverPoints = $derived(
|
|
125
|
+
flat
|
|
126
|
+
.filter((d) => {
|
|
127
|
+
const r = range;
|
|
128
|
+
if (r == null) return true;
|
|
129
|
+
const v = Number(getZ(d));
|
|
130
|
+
return v >= r.min && v <= r.max;
|
|
131
|
+
})
|
|
132
|
+
.map((d) => ({
|
|
133
|
+
x: getX(d) as AxisValue,
|
|
134
|
+
y: getY(d) as AxisValue,
|
|
135
|
+
row: d,
|
|
136
|
+
label: fmtVal(Number(getZ(d)), ''),
|
|
137
|
+
})),
|
|
111
138
|
);
|
|
112
139
|
|
|
113
140
|
const segList = $derived(segments.default ?? []);
|
|
@@ -152,7 +179,11 @@
|
|
|
152
179
|
});
|
|
153
180
|
|
|
154
181
|
const getCellStyle = $derived(
|
|
155
|
-
(d: TData) =>
|
|
182
|
+
(d: TData) =>
|
|
183
|
+
rangeFilteredStyle(
|
|
184
|
+
segStyleMap.get(`${String(getX(d))}::${String(getY(d))}`) ?? {},
|
|
185
|
+
Number(getZ(d)),
|
|
186
|
+
),
|
|
156
187
|
);
|
|
157
188
|
|
|
158
189
|
/**
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import { getConfiguration } from '../../configuration/config.svelte';
|
|
9
9
|
import { paletteColor } from '../../utils/color';
|
|
10
10
|
import { buildSeries } from '../../utils/grouping';
|
|
11
|
+
import { getLegendInteraction } from '../../layout/legend/interaction.svelte';
|
|
12
|
+
import { disabledStrokeOverride, disabledDotsOverride } from '../utils/legendDisabled';
|
|
11
13
|
import BasePlotLayout from '../../layout/plot/BasePlotLayout.svelte';
|
|
12
14
|
import AxisLayout from '../../layout/plot/AxisLayout.svelte';
|
|
13
15
|
import GridLayout from '../../layout/plot/GridLayout.svelte';
|
|
@@ -50,6 +52,7 @@
|
|
|
50
52
|
}: Props = $props();
|
|
51
53
|
|
|
52
54
|
const resolvedSeries = $derived(series ?? buildSeries(data!, x!, y!, z));
|
|
55
|
+
const legendInteraction = $derived(getLegendInteraction());
|
|
53
56
|
|
|
54
57
|
$effect(() => { validateSegments(segments); });
|
|
55
58
|
|
|
@@ -82,8 +85,11 @@
|
|
|
82
85
|
const xAcc = $derived(resolveAccessor(resolvedSeries[0]?.x ?? ((d: TData) => d)));
|
|
83
86
|
const yAcc = $derived(resolveAccessor(resolvedSeries[0]?.y ?? ((d: TData) => d)));
|
|
84
87
|
const flat = $derived(resolvedSeries.flatMap((s) => s.data));
|
|
88
|
+
// A legend-disabled series is dimmed on the mark itself, but never surfaces
|
|
89
|
+
// in hover/tooltip — its points are simply absent from the candidate list.
|
|
85
90
|
const hoverPoints = $derived(
|
|
86
91
|
resolvedSeries.flatMap((s, idx) => {
|
|
92
|
+
if (legendInteraction?.disabledSeries.has(s.name)) return [];
|
|
87
93
|
const sx = resolveAccessor(s.x);
|
|
88
94
|
const sy = resolveAccessor(s.y);
|
|
89
95
|
return s.data.map((d) => ({
|
|
@@ -131,6 +137,9 @@
|
|
|
131
137
|
<RuleLayout {markers} seriesData={resolvedSeries} {matchedPoints} {ruleX} {ruleY} {impliesRuleX} {impliesRuleY}>
|
|
132
138
|
{#each groupedSeries as group, seriesIndex (group.series.name)}
|
|
133
139
|
{@const defaultColor = paletteColor(seriesIndex, cfg.palette)}
|
|
140
|
+
{@const disabledOverride = legendInteraction?.disabledSeries.get(group.series.name)}
|
|
141
|
+
{@const disabledStroke = disabledStrokeOverride(disabledOverride)}
|
|
142
|
+
{@const disabledDots = disabledDotsOverride(disabledOverride)}
|
|
134
143
|
{#each group.visualSegments as seg, i (`${group.series.name}-${i}`)}
|
|
135
144
|
{@const strokeStyle = seg.style.stroke}
|
|
136
145
|
{@const lineData = seg.data as unknown as Record<string | symbol, never>[]}
|
|
@@ -138,12 +147,12 @@
|
|
|
138
147
|
data={lineData}
|
|
139
148
|
x={resolveAccessor(group.series.x)}
|
|
140
149
|
y={resolveAccessor(group.series.y)}
|
|
141
|
-
stroke={strokeStyle?.stroke ?? defaultColor}
|
|
142
|
-
strokeWidth={strokeStyle?.strokeWidth ?? cfg.line.strokeWidth}
|
|
143
|
-
strokeOpacity={strokeStyle?.strokeOpacity ?? 1}
|
|
144
|
-
strokeDasharray={strokeStyle?.strokeDasharray}
|
|
145
|
-
strokeLinejoin={strokeStyle?.strokeLinejoin}
|
|
146
|
-
strokeLinecap={strokeStyle?.strokeLinecap}
|
|
150
|
+
stroke={disabledStroke?.stroke ?? strokeStyle?.stroke ?? defaultColor}
|
|
151
|
+
strokeWidth={disabledStroke?.strokeWidth ?? strokeStyle?.strokeWidth ?? cfg.line.strokeWidth}
|
|
152
|
+
strokeOpacity={disabledStroke?.strokeOpacity ?? strokeStyle?.strokeOpacity ?? 1}
|
|
153
|
+
strokeDasharray={disabledStroke?.strokeDasharray ?? strokeStyle?.strokeDasharray}
|
|
154
|
+
strokeLinejoin={disabledStroke?.strokeLinejoin ?? strokeStyle?.strokeLinejoin}
|
|
155
|
+
strokeLinecap={disabledStroke?.strokeLinecap ?? strokeStyle?.strokeLinecap}
|
|
147
156
|
/>
|
|
148
157
|
{#if seg.style.dots}
|
|
149
158
|
{@const dotStyle = seg.style.dots}
|
|
@@ -151,14 +160,14 @@
|
|
|
151
160
|
data={seg.data}
|
|
152
161
|
x={resolveAccessor(group.series.x)}
|
|
153
162
|
y={resolveAccessor(group.series.y)}
|
|
154
|
-
r={dotStyle.dotRadius ?? cfg.line.dotRadius}
|
|
155
|
-
fill={dotStyle.dotFill ?? strokeStyle?.stroke ?? defaultColor}
|
|
156
|
-
symbol={dotStyle.dotSymbol ?? 'circle'}
|
|
157
|
-
stroke={dotStyle.dotStroke ?? strokeStyle?.stroke ?? defaultColor}
|
|
158
|
-
strokeWidth={dotStyle.dotStrokeWidth ?? 1}
|
|
159
|
-
fillOpacity={dotStyle.dotFillOpacity ?? strokeStyle?.strokeOpacity ?? 1}
|
|
160
|
-
strokeOpacity={dotStyle.dotStrokeOpacity ?? strokeStyle?.strokeOpacity ?? 1}
|
|
161
|
-
strokeDasharray={dotStyle.dotStrokeDasharray}
|
|
163
|
+
r={disabledDots?.dotRadius ?? dotStyle.dotRadius ?? cfg.line.dotRadius}
|
|
164
|
+
fill={disabledDots?.dotFill ?? dotStyle.dotFill ?? strokeStyle?.stroke ?? defaultColor}
|
|
165
|
+
symbol={disabledDots?.dotSymbol ?? dotStyle.dotSymbol ?? 'circle'}
|
|
166
|
+
stroke={disabledDots?.dotStroke ?? dotStyle.dotStroke ?? strokeStyle?.stroke ?? defaultColor}
|
|
167
|
+
strokeWidth={disabledDots?.dotStrokeWidth ?? dotStyle.dotStrokeWidth ?? 1}
|
|
168
|
+
fillOpacity={disabledDots?.dotFillOpacity ?? dotStyle.dotFillOpacity ?? strokeStyle?.strokeOpacity ?? 1}
|
|
169
|
+
strokeOpacity={disabledDots?.dotStrokeOpacity ?? dotStyle.dotStrokeOpacity ?? strokeStyle?.strokeOpacity ?? 1}
|
|
170
|
+
strokeDasharray={disabledDots?.dotStrokeDasharray ?? dotStyle.dotStrokeDasharray}
|
|
162
171
|
/>
|
|
163
172
|
{/if}
|
|
164
173
|
{/each}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import { getConfiguration } from '../../configuration/config.svelte';
|
|
6
6
|
import { paletteColor } from '../../utils/color';
|
|
7
7
|
import { buildSeries } from '../../utils/grouping';
|
|
8
|
+
import { getLegendInteraction } from '../../layout/legend/interaction.svelte';
|
|
9
|
+
import { disabledFillOverride } from '../utils/legendDisabled';
|
|
8
10
|
import { buildGroupedSeries, validateSegments, resolveSegmentsForSeries, matchesSegmentX, getDataForSegments } from '../utils/segments';
|
|
9
11
|
import BasePlotLayout from '../../layout/plot/BasePlotLayout.svelte';
|
|
10
12
|
import AxisLayout from '../../layout/plot/AxisLayout.svelte';
|
|
@@ -65,6 +67,7 @@
|
|
|
65
67
|
$effect(() => { validateSegments(segments); });
|
|
66
68
|
|
|
67
69
|
const cfg = $derived(getConfiguration());
|
|
70
|
+
const legendInteraction = $derived(getLegendInteraction());
|
|
68
71
|
const leftColor = $derived(styles.colors?.left ?? paletteColor(0, cfg.palette));
|
|
69
72
|
const rightColor = $derived(styles.colors?.right ?? paletteColor(1, cfg.palette));
|
|
70
73
|
const showVals = $derived(styles.values?.show ?? false);
|
|
@@ -134,15 +137,19 @@
|
|
|
134
137
|
const getY = (d: TaggedRow) => d.__y;
|
|
135
138
|
const labelFor = (d: TaggedRow) => d.__label;
|
|
136
139
|
|
|
140
|
+
// A legend-disabled series is dimmed on the bar itself, but never surfaces
|
|
141
|
+
// in hover/tooltip — its points are simply absent from the candidate list.
|
|
137
142
|
const hoverPoints = $derived(
|
|
138
|
-
flat
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
flat
|
|
144
|
+
.filter((d) => !legendInteraction?.disabledSeries.has(d.__series))
|
|
145
|
+
.map((d) => ({
|
|
146
|
+
x: d.__x,
|
|
147
|
+
y: d.__y,
|
|
148
|
+
row: d,
|
|
149
|
+
label: d.__label,
|
|
150
|
+
series: d.__series,
|
|
151
|
+
color: d.__side === 'left' ? leftColor : rightColor,
|
|
152
|
+
})),
|
|
146
153
|
);
|
|
147
154
|
|
|
148
155
|
const yDomain = $derived([
|
|
@@ -216,14 +223,15 @@
|
|
|
216
223
|
{#each groupedSeries as group (group.series.name)}
|
|
217
224
|
{@const defaultColor = group.series.side === 'left' ? leftColor : rightColor}
|
|
218
225
|
{@const getCategory = resolveAccessor(group.series.x)}
|
|
226
|
+
{@const disabledFill = disabledFillOverride(legendInteraction?.disabledSeries.get(group.series.name))}
|
|
219
227
|
{#each group.visualSegments as seg, i (`${group.series.name}-${i}`)}
|
|
220
228
|
<BarX
|
|
221
229
|
data={seg.data}
|
|
222
230
|
x1={0}
|
|
223
231
|
x2={(d: TData) => signedValue(group.series, d)}
|
|
224
232
|
y={getCategory}
|
|
225
|
-
fill={seg.style.fill ?? defaultColor}
|
|
226
|
-
fillOpacity={seg.style.fillOpacity ?? 1}
|
|
233
|
+
fill={disabledFill?.fill ?? seg.style.fill ?? defaultColor}
|
|
234
|
+
fillOpacity={disabledFill?.fillOpacity ?? seg.style.fillOpacity ?? 1}
|
|
227
235
|
/>
|
|
228
236
|
{/each}
|
|
229
237
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { LegendDisabledStyle } from '../../types/charts/legend';
|
|
2
|
+
import type { StrokeStyle, DotStyle } from '../../types/plots/styling';
|
|
3
|
+
/**
|
|
4
|
+
* The three per-mark override layers a legend "disabled" state can apply,
|
|
5
|
+
* derived once from a {@link LegendDisabledStyle}. Each is `undefined` when
|
|
6
|
+
* there's no active override, so call sites can slot the result in as just
|
|
7
|
+
* one more `??` layer ahead of their existing segment-style/default-color
|
|
8
|
+
* chain — no branching needed at the call site.
|
|
9
|
+
*/
|
|
10
|
+
export declare function disabledStrokeOverride(disabled: LegendDisabledStyle | undefined): StrokeStyle | undefined;
|
|
11
|
+
export declare function disabledDotsOverride(disabled: LegendDisabledStyle | undefined): DotStyle | undefined;
|
|
12
|
+
export declare function disabledFillOverride(disabled: LegendDisabledStyle | undefined): {
|
|
13
|
+
fill?: string;
|
|
14
|
+
fillOpacity?: number;
|
|
15
|
+
} | undefined;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three per-mark override layers a legend "disabled" state can apply,
|
|
3
|
+
* derived once from a {@link LegendDisabledStyle}. Each is `undefined` when
|
|
4
|
+
* there's no active override, so call sites can slot the result in as just
|
|
5
|
+
* one more `??` layer ahead of their existing segment-style/default-color
|
|
6
|
+
* chain — no branching needed at the call site.
|
|
7
|
+
*/
|
|
8
|
+
export function disabledStrokeOverride(disabled) {
|
|
9
|
+
if (!disabled)
|
|
10
|
+
return undefined;
|
|
11
|
+
return {
|
|
12
|
+
stroke: disabled.stroke?.stroke,
|
|
13
|
+
strokeWidth: disabled.stroke?.strokeWidth,
|
|
14
|
+
strokeOpacity: disabled.stroke?.strokeOpacity ?? disabled.opacity,
|
|
15
|
+
strokeDasharray: disabled.stroke?.strokeDasharray,
|
|
16
|
+
strokeLinecap: disabled.stroke?.strokeLinecap,
|
|
17
|
+
strokeLinejoin: disabled.stroke?.strokeLinejoin,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function disabledDotsOverride(disabled) {
|
|
21
|
+
if (!disabled)
|
|
22
|
+
return undefined;
|
|
23
|
+
return {
|
|
24
|
+
dotRadius: disabled.dots?.dotRadius,
|
|
25
|
+
dotFill: disabled.dots?.dotFill,
|
|
26
|
+
dotFillOpacity: disabled.dots?.dotFillOpacity ?? disabled.opacity,
|
|
27
|
+
dotSymbol: disabled.dots?.dotSymbol,
|
|
28
|
+
dotStroke: disabled.dots?.dotStroke,
|
|
29
|
+
dotStrokeWidth: disabled.dots?.dotStrokeWidth,
|
|
30
|
+
dotStrokeOpacity: disabled.dots?.dotStrokeOpacity ?? disabled.opacity,
|
|
31
|
+
dotStrokeDasharray: disabled.dots?.dotStrokeDasharray,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function disabledFillOverride(disabled) {
|
|
35
|
+
if (!disabled)
|
|
36
|
+
return undefined;
|
|
37
|
+
return {
|
|
38
|
+
fill: disabled.fill,
|
|
39
|
+
fillOpacity: disabled.fillOpacity ?? disabled.opacity,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import type { SymbolType, LineStyle } from '../plots/constants';
|
|
2
|
+
import type { StrokeStyle, DotStyle } from '../plots/styling';
|
|
2
3
|
export type LegendSectionType = 'discrete' | 'continuous';
|
|
4
|
+
/**
|
|
5
|
+
* Style applied to whatever a legend interaction has turned "off" — a
|
|
6
|
+
* toggled-off discrete series, or a datapoint/cell/feature outside an active
|
|
7
|
+
* continuous range filter. `opacity` is a blanket convenience: any
|
|
8
|
+
* `*Opacity` field a consumer doesn't set falls back to it, so
|
|
9
|
+
* `{ opacity: 0.1 }` alone is enough to read as "disabled" everywhere.
|
|
10
|
+
*/
|
|
11
|
+
export type LegendDisabledStyle = {
|
|
12
|
+
opacity?: number;
|
|
13
|
+
stroke?: StrokeStyle;
|
|
14
|
+
dots?: DotStyle;
|
|
15
|
+
fill?: string;
|
|
16
|
+
fillOpacity?: number;
|
|
17
|
+
};
|
|
3
18
|
export type LegendItem = {
|
|
4
19
|
name: string;
|
|
5
20
|
label?: string;
|
|
@@ -7,12 +22,18 @@ export type LegendItem = {
|
|
|
7
22
|
symbol?: SymbolType;
|
|
8
23
|
opacity?: number;
|
|
9
24
|
lineStyle?: LineStyle;
|
|
25
|
+
/** Initial toggled-off state (uncontrolled — the user can still click it back on). */
|
|
26
|
+
disabled?: boolean;
|
|
10
27
|
};
|
|
11
28
|
export type DiscreteLegendSection = {
|
|
12
29
|
type: 'discrete';
|
|
13
30
|
title?: string;
|
|
14
31
|
items: LegendItem[];
|
|
15
32
|
columns?: number;
|
|
33
|
+
/** Enables click-to-toggle on each item. */
|
|
34
|
+
interactive?: boolean;
|
|
35
|
+
/** Style applied to a toggled-off item's series. Falls back to `cfg.legend.disabledOpacity`. */
|
|
36
|
+
disabledStyle?: LegendDisabledStyle;
|
|
16
37
|
};
|
|
17
38
|
export type ContinuousLegendSection = {
|
|
18
39
|
type: 'continuous';
|
|
@@ -25,5 +46,15 @@ export type ContinuousLegendSection = {
|
|
|
25
46
|
ticks?: number[];
|
|
26
47
|
width?: number;
|
|
27
48
|
format?: (v: number) => string;
|
|
49
|
+
/** Enables drag handles on the bar's edges to narrow the visible range. */
|
|
50
|
+
interactive?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* How the color ramp responds to a narrowed range: `'rescale'` reprojects
|
|
53
|
+
* it onto the new bounds, `'filter'` keeps the original domain and only
|
|
54
|
+
* excludes out-of-range values. Defaults to `'filter'`.
|
|
55
|
+
*/
|
|
56
|
+
mode?: 'rescale' | 'filter';
|
|
57
|
+
/** Style applied to values outside the active range. Falls back to `cfg.legend.disabledOpacity`. */
|
|
58
|
+
disabledStyle?: LegendDisabledStyle;
|
|
28
59
|
};
|
|
29
60
|
export type LegendSection = DiscreteLegendSection | ContinuousLegendSection;
|
|
@@ -37,6 +37,8 @@ export type ChartConfig = {
|
|
|
37
37
|
gap: string;
|
|
38
38
|
/** swatch square size */
|
|
39
39
|
swatchSize: string;
|
|
40
|
+
/** Fallback opacity for a legend-disabled series/value when a section doesn't set its own `disabledStyle`. */
|
|
41
|
+
disabledOpacity: number;
|
|
40
42
|
};
|
|
41
43
|
axis: {
|
|
42
44
|
color: string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SvelteMap } from 'svelte/reactivity';
|
|
2
|
+
import type { LegendDisabledStyle } from '../charts/legend';
|
|
3
|
+
/**
|
|
4
|
+
* Shared legend-interaction state for cross-cutting legend → plot wiring.
|
|
5
|
+
*
|
|
6
|
+
* {@link BaseChart} provides one store via context (mirrors {@link HoverStore});
|
|
7
|
+
* `DiscreteSection`/`ContinuousSection` write to it on click/drag, and every
|
|
8
|
+
* plot kind (line/bar/pyramid/heatmap/geo) reads the same instance to know
|
|
9
|
+
* what to dim. A standalone legend/plot with no provider falls back to its
|
|
10
|
+
* own private store via `createLegendInteraction`.
|
|
11
|
+
*/
|
|
12
|
+
export type LegendInteractionStore = {
|
|
13
|
+
/** Legend item name → resolved style to apply. Presence in the map means "disabled". */
|
|
14
|
+
disabledSeries: SvelteMap<string, LegendDisabledStyle>;
|
|
15
|
+
/**
|
|
16
|
+
* The active drag state of the (first) interactive continuous section, or
|
|
17
|
+
* `null` when none is interactive or the user hasn't narrowed it yet (full
|
|
18
|
+
* range — nothing hidden).
|
|
19
|
+
*/
|
|
20
|
+
continuousRange: {
|
|
21
|
+
min: number;
|
|
22
|
+
max: number;
|
|
23
|
+
mode: 'rescale' | 'filter';
|
|
24
|
+
disabledStyle: LegendDisabledStyle;
|
|
25
|
+
} | null;
|
|
26
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|