@fundar/data-chart-telling 0.0.41 → 0.0.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/configuration/config.svelte.js +29 -0
- package/dist/configuration/themes/index.d.ts +72 -0
- package/dist/layout/facet/FacetLayout.svelte +23 -1
- package/dist/layout/facet/FacetLayout.svelte.d.ts +1 -0
- package/dist/layout/legend/ContinuousSection.svelte +95 -44
- package/dist/layout/plot/measureText.d.ts +4 -0
- package/dist/layout/plot/measureText.js +19 -0
- package/dist/plots/bar/Plot.svelte +42 -7
- package/dist/plots/line/Plot.svelte +97 -6
- package/dist/plots/line/buildGapMarkers.d.ts +8 -1
- package/dist/plots/line/buildGapMarkers.js +22 -16
- package/dist/plots/pyramid/Plot.svelte +43 -6
- package/dist/plots/scatter/ValueLabels.svelte +6 -3
- package/dist/plots/utils/labelOverflow.svelte.d.ts +11 -1
- package/dist/plots/utils/labelOverflow.svelte.js +16 -5
- package/dist/types/configuration/styling.d.ts +22 -0
- package/dist/types/layout/facet.d.ts +5 -0
- package/dist/types/layout/legend.d.ts +14 -0
- package/dist/utils/edgeAwareAnchor.d.ts +5 -8
- package/dist/utils/edgeAwareAnchor.js +5 -10
- package/package.json +1 -1
|
@@ -57,6 +57,25 @@ export const DEFAULT_CONFIG = {
|
|
|
57
57
|
size: '1.5rem',
|
|
58
58
|
fontSize: '1rem',
|
|
59
59
|
disabledOpacity: 0.3
|
|
60
|
+
},
|
|
61
|
+
continuous: {
|
|
62
|
+
// A slim vertical bar handle, taller than the 12px gradient track.
|
|
63
|
+
handle: {
|
|
64
|
+
width: '6px',
|
|
65
|
+
height: '20px',
|
|
66
|
+
radius: '3px',
|
|
67
|
+
fill: 'currentColor',
|
|
68
|
+
stroke: 'var(--dct-bg, #fff)',
|
|
69
|
+
strokeWidth: '1.5px'
|
|
70
|
+
},
|
|
71
|
+
tick: {
|
|
72
|
+
color: 'currentColor',
|
|
73
|
+
width: '1px',
|
|
74
|
+
length: '6px'
|
|
75
|
+
},
|
|
76
|
+
label: {
|
|
77
|
+
rotate: '0deg'
|
|
78
|
+
}
|
|
60
79
|
}
|
|
61
80
|
},
|
|
62
81
|
axis: {
|
|
@@ -188,6 +207,16 @@ export function configToCssVars(c) {
|
|
|
188
207
|
'--dct-legend-nav-size': c.legend.navButton.size,
|
|
189
208
|
'--dct-legend-nav-font-size': c.legend.navButton.fontSize,
|
|
190
209
|
'--dct-legend-nav-disabled-opacity': String(c.legend.navButton.disabledOpacity),
|
|
210
|
+
'--dct-continuous-handle-width': c.legend.continuous.handle.width,
|
|
211
|
+
'--dct-continuous-handle-height': c.legend.continuous.handle.height,
|
|
212
|
+
'--dct-continuous-handle-radius': c.legend.continuous.handle.radius,
|
|
213
|
+
'--dct-continuous-handle-fill': c.legend.continuous.handle.fill,
|
|
214
|
+
'--dct-continuous-handle-stroke': c.legend.continuous.handle.stroke,
|
|
215
|
+
'--dct-continuous-handle-stroke-width': c.legend.continuous.handle.strokeWidth,
|
|
216
|
+
'--dct-continuous-tick-color': c.legend.continuous.tick.color,
|
|
217
|
+
'--dct-continuous-tick-width': c.legend.continuous.tick.width,
|
|
218
|
+
'--dct-continuous-tick-length': c.legend.continuous.tick.length,
|
|
219
|
+
'--dct-continuous-label-rotate': c.legend.continuous.label.rotate,
|
|
191
220
|
'--dct-axis-color': c.axis.color,
|
|
192
221
|
'--dct-tooltip-bg': c.tooltip.background,
|
|
193
222
|
'--dct-tooltip-color': c.tooltip.color,
|
|
@@ -56,6 +56,24 @@ export declare const THEMES: {
|
|
|
56
56
|
fontSize?: string | undefined;
|
|
57
57
|
disabledOpacity?: number | undefined;
|
|
58
58
|
} | undefined;
|
|
59
|
+
continuous?: {
|
|
60
|
+
handle?: {
|
|
61
|
+
width?: string | undefined;
|
|
62
|
+
height?: string | undefined;
|
|
63
|
+
radius?: string | undefined;
|
|
64
|
+
fill?: string | undefined;
|
|
65
|
+
stroke?: string | undefined;
|
|
66
|
+
strokeWidth?: string | undefined;
|
|
67
|
+
} | undefined;
|
|
68
|
+
tick?: {
|
|
69
|
+
color?: string | undefined;
|
|
70
|
+
width?: string | undefined;
|
|
71
|
+
length?: string | undefined;
|
|
72
|
+
} | undefined;
|
|
73
|
+
label?: {
|
|
74
|
+
rotate?: string | undefined;
|
|
75
|
+
} | undefined;
|
|
76
|
+
} | undefined;
|
|
59
77
|
} | undefined;
|
|
60
78
|
axis?: {
|
|
61
79
|
color?: string | undefined;
|
|
@@ -263,6 +281,24 @@ export declare const THEMES: {
|
|
|
263
281
|
fontSize?: string | undefined;
|
|
264
282
|
disabledOpacity?: number | undefined;
|
|
265
283
|
} | undefined;
|
|
284
|
+
continuous?: {
|
|
285
|
+
handle?: {
|
|
286
|
+
width?: string | undefined;
|
|
287
|
+
height?: string | undefined;
|
|
288
|
+
radius?: string | undefined;
|
|
289
|
+
fill?: string | undefined;
|
|
290
|
+
stroke?: string | undefined;
|
|
291
|
+
strokeWidth?: string | undefined;
|
|
292
|
+
} | undefined;
|
|
293
|
+
tick?: {
|
|
294
|
+
color?: string | undefined;
|
|
295
|
+
width?: string | undefined;
|
|
296
|
+
length?: string | undefined;
|
|
297
|
+
} | undefined;
|
|
298
|
+
label?: {
|
|
299
|
+
rotate?: string | undefined;
|
|
300
|
+
} | undefined;
|
|
301
|
+
} | undefined;
|
|
266
302
|
} | undefined;
|
|
267
303
|
axis?: {
|
|
268
304
|
color?: string | undefined;
|
|
@@ -470,6 +506,24 @@ export declare const THEMES: {
|
|
|
470
506
|
fontSize?: string | undefined;
|
|
471
507
|
disabledOpacity?: number | undefined;
|
|
472
508
|
} | undefined;
|
|
509
|
+
continuous?: {
|
|
510
|
+
handle?: {
|
|
511
|
+
width?: string | undefined;
|
|
512
|
+
height?: string | undefined;
|
|
513
|
+
radius?: string | undefined;
|
|
514
|
+
fill?: string | undefined;
|
|
515
|
+
stroke?: string | undefined;
|
|
516
|
+
strokeWidth?: string | undefined;
|
|
517
|
+
} | undefined;
|
|
518
|
+
tick?: {
|
|
519
|
+
color?: string | undefined;
|
|
520
|
+
width?: string | undefined;
|
|
521
|
+
length?: string | undefined;
|
|
522
|
+
} | undefined;
|
|
523
|
+
label?: {
|
|
524
|
+
rotate?: string | undefined;
|
|
525
|
+
} | undefined;
|
|
526
|
+
} | undefined;
|
|
473
527
|
} | undefined;
|
|
474
528
|
axis?: {
|
|
475
529
|
color?: string | undefined;
|
|
@@ -677,6 +731,24 @@ export declare const THEMES: {
|
|
|
677
731
|
fontSize?: string | undefined;
|
|
678
732
|
disabledOpacity?: number | undefined;
|
|
679
733
|
} | undefined;
|
|
734
|
+
continuous?: {
|
|
735
|
+
handle?: {
|
|
736
|
+
width?: string | undefined;
|
|
737
|
+
height?: string | undefined;
|
|
738
|
+
radius?: string | undefined;
|
|
739
|
+
fill?: string | undefined;
|
|
740
|
+
stroke?: string | undefined;
|
|
741
|
+
strokeWidth?: string | undefined;
|
|
742
|
+
} | undefined;
|
|
743
|
+
tick?: {
|
|
744
|
+
color?: string | undefined;
|
|
745
|
+
width?: string | undefined;
|
|
746
|
+
length?: string | undefined;
|
|
747
|
+
} | undefined;
|
|
748
|
+
label?: {
|
|
749
|
+
rotate?: string | undefined;
|
|
750
|
+
} | undefined;
|
|
751
|
+
} | undefined;
|
|
680
752
|
} | undefined;
|
|
681
753
|
axis?: {
|
|
682
754
|
color?: string | undefined;
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { isMobile } from '../../stores/device';
|
|
3
3
|
import { groupBy } from '../../utils/grouping';
|
|
4
4
|
import FacetIndicator from './FacetIndicator.svelte';
|
|
5
|
+
import { getConfiguration } from '../../configuration/config.svelte';
|
|
6
|
+
import { measureMaxLabelWidth } from '../plot/measureText';
|
|
5
7
|
import type {
|
|
6
8
|
FacetColumns,
|
|
7
9
|
FacetMode,
|
|
@@ -13,6 +15,7 @@
|
|
|
13
15
|
FacetNavDirection,
|
|
14
16
|
} from '../../types/layout/facet';
|
|
15
17
|
import type { Accessor } from '../../types/plots/data/common';
|
|
18
|
+
import type { MarginConfig } from '../../types/plots/props';
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* Small-multiples layout. It splits the data by `by` and hands each group to
|
|
@@ -23,6 +26,10 @@
|
|
|
23
26
|
* `mode` picks how the groups are arranged: `'grid'` (default) lays them all
|
|
24
27
|
* out at once; `'paginated'` pages through the same grid in fixed chunks;
|
|
25
28
|
* `'carousel'` shows `visible` groups at a time with touch-drag/snap.
|
|
29
|
+
*
|
|
30
|
+
* `labelsForMargin`, if given, sizes one shared left margin across every
|
|
31
|
+
* facet — pass it through `cell`'s own `margins` to a faceted plot's
|
|
32
|
+
* `margins` prop so they all share the same content box.
|
|
26
33
|
*/
|
|
27
34
|
let {
|
|
28
35
|
data,
|
|
@@ -40,6 +47,7 @@
|
|
|
40
47
|
width,
|
|
41
48
|
height,
|
|
42
49
|
cell,
|
|
50
|
+
labelsForMargin,
|
|
43
51
|
}: {
|
|
44
52
|
data: TRow[];
|
|
45
53
|
by: Accessor<TRow, unknown>;
|
|
@@ -56,6 +64,7 @@
|
|
|
56
64
|
width: number;
|
|
57
65
|
height: number;
|
|
58
66
|
cell: FacetCellSnippet<TRow>;
|
|
67
|
+
labelsForMargin?: (rows: TRow[]) => string[];
|
|
59
68
|
} = $props();
|
|
60
69
|
|
|
61
70
|
function resolveResponsive<T>(value: Responsive<T>, mobile: boolean): T {
|
|
@@ -74,6 +83,18 @@
|
|
|
74
83
|
.map((v) => [v, groups.get(v)!] as [string, TRow[]]);
|
|
75
84
|
});
|
|
76
85
|
|
|
86
|
+
// One shared left margin, sized to the widest label across every facet —
|
|
87
|
+
// measured off-DOM (canvas), so it's correct from the very first render
|
|
88
|
+
// with no mount/remount needed. `undefined` when `labelsForMargin` isn't given.
|
|
89
|
+
const cfg = $derived(getConfiguration());
|
|
90
|
+
const axisFont = $derived(`${cfg.axis.fontSize}px ${cfg.font === 'inherit' ? 'sans-serif' : cfg.font}`);
|
|
91
|
+
const cellMargins = $derived.by((): MarginConfig | undefined => {
|
|
92
|
+
if (!labelsForMargin) return undefined;
|
|
93
|
+
const labels = entries.flatMap(([, rows]) => labelsForMargin(rows));
|
|
94
|
+
const left = Math.ceil(measureMaxLabelWidth(labels, axisFont)) + cfg.axis.yTickSize + cfg.axis.yTickPadding;
|
|
95
|
+
return { left };
|
|
96
|
+
});
|
|
97
|
+
|
|
77
98
|
const resolvedMode = $derived(resolveResponsive(mode, $isMobile));
|
|
78
99
|
|
|
79
100
|
// Columns/rows for a "full" grid of every entry — used for plain grid-mode
|
|
@@ -220,7 +241,7 @@
|
|
|
220
241
|
<div class="dct-facet__cell">
|
|
221
242
|
<div class="dct-facet__title">{facetValue}</div>
|
|
222
243
|
{#if cellWidth > 0 && cellHeight > 0}
|
|
223
|
-
{@render cell({ facetValue, data: rowsForCell, width: cellWidth, height: cellHeight })}
|
|
244
|
+
{@render cell({ facetValue, data: rowsForCell, width: cellWidth, height: cellHeight, margins: cellMargins })}
|
|
224
245
|
{/if}
|
|
225
246
|
</div>
|
|
226
247
|
{/each}
|
|
@@ -272,6 +293,7 @@
|
|
|
272
293
|
data: rowsForCell,
|
|
273
294
|
width: carouselCellWidth,
|
|
274
295
|
height: carouselCellHeight,
|
|
296
|
+
margins: cellMargins,
|
|
275
297
|
})}
|
|
276
298
|
{/if}
|
|
277
299
|
</div>
|
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
|
|
14
14
|
const fmt = $derived(section.format ?? ((v: number) => String(v)));
|
|
15
15
|
|
|
16
|
+
// Fill/opacity for the excluded-margin rects.
|
|
17
|
+
const excludedStyle = $derived.by((): { fill: string; opacity: number } => {
|
|
18
|
+
const d = section.disabledStyle;
|
|
19
|
+
if (d?.fill) return { fill: d.fill, opacity: d.fillOpacity ?? d.opacity ?? 1 };
|
|
20
|
+
return { fill: 'currentColor', opacity: d?.fillOpacity ?? d?.opacity ?? 0.35 };
|
|
21
|
+
});
|
|
22
|
+
|
|
16
23
|
const tickPositions = $derived(
|
|
17
24
|
section.interactive
|
|
18
25
|
? []
|
|
@@ -93,6 +100,26 @@
|
|
|
93
100
|
};
|
|
94
101
|
});
|
|
95
102
|
|
|
103
|
+
// Mirrors an externally-set `interaction.continuousRange` into lo/hi.
|
|
104
|
+
$effect(() => {
|
|
105
|
+
if (!section.interactive) return;
|
|
106
|
+
const range = interaction.continuousRange;
|
|
107
|
+
if (range === null) {
|
|
108
|
+
if (untrack(() => loAdjusted || hiAdjusted)) {
|
|
109
|
+
loAdjusted = false;
|
|
110
|
+
hiAdjusted = false;
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const [currentLo, currentHi] = untrack(() => [lo, hi]);
|
|
115
|
+
if (range.min !== currentLo || range.max !== currentHi) {
|
|
116
|
+
lo = range.min;
|
|
117
|
+
hi = range.max;
|
|
118
|
+
loAdjusted = true;
|
|
119
|
+
hiAdjusted = true;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
96
123
|
function startDrag(which: 'lo' | 'hi') {
|
|
97
124
|
return (event: PointerEvent) => {
|
|
98
125
|
if (!section.interactive) return;
|
|
@@ -149,51 +176,59 @@
|
|
|
149
176
|
</defs>
|
|
150
177
|
<rect width={barWidth} height="12" rx="2" fill="url(#{gradientId})" />
|
|
151
178
|
{#each tickPositions as tick (tick.v)}
|
|
152
|
-
<
|
|
153
|
-
x1={tick.t * barWidth}
|
|
154
|
-
y1="12"
|
|
155
|
-
x2={tick.t * barWidth}
|
|
156
|
-
y2="18"
|
|
157
|
-
stroke="currentColor"
|
|
158
|
-
stroke-width="1"
|
|
159
|
-
/>
|
|
179
|
+
<rect class="dct-continuous__tick-mark" x={tick.t * barWidth} y="12" />
|
|
160
180
|
{/each}
|
|
161
181
|
|
|
162
182
|
{#if section.interactive}
|
|
163
183
|
{#if loT > 0}
|
|
164
|
-
<rect x="0" y="0" width={loT * barWidth} height="12" rx="2" fill=
|
|
184
|
+
<rect x="0" y="0" width={loT * barWidth} height="12" rx="2" fill={excludedStyle.fill} fill-opacity={excludedStyle.opacity} />
|
|
165
185
|
{/if}
|
|
166
186
|
{#if hiT < 1}
|
|
167
|
-
<rect x={hiT * barWidth} y="0" width={barWidth - hiT * barWidth} height="12" rx="2" fill=
|
|
187
|
+
<rect x={hiT * barWidth} y="0" width={barWidth - hiT * barWidth} height="12" rx="2" fill={excludedStyle.fill} fill-opacity={excludedStyle.opacity} />
|
|
188
|
+
{/if}
|
|
189
|
+
{#if section.handle}
|
|
190
|
+
{@render section.handle({
|
|
191
|
+
which: 'lo',
|
|
192
|
+
value: lo,
|
|
193
|
+
position: loT,
|
|
194
|
+
onpointerdown: startDrag('lo'),
|
|
195
|
+
ondoubleclick: resetHandle('lo'),
|
|
196
|
+
})}
|
|
197
|
+
{@render section.handle({
|
|
198
|
+
which: 'hi',
|
|
199
|
+
value: hi,
|
|
200
|
+
position: hiT,
|
|
201
|
+
onpointerdown: startDrag('hi'),
|
|
202
|
+
ondoubleclick: resetHandle('hi'),
|
|
203
|
+
})}
|
|
204
|
+
{:else}
|
|
205
|
+
<rect
|
|
206
|
+
class="dct-continuous__handle"
|
|
207
|
+
x={loT * barWidth}
|
|
208
|
+
y="6"
|
|
209
|
+
onpointerdown={startDrag('lo')}
|
|
210
|
+
ondblclick={resetHandle('lo')}
|
|
211
|
+
role="slider"
|
|
212
|
+
aria-label="Margen inferior"
|
|
213
|
+
aria-valuemin={section.min}
|
|
214
|
+
aria-valuemax={hi}
|
|
215
|
+
aria-valuenow={lo}
|
|
216
|
+
tabindex="0"
|
|
217
|
+
/>
|
|
218
|
+
<rect
|
|
219
|
+
class="dct-continuous__handle"
|
|
220
|
+
x={hiT * barWidth}
|
|
221
|
+
y="6"
|
|
222
|
+
onpointerdown={startDrag('hi')}
|
|
223
|
+
ondblclick={resetHandle('hi')}
|
|
224
|
+
role="slider"
|
|
225
|
+
aria-label="Margen superior"
|
|
226
|
+
aria-valuemin={lo}
|
|
227
|
+
aria-valuemax={section.max}
|
|
228
|
+
aria-valuenow={hi}
|
|
229
|
+
tabindex="0"
|
|
230
|
+
/>
|
|
168
231
|
{/if}
|
|
169
|
-
<circle
|
|
170
|
-
class="dct-continuous__handle"
|
|
171
|
-
cx={loT * barWidth}
|
|
172
|
-
cy="6"
|
|
173
|
-
r="6"
|
|
174
|
-
onpointerdown={startDrag('lo')}
|
|
175
|
-
ondblclick={resetHandle('lo')}
|
|
176
|
-
role="slider"
|
|
177
|
-
aria-label="Margen inferior"
|
|
178
|
-
aria-valuemin={section.min}
|
|
179
|
-
aria-valuemax={hi}
|
|
180
|
-
aria-valuenow={lo}
|
|
181
|
-
tabindex="0"
|
|
182
|
-
/>
|
|
183
|
-
<circle
|
|
184
|
-
class="dct-continuous__handle"
|
|
185
|
-
cx={hiT * barWidth}
|
|
186
|
-
cy="6"
|
|
187
|
-
r="6"
|
|
188
|
-
onpointerdown={startDrag('hi')}
|
|
189
|
-
ondblclick={resetHandle('hi')}
|
|
190
|
-
role="slider"
|
|
191
|
-
aria-label="Margen superior"
|
|
192
|
-
aria-valuemin={lo}
|
|
193
|
-
aria-valuemax={section.max}
|
|
194
|
-
aria-valuenow={hi}
|
|
195
|
-
tabindex="0"
|
|
196
|
-
/>
|
|
197
232
|
{/if}
|
|
198
233
|
</svg>
|
|
199
234
|
|
|
@@ -225,6 +260,7 @@
|
|
|
225
260
|
}
|
|
226
261
|
.dct-continuous__edge {
|
|
227
262
|
position: absolute;
|
|
263
|
+
transform: rotate(var(--dct-continuous-label-rotate, 0deg));
|
|
228
264
|
}
|
|
229
265
|
.dct-continuous__edge--start {
|
|
230
266
|
left: 0;
|
|
@@ -234,7 +270,7 @@
|
|
|
234
270
|
}
|
|
235
271
|
.dct-continuous__tick {
|
|
236
272
|
position: absolute;
|
|
237
|
-
transform: translateX(-50%);
|
|
273
|
+
transform: translateX(-50%) rotate(var(--dct-continuous-label-rotate, 0deg));
|
|
238
274
|
}
|
|
239
275
|
/*
|
|
240
276
|
* Anchored outward (not centered, unlike a plain tick) so the label always
|
|
@@ -247,15 +283,30 @@
|
|
|
247
283
|
font-weight: 600;
|
|
248
284
|
}
|
|
249
285
|
.dct-continuous__tick--handle-lo {
|
|
250
|
-
transform:
|
|
286
|
+
transform: rotate(var(--dct-continuous-label-rotate, 0deg));
|
|
251
287
|
}
|
|
252
288
|
.dct-continuous__tick--handle-hi {
|
|
253
|
-
transform: translateX(-100%);
|
|
289
|
+
transform: translateX(-100%) rotate(var(--dct-continuous-label-rotate, 0deg));
|
|
290
|
+
}
|
|
291
|
+
/* Tick mark below the bar at each non-interactive tick's position. */
|
|
292
|
+
.dct-continuous__tick-mark {
|
|
293
|
+
width: var(--dct-continuous-tick-width, 1px);
|
|
294
|
+
height: var(--dct-continuous-tick-length, 6px);
|
|
295
|
+
fill: var(--dct-continuous-tick-color, currentColor);
|
|
296
|
+
transform-box: fill-box;
|
|
297
|
+
transform: translateX(-50%);
|
|
254
298
|
}
|
|
299
|
+
/* Draggable lo/hi handle. */
|
|
255
300
|
.dct-continuous__handle {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
301
|
+
width: var(--dct-continuous-handle-width, 6px);
|
|
302
|
+
height: var(--dct-continuous-handle-height, 20px);
|
|
303
|
+
rx: var(--dct-continuous-handle-radius, 3px);
|
|
304
|
+
fill: var(--dct-continuous-handle-fill, currentColor);
|
|
305
|
+
stroke: var(--dct-continuous-handle-stroke, var(--dct-bg, #fff));
|
|
306
|
+
stroke-width: var(--dct-continuous-handle-stroke-width, 1.5px);
|
|
307
|
+
transform-box: fill-box;
|
|
308
|
+
transform-origin: center;
|
|
309
|
+
transform: translate(-50%, -50%);
|
|
259
310
|
cursor: ew-resize;
|
|
260
311
|
touch-action: none;
|
|
261
312
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Pixel width of `text` set in `font` (a canvas font shorthand, e.g. `"14px sans-serif"`). 0 during SSR. */
|
|
2
|
+
export declare function measureTextWidth(text: string, font: string): number;
|
|
3
|
+
/** Widest of `labels` set in `font`. 0 for an empty list. */
|
|
4
|
+
export declare function measureMaxLabelWidth(labels: string[], font: string): number;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
let measureCtx;
|
|
2
|
+
function getMeasureContext() {
|
|
3
|
+
if (measureCtx !== undefined)
|
|
4
|
+
return measureCtx;
|
|
5
|
+
measureCtx = typeof document === 'undefined' ? null : document.createElement('canvas').getContext('2d');
|
|
6
|
+
return measureCtx;
|
|
7
|
+
}
|
|
8
|
+
/** Pixel width of `text` set in `font` (a canvas font shorthand, e.g. `"14px sans-serif"`). 0 during SSR. */
|
|
9
|
+
export function measureTextWidth(text, font) {
|
|
10
|
+
const ctx = getMeasureContext();
|
|
11
|
+
if (!ctx)
|
|
12
|
+
return 0;
|
|
13
|
+
ctx.font = font;
|
|
14
|
+
return ctx.measureText(text).width;
|
|
15
|
+
}
|
|
16
|
+
/** Widest of `labels` set in `font`. 0 for an empty list. */
|
|
17
|
+
export function measureMaxLabelWidth(labels, font) {
|
|
18
|
+
return labels.reduce((max, label) => Math.max(max, measureTextWidth(label, font)), 0);
|
|
19
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
} from '../utils/segments';
|
|
13
13
|
import { createBarLayout } from './layout.svelte';
|
|
14
14
|
import { createLabelMarginTracker } from '../utils/labelOverflow.svelte';
|
|
15
|
+
import { measureTextWidth } from '../../layout/plot/measureText';
|
|
15
16
|
import { buildHoverPoints } from './hoverPoints';
|
|
16
17
|
import { buildBarMarkers } from './buildBarMarkers';
|
|
17
18
|
import { resolveGapsForSeries, resolveDiscreteGapFills } from '../utils/gaps';
|
|
@@ -30,6 +31,7 @@
|
|
|
30
31
|
import type { ValueAnchor } from '../../types/plots/constants';
|
|
31
32
|
import type { BarSegmentStyle } from '../../types/plots/segments/common';
|
|
32
33
|
import type { LegendDisabledStyle } from '../../types/layout/legend';
|
|
34
|
+
import type { MarginOverflow } from '../utils/labelOverflow.svelte';
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
37
|
* A bar chart, vertical by default. Accepts either a pre-built `series`
|
|
@@ -220,17 +222,50 @@
|
|
|
220
222
|
pointMatching: 'contains' as const
|
|
221
223
|
});
|
|
222
224
|
|
|
225
|
+
// Estimated overflow for an `'outside'`-anchored value label at the most
|
|
226
|
+
// extreme value in the whole dataset — not just the current frame, so it
|
|
227
|
+
// stays stable across a timeline scrubber instead of resetting to zero
|
|
228
|
+
// and regrowing on every tick. Stacked labels sit at their own segment's
|
|
229
|
+
// midpoint (see `valueTextFn` above), well inside the bar, so they carry
|
|
230
|
+
// no such risk; `start`/`end` anchors sit inside the bar too.
|
|
231
|
+
const valueLabelFloor = $derived.by((): MarginOverflow | undefined => {
|
|
232
|
+
if (!showVals || valAnchor !== 'outside' || barLayout.layout === 'stacked') return undefined;
|
|
233
|
+
const font = styles.values?.fontStyle;
|
|
234
|
+
const fontSize = typeof font?.fontSize === 'number' ? font.fontSize : 12;
|
|
235
|
+
const canvasFont = `${fontSize}px ${cfg.font === 'inherit' ? 'sans-serif' : cfg.font}`;
|
|
236
|
+
if (isHorizontal) {
|
|
237
|
+
const offset = Math.abs(typeof font?.dx === 'number' ? font.dx : 6);
|
|
238
|
+
let widestLabel = 0;
|
|
239
|
+
for (const s of resolvedSeries) {
|
|
240
|
+
const sy = resolveAccessor(s.y);
|
|
241
|
+
let extremeValue = 0;
|
|
242
|
+
let extremeRow: TData | undefined;
|
|
243
|
+
for (const d of s.data) {
|
|
244
|
+
const v = Number(sy(d));
|
|
245
|
+
if (Number.isNaN(v)) continue;
|
|
246
|
+
if (extremeRow === undefined || Math.abs(v) > Math.abs(extremeValue)) {
|
|
247
|
+
extremeValue = v;
|
|
248
|
+
extremeRow = d;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (extremeRow === undefined) continue;
|
|
252
|
+
widestLabel = Math.max(widestLabel, measureTextWidth(formatValue(extremeValue, s.name), canvasFont));
|
|
253
|
+
}
|
|
254
|
+
return { left: 0, right: Math.ceil(offset + widestLabel), top: 0, bottom: 0 };
|
|
255
|
+
}
|
|
256
|
+
const offset = Math.abs(typeof font?.dy === 'number' ? font.dy : 6);
|
|
257
|
+
return { left: 0, right: 0, top: Math.ceil(offset + fontSize * 1.2), bottom: 0 };
|
|
258
|
+
});
|
|
259
|
+
|
|
223
260
|
// A bar's value label can grow past the plot's own content box (e.g. a
|
|
224
261
|
// tall bar's `anchor: 'outside'` label pushing past the top margin) —
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
// overflow from an earlier, differently-shaped frame (e.g. a timeline
|
|
229
|
-
// scrubber revealing a different slice of rows) doesn't linger once
|
|
230
|
-
// that frame is gone.
|
|
262
|
+
// `valueLabelFloor` covers the common case up front; each label's own
|
|
263
|
+
// rendered overflow still feeds `labelMargin.report` for whatever that
|
|
264
|
+
// estimate doesn't cover. See `labelOverflow.svelte.ts`.
|
|
231
265
|
const labelMargin = createLabelMarginTracker(
|
|
232
266
|
() => margins,
|
|
233
|
-
() => resolvedSeries
|
|
267
|
+
() => resolvedSeries,
|
|
268
|
+
() => valueLabelFloor ?? { left: 0, right: 0, top: 0, bottom: 0 }
|
|
234
269
|
);
|
|
235
270
|
</script>
|
|
236
271
|
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { buildGapMarkers } from './buildGapMarkers';
|
|
10
10
|
import { isGapValue } from '../utils/gaps';
|
|
11
11
|
import { createLabelMarginTracker } from '../utils/labelOverflow.svelte';
|
|
12
|
+
import { measureTextWidth } from '../../layout/plot/measureText';
|
|
12
13
|
import BasePlotLayout from '../../layout/plot/BasePlotLayout.svelte';
|
|
13
14
|
import ValueLabels from './ValueLabels.svelte';
|
|
14
15
|
import {
|
|
@@ -28,6 +29,8 @@
|
|
|
28
29
|
import type { LineSegmentStyle } from '../../types/plots/segments/common';
|
|
29
30
|
import type { LineMarkersConfig } from '../../types/markers/line';
|
|
30
31
|
import type { LegendDisabledStyle } from '../../types/layout/legend';
|
|
32
|
+
import type { ValueAnchor } from '../../types/plots/constants';
|
|
33
|
+
import type { MarginOverflow } from '../utils/labelOverflow.svelte';
|
|
31
34
|
|
|
32
35
|
type Props = BasePlotProps<
|
|
33
36
|
SeriesProps<TData> | DataProps<TData>,
|
|
@@ -72,6 +75,8 @@
|
|
|
72
75
|
const cfg = $derived(getConfiguration());
|
|
73
76
|
const showVals = $derived(styles.values?.show ?? false);
|
|
74
77
|
const hoverIsolate = $derived(styles.values?.hoverIsolate ?? false);
|
|
78
|
+
const valAnchor = $derived<ValueAnchor>(styles.values?.anchor ?? 'start');
|
|
79
|
+
const formatValue = $derived(styles.values?.format ?? ((v: number, name: string) => `${name} - ${v}`));
|
|
75
80
|
|
|
76
81
|
// Dims every other series like a legend toggle when a value label is
|
|
77
82
|
// hovered, local to this `<Plot>` instance.
|
|
@@ -129,7 +134,9 @@
|
|
|
129
134
|
buildGapMarkers<TData>({
|
|
130
135
|
seriesList: resolvedSeries,
|
|
131
136
|
gaps,
|
|
132
|
-
colorFor: (seriesIndex) => paletteColor(seriesIndex, cfg.palette)
|
|
137
|
+
colorFor: (seriesIndex) => paletteColor(seriesIndex, cfg.palette),
|
|
138
|
+
disabledStrokeFor: (name) => disabledStrokeOverride(effectiveDisabledFor(name)),
|
|
139
|
+
disabledDotsFor: (name) => disabledDotsOverride(effectiveDisabledFor(name))
|
|
133
140
|
})
|
|
134
141
|
);
|
|
135
142
|
|
|
@@ -160,14 +167,98 @@
|
|
|
160
167
|
.filter((p): p is { series: Series<TData>; lastRow: TData; color: string } => p !== undefined)
|
|
161
168
|
);
|
|
162
169
|
|
|
170
|
+
// Estimated overflow for a rightward-fanning end-of-line label (the
|
|
171
|
+
// default: `valAnchor: 'start'`, or any anchor whose resolved `dx` isn't
|
|
172
|
+
// negative). `lanesUsed` (see `declutter1D`/`laneAssignment`) never
|
|
173
|
+
// exceeds the series count, so that bounds the reach even without
|
|
174
|
+
// knowing which points will actually end up clustered together. A
|
|
175
|
+
// leftward fan (negative `dx`) instead flips itself to the right when it
|
|
176
|
+
// runs out of room — see `ValueLabels.svelte`'s own doc comment — so it
|
|
177
|
+
// carries no such risk and is left to the DOM-measured fallback below.
|
|
178
|
+
//
|
|
179
|
+
// Only the fraction of the label that actually extends to the right of
|
|
180
|
+
// its own anchor point counts toward the margin — `textAnchor: 'start'`
|
|
181
|
+
// (the default `'start'`/`'outside'`-less case) puts the whole label
|
|
182
|
+
// there, but `'middle'` (`valAnchor: 'outside'`, or the shared-anchor
|
|
183
|
+
// default for anything other than `'start'`/`'end'`) centers it on the
|
|
184
|
+
// anchor, so only half sticks out; `'end'` puts none of it there at all.
|
|
185
|
+
// Same `preferredTA` derivation as `ValueLabels.svelte` (`'outside'`
|
|
186
|
+
// itself is a `dy`/`lineAnchor` shorthand, not a real textAnchor value —
|
|
187
|
+
// it resolves through `defaultTA` same as an unset one).
|
|
188
|
+
//
|
|
189
|
+
// The widest label is measured from `scales.y.domain` when the caller
|
|
190
|
+
// pins one (e.g. a shared domain covering a whole timeline range) rather
|
|
191
|
+
// than from `resolvedSeries`'s own values — a timeline scrubber's `data`
|
|
192
|
+
// is only the currently-revealed slice, so measuring straight off it
|
|
193
|
+
// would keep changing the floor (and, via `resetKey`, remounting the
|
|
194
|
+
// plot) on every tick even though the true worst case across the whole
|
|
195
|
+
// range never changes. Without a pinned domain there's no such stable
|
|
196
|
+
// source, so this falls back to the current frame's own values, same as
|
|
197
|
+
// no floor at all for a caller not pinning a domain in the first place.
|
|
198
|
+
//
|
|
199
|
+
// This estimate is close but not exact (declutter's real elbow/connector
|
|
200
|
+
// geometry vs. this reach formula) — with a pinned domain, that gap can
|
|
201
|
+
// still cost one extra DOM-measured correction (and its remount) on
|
|
202
|
+
// whichever tick first renders real declutter geometry, but never more
|
|
203
|
+
// than one across a whole scrub — see `LinePlot.ValueLabelStability`'s
|
|
204
|
+
// own test for what "settled" means here.
|
|
205
|
+
const valueLabelFloor = $derived.by((): MarginOverflow | undefined => {
|
|
206
|
+
if (!showVals) return undefined;
|
|
207
|
+
const font = styles.values?.fontStyle;
|
|
208
|
+
const defaultDx = valAnchor === 'start' ? 6 : valAnchor === 'end' ? -6 : 0;
|
|
209
|
+
const baseDx = typeof font?.dx === 'number' ? font.dx : defaultDx;
|
|
210
|
+
if ((Math.sign(baseDx) || 1) !== 1) return undefined;
|
|
211
|
+
|
|
212
|
+
const defaultTA = valAnchor === 'start' ? 'start' : valAnchor === 'end' ? 'end' : 'middle';
|
|
213
|
+
const resolvedTextAnchor = font?.textAnchor === 'outside' ? undefined : font?.textAnchor;
|
|
214
|
+
const preferredTA = resolvedTextAnchor ?? defaultTA;
|
|
215
|
+
const rightFraction = preferredTA === 'start' ? 1 : preferredTA === 'end' ? 0 : 0.5;
|
|
216
|
+
|
|
217
|
+
const fontSize = typeof font?.fontSize === 'number' ? font.fontSize : 12;
|
|
218
|
+
const canvasFont = `${fontSize}px ${cfg.font === 'inherit' ? 'sans-serif' : cfg.font}`;
|
|
219
|
+
const lineCfg = cfg.line.valueLabels;
|
|
220
|
+
const seriesCount = resolvedSeries.length;
|
|
221
|
+
const reach =
|
|
222
|
+
seriesCount > 1
|
|
223
|
+
? lineCfg.elbowGap + (seriesCount - 1) * lineCfg.laneStep + lineCfg.labelGap
|
|
224
|
+
: Math.abs(baseDx);
|
|
225
|
+
|
|
226
|
+
const pinnedDomain = scales.y?.domain;
|
|
227
|
+
let maxLabelWidth = 0;
|
|
228
|
+
if (pinnedDomain && pinnedDomain.length >= 2) {
|
|
229
|
+
const lo = Number(pinnedDomain[0]);
|
|
230
|
+
const hi = Number(pinnedDomain[pinnedDomain.length - 1]);
|
|
231
|
+
for (const s of resolvedSeries) {
|
|
232
|
+
maxLabelWidth = Math.max(
|
|
233
|
+
maxLabelWidth,
|
|
234
|
+
measureTextWidth(formatValue(lo, s.name), canvasFont),
|
|
235
|
+
measureTextWidth(formatValue(hi, s.name), canvasFont)
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
for (const s of resolvedSeries) {
|
|
240
|
+
const sy = resolveAccessor(s.y);
|
|
241
|
+
for (const d of s.data) {
|
|
242
|
+
const v = Number(sy(d));
|
|
243
|
+
if (Number.isNaN(v)) continue;
|
|
244
|
+
maxLabelWidth = Math.max(maxLabelWidth, measureTextWidth(formatValue(v, s.name), canvasFont));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// +2px: canvas text measurement and svelteplot's own rendered SVG text
|
|
249
|
+
// can disagree by a pixel or so (different rendering engines) — a small
|
|
250
|
+
// buffer that narrows, but doesn't eliminate, the gap noted above.
|
|
251
|
+
return { left: 0, right: Math.ceil(reach + rightFraction * maxLabelWidth) + 2, top: 0, bottom: 0 };
|
|
252
|
+
});
|
|
253
|
+
|
|
163
254
|
// Grows the plot's margin to fit value labels that overflow the content
|
|
164
|
-
// box.
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
// see `createLabelMarginTracker`'s own doc comment.
|
|
255
|
+
// box. `valueLabelFloor` covers the common rightward case up front; each
|
|
256
|
+
// label's own rendered overflow still feeds `labelMargin.report` for
|
|
257
|
+
// whatever that estimate doesn't cover. See `labelOverflow.svelte.ts`.
|
|
168
258
|
const labelMargin = createLabelMarginTracker(
|
|
169
259
|
() => margins,
|
|
170
|
-
() => resolvedSeries
|
|
260
|
+
() => resolvedSeries,
|
|
261
|
+
() => valueLabelFloor ?? { left: 0, right: 0, top: 0, bottom: 0 }
|
|
171
262
|
);
|
|
172
263
|
|
|
173
264
|
// Writes the resolved margin back into the bindable `margins` prop once it
|
|
@@ -2,6 +2,7 @@ import type { Series } from '../../types/plots/data/common';
|
|
|
2
2
|
import type { GapsConfig } from '../../types/plots/gaps';
|
|
3
3
|
import type { LineSegmentStyle } from '../../types/plots/segments/common';
|
|
4
4
|
import type { LineMarkerConfig, DotMarkerConfig } from '../../types/markers/common';
|
|
5
|
+
import type { StrokeStyle, DotStyle } from '../../types/plots/styling';
|
|
5
6
|
/**
|
|
6
7
|
* One `'line'` (+ `'dot'`, when its zone sets `style.dots`) marker per
|
|
7
8
|
* detected, configured gap run across every series — the interpolated
|
|
@@ -12,10 +13,16 @@ import type { LineMarkerConfig, DotMarkerConfig } from '../../types/markers/comm
|
|
|
12
13
|
* muted out of the box), so a gap-fill reads as estimated rather than real
|
|
13
14
|
* data unless a zone or the global configuration overrides it. Dots (when
|
|
14
15
|
* requested) are drawn only at a run's two endpoints, not along every
|
|
15
|
-
* sampled bridge point.
|
|
16
|
+
* sampled bridge point. `disabledStrokeFor`/`disabledDotsFor` mirror
|
|
17
|
+
* `buildLineMarkers`'s own params — a legend-disabled (or hover-isolated)
|
|
18
|
+
* series must dim its gap bridge exactly like it dims the real line it's
|
|
19
|
+
* bridging, so both take the same precedence: disabled override, then the
|
|
20
|
+
* gap zone's own style, then the shared gap default.
|
|
16
21
|
*/
|
|
17
22
|
export declare function buildGapMarkers<TData extends Record<string, unknown>>(args: {
|
|
18
23
|
seriesList: Series<TData>[];
|
|
19
24
|
gaps: GapsConfig<LineSegmentStyle>;
|
|
20
25
|
colorFor: (seriesIndex: number) => string;
|
|
26
|
+
disabledStrokeFor: (seriesName: string) => StrokeStyle | undefined;
|
|
27
|
+
disabledDotsFor: (seriesName: string) => DotStyle | undefined;
|
|
21
28
|
}): (LineMarkerConfig | DotMarkerConfig)[];
|
|
@@ -12,10 +12,14 @@ const gy = (p) => p.y;
|
|
|
12
12
|
* muted out of the box), so a gap-fill reads as estimated rather than real
|
|
13
13
|
* data unless a zone or the global configuration overrides it. Dots (when
|
|
14
14
|
* requested) are drawn only at a run's two endpoints, not along every
|
|
15
|
-
* sampled bridge point.
|
|
15
|
+
* sampled bridge point. `disabledStrokeFor`/`disabledDotsFor` mirror
|
|
16
|
+
* `buildLineMarkers`'s own params — a legend-disabled (or hover-isolated)
|
|
17
|
+
* series must dim its gap bridge exactly like it dims the real line it's
|
|
18
|
+
* bridging, so both take the same precedence: disabled override, then the
|
|
19
|
+
* gap zone's own style, then the shared gap default.
|
|
16
20
|
*/
|
|
17
21
|
export function buildGapMarkers(args) {
|
|
18
|
-
const { seriesList, gaps, colorFor } = args;
|
|
22
|
+
const { seriesList, gaps, colorFor, disabledStrokeFor, disabledDotsFor } = args;
|
|
19
23
|
const cfg = getConfiguration();
|
|
20
24
|
const gapDefaults = cfg.line.gap;
|
|
21
25
|
const markers = [];
|
|
@@ -25,6 +29,8 @@ export function buildGapMarkers(args) {
|
|
|
25
29
|
if (runs.length === 0)
|
|
26
30
|
return;
|
|
27
31
|
const defaultColor = colorFor(seriesIndex);
|
|
32
|
+
const disabledStroke = disabledStrokeFor(series.name);
|
|
33
|
+
const disabledDots = disabledDotsFor(series.name);
|
|
28
34
|
for (const run of runs) {
|
|
29
35
|
const strokeStyle = run.zone.style?.stroke;
|
|
30
36
|
const dotStyle = run.zone.style?.dots;
|
|
@@ -34,12 +40,12 @@ export function buildGapMarkers(args) {
|
|
|
34
40
|
x: gx,
|
|
35
41
|
y: gy,
|
|
36
42
|
style: {
|
|
37
|
-
stroke: strokeStyle?.stroke ?? defaultColor,
|
|
38
|
-
strokeWidth: strokeStyle?.strokeWidth ?? gapDefaults.stroke.strokeWidth ?? cfg.line.strokeWidth,
|
|
39
|
-
strokeOpacity: strokeStyle?.strokeOpacity ?? gapDefaults.stroke.strokeOpacity,
|
|
40
|
-
strokeDasharray: strokeStyle?.strokeDasharray ?? gapDefaults.stroke.strokeDasharray,
|
|
41
|
-
strokeLinejoin: strokeStyle?.strokeLinejoin ?? gapDefaults.stroke.strokeLinejoin,
|
|
42
|
-
strokeLinecap: strokeStyle?.strokeLinecap ?? gapDefaults.stroke.strokeLinecap,
|
|
43
|
+
stroke: disabledStroke?.stroke ?? strokeStyle?.stroke ?? defaultColor,
|
|
44
|
+
strokeWidth: disabledStroke?.strokeWidth ?? strokeStyle?.strokeWidth ?? gapDefaults.stroke.strokeWidth ?? cfg.line.strokeWidth,
|
|
45
|
+
strokeOpacity: disabledStroke?.strokeOpacity ?? strokeStyle?.strokeOpacity ?? gapDefaults.stroke.strokeOpacity,
|
|
46
|
+
strokeDasharray: disabledStroke?.strokeDasharray ?? strokeStyle?.strokeDasharray ?? gapDefaults.stroke.strokeDasharray,
|
|
47
|
+
strokeLinejoin: disabledStroke?.strokeLinejoin ?? strokeStyle?.strokeLinejoin ?? gapDefaults.stroke.strokeLinejoin,
|
|
48
|
+
strokeLinecap: disabledStroke?.strokeLinecap ?? strokeStyle?.strokeLinecap ?? gapDefaults.stroke.strokeLinecap,
|
|
43
49
|
},
|
|
44
50
|
});
|
|
45
51
|
if (!dotStyle)
|
|
@@ -52,14 +58,14 @@ export function buildGapMarkers(args) {
|
|
|
52
58
|
x: gx,
|
|
53
59
|
y: gy,
|
|
54
60
|
style: {
|
|
55
|
-
dotRadius: dotStyle.dotRadius ?? dotDefaults.dotRadius ?? cfg.line.dotRadius,
|
|
56
|
-
dotFill: dotStyle.dotFill ?? strokeStyle?.stroke ?? dotDefaults.dotFill ?? defaultColor,
|
|
57
|
-
dotSymbol: dotStyle.dotSymbol ?? dotDefaults.dotSymbol ?? 'circle',
|
|
58
|
-
dotStroke: dotStyle.dotStroke ?? strokeStyle?.stroke ?? dotDefaults.dotStroke ?? defaultColor,
|
|
59
|
-
dotStrokeWidth: dotStyle.dotStrokeWidth ?? dotDefaults.dotStrokeWidth ?? 1,
|
|
60
|
-
dotFillOpacity: dotStyle.dotFillOpacity ?? dotDefaults.dotFillOpacity ?? 1,
|
|
61
|
-
dotStrokeOpacity: dotStyle.dotStrokeOpacity ?? dotDefaults.dotStrokeOpacity ?? 1,
|
|
62
|
-
dotStrokeDasharray: dotStyle.dotStrokeDasharray ?? dotDefaults.dotStrokeDasharray,
|
|
61
|
+
dotRadius: disabledDots?.dotRadius ?? dotStyle.dotRadius ?? dotDefaults.dotRadius ?? cfg.line.dotRadius,
|
|
62
|
+
dotFill: disabledDots?.dotFill ?? dotStyle.dotFill ?? strokeStyle?.stroke ?? dotDefaults.dotFill ?? defaultColor,
|
|
63
|
+
dotSymbol: disabledDots?.dotSymbol ?? dotStyle.dotSymbol ?? dotDefaults.dotSymbol ?? 'circle',
|
|
64
|
+
dotStroke: disabledDots?.dotStroke ?? dotStyle.dotStroke ?? strokeStyle?.stroke ?? dotDefaults.dotStroke ?? defaultColor,
|
|
65
|
+
dotStrokeWidth: disabledDots?.dotStrokeWidth ?? dotStyle.dotStrokeWidth ?? dotDefaults.dotStrokeWidth ?? 1,
|
|
66
|
+
dotFillOpacity: disabledDots?.dotFillOpacity ?? dotStyle.dotFillOpacity ?? dotDefaults.dotFillOpacity ?? 1,
|
|
67
|
+
dotStrokeOpacity: disabledDots?.dotStrokeOpacity ?? dotStyle.dotStrokeOpacity ?? dotDefaults.dotStrokeOpacity ?? 1,
|
|
68
|
+
dotStrokeDasharray: disabledDots?.dotStrokeDasharray ?? dotStyle.dotStrokeDasharray ?? dotDefaults.dotStrokeDasharray,
|
|
63
69
|
},
|
|
64
70
|
});
|
|
65
71
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { disabledFillOverride } from '../utils/legendDisabled';
|
|
8
8
|
import { buildPyramidBarMarkers } from './buildPyramidBarMarkers';
|
|
9
9
|
import { createLabelMarginTracker } from '../utils/labelOverflow.svelte';
|
|
10
|
+
import { measureTextWidth } from '../../layout/plot/measureText';
|
|
10
11
|
import {
|
|
11
12
|
buildGroupedSeries,
|
|
12
13
|
validateSegments,
|
|
@@ -28,6 +29,7 @@
|
|
|
28
29
|
import type { BarSegmentStyle } from '../../types/plots/segments/common';
|
|
29
30
|
import type { AxisBasedMarkersConfig } from '../../types/markers/common';
|
|
30
31
|
import type { LegendDisabledStyle } from '../../types/layout/legend';
|
|
32
|
+
import type { MarginOverflow } from '../utils/labelOverflow.svelte';
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
35
|
* A population-pyramid-style diverging bar chart: each series is mirrored
|
|
@@ -261,15 +263,50 @@
|
|
|
261
263
|
})
|
|
262
264
|
);
|
|
263
265
|
|
|
266
|
+
// Estimated overflow for an `'outside'`- or `'start'`-anchored value label
|
|
267
|
+
// (both anchor at the row's own full magnitude, pushed further outward —
|
|
268
|
+
// see `ValueLabels.svelte`'s `xFn`) at the most extreme value on each
|
|
269
|
+
// side of the whole dataset, not just the current frame, so it stays
|
|
270
|
+
// stable across a timeline scrubber instead of resetting to zero and
|
|
271
|
+
// regrowing on every tick. `'middle'`/`'end'` anchor inside the bar,
|
|
272
|
+
// toward its own zero baseline, so they carry no such risk.
|
|
273
|
+
const valueLabelFloor = $derived.by((): MarginOverflow | undefined => {
|
|
274
|
+
if (!showVals || valAnchor === 'middle' || valAnchor === 'end') return undefined;
|
|
275
|
+
const font = styles.values?.fontStyle;
|
|
276
|
+
const fontSize = typeof font?.fontSize === 'number' ? font.fontSize : 12;
|
|
277
|
+
const canvasFont = `${fontSize}px ${cfg.font === 'inherit' ? 'sans-serif' : cfg.font}`;
|
|
278
|
+
const offset = Math.abs(typeof font?.dx === 'number' ? font.dx : 6);
|
|
279
|
+
|
|
280
|
+
let leftWidth = 0;
|
|
281
|
+
let rightWidth = 0;
|
|
282
|
+
for (const s of resolvedSeries) {
|
|
283
|
+
const sy = resolveAccessor(s.y);
|
|
284
|
+
let extreme = 0;
|
|
285
|
+
let extremeRow: TData | undefined;
|
|
286
|
+
for (const d of s.data) {
|
|
287
|
+
const v = Math.abs(Number(sy(d)));
|
|
288
|
+
if (Number.isNaN(v)) continue;
|
|
289
|
+
if (extremeRow === undefined || v > extreme) {
|
|
290
|
+
extreme = v;
|
|
291
|
+
extremeRow = d;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (extremeRow === undefined) continue;
|
|
295
|
+
const labelWidth = measureTextWidth(formatValue(extreme, s.name), canvasFont);
|
|
296
|
+
if (s.side === 'left') leftWidth = Math.max(leftWidth, labelWidth);
|
|
297
|
+
else rightWidth = Math.max(rightWidth, labelWidth);
|
|
298
|
+
}
|
|
299
|
+
return { left: Math.ceil(offset + leftWidth), right: Math.ceil(offset + rightWidth), top: 0, bottom: 0 };
|
|
300
|
+
});
|
|
301
|
+
|
|
264
302
|
// A pyramid bar's value label can grow past the plot's own content box —
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
// overflow from an earlier, differently-shaped frame doesn't linger once
|
|
269
|
-
// that frame is gone.
|
|
303
|
+
// `valueLabelFloor` covers the common case up front; each label's own
|
|
304
|
+
// rendered overflow still feeds `labelMargin.report` for whatever that
|
|
305
|
+
// estimate doesn't cover. See `labelOverflow.svelte.ts`.
|
|
270
306
|
const labelMargin = createLabelMarginTracker(
|
|
271
307
|
() => margins,
|
|
272
|
-
() => resolvedSeries
|
|
308
|
+
() => resolvedSeries,
|
|
309
|
+
() => valueLabelFloor ?? { left: 0, right: 0, top: 0, bottom: 0 }
|
|
273
310
|
);
|
|
274
311
|
</script>
|
|
275
312
|
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
import { Text, usePlot } from 'svelteplot';
|
|
3
3
|
import { SvelteMap } from 'svelte/reactivity';
|
|
4
4
|
import { resolveAccessor } from '../utils/accessors';
|
|
5
|
+
import { getConfiguration } from '../../configuration/config.svelte';
|
|
5
6
|
import { disabledFillOverride } from '../utils/legendDisabled';
|
|
6
7
|
import { watchLabelOverflow } from '../utils/labelOverflow.svelte';
|
|
7
|
-
import { resolveEdgeAwareAnchor
|
|
8
|
+
import { resolveEdgeAwareAnchor } from '../../utils/edgeAwareAnchor';
|
|
9
|
+
import { measureTextWidth } from '../../layout/plot/measureText';
|
|
8
10
|
import type { Series } from '../../types/plots/data/common';
|
|
9
11
|
import type { ValuesStyle } from '../../types/layout/styles';
|
|
10
12
|
import type { ScatterSegmentStyle } from '../../types/plots/segments/common';
|
|
@@ -83,6 +85,7 @@
|
|
|
83
85
|
onOverflow: (overflow) => onOverflow?.(overflow),
|
|
84
86
|
});
|
|
85
87
|
|
|
88
|
+
const cfg = $derived(getConfiguration());
|
|
86
89
|
const font = $derived(values?.fontStyle);
|
|
87
90
|
const preferredTextAnchor = $derived(
|
|
88
91
|
font?.textAnchor === 'outside' || font?.textAnchor === undefined ? 'start' : font.textAnchor,
|
|
@@ -91,6 +94,7 @@
|
|
|
91
94
|
const gapX = $derived(Math.abs(font?.dx ?? 8));
|
|
92
95
|
const gapY = $derived(Math.abs(font?.dy ?? 0));
|
|
93
96
|
const fontSize = $derived(typeof font?.fontSize === 'number' ? font.fontSize : 11);
|
|
97
|
+
const canvasFont = $derived(`${fontSize}px ${cfg.font === 'inherit' ? 'sans-serif' : cfg.font}`);
|
|
94
98
|
const fillOverride = $derived(disabledFillOverride(disabled));
|
|
95
99
|
|
|
96
100
|
function textOf(d: TData): string {
|
|
@@ -118,8 +122,7 @@
|
|
|
118
122
|
lineAnchor: preferredLineAnchor,
|
|
119
123
|
gapX,
|
|
120
124
|
gapY,
|
|
121
|
-
|
|
122
|
-
extent: { width: estimateTextWidth(text, fontSize), height: fontSize * 1.2 },
|
|
125
|
+
extent: { width: measureTextWidth(text, canvasFont), height: fontSize * 1.2 },
|
|
123
126
|
}),
|
|
124
127
|
);
|
|
125
128
|
}
|
|
@@ -56,7 +56,17 @@ export declare function watchLabelOverflow(args: {
|
|
|
56
56
|
* series list) — a plain reference/value comparison, not a deep one, so it
|
|
57
57
|
* only need change identity when the rendered content actually does.
|
|
58
58
|
*/
|
|
59
|
-
export declare function createLabelMarginTracker(margins: () => MarginConfig | undefined, resetKey?: () => unknown
|
|
59
|
+
export declare function createLabelMarginTracker(margins: () => MarginConfig | undefined, resetKey?: () => unknown,
|
|
60
|
+
/**
|
|
61
|
+
* A precomputed overflow estimate — from the full dataset, not just the
|
|
62
|
+
* current frame — used as the reset target instead of zero. Since it's
|
|
63
|
+
* derived from stable inputs, a `resetKey` change resets `measured` back
|
|
64
|
+
* to roughly the same value instead of zero, so DOM growth rarely needs
|
|
65
|
+
* to re-detect and re-grow it (which would otherwise remount the plot on
|
|
66
|
+
* every reset). DOM measurement can still grow past it for whatever this
|
|
67
|
+
* estimate doesn't cover.
|
|
68
|
+
*/
|
|
69
|
+
floor?: () => MarginOverflow): {
|
|
60
70
|
report: (overflow: MarginOverflow) => void;
|
|
61
71
|
readonly resolvedMargins: Partial<{
|
|
62
72
|
top: number | "auto";
|
|
@@ -104,11 +104,22 @@ export function watchLabelOverflow(args) {
|
|
|
104
104
|
* series list) — a plain reference/value comparison, not a deep one, so it
|
|
105
105
|
* only need change identity when the rendered content actually does.
|
|
106
106
|
*/
|
|
107
|
-
export function createLabelMarginTracker(margins, resetKey
|
|
108
|
-
|
|
107
|
+
export function createLabelMarginTracker(margins, resetKey,
|
|
108
|
+
/**
|
|
109
|
+
* A precomputed overflow estimate — from the full dataset, not just the
|
|
110
|
+
* current frame — used as the reset target instead of zero. Since it's
|
|
111
|
+
* derived from stable inputs, a `resetKey` change resets `measured` back
|
|
112
|
+
* to roughly the same value instead of zero, so DOM growth rarely needs
|
|
113
|
+
* to re-detect and re-grow it (which would otherwise remount the plot on
|
|
114
|
+
* every reset). DOM measurement can still grow past it for whatever this
|
|
115
|
+
* estimate doesn't cover.
|
|
116
|
+
*/
|
|
117
|
+
floor) {
|
|
118
|
+
const floorValue = () => floor?.() ?? NO_OVERFLOW;
|
|
119
|
+
let measured = $state(floorValue());
|
|
109
120
|
// Skips the reset on the very first run (there's nothing to reset yet,
|
|
110
|
-
// and `measured` already starts at
|
|
111
|
-
//
|
|
121
|
+
// and `measured` already starts at the floor) so only a later, genuine
|
|
122
|
+
// change to `resetKey` clears accumulated growth.
|
|
112
123
|
let resetKeyInitialized = false;
|
|
113
124
|
let lastResetKey;
|
|
114
125
|
$effect(() => {
|
|
@@ -120,7 +131,7 @@ export function createLabelMarginTracker(margins, resetKey) {
|
|
|
120
131
|
}
|
|
121
132
|
if (key !== lastResetKey) {
|
|
122
133
|
lastResetKey = key;
|
|
123
|
-
measured =
|
|
134
|
+
measured = floorValue();
|
|
124
135
|
}
|
|
125
136
|
});
|
|
126
137
|
function report(overflow) {
|
|
@@ -47,6 +47,28 @@ export type ChartConfig = {
|
|
|
47
47
|
fontSize: string;
|
|
48
48
|
disabledOpacity: number;
|
|
49
49
|
};
|
|
50
|
+
/** Continuous section styling: the draggable lo/hi handles and the tick/edge labels. */
|
|
51
|
+
continuous: {
|
|
52
|
+
/** The draggable lo/hi handle. */
|
|
53
|
+
handle: {
|
|
54
|
+
width: string;
|
|
55
|
+
height: string;
|
|
56
|
+
radius: string;
|
|
57
|
+
fill: string;
|
|
58
|
+
stroke: string;
|
|
59
|
+
strokeWidth: string;
|
|
60
|
+
};
|
|
61
|
+
tick: {
|
|
62
|
+
color: string;
|
|
63
|
+
width: string;
|
|
64
|
+
/** Length of the tick mark below the gradient bar. */
|
|
65
|
+
length: string;
|
|
66
|
+
};
|
|
67
|
+
label: {
|
|
68
|
+
/** Rotation applied to tick/edge label text. */
|
|
69
|
+
rotate: string;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
50
72
|
};
|
|
51
73
|
axis: {
|
|
52
74
|
color: string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
import type { Accessor } from '../plots/data/common';
|
|
3
|
+
import type { MarginConfig } from '../plots/props';
|
|
3
4
|
export type Responsive<T> = T | {
|
|
4
5
|
desktop: T;
|
|
5
6
|
mobile: T;
|
|
@@ -12,6 +13,8 @@ export type FacetCellContext<TRow extends Record<string, unknown>> = {
|
|
|
12
13
|
data: TRow[];
|
|
13
14
|
width: number;
|
|
14
15
|
height: number;
|
|
16
|
+
/** Left margin sized to the widest label across every facet — see `labelsForMargin`. Present only when it's given. */
|
|
17
|
+
margins?: MarginConfig;
|
|
15
18
|
};
|
|
16
19
|
export type FacetCellSnippet<TRow extends Record<string, unknown>> = Snippet<[
|
|
17
20
|
FacetCellContext<TRow>
|
|
@@ -40,4 +43,6 @@ export type FacetConfig<TRow extends Record<string, unknown>> = {
|
|
|
40
43
|
formatIndicator?: (current: number, total: number) => string;
|
|
41
44
|
indicator?: FacetIndicatorSnippet;
|
|
42
45
|
navButton?: FacetNavSnippet;
|
|
46
|
+
/** Candidate label strings for one facet's own rows — measured across every facet to size a shared left margin (`cell`'s own `margins`). */
|
|
47
|
+
labelsForMargin?: (rows: TRow[]) => string[];
|
|
43
48
|
};
|
|
@@ -48,6 +48,18 @@ export type LegendNavContext = {
|
|
|
48
48
|
};
|
|
49
49
|
/** Custom prev/next button renderer for a paginated horizontal section. Falls back to a small default arrow button. */
|
|
50
50
|
export type LegendNavSnippet = Snippet<[LegendNavContext]>;
|
|
51
|
+
export type LegendHandleWhich = 'lo' | 'hi';
|
|
52
|
+
export type LegendHandleContext = {
|
|
53
|
+
which: LegendHandleWhich;
|
|
54
|
+
/** The handle's current data value. */
|
|
55
|
+
value: number;
|
|
56
|
+
/** The handle's position along the bar, from 0 to 1. */
|
|
57
|
+
position: number;
|
|
58
|
+
onpointerdown: (event: PointerEvent) => void;
|
|
59
|
+
ondoubleclick: () => void;
|
|
60
|
+
};
|
|
61
|
+
/** Custom drag-handle renderer for an interactive continuous section. */
|
|
62
|
+
export type LegendHandleSnippet = Snippet<[LegendHandleContext]>;
|
|
51
63
|
export type DiscreteLegendSection = {
|
|
52
64
|
type: 'discrete';
|
|
53
65
|
title?: string;
|
|
@@ -78,6 +90,8 @@ export type ContinuousLegendSection = {
|
|
|
78
90
|
*/
|
|
79
91
|
mode?: 'rescale' | 'filter';
|
|
80
92
|
disabledStyle?: LegendDisabledStyle;
|
|
93
|
+
/** Custom renderer for the draggable lo/hi handles. */
|
|
94
|
+
handle?: LegendHandleSnippet;
|
|
81
95
|
};
|
|
82
96
|
export type LegendSection = DiscreteLegendSection | ContinuousLegendSection;
|
|
83
97
|
/**
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
export type HorizontalAnchor = 'start' | 'middle' | 'end';
|
|
2
2
|
export type VerticalAnchor = 'top' | 'middle' | 'bottom';
|
|
3
|
-
/** Rough estimate of rendered text width in pixels — character count × font size, not a real DOM measurement. Shared so a pre-render space reservation (e.g. domain padding) and this file's own overflow check agree on the same number. */
|
|
4
|
-
export declare function estimateTextWidth(text: string, fontSize: number): number;
|
|
5
3
|
export type AnchorPlacement = {
|
|
6
4
|
textAnchor: HorizontalAnchor;
|
|
7
5
|
lineAnchor: VerticalAnchor;
|
|
@@ -11,12 +9,11 @@ export type AnchorPlacement = {
|
|
|
11
9
|
/**
|
|
12
10
|
* Flips (or nudges) a label's anchor/offset when its default placement
|
|
13
11
|
* would push it past a pixel box — e.g. the plot's own inner content box.
|
|
14
|
-
* `extent`
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* self-contained and read-only against the live scale instead.
|
|
12
|
+
* `extent` should be measured off-DOM (see `measureTextWidth`) rather than
|
|
13
|
+
* via `getBBox()` on the rendered label — feeding a `getBBox()` reading back
|
|
14
|
+
* into anything reactive to the plot's own layout (like svelteplot's shared
|
|
15
|
+
* margin-auto-sizing context) risks a feedback loop between this
|
|
16
|
+
* component's own effect and svelteplot's.
|
|
20
17
|
*/
|
|
21
18
|
export declare function resolveEdgeAwareAnchor(args: {
|
|
22
19
|
/** Pixel position of the anchored point. */
|
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
/** Rough estimate of rendered text width in pixels — character count × font size, not a real DOM measurement. Shared so a pre-render space reservation (e.g. domain padding) and this file's own overflow check agree on the same number. */
|
|
2
|
-
export function estimateTextWidth(text, fontSize) {
|
|
3
|
-
return text.length * fontSize * 0.62;
|
|
4
|
-
}
|
|
5
1
|
/**
|
|
6
2
|
* Flips (or nudges) a label's anchor/offset when its default placement
|
|
7
3
|
* would push it past a pixel box — e.g. the plot's own inner content box.
|
|
8
|
-
* `extent`
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* self-contained and read-only against the live scale instead.
|
|
4
|
+
* `extent` should be measured off-DOM (see `measureTextWidth`) rather than
|
|
5
|
+
* via `getBBox()` on the rendered label — feeding a `getBBox()` reading back
|
|
6
|
+
* into anything reactive to the plot's own layout (like svelteplot's shared
|
|
7
|
+
* margin-auto-sizing context) risks a feedback loop between this
|
|
8
|
+
* component's own effect and svelteplot's.
|
|
14
9
|
*/
|
|
15
10
|
export function resolveEdgeAwareAnchor(args) {
|
|
16
11
|
const { px, py, width, height, gapX, gapY, extent } = args;
|