@fundar/data-chart-telling 0.0.47 → 0.0.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -35,7 +35,7 @@ export { setConfiguration, getConfiguration, resetConfiguration, configToCssVars
35
35
  export { THEMES } from './configuration/themes';
36
36
  export type { ThemeName } from './configuration/themes';
37
37
  export { groupBy, buildSeries } from './utils/grouping';
38
- export { makeColorScale, normalize, resolveCssColor, paletteColor } from './utils/color';
38
+ export { makeColorScale, normalize, resolveCssColor, paletteColor, contrastTextColor, contrastRatio } from './utils/color';
39
39
  export { estimateCategoricalAxisMargin } from './plots/utils/categoricalAxisMargin';
40
40
  export { estimateLineValueLabelMargin } from './plots/line/valueLabelMargin';
41
41
  export type { AxisValue, AxisScale, AxisBasedScalesConfig } from './types/layout/scales';
@@ -58,7 +58,7 @@ export type { GeoMarkersConfig, GeoInsetMarkerConfig, GeoInsetTooltipConfig, Geo
58
58
  export type { ChartPlotContext, ChartPlotSnippet } from './types/charts/common';
59
59
  export type { TimeValue, Orientation, TimelineConfig } from './types/layout/timeline';
60
60
  export type { Responsive, FacetColumns, FacetMode, FacetNavLayout, FacetConfig, FacetIndicatorContext, FacetIndicatorSnippet, FacetNavDirection, FacetNavContext, FacetNavSnippet } from './types/layout/facet';
61
- export type { LegendSection, DiscreteLegendSection, ContinuousLegendSection, LegendItem, LegendDisabledStyle, LegendItemsLayout, LegendNavDirection, LegendNavContext, LegendNavSnippet, LegendInteractionStore, LegendPlotContext } from './types/layout/legend';
61
+ export type { LegendSection, DiscreteLegendSection, ContinuousLegendSection, LegendItem, LegendDisabledStyle, LegendItemsLayout, LegendInteractiveStyle, DiscreteInteractiveConfig, ContinuousInteractiveConfig, LegendNavDirection, LegendNavContext, LegendNavSnippet, LegendInteractionStore, LegendPlotContext } from './types/layout/legend';
62
62
  export type { HoverStrategy, TooltipOptions, TooltipProp, TooltipAnchorX, TooltipAnchorY } from './types/layout/tooltip';
63
63
  export type { ChartConfig, ChartConfigInput, TextStyle } from './types/configuration/styling';
64
64
  export type { ChartProps, GapsAwareChartProps } from './types/charts/props';
package/dist/index.js CHANGED
@@ -42,6 +42,6 @@ export { setConfiguration, getConfiguration, resetConfiguration, configToCssVars
42
42
  export { THEMES } from './configuration/themes';
43
43
  // ── Utils ─────────────────────────────────────────────────────────────────────
44
44
  export { groupBy, buildSeries } from './utils/grouping';
45
- export { makeColorScale, normalize, resolveCssColor, paletteColor } from './utils/color';
45
+ export { makeColorScale, normalize, resolveCssColor, paletteColor, contrastTextColor, contrastRatio } from './utils/color';
46
46
  export { estimateCategoricalAxisMargin } from './plots/utils/categoricalAxisMargin';
47
47
  export { estimateLineValueLabelMargin } from './plots/line/valueLabelMargin';
@@ -12,16 +12,17 @@
12
12
  const barWidth = $derived(section.width ?? 170);
13
13
 
14
14
  const fmt = $derived(section.format ?? ((v: number) => String(v)));
15
+ const isInteractive = $derived(section.interactive?.enabled ?? false);
15
16
 
16
17
  // Fill/opacity for the excluded-margin rects.
17
18
  const excludedStyle = $derived.by((): { fill: string; opacity: number } => {
18
- const d = section.disabledStyle;
19
+ const d = section.interactive?.disabledStyle;
19
20
  if (d?.fill) return { fill: d.fill, opacity: d.fillOpacity ?? d.opacity ?? 1 };
20
21
  return { fill: 'currentColor', opacity: d?.fillOpacity ?? d?.opacity ?? 0.35 };
21
22
  });
22
23
 
23
24
  const tickPositions = $derived(
24
- section.interactive
25
+ isInteractive
25
26
  ? []
26
27
  : (section.ticks ?? []).map((v) => ({
27
28
  v,
@@ -68,7 +69,9 @@
68
69
  const hiT = $derived(toT(hi));
69
70
 
70
71
  const colorDomain = $derived.by((): [number, number] =>
71
- section.interactive && (section.mode ?? 'filter') === 'rescale' ? [lo, hi] : [section.min, section.max],
72
+ isInteractive && (section.interactive?.mode ?? 'filter') === 'rescale'
73
+ ? [lo, hi]
74
+ : [section.min, section.max],
72
75
  );
73
76
  const colorScale = $derived(
74
77
  makeColorScale(
@@ -88,21 +91,21 @@
88
91
  );
89
92
 
90
93
  $effect(() => {
91
- if (!section.interactive) return;
94
+ if (!isInteractive) return;
92
95
  interaction.continuousRange =
93
96
  lo <= section.min && hi >= section.max
94
97
  ? null
95
98
  : {
96
99
  min: lo,
97
100
  max: hi,
98
- mode: section.mode ?? 'filter',
99
- disabledStyle: section.disabledStyle ?? { opacity: cfg.legend.disabledOpacity },
101
+ mode: section.interactive?.mode ?? 'filter',
102
+ disabledStyle: section.interactive?.disabledStyle ?? { opacity: cfg.legend.disabledOpacity },
100
103
  };
101
104
  });
102
105
 
103
106
  // Mirrors an externally-set `interaction.continuousRange` into lo/hi.
104
107
  $effect(() => {
105
- if (!section.interactive) return;
108
+ if (!isInteractive) return;
106
109
  const range = interaction.continuousRange;
107
110
  if (range === null) {
108
111
  if (untrack(() => loAdjusted || hiAdjusted)) {
@@ -122,7 +125,7 @@
122
125
 
123
126
  function startDrag(which: 'lo' | 'hi') {
124
127
  return (event: PointerEvent) => {
125
- if (!section.interactive) return;
128
+ if (!isInteractive) return;
126
129
  event.preventDefault();
127
130
  if (which === 'lo') loAdjusted = true;
128
131
  else hiAdjusted = true;
@@ -179,22 +182,22 @@
179
182
  <rect class="dct-continuous__tick-mark" x={tick.t * barWidth} y="12" />
180
183
  {/each}
181
184
 
182
- {#if section.interactive}
185
+ {#if isInteractive}
183
186
  {#if loT > 0}
184
187
  <rect x="0" y="0" width={loT * barWidth} height="12" rx="2" fill={excludedStyle.fill} fill-opacity={excludedStyle.opacity} />
185
188
  {/if}
186
189
  {#if hiT < 1}
187
190
  <rect x={hiT * barWidth} y="0" width={barWidth - hiT * barWidth} height="12" rx="2" fill={excludedStyle.fill} fill-opacity={excludedStyle.opacity} />
188
191
  {/if}
189
- {#if section.handle}
190
- {@render section.handle({
192
+ {#if section.interactive?.handle}
193
+ {@render section.interactive.handle({
191
194
  which: 'lo',
192
195
  value: lo,
193
196
  position: loT,
194
197
  onpointerdown: startDrag('lo'),
195
198
  ondoubleclick: resetHandle('lo'),
196
199
  })}
197
- {@render section.handle({
200
+ {@render section.interactive.handle({
198
201
  which: 'hi',
199
202
  value: hi,
200
203
  position: hiT,
@@ -233,7 +236,7 @@
233
236
  </svg>
234
237
 
235
238
  <div class="dct-continuous__labels" style:width="{barWidth}px">
236
- {#if section.interactive}
239
+ {#if isInteractive}
237
240
  <span class="dct-continuous__tick dct-continuous__tick--handle-lo" style:left="{loT * barWidth}px">{fmt(lo)}</span>
238
241
  <span class="dct-continuous__tick dct-continuous__tick--handle-hi" style:left="{hiT * barWidth}px">{fmt(hi)}</span>
239
242
  {:else}
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ import { contrastTextColor } from '../../utils/color';
3
+ import DiscreteSymbolIcon from './DiscreteSymbolIcon.svelte';
4
+ import type { LegendItem } from '../../types/layout/legend';
5
+
6
+ /**
7
+ * The `interactive.legendStyle: 'badge'` item look: a pill filled with the
8
+ * series color, its symbol icon (unless `showIcon` is off) and label
9
+ * auto-contrasted against that fill, and an eye icon that's crossed out
10
+ * while `disabled`. The toggled-off dim itself is applied by the parent
11
+ * `.dct-discrete__item`.
12
+ */
13
+ let {
14
+ item,
15
+ color,
16
+ disabled,
17
+ showIcon = true
18
+ }: { item: LegendItem; color: string; disabled: boolean; showIcon?: boolean } = $props();
19
+ const textColor = $derived(contrastTextColor(color));
20
+ </script>
21
+
22
+ <span class="dct-discrete__badge" style:background={color} style:color={textColor}>
23
+ {#if showIcon}
24
+ <DiscreteSymbolIcon {item} color={textColor} />
25
+ {/if}
26
+ <span class="dct-discrete__label">{item.label || item.name}</span>
27
+ <svg class="dct-discrete__eye" viewBox="0 0 24 24" aria-hidden="true">
28
+ {#if disabled}
29
+ <path
30
+ d="M17.94 17.94A10.94 10.94 0 0 1 12 20c-7 0-11-8-11-8a18.5 18.5 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
31
+ />
32
+ <line x1="1" y1="1" x2="23" y2="23" />
33
+ {:else}
34
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
35
+ <circle cx="12" cy="12" r="3" />
36
+ {/if}
37
+ </svg>
38
+ </span>
39
+
40
+ <style>
41
+ .dct-discrete__badge {
42
+ display: flex;
43
+ align-items: center;
44
+ gap: 0.4rem;
45
+ min-width: 0;
46
+ padding: var(--dct-legend-badge-pad, 0.25rem 0.65rem);
47
+ border-radius: var(--dct-legend-badge-radius, 999px);
48
+ }
49
+ .dct-discrete__label {
50
+ font-size: var(--dct-legend-size, 0.875rem);
51
+ font-weight: var(--dct-legend-weight, 400);
52
+ white-space: nowrap;
53
+ overflow: hidden;
54
+ text-overflow: ellipsis;
55
+ min-width: 0;
56
+ }
57
+ .dct-discrete__eye {
58
+ width: var(--dct-legend-badge-eye-size, 0.9rem);
59
+ height: var(--dct-legend-badge-eye-size, 0.9rem);
60
+ flex-shrink: 0;
61
+ fill: none;
62
+ stroke: currentColor;
63
+ stroke-width: 2;
64
+ stroke-linecap: round;
65
+ stroke-linejoin: round;
66
+ }
67
+ </style>
@@ -0,0 +1,10 @@
1
+ import type { LegendItem } from '../../types/layout/legend';
2
+ type $$ComponentProps = {
3
+ item: LegendItem;
4
+ color: string;
5
+ disabled: boolean;
6
+ showIcon?: boolean;
7
+ };
8
+ declare const DiscreteBadgeItem: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type DiscreteBadgeItem = ReturnType<typeof DiscreteBadgeItem>;
10
+ export default DiscreteBadgeItem;
@@ -0,0 +1,20 @@
1
+ <script lang="ts">
2
+ import DiscreteSymbolIcon from './DiscreteSymbolIcon.svelte';
3
+ import type { LegendItem } from '../../types/layout/legend';
4
+
5
+ let { item, color }: { item: LegendItem; color: string } = $props();
6
+ </script>
7
+
8
+ <DiscreteSymbolIcon {item} {color} />
9
+ <span class="dct-discrete__label">{item.label || item.name}</span>
10
+
11
+ <style>
12
+ .dct-discrete__label {
13
+ font-size: var(--dct-legend-size, 0.875rem);
14
+ font-weight: var(--dct-legend-weight, 400);
15
+ white-space: nowrap;
16
+ overflow: hidden;
17
+ text-overflow: ellipsis;
18
+ min-width: 0;
19
+ }
20
+ </style>
@@ -0,0 +1,8 @@
1
+ import type { LegendItem } from '../../types/layout/legend';
2
+ type $$ComponentProps = {
3
+ item: LegendItem;
4
+ color: string;
5
+ };
6
+ declare const DiscreteSwatchItem: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type DiscreteSwatchItem = ReturnType<typeof DiscreteSwatchItem>;
8
+ export default DiscreteSwatchItem;
@@ -0,0 +1,60 @@
1
+ <script lang="ts">
2
+ import type { LegendItem } from '../../types/layout/legend';
3
+ import type { LineStyle } from '../../types/plots/constants';
4
+
5
+ /**
6
+ * The series' symbol/line-style icon, shared by both discrete item looks
7
+ * (`DiscreteSwatchItem` and `DiscreteBadgeItem`) so the two stay in sync.
8
+ * `color` is caller-supplied rather than read off `item` — the swatch look
9
+ * fills it with the series color, the badge look with its own label color.
10
+ */
11
+ let { item, color }: { item: LegendItem; color: string } = $props();
12
+
13
+ function dashArray(style?: LineStyle): string | undefined {
14
+ switch (style) {
15
+ case 'dashed':
16
+ return '2,5';
17
+ case 'dotted':
18
+ return '1,4';
19
+ case 'dashdot':
20
+ return '5,3,1,3';
21
+ default:
22
+ return undefined;
23
+ }
24
+ }
25
+ </script>
26
+
27
+ <svg class="dct-discrete__swatch" viewBox="0 0 15 10">
28
+ {#if item.symbol === 'circle'}
29
+ <circle cx="5" cy="5" r="4" fill={color} />
30
+ {:else if item.symbol === 'square'}
31
+ <rect x="1" y="1" width="8" height="8" rx="2" fill={color} />
32
+ {:else if item.symbol === 'triangle'}
33
+ <path
34
+ d="M 6 2 L 9 7.5 A 1.5 1.5 0 0 1 7.8 10 L 2.2 10 A 1.5 1.5 0 0 1 1 7.5 L 4 2 A 1.5 1.5 0 0 1 6 2"
35
+ fill={color}
36
+ transform="translate(0,-1)"
37
+ />
38
+ {:else if item.symbol === 'line'}
39
+ <line
40
+ x1="2"
41
+ y1="5"
42
+ x2="13"
43
+ y2="5"
44
+ stroke={color}
45
+ stroke-width="3"
46
+ stroke-linecap="round"
47
+ stroke-dasharray={dashArray(item.lineStyle)}
48
+ />
49
+ {:else}
50
+ <rect x="1" y="1" width="8" height="8" fill={color} />
51
+ {/if}
52
+ </svg>
53
+
54
+ <style>
55
+ .dct-discrete__swatch {
56
+ width: var(--dct-legend-swatch, 1.25rem);
57
+ height: var(--dct-legend-swatch, 1.25rem);
58
+ flex-shrink: 0;
59
+ }
60
+ </style>
@@ -0,0 +1,8 @@
1
+ import type { LegendItem } from '../../types/layout/legend';
2
+ type $$ComponentProps = {
3
+ item: LegendItem;
4
+ color: string;
5
+ };
6
+ declare const DiscreteSymbolIcon: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type DiscreteSymbolIcon = ReturnType<typeof DiscreteSymbolIcon>;
8
+ export default DiscreteSymbolIcon;
@@ -170,14 +170,8 @@
170
170
  .filter((p): p is { series: Series<TData>; lastRow: TData; color: string } => p !== undefined)
171
171
  );
172
172
 
173
- // Estimated overflow for a rightward-fanning end-of-line label see
174
- // `estimateLineValueLabelMargin`'s own doc comment for the covered case,
175
- // the stability rationale (worst-case lane count, domain-pinned text), and
176
- // its "close but not exact" caveat versus `ValueLabels.svelte`'s real
177
- // declutter/connector geometry (see `LinePlot.ValueLabelStability`'s own
178
- // test for what "settled" means here). Shared with a faceted caller's own
179
- // `FacetConfig.marginsForFacet`, so a single plot and a facet-wide margin
180
- // are always computed by the exact same code.
173
+ // Estimated overflow for the end-of-line label fan and its own vertical
174
+ // reach — see `estimateLineValueLabelMargin`'s own doc comment.
181
175
  const valueLabelFloor = $derived.by((): MarginOverflow | undefined => {
182
176
  if (!showVals) return undefined;
183
177
  return estimateLineValueLabelMargin(resolvedSeries, {
@@ -3,20 +3,12 @@ import type { Series } from '../../types/plots/data/common';
3
3
  import type { FontStyle } from '../../types/plots/styling';
4
4
  import type { ValueAnchor } from '../../types/plots/constants';
5
5
  /**
6
- * `LinePlot`'s own value-label margin estimate stable across a timeline
7
- * scrubber or a dataset change, since it's a fixed function of `series` and
8
- * config, never DOM-measured (see `estimateValueLabelMargin`'s own doc
9
- * comment). Used two ways: `LinePlot` itself calls this internally as its
10
- * single-plot `valueLabelFloor`, and a faceted caller (e.g. `ChartPanel` in
11
- * the consuming app) calls it per facet against that facet's own rows to
12
- * build a shared `FacetConfig.marginsForFacet` — both get the exact same
13
- * number for the exact same series, by construction, instead of the two call
14
- * sites drifting apart with their own separate formulas.
15
- *
16
- * Only covers the common case: a rightward-fanning end-of-line label
17
- * (`anchor: 'start'`, or any anchor whose resolved `dx` isn't negative) —
18
- * see `LinePlot`'s own `valueLabelFloor` doc comment (unchanged) for why a
19
- * leftward fan isn't covered here and is left to the DOM-measured fallback.
6
+ * `LinePlot`'s value-label margin estimate: the end-of-line fan's
7
+ * horizontal reach (`left` or `right`, whichever side it fans toward, with
8
+ * the same amount mirrored to the other side for a fan that can flip),
9
+ * plus the label text's own vertical reach on `top`/`bottom`. Stable across
10
+ * a timeline scrubber or dataset change, not DOM-measured. Also used by a
11
+ * faceted caller to build a shared `FacetConfig.marginsForFacet`.
20
12
  */
21
13
  export declare function estimateLineValueLabelMargin<TData extends Record<string, unknown>>(series: Series<TData>[], options?: {
22
14
  valAnchor?: ValueAnchor;
@@ -24,4 +16,4 @@ export declare function estimateLineValueLabelMargin<TData extends Record<string
24
16
  formatValue?: (value: number, name: string) => string;
25
17
  /** A pinned y domain (e.g. `scales.y.domain`), covering the whole range a timeline scrubber might reveal — see this function's own stability note above. Falls back to `series`'s own current values when omitted. */
26
18
  yDomain?: readonly (number | string | boolean | Date | null)[];
27
- }): MarginOverflow | undefined;
19
+ }): MarginOverflow;
@@ -1,21 +1,13 @@
1
1
  import { resolveAccessor } from '../utils/accessors';
2
2
  import { getConfiguration } from '../../configuration/config.svelte';
3
- import { estimateValueLabelMargin, laneReachUpperBound } from '../utils/valueLabelMargin';
3
+ import { estimateValueLabelMargin, estimateValueLabelVerticalMargin, laneReachUpperBound } from '../utils/valueLabelMargin';
4
4
  /**
5
- * `LinePlot`'s own value-label margin estimate stable across a timeline
6
- * scrubber or a dataset change, since it's a fixed function of `series` and
7
- * config, never DOM-measured (see `estimateValueLabelMargin`'s own doc
8
- * comment). Used two ways: `LinePlot` itself calls this internally as its
9
- * single-plot `valueLabelFloor`, and a faceted caller (e.g. `ChartPanel` in
10
- * the consuming app) calls it per facet against that facet's own rows to
11
- * build a shared `FacetConfig.marginsForFacet` — both get the exact same
12
- * number for the exact same series, by construction, instead of the two call
13
- * sites drifting apart with their own separate formulas.
14
- *
15
- * Only covers the common case: a rightward-fanning end-of-line label
16
- * (`anchor: 'start'`, or any anchor whose resolved `dx` isn't negative) —
17
- * see `LinePlot`'s own `valueLabelFloor` doc comment (unchanged) for why a
18
- * leftward fan isn't covered here and is left to the DOM-measured fallback.
5
+ * `LinePlot`'s value-label margin estimate: the end-of-line fan's
6
+ * horizontal reach (`left` or `right`, whichever side it fans toward, with
7
+ * the same amount mirrored to the other side for a fan that can flip),
8
+ * plus the label text's own vertical reach on `top`/`bottom`. Stable across
9
+ * a timeline scrubber or dataset change, not DOM-measured. Also used by a
10
+ * faceted caller to build a shared `FacetConfig.marginsForFacet`.
19
11
  */
20
12
  export function estimateLineValueLabelMargin(series, options = {}) {
21
13
  const cfg = getConfiguration();
@@ -24,12 +16,11 @@ export function estimateLineValueLabelMargin(series, options = {}) {
24
16
  const font = options.fontStyle;
25
17
  const defaultDx = valAnchor === 'start' ? 6 : valAnchor === 'end' ? -6 : 0;
26
18
  const baseDx = typeof font?.dx === 'number' ? font.dx : defaultDx;
27
- if ((Math.sign(baseDx) || 1) !== 1)
28
- return undefined;
19
+ const dir = Math.sign(baseDx) || 1;
29
20
  const defaultTA = valAnchor === 'start' ? 'start' : valAnchor === 'end' ? 'end' : 'middle';
30
21
  const resolvedTextAnchor = font?.textAnchor === 'outside' ? undefined : font?.textAnchor;
31
22
  const preferredTA = resolvedTextAnchor ?? defaultTA;
32
- const rightFraction = preferredTA === 'start' ? 1 : preferredTA === 'end' ? 0 : 0.5;
23
+ const outwardFraction = preferredTA === 'start' ? 1 : preferredTA === 'end' ? 0 : 0.5;
33
24
  const fontSize = typeof font?.fontSize === 'number' ? font.fontSize : 12;
34
25
  const canvasFont = `${fontSize}px ${cfg.font === 'inherit' ? 'sans-serif' : cfg.font}`;
35
26
  const seriesCount = series.length;
@@ -56,6 +47,19 @@ export function estimateLineValueLabelMargin(series, options = {}) {
56
47
  // +2px: canvas text measurement and svelteplot's own rendered SVG text
57
48
  // can disagree by a pixel or so (different rendering engines) — a small
58
49
  // buffer that narrows, but doesn't eliminate, the gap noted above.
59
- const right = estimateValueLabelMargin(texts, { canvasFont, textFraction: rightFraction, reach }) + 2;
60
- return { left: 0, right, top: 0, bottom: 0 };
50
+ const horizontal = estimateValueLabelMargin(texts, { canvasFont, textFraction: outwardFraction, reach }) + 2;
51
+ const defaultDy = valAnchor === 'outside' ? -8 : 0;
52
+ const defaultLA = valAnchor === 'outside' ? 'bottom' : 'middle';
53
+ const dy = typeof font?.dy === 'number' ? font.dy : defaultDy;
54
+ const { top, bottom } = estimateValueLabelVerticalMargin({
55
+ fontSize,
56
+ lineAnchor: font?.lineAnchor ?? defaultLA,
57
+ dy
58
+ });
59
+ return {
60
+ left: dir === -1 ? horizontal : 0,
61
+ right: dir === 1 ? horizontal : seriesCount > 1 ? horizontal : 0,
62
+ top,
63
+ bottom
64
+ };
61
65
  }
@@ -1,3 +1,4 @@
1
+ import type { LineAnchor } from '../../types/plots/constants';
1
2
  /**
2
3
  * The one calculation every value-label-drawing plot (`LinePlot`, `BarPlot`,
3
4
  * `PyramidPlot`, …) needs to reserve margin for its own value labels ahead of
@@ -35,6 +36,18 @@ export declare function estimateValueLabelMargin(texts: string[], options: {
35
36
  */
36
37
  reach?: number;
37
38
  }): number;
39
+ /** Estimated full rendered height of a single-line value label at `fontSize`. */
40
+ export declare function estimateLabelHeight(fontSize: number): number;
41
+ /** Vertical analogue of `estimateValueLabelMargin`: how far a value label's text extends above/below its anchor point. */
42
+ export declare function estimateValueLabelVerticalMargin(options: {
43
+ fontSize: number;
44
+ lineAnchor: LineAnchor;
45
+ /** Pixel offset from the anchor point to the label's line-anchor edge, same sign as SVG's `dy`. */
46
+ dy?: number;
47
+ }): {
48
+ top: number;
49
+ bottom: number;
50
+ };
38
51
  /**
39
52
  * Upper bound on how far `LinePlot`'s end-of-line labels can ever fan out
40
53
  * into lanes (see `plots/utils/declutter.ts`'s `laneAssignment`): a run of
@@ -21,6 +21,26 @@ export function estimateValueLabelMargin(texts, options) {
21
21
  const reach = options.reach ?? 0;
22
22
  return Math.ceil(reach + textFraction * maxWidth);
23
23
  }
24
+ /** Estimated full rendered height of a single-line value label at `fontSize`. */
25
+ export function estimateLabelHeight(fontSize) {
26
+ return fontSize * 1.2;
27
+ }
28
+ /** Vertical analogue of `estimateValueLabelMargin`: how far a value label's text extends above/below its anchor point. */
29
+ export function estimateValueLabelVerticalMargin(options) {
30
+ const height = estimateLabelHeight(options.fontSize);
31
+ const dy = options.dy ?? 0;
32
+ if (options.lineAnchor === 'top') {
33
+ return { top: 0, bottom: Math.ceil(Math.max(0, dy) + height) + 2 };
34
+ }
35
+ if (options.lineAnchor === 'bottom') {
36
+ return { top: Math.ceil(Math.max(0, -dy) + height) + 2, bottom: 0 };
37
+ }
38
+ const half = height / 2;
39
+ return {
40
+ top: Math.ceil(Math.max(0, half - dy)) + 2,
41
+ bottom: Math.ceil(Math.max(0, half + dy)) + 2
42
+ };
43
+ }
24
44
  /**
25
45
  * Upper bound on how far `LinePlot`'s end-of-line labels can ever fan out
26
46
  * into lanes (see `plots/utils/declutter.ts`'s `laneAssignment`): a run of
@@ -60,6 +60,26 @@ export type LegendHandleContext = {
60
60
  };
61
61
  /** Custom drag-handle renderer for an interactive continuous section. */
62
62
  export type LegendHandleSnippet = Snippet<[LegendHandleContext]>;
63
+ /**
64
+ * Item look for an interactive discrete section. `'badge'` (the default once
65
+ * `interactive.enabled` is on) draws each item as a pill filled with its
66
+ * series color, with the symbol icon and label auto-contrasted against that
67
+ * fill and an eye icon on the right — crossed out once the item is toggled
68
+ * off. `'basic'` keeps the plain swatch + label, same as a non-interactive
69
+ * section.
70
+ */
71
+ export type LegendInteractiveStyle = 'badge' | 'basic';
72
+ /** Click-to-toggle configuration for a discrete section: whether it's on, how toggled items look, and the style applied to a toggled-off one. */
73
+ export type DiscreteInteractiveConfig = {
74
+ /** Turns click-to-toggle on for every item in the section. */
75
+ enabled?: boolean;
76
+ /** Item look while `enabled`. Defaults to `'badge'`. */
77
+ legendStyle?: LegendInteractiveStyle;
78
+ /** Style applied to a toggled-off item. Defaults to `{ opacity: cfg.legend.disabledOpacity }`. */
79
+ disabledStyle?: LegendDisabledStyle;
80
+ /** Shows the symbol icon inside a `'badge'` pill. Defaults to `true`; ignored by `'basic'`, which always shows it. */
81
+ showIcon?: boolean;
82
+ };
63
83
  export type DiscreteLegendSection = {
64
84
  type: 'discrete';
65
85
  title?: string;
@@ -67,10 +87,24 @@ export type DiscreteLegendSection = {
67
87
  columns?: number;
68
88
  /** Item flow direction. Defaults to `{ type: 'vertical' }` (unchanged prior behaviour). */
69
89
  layout?: LegendItemsLayout;
70
- interactive?: boolean;
71
- disabledStyle?: LegendDisabledStyle;
90
+ interactive?: DiscreteInteractiveConfig;
72
91
  navButton?: LegendNavSnippet;
73
92
  };
93
+ /** Drag-to-narrow configuration for a continuous section: whether it's on, how the range behaves, and its look. */
94
+ export type ContinuousInteractiveConfig = {
95
+ /** Turns drag-to-narrow on, adding lo/hi handles at the edges of the excluded range. */
96
+ enabled?: boolean;
97
+ /**
98
+ * How the color ramp responds to a narrowed range: `'rescale'` reprojects
99
+ * it onto the new bounds, `'filter'` keeps the original domain and only
100
+ * excludes out-of-range values. Defaults to `'filter'`.
101
+ */
102
+ mode?: 'rescale' | 'filter';
103
+ /** Style applied to the excluded (narrowed-out) range. Defaults to `{ opacity: cfg.legend.disabledOpacity }`. */
104
+ disabledStyle?: LegendDisabledStyle;
105
+ /** Custom renderer for the draggable lo/hi handles. */
106
+ handle?: LegendHandleSnippet;
107
+ };
74
108
  export type ContinuousLegendSection = {
75
109
  type: 'continuous';
76
110
  title?: string;
@@ -82,16 +116,7 @@ export type ContinuousLegendSection = {
82
116
  ticks?: number[];
83
117
  width?: number;
84
118
  format?: (v: number) => string;
85
- interactive?: boolean;
86
- /**
87
- * How the color ramp responds to a narrowed range: `'rescale'` reprojects
88
- * it onto the new bounds, `'filter'` keeps the original domain and only
89
- * excludes out-of-range values. Defaults to `'filter'`.
90
- */
91
- mode?: 'rescale' | 'filter';
92
- disabledStyle?: LegendDisabledStyle;
93
- /** Custom renderer for the draggable lo/hi handles. */
94
- handle?: LegendHandleSnippet;
119
+ interactive?: ContinuousInteractiveConfig;
95
120
  };
96
121
  export type LegendSection = DiscreteLegendSection | ContinuousLegendSection;
97
122
  /**
@@ -2,4 +2,13 @@ export declare function resolveCssColor(color: string): string;
2
2
  export declare function normalize(v: number, min: number, max: number): number;
3
3
  /** Cycles through a discrete palette by series index — the default colour for an unstyled series. */
4
4
  export declare function paletteColor(index: number, palette: string[]): string;
5
+ /** WCAG contrast ratio between two colors, from 1 (no contrast) to 21 (black on white). */
6
+ export declare function contrastRatio(colorA: string, colorB: string): number;
7
+ /**
8
+ * The `candidates` entry with the highest WCAG contrast ratio against
9
+ * `background`. Defaults to plain white/black; pass a grayscale or other
10
+ * palette to widen the choice. Falls back to the first candidate when
11
+ * `background` can't be parsed.
12
+ */
13
+ export declare function contrastTextColor(background: string, candidates?: string[]): string;
5
14
  export declare function makeColorScale(min: number, max: number, colorMin: string, colorMax: string): (v: number) => string;
@@ -23,6 +23,68 @@ export function paletteColor(index, palette) {
23
23
  return 'currentColor';
24
24
  return palette[index % palette.length];
25
25
  }
26
+ function parseRgb(color) {
27
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
28
+ if (hex) {
29
+ const digits = hex[1].length === 3 ? hex[1].split('').map((c) => c + c).join('') : hex[1];
30
+ const num = parseInt(digits, 16);
31
+ return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
32
+ }
33
+ const rgb = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(color);
34
+ if (rgb)
35
+ return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
36
+ // Named colors, hsl(), and any other CSS color syntax: resolved to
37
+ // `rgb(...)` via the browser's computed style, then reparsed.
38
+ if (!isBrowser)
39
+ return null;
40
+ const el = document.createElement('div');
41
+ el.style.color = color;
42
+ document.body.appendChild(el);
43
+ const computed = getComputedStyle(el).color;
44
+ document.body.removeChild(el);
45
+ if (!computed || computed === color)
46
+ return null;
47
+ return parseRgb(computed);
48
+ }
49
+ function relativeLuminance([r, g, b]) {
50
+ const [rs, gs, bs] = [r, g, b].map((c) => {
51
+ const s = c / 255;
52
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
53
+ });
54
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
55
+ }
56
+ /** WCAG contrast ratio between two colors, from 1 (no contrast) to 21 (black on white). */
57
+ export function contrastRatio(colorA, colorB) {
58
+ const a = parseRgb(resolveCssColor(colorA));
59
+ const b = parseRgb(resolveCssColor(colorB));
60
+ if (!a || !b)
61
+ return 1;
62
+ const lA = relativeLuminance(a);
63
+ const lB = relativeLuminance(b);
64
+ const lighter = Math.max(lA, lB);
65
+ const darker = Math.min(lA, lB);
66
+ return (lighter + 0.05) / (darker + 0.05);
67
+ }
68
+ /**
69
+ * The `candidates` entry with the highest WCAG contrast ratio against
70
+ * `background`. Defaults to plain white/black; pass a grayscale or other
71
+ * palette to widen the choice. Falls back to the first candidate when
72
+ * `background` can't be parsed.
73
+ */
74
+ export function contrastTextColor(background, candidates = ['#ffffff', '#000000']) {
75
+ if (candidates.length === 0)
76
+ return '#000000';
77
+ let best = candidates[0];
78
+ let bestRatio = -Infinity;
79
+ for (const candidate of candidates) {
80
+ const ratio = contrastRatio(background, candidate);
81
+ if (ratio > bestRatio) {
82
+ bestRatio = ratio;
83
+ best = candidate;
84
+ }
85
+ }
86
+ return best;
87
+ }
26
88
  export function makeColorScale(min, max, colorMin, colorMax) {
27
89
  const resolvedMin = resolveCssColor(colorMin);
28
90
  const resolvedMax = resolveCssColor(colorMax);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.47",
3
+ "version": "0.0.48",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"