@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.
@@ -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>
@@ -17,6 +17,7 @@ declare function $$render<TRow extends Record<string, unknown>>(): {
17
17
  width: number;
18
18
  height: number;
19
19
  cell: FacetCellSnippet<TRow>;
20
+ labelsForMargin?: (rows: TRow[]) => string[];
20
21
  };
21
22
  exports: {};
22
23
  bindings: "";
@@ -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
- <line
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="currentColor" fill-opacity="0.35" />
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="currentColor" fill-opacity="0.35" />
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: none;
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
- fill: currentColor;
257
- stroke: var(--dct-bg, #fff);
258
- stroke-width: 1.5;
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
- // reserving real space there is what each label's own overflow
226
- // measurement feeds into here, via a tracker local to this one `<Plot>`
227
- // instance. See `labelOverflow.svelte.ts`. Resets on `resolvedSeries` so
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. Resets on `resolvedSeries` (e.g. a timeline scrubber revealing a
165
- // different slice of rows) so overflow measured against an earlier frame's
166
- // labels doesn't linger and inflate the margin once that frame is gone —
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
- // reserving real space there is what each label's own overflow
266
- // measurement feeds into here, via a tracker local to this one `<Plot>`
267
- // instance. See `labelOverflow.svelte.ts`. Resets on `resolvedSeries` so
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, estimateTextWidth } from '../../utils/edgeAwareAnchor';
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
- // Rough average glyph width these labels are short numeric/formatted strings, not measured DOM text.
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
- let measured = $state(NO_OVERFLOW);
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 `NO_OVERFLOW`) so only a later,
111
- // genuine change to `resetKey` clears accumulated growth.
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 = NO_OVERFLOW;
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` estimates the rendered label size (character count × font size,
15
- * not a real DOM measurement) deliberately: measuring via `getBBox()` and
16
- * feeding the result back into anything reactive to the plot's own layout
17
- * (like svelteplot's shared margin-auto-sizing context) risks a feedback
18
- * loop between this component's own effect and svelteplot's — this stays
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` estimates the rendered label size (character count × font size,
9
- * not a real DOM measurement) deliberately: measuring via `getBBox()` and
10
- * feeding the result back into anything reactive to the plot's own layout
11
- * (like svelteplot's shared margin-auto-sizing context) risks a feedback
12
- * loop between this component's own effect and svelteplot's — this stays
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.41",
3
+ "version": "0.0.43",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"