@fundar/data-chart-telling 0.0.46 → 0.0.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/layout/legend/ContinuousSection.svelte +16 -13
- package/dist/layout/legend/DiscreteBadgeItem.svelte +67 -0
- package/dist/layout/legend/DiscreteBadgeItem.svelte.d.ts +10 -0
- package/dist/layout/legend/DiscreteSection.svelte +0 -0
- package/dist/layout/legend/DiscreteSwatchItem.svelte +20 -0
- package/dist/layout/legend/DiscreteSwatchItem.svelte.d.ts +8 -0
- package/dist/layout/legend/DiscreteSymbolIcon.svelte +60 -0
- package/dist/layout/legend/DiscreteSymbolIcon.svelte.d.ts +8 -0
- package/dist/plots/bar/Plot.svelte +9 -2
- package/dist/plots/line/Plot.svelte +8 -20
- package/dist/plots/line/valueLabelMargin.d.ts +7 -15
- package/dist/plots/line/valueLabelMargin.js +24 -20
- package/dist/plots/pyramid/Plot.svelte +9 -2
- package/dist/plots/scatter/Plot.svelte +12 -2
- package/dist/plots/utils/labelOverflow.svelte.d.ts +10 -0
- package/dist/plots/utils/labelOverflow.svelte.js +70 -26
- package/dist/plots/utils/valueLabelMargin.d.ts +13 -0
- package/dist/plots/utils/valueLabelMargin.js +20 -0
- package/dist/types/layout/legend.d.ts +37 -12
- package/dist/utils/color.d.ts +9 -0
- package/dist/utils/color.js +62 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ export { setConfiguration, getConfiguration, resetConfiguration, configToCssVars
|
|
|
35
35
|
export { THEMES } from './configuration/themes';
|
|
36
36
|
export type { ThemeName } from './configuration/themes';
|
|
37
37
|
export { groupBy, buildSeries } from './utils/grouping';
|
|
38
|
-
export { makeColorScale, normalize, resolveCssColor, paletteColor } from './utils/color';
|
|
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';
|
|
41
41
|
export type { AxisValue, AxisScale, AxisBasedScalesConfig } from './types/layout/scales';
|
|
@@ -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, LegendNavDirection, LegendNavContext, LegendNavSnippet, LegendInteractionStore, LegendPlotContext } from './types/layout/legend';
|
|
61
|
+
export type { LegendSection, DiscreteLegendSection, ContinuousLegendSection, LegendItem, LegendDisabledStyle, 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
|
@@ -42,6 +42,6 @@ export { setConfiguration, getConfiguration, resetConfiguration, configToCssVars
|
|
|
42
42
|
export { THEMES } from './configuration/themes';
|
|
43
43
|
// ── Utils ─────────────────────────────────────────────────────────────────────
|
|
44
44
|
export { groupBy, buildSeries } from './utils/grouping';
|
|
45
|
-
export { makeColorScale, normalize, resolveCssColor, paletteColor } from './utils/color';
|
|
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';
|
|
@@ -12,16 +12,17 @@
|
|
|
12
12
|
const barWidth = $derived(section.width ?? 170);
|
|
13
13
|
|
|
14
14
|
const fmt = $derived(section.format ?? ((v: number) => String(v)));
|
|
15
|
+
const isInteractive = $derived(section.interactive?.enabled ?? false);
|
|
15
16
|
|
|
16
17
|
// Fill/opacity for the excluded-margin rects.
|
|
17
18
|
const excludedStyle = $derived.by((): { fill: string; opacity: number } => {
|
|
18
|
-
const d = section.disabledStyle;
|
|
19
|
+
const d = section.interactive?.disabledStyle;
|
|
19
20
|
if (d?.fill) return { fill: d.fill, opacity: d.fillOpacity ?? d.opacity ?? 1 };
|
|
20
21
|
return { fill: 'currentColor', opacity: d?.fillOpacity ?? d?.opacity ?? 0.35 };
|
|
21
22
|
});
|
|
22
23
|
|
|
23
24
|
const tickPositions = $derived(
|
|
24
|
-
|
|
25
|
+
isInteractive
|
|
25
26
|
? []
|
|
26
27
|
: (section.ticks ?? []).map((v) => ({
|
|
27
28
|
v,
|
|
@@ -68,7 +69,9 @@
|
|
|
68
69
|
const hiT = $derived(toT(hi));
|
|
69
70
|
|
|
70
71
|
const colorDomain = $derived.by((): [number, number] =>
|
|
71
|
-
|
|
72
|
+
isInteractive && (section.interactive?.mode ?? 'filter') === 'rescale'
|
|
73
|
+
? [lo, hi]
|
|
74
|
+
: [section.min, section.max],
|
|
72
75
|
);
|
|
73
76
|
const colorScale = $derived(
|
|
74
77
|
makeColorScale(
|
|
@@ -88,21 +91,21 @@
|
|
|
88
91
|
);
|
|
89
92
|
|
|
90
93
|
$effect(() => {
|
|
91
|
-
if (!
|
|
94
|
+
if (!isInteractive) return;
|
|
92
95
|
interaction.continuousRange =
|
|
93
96
|
lo <= section.min && hi >= section.max
|
|
94
97
|
? null
|
|
95
98
|
: {
|
|
96
99
|
min: lo,
|
|
97
100
|
max: hi,
|
|
98
|
-
mode: section.mode ?? 'filter',
|
|
99
|
-
disabledStyle: section.disabledStyle ?? { opacity: cfg.legend.disabledOpacity },
|
|
101
|
+
mode: section.interactive?.mode ?? 'filter',
|
|
102
|
+
disabledStyle: section.interactive?.disabledStyle ?? { opacity: cfg.legend.disabledOpacity },
|
|
100
103
|
};
|
|
101
104
|
});
|
|
102
105
|
|
|
103
106
|
// Mirrors an externally-set `interaction.continuousRange` into lo/hi.
|
|
104
107
|
$effect(() => {
|
|
105
|
-
if (!
|
|
108
|
+
if (!isInteractive) return;
|
|
106
109
|
const range = interaction.continuousRange;
|
|
107
110
|
if (range === null) {
|
|
108
111
|
if (untrack(() => loAdjusted || hiAdjusted)) {
|
|
@@ -122,7 +125,7 @@
|
|
|
122
125
|
|
|
123
126
|
function startDrag(which: 'lo' | 'hi') {
|
|
124
127
|
return (event: PointerEvent) => {
|
|
125
|
-
if (!
|
|
128
|
+
if (!isInteractive) return;
|
|
126
129
|
event.preventDefault();
|
|
127
130
|
if (which === 'lo') loAdjusted = true;
|
|
128
131
|
else hiAdjusted = true;
|
|
@@ -179,22 +182,22 @@
|
|
|
179
182
|
<rect class="dct-continuous__tick-mark" x={tick.t * barWidth} y="12" />
|
|
180
183
|
{/each}
|
|
181
184
|
|
|
182
|
-
{#if
|
|
185
|
+
{#if isInteractive}
|
|
183
186
|
{#if loT > 0}
|
|
184
187
|
<rect x="0" y="0" width={loT * barWidth} height="12" rx="2" fill={excludedStyle.fill} fill-opacity={excludedStyle.opacity} />
|
|
185
188
|
{/if}
|
|
186
189
|
{#if hiT < 1}
|
|
187
190
|
<rect x={hiT * barWidth} y="0" width={barWidth - hiT * barWidth} height="12" rx="2" fill={excludedStyle.fill} fill-opacity={excludedStyle.opacity} />
|
|
188
191
|
{/if}
|
|
189
|
-
{#if section.handle}
|
|
190
|
-
{@render section.handle({
|
|
192
|
+
{#if section.interactive?.handle}
|
|
193
|
+
{@render section.interactive.handle({
|
|
191
194
|
which: 'lo',
|
|
192
195
|
value: lo,
|
|
193
196
|
position: loT,
|
|
194
197
|
onpointerdown: startDrag('lo'),
|
|
195
198
|
ondoubleclick: resetHandle('lo'),
|
|
196
199
|
})}
|
|
197
|
-
{@render section.handle({
|
|
200
|
+
{@render section.interactive.handle({
|
|
198
201
|
which: 'hi',
|
|
199
202
|
value: hi,
|
|
200
203
|
position: hiT,
|
|
@@ -233,7 +236,7 @@
|
|
|
233
236
|
</svg>
|
|
234
237
|
|
|
235
238
|
<div class="dct-continuous__labels" style:width="{barWidth}px">
|
|
236
|
-
{#if
|
|
239
|
+
{#if isInteractive}
|
|
237
240
|
<span class="dct-continuous__tick dct-continuous__tick--handle-lo" style:left="{loT * barWidth}px">{fmt(lo)}</span>
|
|
238
241
|
<span class="dct-continuous__tick dct-continuous__tick--handle-hi" style:left="{hiT * barWidth}px">{fmt(hi)}</span>
|
|
239
242
|
{:else}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { contrastTextColor } from '../../utils/color';
|
|
3
|
+
import DiscreteSymbolIcon from './DiscreteSymbolIcon.svelte';
|
|
4
|
+
import type { LegendItem } from '../../types/layout/legend';
|
|
5
|
+
|
|
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`.
|
|
12
|
+
*/
|
|
13
|
+
let {
|
|
14
|
+
item,
|
|
15
|
+
color,
|
|
16
|
+
disabled,
|
|
17
|
+
showIcon = true
|
|
18
|
+
}: { item: LegendItem; color: string; disabled: boolean; showIcon?: boolean } = $props();
|
|
19
|
+
const textColor = $derived(contrastTextColor(color));
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<span class="dct-discrete__badge" style:background={color} style:color={textColor}>
|
|
23
|
+
{#if showIcon}
|
|
24
|
+
<DiscreteSymbolIcon {item} color={textColor} />
|
|
25
|
+
{/if}
|
|
26
|
+
<span class="dct-discrete__label">{item.label || item.name}</span>
|
|
27
|
+
<svg class="dct-discrete__eye" viewBox="0 0 24 24" aria-hidden="true">
|
|
28
|
+
{#if disabled}
|
|
29
|
+
<path
|
|
30
|
+
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
|
+
/>
|
|
32
|
+
<line x1="1" y1="1" x2="23" y2="23" />
|
|
33
|
+
{:else}
|
|
34
|
+
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
35
|
+
<circle cx="12" cy="12" r="3" />
|
|
36
|
+
{/if}
|
|
37
|
+
</svg>
|
|
38
|
+
</span>
|
|
39
|
+
|
|
40
|
+
<style>
|
|
41
|
+
.dct-discrete__badge {
|
|
42
|
+
display: flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
gap: 0.4rem;
|
|
45
|
+
min-width: 0;
|
|
46
|
+
padding: var(--dct-legend-badge-pad, 0.25rem 0.65rem);
|
|
47
|
+
border-radius: var(--dct-legend-badge-radius, 999px);
|
|
48
|
+
}
|
|
49
|
+
.dct-discrete__label {
|
|
50
|
+
font-size: var(--dct-legend-size, 0.875rem);
|
|
51
|
+
font-weight: var(--dct-legend-weight, 400);
|
|
52
|
+
white-space: nowrap;
|
|
53
|
+
overflow: hidden;
|
|
54
|
+
text-overflow: ellipsis;
|
|
55
|
+
min-width: 0;
|
|
56
|
+
}
|
|
57
|
+
.dct-discrete__eye {
|
|
58
|
+
width: var(--dct-legend-badge-eye-size, 0.9rem);
|
|
59
|
+
height: var(--dct-legend-badge-eye-size, 0.9rem);
|
|
60
|
+
flex-shrink: 0;
|
|
61
|
+
fill: none;
|
|
62
|
+
stroke: currentColor;
|
|
63
|
+
stroke-width: 2;
|
|
64
|
+
stroke-linecap: round;
|
|
65
|
+
stroke-linejoin: round;
|
|
66
|
+
}
|
|
67
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LegendItem } from '../../types/layout/legend';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
item: LegendItem;
|
|
4
|
+
color: string;
|
|
5
|
+
disabled: boolean;
|
|
6
|
+
showIcon?: boolean;
|
|
7
|
+
};
|
|
8
|
+
declare const DiscreteBadgeItem: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
9
|
+
type DiscreteBadgeItem = ReturnType<typeof DiscreteBadgeItem>;
|
|
10
|
+
export default DiscreteBadgeItem;
|
|
Binary file
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import DiscreteSymbolIcon from './DiscreteSymbolIcon.svelte';
|
|
3
|
+
import type { LegendItem } from '../../types/layout/legend';
|
|
4
|
+
|
|
5
|
+
let { item, color }: { item: LegendItem; color: string } = $props();
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<DiscreteSymbolIcon {item} {color} />
|
|
9
|
+
<span class="dct-discrete__label">{item.label || item.name}</span>
|
|
10
|
+
|
|
11
|
+
<style>
|
|
12
|
+
.dct-discrete__label {
|
|
13
|
+
font-size: var(--dct-legend-size, 0.875rem);
|
|
14
|
+
font-weight: var(--dct-legend-weight, 400);
|
|
15
|
+
white-space: nowrap;
|
|
16
|
+
overflow: hidden;
|
|
17
|
+
text-overflow: ellipsis;
|
|
18
|
+
min-width: 0;
|
|
19
|
+
}
|
|
20
|
+
</style>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { LegendItem } from '../../types/layout/legend';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
item: LegendItem;
|
|
4
|
+
color: string;
|
|
5
|
+
};
|
|
6
|
+
declare const DiscreteSwatchItem: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type DiscreteSwatchItem = ReturnType<typeof DiscreteSwatchItem>;
|
|
8
|
+
export default DiscreteSwatchItem;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { LegendItem } from '../../types/layout/legend';
|
|
3
|
+
import type { LineStyle } from '../../types/plots/constants';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The series' symbol/line-style icon, shared by both discrete item looks
|
|
7
|
+
* (`DiscreteSwatchItem` and `DiscreteBadgeItem`) so the two stay in sync.
|
|
8
|
+
* `color` is caller-supplied rather than read off `item` — the swatch look
|
|
9
|
+
* fills it with the series color, the badge look with its own label color.
|
|
10
|
+
*/
|
|
11
|
+
let { item, color }: { item: LegendItem; color: string } = $props();
|
|
12
|
+
|
|
13
|
+
function dashArray(style?: LineStyle): string | undefined {
|
|
14
|
+
switch (style) {
|
|
15
|
+
case 'dashed':
|
|
16
|
+
return '2,5';
|
|
17
|
+
case 'dotted':
|
|
18
|
+
return '1,4';
|
|
19
|
+
case 'dashdot':
|
|
20
|
+
return '5,3,1,3';
|
|
21
|
+
default:
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<svg class="dct-discrete__swatch" viewBox="0 0 15 10">
|
|
28
|
+
{#if item.symbol === 'circle'}
|
|
29
|
+
<circle cx="5" cy="5" r="4" fill={color} />
|
|
30
|
+
{:else if item.symbol === 'square'}
|
|
31
|
+
<rect x="1" y="1" width="8" height="8" rx="2" fill={color} />
|
|
32
|
+
{:else if item.symbol === 'triangle'}
|
|
33
|
+
<path
|
|
34
|
+
d="M 6 2 L 9 7.5 A 1.5 1.5 0 0 1 7.8 10 L 2.2 10 A 1.5 1.5 0 0 1 1 7.5 L 4 2 A 1.5 1.5 0 0 1 6 2"
|
|
35
|
+
fill={color}
|
|
36
|
+
transform="translate(0,-1)"
|
|
37
|
+
/>
|
|
38
|
+
{:else if item.symbol === 'line'}
|
|
39
|
+
<line
|
|
40
|
+
x1="2"
|
|
41
|
+
y1="5"
|
|
42
|
+
x2="13"
|
|
43
|
+
y2="5"
|
|
44
|
+
stroke={color}
|
|
45
|
+
stroke-width="3"
|
|
46
|
+
stroke-linecap="round"
|
|
47
|
+
stroke-dasharray={dashArray(item.lineStyle)}
|
|
48
|
+
/>
|
|
49
|
+
{:else}
|
|
50
|
+
<rect x="1" y="1" width="8" height="8" fill={color} />
|
|
51
|
+
{/if}
|
|
52
|
+
</svg>
|
|
53
|
+
|
|
54
|
+
<style>
|
|
55
|
+
.dct-discrete__swatch {
|
|
56
|
+
width: var(--dct-legend-swatch, 1.25rem);
|
|
57
|
+
height: var(--dct-legend-swatch, 1.25rem);
|
|
58
|
+
flex-shrink: 0;
|
|
59
|
+
}
|
|
60
|
+
</style>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { LegendItem } from '../../types/layout/legend';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
item: LegendItem;
|
|
4
|
+
color: string;
|
|
5
|
+
};
|
|
6
|
+
declare const DiscreteSymbolIcon: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type DiscreteSymbolIcon = ReturnType<typeof DiscreteSymbolIcon>;
|
|
8
|
+
export default DiscreteSymbolIcon;
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
buildScopedMarkerGroups
|
|
12
12
|
} from '../utils/segments';
|
|
13
13
|
import { createBarLayout } from './layout.svelte';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
createLabelMarginTracker,
|
|
16
|
+
halfDimensionOverflowCap
|
|
17
|
+
} from '../utils/labelOverflow.svelte';
|
|
15
18
|
import { estimateValueLabelMargin } from '../utils/valueLabelMargin';
|
|
16
19
|
import { buildHoverPoints } from './hoverPoints';
|
|
17
20
|
import { buildBarMarkers } from './buildBarMarkers';
|
|
@@ -258,6 +261,9 @@
|
|
|
258
261
|
return { left: 0, right: 0, top: Math.ceil(offset + fontSize * 1.2), bottom: 0 };
|
|
259
262
|
});
|
|
260
263
|
|
|
264
|
+
// See `halfDimensionOverflowCap`'s own doc comment.
|
|
265
|
+
const maxLabelOverflow = $derived(halfDimensionOverflowCap(width, height));
|
|
266
|
+
|
|
261
267
|
// A bar's value label can grow past the plot's own content box (e.g. a
|
|
262
268
|
// tall bar's `anchor: 'outside'` label pushing past the top margin) —
|
|
263
269
|
// `valueLabelFloor` covers the common case up front; each label's own
|
|
@@ -266,7 +272,8 @@
|
|
|
266
272
|
const labelMargin = createLabelMarginTracker(
|
|
267
273
|
() => margins,
|
|
268
274
|
() => resolvedSeries,
|
|
269
|
-
() => valueLabelFloor ?? { left: 0, right: 0, top: 0, bottom: 0 }
|
|
275
|
+
() => valueLabelFloor ?? { left: 0, right: 0, top: 0, bottom: 0 },
|
|
276
|
+
() => maxLabelOverflow
|
|
270
277
|
);
|
|
271
278
|
</script>
|
|
272
279
|
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
import { buildLineMarkers } from './buildLineMarkers';
|
|
9
9
|
import { buildGapMarkers } from './buildGapMarkers';
|
|
10
10
|
import { isGapValue } from '../utils/gaps';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
createLabelMarginTracker,
|
|
13
|
+
halfDimensionOverflowCap
|
|
14
|
+
} from '../utils/labelOverflow.svelte';
|
|
12
15
|
import { estimateLineValueLabelMargin } from './valueLabelMargin';
|
|
13
16
|
import BasePlotLayout from '../../layout/plot/BasePlotLayout.svelte';
|
|
14
17
|
import ValueLabels from './ValueLabels.svelte';
|
|
@@ -167,14 +170,8 @@
|
|
|
167
170
|
.filter((p): p is { series: Series<TData>; lastRow: TData; color: string } => p !== undefined)
|
|
168
171
|
);
|
|
169
172
|
|
|
170
|
-
// Estimated overflow for
|
|
171
|
-
// `estimateLineValueLabelMargin`'s own doc comment
|
|
172
|
-
// the stability rationale (worst-case lane count, domain-pinned text), and
|
|
173
|
-
// its "close but not exact" caveat versus `ValueLabels.svelte`'s real
|
|
174
|
-
// declutter/connector geometry (see `LinePlot.ValueLabelStability`'s own
|
|
175
|
-
// test for what "settled" means here). Shared with a faceted caller's own
|
|
176
|
-
// `FacetConfig.marginsForFacet`, so a single plot and a facet-wide margin
|
|
177
|
-
// are always computed by the exact same code.
|
|
173
|
+
// Estimated overflow for the end-of-line label fan and its own vertical
|
|
174
|
+
// reach — see `estimateLineValueLabelMargin`'s own doc comment.
|
|
178
175
|
const valueLabelFloor = $derived.by((): MarginOverflow | undefined => {
|
|
179
176
|
if (!showVals) return undefined;
|
|
180
177
|
return estimateLineValueLabelMargin(resolvedSeries, {
|
|
@@ -185,17 +182,8 @@
|
|
|
185
182
|
});
|
|
186
183
|
});
|
|
187
184
|
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
// `createLabelMarginTracker`'s own doc comment) — capped at half of each
|
|
191
|
-
// dimension, so the plot's own content box never shrinks past the other
|
|
192
|
-
// half no matter how little room a label has to work with.
|
|
193
|
-
const maxLabelOverflow = $derived({
|
|
194
|
-
left: Math.max(0, width * 0.5),
|
|
195
|
-
right: Math.max(0, width * 0.5),
|
|
196
|
-
top: Math.max(0, height * 0.5),
|
|
197
|
-
bottom: Math.max(0, height * 0.5)
|
|
198
|
-
});
|
|
185
|
+
// See `halfDimensionOverflowCap`'s own doc comment.
|
|
186
|
+
const maxLabelOverflow = $derived(halfDimensionOverflowCap(width, height));
|
|
199
187
|
|
|
200
188
|
// Grows the plot's margin to fit value labels that overflow the content
|
|
201
189
|
// box. `valueLabelFloor` covers the common rightward case up front; each
|
|
@@ -3,20 +3,12 @@ import type { Series } from '../../types/plots/data/common';
|
|
|
3
3
|
import type { FontStyle } from '../../types/plots/styling';
|
|
4
4
|
import type { ValueAnchor } from '../../types/plots/constants';
|
|
5
5
|
/**
|
|
6
|
-
* `LinePlot`'s
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* build a shared `FacetConfig.marginsForFacet` — both get the exact same
|
|
13
|
-
* number for the exact same series, by construction, instead of the two call
|
|
14
|
-
* sites drifting apart with their own separate formulas.
|
|
15
|
-
*
|
|
16
|
-
* Only covers the common case: a rightward-fanning end-of-line label
|
|
17
|
-
* (`anchor: 'start'`, or any anchor whose resolved `dx` isn't negative) —
|
|
18
|
-
* see `LinePlot`'s own `valueLabelFloor` doc comment (unchanged) for why a
|
|
19
|
-
* leftward fan isn't covered here and is left to the DOM-measured fallback.
|
|
6
|
+
* `LinePlot`'s value-label margin estimate: the end-of-line fan's
|
|
7
|
+
* horizontal reach (`left` or `right`, whichever side it fans toward, with
|
|
8
|
+
* the same amount mirrored to the other side for a fan that can flip),
|
|
9
|
+
* plus the label text's own vertical reach on `top`/`bottom`. Stable across
|
|
10
|
+
* a timeline scrubber or dataset change, not DOM-measured. Also used by a
|
|
11
|
+
* faceted caller to build a shared `FacetConfig.marginsForFacet`.
|
|
20
12
|
*/
|
|
21
13
|
export declare function estimateLineValueLabelMargin<TData extends Record<string, unknown>>(series: Series<TData>[], options?: {
|
|
22
14
|
valAnchor?: ValueAnchor;
|
|
@@ -24,4 +16,4 @@ export declare function estimateLineValueLabelMargin<TData extends Record<string
|
|
|
24
16
|
formatValue?: (value: number, name: string) => string;
|
|
25
17
|
/** A pinned y domain (e.g. `scales.y.domain`), covering the whole range a timeline scrubber might reveal — see this function's own stability note above. Falls back to `series`'s own current values when omitted. */
|
|
26
18
|
yDomain?: readonly (number | string | boolean | Date | null)[];
|
|
27
|
-
}): MarginOverflow
|
|
19
|
+
}): MarginOverflow;
|
|
@@ -1,21 +1,13 @@
|
|
|
1
1
|
import { resolveAccessor } from '../utils/accessors';
|
|
2
2
|
import { getConfiguration } from '../../configuration/config.svelte';
|
|
3
|
-
import { estimateValueLabelMargin, laneReachUpperBound } from '../utils/valueLabelMargin';
|
|
3
|
+
import { estimateValueLabelMargin, estimateValueLabelVerticalMargin, laneReachUpperBound } from '../utils/valueLabelMargin';
|
|
4
4
|
/**
|
|
5
|
-
* `LinePlot`'s
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* build a shared `FacetConfig.marginsForFacet` — both get the exact same
|
|
12
|
-
* number for the exact same series, by construction, instead of the two call
|
|
13
|
-
* sites drifting apart with their own separate formulas.
|
|
14
|
-
*
|
|
15
|
-
* Only covers the common case: a rightward-fanning end-of-line label
|
|
16
|
-
* (`anchor: 'start'`, or any anchor whose resolved `dx` isn't negative) —
|
|
17
|
-
* see `LinePlot`'s own `valueLabelFloor` doc comment (unchanged) for why a
|
|
18
|
-
* leftward fan isn't covered here and is left to the DOM-measured fallback.
|
|
5
|
+
* `LinePlot`'s value-label margin estimate: the end-of-line fan's
|
|
6
|
+
* horizontal reach (`left` or `right`, whichever side it fans toward, with
|
|
7
|
+
* the same amount mirrored to the other side for a fan that can flip),
|
|
8
|
+
* plus the label text's own vertical reach on `top`/`bottom`. Stable across
|
|
9
|
+
* a timeline scrubber or dataset change, not DOM-measured. Also used by a
|
|
10
|
+
* faceted caller to build a shared `FacetConfig.marginsForFacet`.
|
|
19
11
|
*/
|
|
20
12
|
export function estimateLineValueLabelMargin(series, options = {}) {
|
|
21
13
|
const cfg = getConfiguration();
|
|
@@ -24,12 +16,11 @@ export function estimateLineValueLabelMargin(series, options = {}) {
|
|
|
24
16
|
const font = options.fontStyle;
|
|
25
17
|
const defaultDx = valAnchor === 'start' ? 6 : valAnchor === 'end' ? -6 : 0;
|
|
26
18
|
const baseDx = typeof font?.dx === 'number' ? font.dx : defaultDx;
|
|
27
|
-
|
|
28
|
-
return undefined;
|
|
19
|
+
const dir = Math.sign(baseDx) || 1;
|
|
29
20
|
const defaultTA = valAnchor === 'start' ? 'start' : valAnchor === 'end' ? 'end' : 'middle';
|
|
30
21
|
const resolvedTextAnchor = font?.textAnchor === 'outside' ? undefined : font?.textAnchor;
|
|
31
22
|
const preferredTA = resolvedTextAnchor ?? defaultTA;
|
|
32
|
-
const
|
|
23
|
+
const outwardFraction = preferredTA === 'start' ? 1 : preferredTA === 'end' ? 0 : 0.5;
|
|
33
24
|
const fontSize = typeof font?.fontSize === 'number' ? font.fontSize : 12;
|
|
34
25
|
const canvasFont = `${fontSize}px ${cfg.font === 'inherit' ? 'sans-serif' : cfg.font}`;
|
|
35
26
|
const seriesCount = series.length;
|
|
@@ -56,6 +47,19 @@ export function estimateLineValueLabelMargin(series, options = {}) {
|
|
|
56
47
|
// +2px: canvas text measurement and svelteplot's own rendered SVG text
|
|
57
48
|
// can disagree by a pixel or so (different rendering engines) — a small
|
|
58
49
|
// buffer that narrows, but doesn't eliminate, the gap noted above.
|
|
59
|
-
const
|
|
60
|
-
|
|
50
|
+
const horizontal = estimateValueLabelMargin(texts, { canvasFont, textFraction: outwardFraction, reach }) + 2;
|
|
51
|
+
const defaultDy = valAnchor === 'outside' ? -8 : 0;
|
|
52
|
+
const defaultLA = valAnchor === 'outside' ? 'bottom' : 'middle';
|
|
53
|
+
const dy = typeof font?.dy === 'number' ? font.dy : defaultDy;
|
|
54
|
+
const { top, bottom } = estimateValueLabelVerticalMargin({
|
|
55
|
+
fontSize,
|
|
56
|
+
lineAnchor: font?.lineAnchor ?? defaultLA,
|
|
57
|
+
dy
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
left: dir === -1 ? horizontal : 0,
|
|
61
|
+
right: dir === 1 ? horizontal : seriesCount > 1 ? horizontal : 0,
|
|
62
|
+
top,
|
|
63
|
+
bottom
|
|
64
|
+
};
|
|
61
65
|
}
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
import { getLegendInteraction } from '../../layout/legend/interaction.svelte';
|
|
7
7
|
import { disabledFillOverride } from '../utils/legendDisabled';
|
|
8
8
|
import { buildPyramidBarMarkers } from './buildPyramidBarMarkers';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
createLabelMarginTracker,
|
|
11
|
+
halfDimensionOverflowCap
|
|
12
|
+
} from '../utils/labelOverflow.svelte';
|
|
10
13
|
import { estimateValueLabelMargin } from '../utils/valueLabelMargin';
|
|
11
14
|
import {
|
|
12
15
|
buildGroupedSeries,
|
|
@@ -301,6 +304,9 @@
|
|
|
301
304
|
return { left, right, top: 0, bottom: 0 };
|
|
302
305
|
});
|
|
303
306
|
|
|
307
|
+
// See `halfDimensionOverflowCap`'s own doc comment.
|
|
308
|
+
const maxLabelOverflow = $derived(halfDimensionOverflowCap(width, height));
|
|
309
|
+
|
|
304
310
|
// A pyramid bar's value label can grow past the plot's own content box —
|
|
305
311
|
// `valueLabelFloor` covers the common case up front; each label's own
|
|
306
312
|
// rendered overflow still feeds `labelMargin.report` for whatever that
|
|
@@ -308,7 +314,8 @@
|
|
|
308
314
|
const labelMargin = createLabelMarginTracker(
|
|
309
315
|
() => margins,
|
|
310
316
|
() => resolvedSeries,
|
|
311
|
-
() => valueLabelFloor ?? { left: 0, right: 0, top: 0, bottom: 0 }
|
|
317
|
+
() => valueLabelFloor ?? { left: 0, right: 0, top: 0, bottom: 0 },
|
|
318
|
+
() => maxLabelOverflow
|
|
312
319
|
);
|
|
313
320
|
</script>
|
|
314
321
|
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
import { disabledDotsOverride } from '../utils/legendDisabled';
|
|
8
8
|
import { buildScatterMarkers } from './buildScatterMarkers';
|
|
9
9
|
import { resolveRadiusPaddedDomain } from './resolveRadiusPaddedDomain';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
createLabelMarginTracker,
|
|
12
|
+
halfDimensionOverflowCap
|
|
13
|
+
} from '../utils/labelOverflow.svelte';
|
|
11
14
|
import BasePlotLayout from '../../layout/plot/BasePlotLayout.svelte';
|
|
12
15
|
import ValueLabels from './ValueLabels.svelte';
|
|
13
16
|
import {
|
|
@@ -214,6 +217,11 @@
|
|
|
214
217
|
};
|
|
215
218
|
});
|
|
216
219
|
|
|
220
|
+
// See `halfDimensionOverflowCap`'s own doc comment — relevant here mainly
|
|
221
|
+
// for a corner point with no room in any direction even after
|
|
222
|
+
// `resolveEdgeAwareAnchor`'s own flip.
|
|
223
|
+
const maxLabelOverflow = $derived(halfDimensionOverflowCap(width, height));
|
|
224
|
+
|
|
217
225
|
// A scatter point's value label can grow past the plot's own content box
|
|
218
226
|
// (e.g. a point volcado toward a corner, past what its own edge-aware flip
|
|
219
227
|
// can fit) — reserving real space there is what each label's own overflow
|
|
@@ -223,7 +231,9 @@
|
|
|
223
231
|
// that frame is gone.
|
|
224
232
|
const labelMargin = createLabelMarginTracker(
|
|
225
233
|
() => margins,
|
|
226
|
-
() => resolvedSeries
|
|
234
|
+
() => resolvedSeries,
|
|
235
|
+
undefined,
|
|
236
|
+
() => maxLabelOverflow
|
|
227
237
|
);
|
|
228
238
|
</script>
|
|
229
239
|
|
|
@@ -6,6 +6,16 @@ export type MarginOverflow = {
|
|
|
6
6
|
top: number;
|
|
7
7
|
bottom: number;
|
|
8
8
|
};
|
|
9
|
+
/**
|
|
10
|
+
* A generic `maxOverflow` cap for {@link createLabelMarginTracker}: half of
|
|
11
|
+
* each dimension, so a facet cramped enough that its own value label can
|
|
12
|
+
* never fully fit still leaves the plot's own content box the other half,
|
|
13
|
+
* rather than `measured` growing without bound (see that function's own
|
|
14
|
+
* `maxOverflow` doc comment). The same backstop applies regardless of *what*
|
|
15
|
+
* overflows or *why* — every value-label-drawing plot (line/bar/pyramid/
|
|
16
|
+
* scatter) passes this, sized off its own `width`/`height`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function halfDimensionOverflowCap(width: number, height: number): MarginOverflow;
|
|
9
19
|
/**
|
|
10
20
|
* Watches an SVG group's rendered bounding box against the plot's own
|
|
11
21
|
* content box (`bounds`) and reports how far it overflows on each side.
|
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { AUTO_MARGIN_ESTIMATE } from '../../layout/plot/margins';
|
|
2
2
|
const NO_OVERFLOW = { left: 0, right: 0, top: 0, bottom: 0 };
|
|
3
|
+
/**
|
|
4
|
+
* A generic `maxOverflow` cap for {@link createLabelMarginTracker}: half of
|
|
5
|
+
* each dimension, so a facet cramped enough that its own value label can
|
|
6
|
+
* never fully fit still leaves the plot's own content box the other half,
|
|
7
|
+
* rather than `measured` growing without bound (see that function's own
|
|
8
|
+
* `maxOverflow` doc comment). The same backstop applies regardless of *what*
|
|
9
|
+
* overflows or *why* — every value-label-drawing plot (line/bar/pyramid/
|
|
10
|
+
* scatter) passes this, sized off its own `width`/`height`.
|
|
11
|
+
*/
|
|
12
|
+
export function halfDimensionOverflowCap(width, height) {
|
|
13
|
+
return {
|
|
14
|
+
left: Math.max(0, width * 0.5),
|
|
15
|
+
right: Math.max(0, width * 0.5),
|
|
16
|
+
top: Math.max(0, height * 0.5),
|
|
17
|
+
bottom: Math.max(0, height * 0.5)
|
|
18
|
+
};
|
|
19
|
+
}
|
|
3
20
|
/**
|
|
4
21
|
* Watches an SVG group's rendered bounding box against the plot's own
|
|
5
22
|
* content box (`bounds`) and reports how far it overflows on each side.
|
|
@@ -145,18 +162,23 @@ maxOverflow) {
|
|
|
145
162
|
measured = floorValue();
|
|
146
163
|
}
|
|
147
164
|
});
|
|
165
|
+
// Pure `Math.max` — no `maxOverflow` cap here. `measured` is a *tracked*
|
|
166
|
+
// value, not the applied margin, and must stay strictly monotonic
|
|
167
|
+
// regardless of anything else changing (see this function's own doc
|
|
168
|
+
// comment on why shrinking it back oscillates). `maxOverflow` is
|
|
169
|
+
// typically sized off the plot's own live `width`/`height` (see
|
|
170
|
+
// `halfDimensionOverflowCap`), which moves continuously during a resize
|
|
171
|
+
// — capping *here* would let a shrinking cap force `measured` down mid-
|
|
172
|
+
// resize, immediately re-exposing the overflow that grew it in the first
|
|
173
|
+
// place and re-triggering growth, over and over, fast enough to trip
|
|
174
|
+
// Svelte's `effect_update_depth_exceeded` guard. The cap is applied only
|
|
175
|
+
// where it can't feed back into `measured` itself — see `resolvedMargins`.
|
|
148
176
|
function report(overflow) {
|
|
149
|
-
const cap = maxOverflow?.() ?? {
|
|
150
|
-
left: Infinity,
|
|
151
|
-
right: Infinity,
|
|
152
|
-
top: Infinity,
|
|
153
|
-
bottom: Infinity
|
|
154
|
-
};
|
|
155
177
|
const next = {
|
|
156
|
-
left: Math.
|
|
157
|
-
right: Math.
|
|
158
|
-
top: Math.
|
|
159
|
-
bottom: Math.
|
|
178
|
+
left: Math.max(measured.left, overflow.left),
|
|
179
|
+
right: Math.max(measured.right, overflow.right),
|
|
180
|
+
top: Math.max(measured.top, overflow.top),
|
|
181
|
+
bottom: Math.max(measured.bottom, overflow.bottom)
|
|
160
182
|
};
|
|
161
183
|
if (next.left !== measured.left ||
|
|
162
184
|
next.right !== measured.right ||
|
|
@@ -173,33 +195,55 @@ maxOverflow) {
|
|
|
173
195
|
// number that only accounts for the value label. `AUTO_MARGIN_ESTIMATE` is
|
|
174
196
|
// a fixed constant, not `numericMargins`' own already-grown value, so this
|
|
175
197
|
// can't compound into unbounded growth across repeated reports.
|
|
198
|
+
//
|
|
199
|
+
// `maxOverflow`, if given, clamps `measured` down to a live cap *here*,
|
|
200
|
+
// read-only — every render re-reads the current cap and applies it fresh
|
|
201
|
+
// to the unclamped, monotonically-tracked `measured`, without ever
|
|
202
|
+
// writing the clamped value back into `measured` itself (see `report`'s
|
|
203
|
+
// own doc comment for why that distinction matters).
|
|
176
204
|
const resolvedMargins = $derived.by(() => {
|
|
177
205
|
const base = margins();
|
|
178
|
-
|
|
206
|
+
const cap = maxOverflow?.() ?? {
|
|
207
|
+
left: Infinity,
|
|
208
|
+
right: Infinity,
|
|
209
|
+
top: Infinity,
|
|
210
|
+
bottom: Infinity
|
|
211
|
+
};
|
|
212
|
+
const clamped = {
|
|
213
|
+
left: Math.min(measured.left, cap.left),
|
|
214
|
+
right: Math.min(measured.right, cap.right),
|
|
215
|
+
top: Math.min(measured.top, cap.top),
|
|
216
|
+
bottom: Math.min(measured.bottom, cap.bottom)
|
|
217
|
+
};
|
|
218
|
+
if (clamped.left <= 0 && clamped.right <= 0 && clamped.top <= 0 && clamped.bottom <= 0)
|
|
179
219
|
return base;
|
|
180
220
|
const out = { ...base };
|
|
181
|
-
if (
|
|
182
|
-
out.right = AUTO_MARGIN_ESTIMATE.right +
|
|
183
|
-
if (
|
|
184
|
-
out.left = AUTO_MARGIN_ESTIMATE.left +
|
|
185
|
-
if (
|
|
186
|
-
out.top = AUTO_MARGIN_ESTIMATE.top +
|
|
187
|
-
if (
|
|
188
|
-
out.bottom = AUTO_MARGIN_ESTIMATE.bottom +
|
|
221
|
+
if (clamped.right > 0 && base?.right === undefined)
|
|
222
|
+
out.right = AUTO_MARGIN_ESTIMATE.right + clamped.right;
|
|
223
|
+
if (clamped.left > 0 && base?.left === undefined)
|
|
224
|
+
out.left = AUTO_MARGIN_ESTIMATE.left + clamped.left;
|
|
225
|
+
if (clamped.top > 0 && base?.top === undefined)
|
|
226
|
+
out.top = AUTO_MARGIN_ESTIMATE.top + clamped.top;
|
|
227
|
+
if (clamped.bottom > 0 && base?.bottom === undefined)
|
|
228
|
+
out.bottom = AUTO_MARGIN_ESTIMATE.bottom + clamped.bottom;
|
|
189
229
|
return out;
|
|
190
230
|
});
|
|
191
231
|
// Bundles `resolvedMargins` with a remount key, ready to spread straight
|
|
192
232
|
// onto `<BasePlotLayout>` as its `margins`/`marginRemountKey` props (see
|
|
193
233
|
// that prop's doc comment for *why* a remount key is needed here at all)
|
|
194
|
-
// — keeps the caller from re-deriving the same key by hand.
|
|
195
|
-
// `
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
// `
|
|
234
|
+
// — keeps the caller from re-deriving the same key by hand. Keyed off
|
|
235
|
+
// `resolvedMargins` itself (the actually-applied value) rather than raw
|
|
236
|
+
// `measured` — with a `maxOverflow` cap in play, `measured` can keep
|
|
237
|
+
// climbing past the point where it's clamped without the applied margin
|
|
238
|
+
// changing at all, and remounting for that would be pure waste. Also
|
|
239
|
+
// tracks the caller's own `margins()` — a caller that changes its
|
|
240
|
+
// explicit margins prop after mount (e.g. locking in a previously-
|
|
241
|
+
// measured value) needs that picked up just as cleanly as internal
|
|
242
|
+
// growth does; `resolvedMargins` alone already reflects that too, but
|
|
243
|
+
// spelling it out keeps the key's intent obvious.
|
|
200
244
|
const plotProps = $derived.by(() => ({
|
|
201
245
|
margins: resolvedMargins,
|
|
202
|
-
marginRemountKey: JSON.stringify({ base: margins(),
|
|
246
|
+
marginRemountKey: JSON.stringify({ base: margins(), resolvedMargins })
|
|
203
247
|
}));
|
|
204
248
|
return {
|
|
205
249
|
report,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { LineAnchor } from '../../types/plots/constants';
|
|
1
2
|
/**
|
|
2
3
|
* The one calculation every value-label-drawing plot (`LinePlot`, `BarPlot`,
|
|
3
4
|
* `PyramidPlot`, …) needs to reserve margin for its own value labels ahead of
|
|
@@ -35,6 +36,18 @@ export declare function estimateValueLabelMargin(texts: string[], options: {
|
|
|
35
36
|
*/
|
|
36
37
|
reach?: number;
|
|
37
38
|
}): number;
|
|
39
|
+
/** Estimated full rendered height of a single-line value label at `fontSize`. */
|
|
40
|
+
export declare function estimateLabelHeight(fontSize: number): number;
|
|
41
|
+
/** Vertical analogue of `estimateValueLabelMargin`: how far a value label's text extends above/below its anchor point. */
|
|
42
|
+
export declare function estimateValueLabelVerticalMargin(options: {
|
|
43
|
+
fontSize: number;
|
|
44
|
+
lineAnchor: LineAnchor;
|
|
45
|
+
/** Pixel offset from the anchor point to the label's line-anchor edge, same sign as SVG's `dy`. */
|
|
46
|
+
dy?: number;
|
|
47
|
+
}): {
|
|
48
|
+
top: number;
|
|
49
|
+
bottom: number;
|
|
50
|
+
};
|
|
38
51
|
/**
|
|
39
52
|
* Upper bound on how far `LinePlot`'s end-of-line labels can ever fan out
|
|
40
53
|
* into lanes (see `plots/utils/declutter.ts`'s `laneAssignment`): a run of
|
|
@@ -21,6 +21,26 @@ export function estimateValueLabelMargin(texts, options) {
|
|
|
21
21
|
const reach = options.reach ?? 0;
|
|
22
22
|
return Math.ceil(reach + textFraction * maxWidth);
|
|
23
23
|
}
|
|
24
|
+
/** Estimated full rendered height of a single-line value label at `fontSize`. */
|
|
25
|
+
export function estimateLabelHeight(fontSize) {
|
|
26
|
+
return fontSize * 1.2;
|
|
27
|
+
}
|
|
28
|
+
/** Vertical analogue of `estimateValueLabelMargin`: how far a value label's text extends above/below its anchor point. */
|
|
29
|
+
export function estimateValueLabelVerticalMargin(options) {
|
|
30
|
+
const height = estimateLabelHeight(options.fontSize);
|
|
31
|
+
const dy = options.dy ?? 0;
|
|
32
|
+
if (options.lineAnchor === 'top') {
|
|
33
|
+
return { top: 0, bottom: Math.ceil(Math.max(0, dy) + height) + 2 };
|
|
34
|
+
}
|
|
35
|
+
if (options.lineAnchor === 'bottom') {
|
|
36
|
+
return { top: Math.ceil(Math.max(0, -dy) + height) + 2, bottom: 0 };
|
|
37
|
+
}
|
|
38
|
+
const half = height / 2;
|
|
39
|
+
return {
|
|
40
|
+
top: Math.ceil(Math.max(0, half - dy)) + 2,
|
|
41
|
+
bottom: Math.ceil(Math.max(0, half + dy)) + 2
|
|
42
|
+
};
|
|
43
|
+
}
|
|
24
44
|
/**
|
|
25
45
|
* Upper bound on how far `LinePlot`'s end-of-line labels can ever fan out
|
|
26
46
|
* into lanes (see `plots/utils/declutter.ts`'s `laneAssignment`): a run of
|
|
@@ -60,6 +60,26 @@ export type LegendHandleContext = {
|
|
|
60
60
|
};
|
|
61
61
|
/** Custom drag-handle renderer for an interactive continuous section. */
|
|
62
62
|
export type LegendHandleSnippet = Snippet<[LegendHandleContext]>;
|
|
63
|
+
/**
|
|
64
|
+
* Item look for an interactive discrete section. `'badge'` (the default once
|
|
65
|
+
* `interactive.enabled` is on) draws each item as a pill filled with its
|
|
66
|
+
* series color, with the symbol icon and label auto-contrasted against that
|
|
67
|
+
* fill and an eye icon on the right — crossed out once the item is toggled
|
|
68
|
+
* off. `'basic'` keeps the plain swatch + label, same as a non-interactive
|
|
69
|
+
* section.
|
|
70
|
+
*/
|
|
71
|
+
export type LegendInteractiveStyle = 'badge' | 'basic';
|
|
72
|
+
/** Click-to-toggle configuration for a discrete section: whether it's on, how toggled items look, and the style applied to a toggled-off one. */
|
|
73
|
+
export type DiscreteInteractiveConfig = {
|
|
74
|
+
/** Turns click-to-toggle on for every item in the section. */
|
|
75
|
+
enabled?: boolean;
|
|
76
|
+
/** Item look while `enabled`. Defaults to `'badge'`. */
|
|
77
|
+
legendStyle?: LegendInteractiveStyle;
|
|
78
|
+
/** Style applied to a toggled-off item. Defaults to `{ opacity: cfg.legend.disabledOpacity }`. */
|
|
79
|
+
disabledStyle?: LegendDisabledStyle;
|
|
80
|
+
/** Shows the symbol icon inside a `'badge'` pill. Defaults to `true`; ignored by `'basic'`, which always shows it. */
|
|
81
|
+
showIcon?: boolean;
|
|
82
|
+
};
|
|
63
83
|
export type DiscreteLegendSection = {
|
|
64
84
|
type: 'discrete';
|
|
65
85
|
title?: string;
|
|
@@ -67,10 +87,24 @@ export type DiscreteLegendSection = {
|
|
|
67
87
|
columns?: number;
|
|
68
88
|
/** Item flow direction. Defaults to `{ type: 'vertical' }` (unchanged prior behaviour). */
|
|
69
89
|
layout?: LegendItemsLayout;
|
|
70
|
-
interactive?:
|
|
71
|
-
disabledStyle?: LegendDisabledStyle;
|
|
90
|
+
interactive?: DiscreteInteractiveConfig;
|
|
72
91
|
navButton?: LegendNavSnippet;
|
|
73
92
|
};
|
|
93
|
+
/** Drag-to-narrow configuration for a continuous section: whether it's on, how the range behaves, and its look. */
|
|
94
|
+
export type ContinuousInteractiveConfig = {
|
|
95
|
+
/** Turns drag-to-narrow on, adding lo/hi handles at the edges of the excluded range. */
|
|
96
|
+
enabled?: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* How the color ramp responds to a narrowed range: `'rescale'` reprojects
|
|
99
|
+
* it onto the new bounds, `'filter'` keeps the original domain and only
|
|
100
|
+
* excludes out-of-range values. Defaults to `'filter'`.
|
|
101
|
+
*/
|
|
102
|
+
mode?: 'rescale' | 'filter';
|
|
103
|
+
/** Style applied to the excluded (narrowed-out) range. Defaults to `{ opacity: cfg.legend.disabledOpacity }`. */
|
|
104
|
+
disabledStyle?: LegendDisabledStyle;
|
|
105
|
+
/** Custom renderer for the draggable lo/hi handles. */
|
|
106
|
+
handle?: LegendHandleSnippet;
|
|
107
|
+
};
|
|
74
108
|
export type ContinuousLegendSection = {
|
|
75
109
|
type: 'continuous';
|
|
76
110
|
title?: string;
|
|
@@ -82,16 +116,7 @@ export type ContinuousLegendSection = {
|
|
|
82
116
|
ticks?: number[];
|
|
83
117
|
width?: number;
|
|
84
118
|
format?: (v: number) => string;
|
|
85
|
-
interactive?:
|
|
86
|
-
/**
|
|
87
|
-
* How the color ramp responds to a narrowed range: `'rescale'` reprojects
|
|
88
|
-
* it onto the new bounds, `'filter'` keeps the original domain and only
|
|
89
|
-
* excludes out-of-range values. Defaults to `'filter'`.
|
|
90
|
-
*/
|
|
91
|
-
mode?: 'rescale' | 'filter';
|
|
92
|
-
disabledStyle?: LegendDisabledStyle;
|
|
93
|
-
/** Custom renderer for the draggable lo/hi handles. */
|
|
94
|
-
handle?: LegendHandleSnippet;
|
|
119
|
+
interactive?: ContinuousInteractiveConfig;
|
|
95
120
|
};
|
|
96
121
|
export type LegendSection = DiscreteLegendSection | ContinuousLegendSection;
|
|
97
122
|
/**
|
package/dist/utils/color.d.ts
CHANGED
|
@@ -2,4 +2,13 @@ export declare function resolveCssColor(color: string): string;
|
|
|
2
2
|
export declare function normalize(v: number, min: number, max: number): number;
|
|
3
3
|
/** Cycles through a discrete palette by series index — the default colour for an unstyled series. */
|
|
4
4
|
export declare function paletteColor(index: number, palette: string[]): string;
|
|
5
|
+
/** WCAG contrast ratio between two colors, from 1 (no contrast) to 21 (black on white). */
|
|
6
|
+
export declare function contrastRatio(colorA: string, colorB: string): number;
|
|
7
|
+
/**
|
|
8
|
+
* The `candidates` entry with the highest WCAG contrast ratio against
|
|
9
|
+
* `background`. Defaults to plain white/black; pass a grayscale or other
|
|
10
|
+
* palette to widen the choice. Falls back to the first candidate when
|
|
11
|
+
* `background` can't be parsed.
|
|
12
|
+
*/
|
|
13
|
+
export declare function contrastTextColor(background: string, candidates?: string[]): string;
|
|
5
14
|
export declare function makeColorScale(min: number, max: number, colorMin: string, colorMax: string): (v: number) => string;
|
package/dist/utils/color.js
CHANGED
|
@@ -23,6 +23,68 @@ export function paletteColor(index, palette) {
|
|
|
23
23
|
return 'currentColor';
|
|
24
24
|
return palette[index % palette.length];
|
|
25
25
|
}
|
|
26
|
+
function parseRgb(color) {
|
|
27
|
+
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
|
|
28
|
+
if (hex) {
|
|
29
|
+
const digits = hex[1].length === 3 ? hex[1].split('').map((c) => c + c).join('') : hex[1];
|
|
30
|
+
const num = parseInt(digits, 16);
|
|
31
|
+
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
|
|
32
|
+
}
|
|
33
|
+
const rgb = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(color);
|
|
34
|
+
if (rgb)
|
|
35
|
+
return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
|
|
36
|
+
// Named colors, hsl(), and any other CSS color syntax: resolved to
|
|
37
|
+
// `rgb(...)` via the browser's computed style, then reparsed.
|
|
38
|
+
if (!isBrowser)
|
|
39
|
+
return null;
|
|
40
|
+
const el = document.createElement('div');
|
|
41
|
+
el.style.color = color;
|
|
42
|
+
document.body.appendChild(el);
|
|
43
|
+
const computed = getComputedStyle(el).color;
|
|
44
|
+
document.body.removeChild(el);
|
|
45
|
+
if (!computed || computed === color)
|
|
46
|
+
return null;
|
|
47
|
+
return parseRgb(computed);
|
|
48
|
+
}
|
|
49
|
+
function relativeLuminance([r, g, b]) {
|
|
50
|
+
const [rs, gs, bs] = [r, g, b].map((c) => {
|
|
51
|
+
const s = c / 255;
|
|
52
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
53
|
+
});
|
|
54
|
+
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
|
55
|
+
}
|
|
56
|
+
/** WCAG contrast ratio between two colors, from 1 (no contrast) to 21 (black on white). */
|
|
57
|
+
export function contrastRatio(colorA, colorB) {
|
|
58
|
+
const a = parseRgb(resolveCssColor(colorA));
|
|
59
|
+
const b = parseRgb(resolveCssColor(colorB));
|
|
60
|
+
if (!a || !b)
|
|
61
|
+
return 1;
|
|
62
|
+
const lA = relativeLuminance(a);
|
|
63
|
+
const lB = relativeLuminance(b);
|
|
64
|
+
const lighter = Math.max(lA, lB);
|
|
65
|
+
const darker = Math.min(lA, lB);
|
|
66
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The `candidates` entry with the highest WCAG contrast ratio against
|
|
70
|
+
* `background`. Defaults to plain white/black; pass a grayscale or other
|
|
71
|
+
* palette to widen the choice. Falls back to the first candidate when
|
|
72
|
+
* `background` can't be parsed.
|
|
73
|
+
*/
|
|
74
|
+
export function contrastTextColor(background, candidates = ['#ffffff', '#000000']) {
|
|
75
|
+
if (candidates.length === 0)
|
|
76
|
+
return '#000000';
|
|
77
|
+
let best = candidates[0];
|
|
78
|
+
let bestRatio = -Infinity;
|
|
79
|
+
for (const candidate of candidates) {
|
|
80
|
+
const ratio = contrastRatio(background, candidate);
|
|
81
|
+
if (ratio > bestRatio) {
|
|
82
|
+
bestRatio = ratio;
|
|
83
|
+
best = candidate;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return best;
|
|
87
|
+
}
|
|
26
88
|
export function makeColorScale(min, max, colorMin, colorMax) {
|
|
27
89
|
const resolvedMin = resolveCssColor(colorMin);
|
|
28
90
|
const resolvedMax = resolveCssColor(colorMax);
|