@humanforest/charts 0.3.5 → 0.3.7

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/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ Every @humanforest package shares one version, so one entry covers all seven packages. Newest first.
4
+
5
+ ## 0.3.7 — 2026-09-13
6
+
7
+ ### Features
8
+
9
+ - **ui**: add FMapContextMenu, a right-click menu on the map
10
+ - **charts**: chart frames and sparklines can be named for screen readers
11
+
12
+ ### Fixes
13
+
14
+ - **charts**: CellScale types what useCellScale returns
15
+ - **charts**: an empty distribution bar is still named, as "No data"
16
+ - **charts**: a bar sparkline warns that :curve="false" is ignored
17
+ - **charts**: a sparkline leaves non-finite readings off its scale
18
+ - **tokens**: the sub-logo builder passes noUncheckedIndexedAccess
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@humanforest/charts",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "type": "module",
5
5
  "files": [
6
- "src"
6
+ "src",
7
+ "CHANGELOG.md"
7
8
  ],
8
9
  "exports": {
9
10
  ".": "./src/index.ts",
@@ -13,6 +14,9 @@
13
14
  "./forestTooltip": "./src/forestTooltip.ts",
14
15
  "./useChartRepaintKey": "./src/useChartRepaintKey.ts"
15
16
  },
17
+ "scripts": {
18
+ "test": "vitest run"
19
+ },
16
20
  "peerDependencies": {
17
21
  "@nuxt/ui": "^4",
18
22
  "@unovis/ts": "^1.6.7",
@@ -21,10 +25,11 @@
21
25
  },
22
26
  "devDependencies": {
23
27
  "@unovis/ts": "^1.6.7",
28
+ "vitest": "^4.1.10",
24
29
  "vue": "^3.5.22"
25
30
  },
26
31
  "dependencies": {
27
- "@humanforest/tokens": "^0.3.5"
32
+ "@humanforest/tokens": "^0.3.7"
28
33
  },
29
34
  "publishConfig": {
30
35
  "access": "public"
@@ -6,7 +6,7 @@
6
6
  // Deliberately does one thing. No data, no ramp, no labels, no wards, no selection — the moment a
7
7
  // caller wants any of those they want FLondonMap. Keeping this presentational is the whole point.
8
8
  import { computed } from 'vue';
9
- import { LONDON_BOROUGHS, indexAreas } from './londonAreas';
9
+ import { LONDON_BOROUGHS, frameArea } from './londonAreas';
10
10
 
11
11
  const props = withDefaults(
12
12
  defineProps<{
@@ -24,22 +24,14 @@ const props = withDefaults(
24
24
  );
25
25
 
26
26
  // Boroughs only — the ward set is loaded on demand elsewhere, and a glyph has no reason to pull it.
27
- const area = computed(() => indexAreas(LONDON_BOROUGHS.areas).get(props.borough.toLowerCase()));
28
-
29
- // Framed to the shape's own bounds, from the stored box — no path parsing. An unknown borough draws
30
- // nothing rather than a stray full-London frame.
31
- const viewBox = computed(() => {
32
- const a = area.value;
33
- if (!a) return undefined;
34
- const p = Math.max(a.w, a.h) * props.pad;
35
- return `${a.x - p} ${a.y - p} ${a.w + p * 2} ${a.h + p * 2}`;
36
- });
27
+ // Framed to the shape's own bounds from the stored box, no path parsing.
28
+ const framed = computed(() => frameArea(LONDON_BOROUGHS.areas, props.borough, props.pad));
37
29
  </script>
38
30
 
39
31
  <template>
40
32
  <svg
41
- v-if="area"
42
- :viewBox="viewBox"
33
+ v-if="framed"
34
+ :viewBox="framed.viewBox"
43
35
  xmlns="http://www.w3.org/2000/svg"
44
36
  :role="label ? 'img' : undefined"
45
37
  :aria-label="label"
@@ -47,6 +39,6 @@ const viewBox = computed(() => {
47
39
  >
48
40
  <!-- currentColor so a text-* class paints the fill; non-scaling-stroke keeps any border the
49
41
  caller adds one hairline whatever the rendered size. -->
50
- <path :d="area.d" fill="currentColor" vector-effect="non-scaling-stroke" />
42
+ <path :d="framed.area.d" fill="currentColor" vector-effect="non-scaling-stroke" />
51
43
  </svg>
52
44
  </template>
@@ -15,7 +15,7 @@ import type {
15
15
  } from '@unovis/ts';
16
16
  // Relative file imports (not the package barrel): the barrel exports this component, and a
17
17
  // self-import through it would create a module cycle.
18
- import { fromCategories } from './fromCategories';
18
+ import { chartLabel, fromCategories } from './fromCategories';
19
19
  import { forestTooltip, type ForestTooltipOptions } from './forestTooltip';
20
20
  import { motionDuration } from './motionDuration';
21
21
  import {
@@ -70,6 +70,8 @@ const props = withDefaults(
70
70
  duration?: number;
71
71
  /** Extra signals that must remount the chart (theme flips and palette identity are automatic). */
72
72
  repaintOn?: unknown[];
73
+ /** The chart's accessible name. Defaults to its series, as "Chart: Trips, Utilisation". */
74
+ ariaLabel?: string;
73
75
  }>(),
74
76
  {
75
77
  height: 280,
@@ -164,7 +166,12 @@ const repaintKey = computed(() =>
164
166
  </script>
165
167
 
166
168
  <template>
167
- <div ref="rootEl" style="display: flex; flex-direction: column; gap: 0.75rem">
169
+ <div
170
+ ref="rootEl"
171
+ role="img"
172
+ :aria-label="chartLabel(categories, ariaLabel)"
173
+ style="display: flex; flex-direction: column; gap: 0.75rem"
174
+ >
168
175
  <VisXYContainer
169
176
  :key="repaintKey"
170
177
  :data="data"
@@ -8,7 +8,7 @@ import { VisSingleContainer, VisTooltip, VisBulletLegend } from '@unovis/vue';
8
8
  import type { BulletLegendItemInterface, TooltipConfigInterface } from '@unovis/ts';
9
9
  // Relative file imports (not the package barrel): the barrel exports this component, and a
10
10
  // self-import through it would create a module cycle.
11
- import { fromCategories } from './fromCategories';
11
+ import { chartLabel, fromCategories } from './fromCategories';
12
12
  import { motionDuration } from './motionDuration';
13
13
  import { tooltipDefaults, tooltipContainer } from './presets';
14
14
  import { useThemeVersion } from './useThemeVersion';
@@ -37,6 +37,9 @@ const props = withDefaults(
37
37
  duration?: number;
38
38
  /** Extra signals that must remount the chart (theme flips and palette identity are automatic). */
39
39
  repaintOn?: unknown[];
40
+ /** The chart's accessible name. Defaults to its categories, as "Chart: Trips, Utilisation", or
41
+ * "Chart" without any — a chart that labels directly should name itself here. */
42
+ ariaLabel?: string;
40
43
  }>(),
41
44
  { height: 260, legend: true, legendPosition: 'bottom', followCursor: false, repaintOn: () => [] },
42
45
  );
@@ -71,7 +74,12 @@ const repaintKey = computed(() =>
71
74
  </script>
72
75
 
73
76
  <template>
74
- <div ref="rootEl" style="display: flex; flex-direction: column; gap: 0.75rem">
77
+ <div
78
+ ref="rootEl"
79
+ role="img"
80
+ :aria-label="chartLabel(categories, ariaLabel)"
81
+ style="display: flex; flex-direction: column; gap: 0.75rem"
82
+ >
75
83
  <VisSingleContainer
76
84
  :key="repaintKey"
77
85
  :data="data"
@@ -26,6 +26,7 @@
26
26
  import { computed, ref } from 'vue';
27
27
  import { categoricalVar, type CategoricalPalette } from './useChartPalette';
28
28
  import {
29
+ distributionLabel,
29
30
  distributionShares,
30
31
  formatShare,
31
32
  segmentRadius,
@@ -105,17 +106,7 @@ const trackCount = computed(() => drawn.value.length + (shares.value.remainder >
105
106
  const radiusAt = (index: number) =>
106
107
  segmentRadius(index, trackCount.value, props.height / 2, props.joined);
107
108
 
108
- // The bar's lengths are the only place the shares are stated unless `showShare` is on, so the
109
- // sentence carries them regardless.
110
- const label = computed(() => {
111
- if (props.ariaLabel) return props.ariaLabel;
112
- const parts = shares.value.parts.map(
113
- (p) => `${p.segment.label} ${formatShare(p.share, props.locale)}`,
114
- );
115
- if (shares.value.remainder > 0)
116
- parts.push(`unaccounted ${formatShare(shares.value.remainder, props.locale)}`);
117
- return parts.join(', ');
118
- });
109
+ const label = computed(() => props.ariaLabel || distributionLabel(shares.value, props.locale));
119
110
 
120
111
  const interactive = computed(() => props.tooltip !== false);
121
112
 
@@ -18,7 +18,7 @@ import { VisXYContainer, VisLine, VisArea } from '@unovis/vue';
18
18
  import { CurveType } from '@unovis/ts';
19
19
  import { motionDuration } from './motionDuration';
20
20
  import { useThemeVersion } from './useThemeVersion';
21
- import { sparklineDomain } from './sparklineDomain';
21
+ import { sparklineBar, sparklineBarDomain, sparklineDomain, sparklineEndpoint } from './sparklineDomain';
22
22
 
23
23
  const props = withDefaults(
24
24
  defineProps<{
@@ -74,9 +74,15 @@ const props = withDefaults(
74
74
  * counts belong to them.
75
75
  *
76
76
  * `compare`, `area`, `endpoint` and `curve` are line-only; a dev-mode warning fires if one is
77
- * passed alongside `bar` rather than being silently dropped.
77
+ * set alongside `bar` rather than being silently dropped. `curve` is on by default, so for it
78
+ * the warning is for `:curve="false"`.
78
79
  */
79
80
  mark?: 'line' | 'bar';
81
+ /**
82
+ * The graphic's accessible name. With it the sparkline is announced as an image; without it it is
83
+ * decorative and hidden from the accessibility tree, the reading being the figure beside it.
84
+ */
85
+ ariaLabel?: string;
80
86
  }>(),
81
87
  { height: 32, color: 'var(--ui-primary)', area: false, endpoint: false, fill: false, curve: true, mark: 'line', compare: undefined, compareColor: undefined },
82
88
  );
@@ -84,7 +90,10 @@ const props = withDefaults(
84
90
  if (import.meta.env.DEV) {
85
91
  watchEffect(() => {
86
92
  if (props.mark !== 'bar') return;
87
- const ignored = (['compare', 'area', 'endpoint'] as const).filter((k) => props[k]);
93
+ const ignored = [
94
+ ...(['compare', 'area', 'endpoint'] as const).filter((k) => props[k]),
95
+ ...(props.curve ? [] : ['curve']),
96
+ ];
88
97
  if (ignored.length)
89
98
  console.warn(`[FSparkline] \`${ignored.join('`, `')}\` ${ignored.length > 1 ? 'are' : 'is'} line-only and ignored under mark="bar".`);
90
99
  });
@@ -114,47 +123,24 @@ const hasCompare = computed(() => !!props.compare?.some((v) => v !== null && v !
114
123
  // the floor at 0 it runs from 0 up to each reading's height above the floor.
115
124
  const frame = computed(() => sparklineDomain(props.data, props.compare, props.area));
116
125
 
117
- // Bars are the exception: they are measured from zero, not from the data's own floor. A bar reads
118
- // as a LENGTH, and a bar that starts anywhere but zero has a length no longer proportional to its
119
- // value. The bar domain therefore always contains zero, which also puts negative readings below the
120
- // baseline instead of quietly flipping them.
121
- const barDomain = computed(() => {
122
- const vals = props.data.filter((v): v is number => v !== null);
123
- const lo = Math.min(0, ...vals);
124
- const hi = Math.max(0, ...vals);
125
- return { lo, span: hi - lo || 1 };
126
- });
127
-
128
- /** Bottom offset and height as percentages, or null for a gap. */
129
- const barFor = (v: number | null) => {
130
- if (v === null) return null;
131
- const { lo, span } = barDomain.value;
132
- const zero = (0 - lo) / span;
133
- const value = (v - lo) / span;
134
- const from = Math.min(zero, value);
135
- const to = Math.max(zero, value);
136
- return {
137
- bottom: `${from * 100}%`,
138
- // A reading of zero keeps a hairline. Zero is a real measurement and a gap is the absence of
139
- // one; collapsing the bar to nothing would render the two identically.
140
- height: `max(${(to - from) * 100}%, 1px)`,
141
- };
142
- };
126
+ // Bars are the exception: measured from zero, not from the data's own floor. Why lives in
127
+ // sparklineBarDomain.
128
+ const barDomain = computed(() => sparklineBarDomain(props.data));
129
+ const barFor = (v: number | null) => sparklineBar(v, barDomain.value);
143
130
 
144
- const last = computed(() => {
145
- for (let i = props.data.length - 1; i >= 0; i--) if (props.data[i] !== null) return props.data[i] as number;
146
- return null;
147
- });
148
- // Fraction of the height the final point sits at, for the endpoint dot.
149
- const endpointTop = computed(() => {
150
- const [lo, hi] = frame.value.domain ?? [0, 0];
151
- if (last.value === null || hi === lo) return 50;
152
- return (1 - (last.value - frame.value.floor - lo) / (hi - lo)) * 100;
153
- });
131
+ // Percentage down the height for the endpoint dot, or null when there is no reading to mark.
132
+ const endpointTop = computed(() => sparklineEndpoint(props.data, frame.value));
154
133
  </script>
155
134
 
156
135
  <template>
157
- <div class="relative w-full" :class="fill ? 'h-full' : ''" :style="fill ? { minHeight: `${height}px`, color } : { height: `${height}px`, color }">
136
+ <div
137
+ class="relative w-full"
138
+ :class="fill ? 'h-full' : ''"
139
+ :style="fill ? { minHeight: `${height}px`, color } : { height: `${height}px`, color }"
140
+ :role="ariaLabel ? 'img' : undefined"
141
+ :aria-label="ariaLabel || undefined"
142
+ :aria-hidden="ariaLabel ? undefined : 'true'"
143
+ >
158
144
  <!-- Plain DOM rather than a mark: there are no axes, scales or tooltip to share with the XY
159
145
  container, so a flex row of columns is the whole implementation and costs no chart runtime.
160
146
  The 2px gap is fixed, so the bucket count sets how thin the bars get: n buckets across w
@@ -162,7 +148,7 @@ const endpointTop = computed(() => {
162
148
  2.7px — still bars; past roughly 60 the gap outweighs the bar and the row reads as a
163
149
  texture, where the measure belongs to a line instead. Each bar is rounded at both ends:
164
150
  no baseline is drawn for a square end to sit on. -->
165
- <div v-if="mark === 'bar'" class="flex w-full items-end gap-0.5" :class="fill ? 'absolute inset-0' : 'h-full'" aria-hidden="true">
151
+ <div v-if="mark === 'bar'" class="flex w-full items-end gap-0.5" :class="fill ? 'absolute inset-0' : 'h-full'">
166
152
  <span
167
153
  v-for="(v, i) in data"
168
154
  :key="i"
@@ -211,7 +197,7 @@ const endpointTop = computed(() => {
211
197
  <VisLine :x="x" :y="y" :color="color" :line-width="1.5" :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear" />
212
198
  </VisXYContainer>
213
199
  <span
214
- v-if="mark === 'line' && endpoint && last !== null"
200
+ v-if="mark === 'line' && endpoint && endpointTop !== null"
215
201
  class="pointer-events-none absolute right-0 size-1.5 -translate-y-1/2 rounded-full bg-current"
216
202
  :style="{ top: `${endpointTop}%` }"
217
203
  aria-hidden="true"
@@ -88,6 +88,18 @@ export function segmentRadius(index: number, count: number, radius: number, join
88
88
  return '0';
89
89
  }
90
90
 
91
+ /**
92
+ * The bar's sentence for assistive tech: each part with its share, then any remainder, as
93
+ * "Food 62%, Software 24%, unaccounted 14%". The bar's lengths are the only place the shares are
94
+ * stated unless `showShare` is on, so the sentence carries them regardless. A bar with nothing to
95
+ * state is "No data", because the element carrying it needs a non-empty name.
96
+ */
97
+ export function distributionLabel(shares: DistributionShares, locale = 'en-GB'): string {
98
+ const parts = shares.parts.map((p) => `${p.segment.label} ${formatShare(p.share, locale)}`);
99
+ if (shares.remainder > 0) parts.push(`unaccounted ${formatShare(shares.remainder, locale)}`);
100
+ return parts.join(', ') || 'No data';
101
+ }
102
+
91
103
  /**
92
104
  * A share as a percentage. Whole numbers: at the width this component is read at, a decimal place
93
105
  * is precision the bar itself cannot show. A non-zero share that would round to nothing is stated
@@ -97,7 +97,7 @@ export function forestTooltip<Datum extends Record<string, unknown>>(
97
97
  const present = keys.filter((k) => k in d);
98
98
  const title = opts.titleFormatter ? opts.titleFormatter(d) : Object.values(d)[0];
99
99
  const rows: ForestTipRow[] = present.map((k, i) => {
100
- const item = categories[k];
100
+ const item = categories[k]!;
101
101
  const raw = d[k];
102
102
  return {
103
103
  swatch: typeof item.color === 'string' ? item.color : `var(--vis-color${i})`,
@@ -35,9 +35,22 @@ export function fromCategories<Datum extends Record<string, unknown>>(
35
35
  : Array.isArray(item.color) && typeof item.color[0] === 'string' ? item.color[0]
36
36
  : `var(--vis-color${i})`;
37
37
  return {
38
- colors: keys.map((k, i) => colorOf(categories[k], i)),
39
- legendItems: keys.map((k, i) => ({ ...categories[k], color: colorOf(categories[k], i) })),
38
+ colors: keys.map((k, i) => colorOf(categories[k]!, i)),
39
+ legendItems: keys.map((k, i) => ({ ...categories[k]!, color: colorOf(categories[k]!, i) })),
40
40
  yAccessors: keys.map((k) => (d: Datum) => d[k] as number | null | undefined),
41
41
  xAccessor: xKey ? (d) => d[xKey] as number : (_d, i) => i,
42
42
  };
43
43
  }
44
+
45
+ /**
46
+ * A chart frame's accessible name: `ariaLabel` when given, otherwise the series it plots, as
47
+ * "Chart: Trips, Utilisation". With no categories it is "Chart".
48
+ */
49
+ export function chartLabel(
50
+ categories: Record<string, BulletLegendItemInterface> | undefined,
51
+ ariaLabel?: string,
52
+ ): string {
53
+ if (ariaLabel) return ariaLabel;
54
+ const names = Object.entries(categories ?? {}).map(([key, item]) => String(item.name ?? key));
55
+ return names.length ? `Chart: ${names.join(', ')}` : 'Chart';
56
+ }
@@ -39,9 +39,9 @@ export const LONDON_BOROUGHS = boroughs as unknown as LondonAreaSet;
39
39
  /**
40
40
  * The wards, fetched on first use.
41
41
  *
42
- * Wards are three times the weight of the boroughs and most maps never draw them, so they are a
43
- * separate chunk rather than part of the barrel. The promise is cached, so concurrent callers and
44
- * re-renders share one request.
42
+ * Wards are more than four times the weight of the boroughs and most maps never draw them, so they
43
+ * are a separate chunk rather than part of the barrel. The promise is cached, so concurrent callers
44
+ * and re-renders share one request.
45
45
  */
46
46
  let wardsPromise: Promise<LondonAreaSet> | null = null;
47
47
  export const loadLondonWards = (): Promise<LondonAreaSet> => {
@@ -73,3 +73,19 @@ export const indexAreas = (areas: LondonArea[]): Map<string, LondonArea> => {
73
73
  }
74
74
  return byKey;
75
75
  };
76
+
77
+ /**
78
+ * One area framed to itself: the area `key` names (by code or name, either case) and a viewBox of
79
+ * its stored box, padded on every side by `pad` × its longer side. Undefined for a key the set does
80
+ * not hold, so an unknown area draws nothing rather than a stray full-London frame.
81
+ */
82
+ export const frameArea = (
83
+ areas: LondonArea[],
84
+ key: string,
85
+ pad: number,
86
+ ): { area: LondonArea; viewBox: string } | undefined => {
87
+ const area = indexAreas(areas).get(key.toLowerCase());
88
+ if (!area) return undefined;
89
+ const p = Math.max(area.w, area.h) * pad;
90
+ return { area, viewBox: `${area.x - p} ${area.y - p} ${area.w + p * 2} ${area.h + p * 2}` };
91
+ };
package/src/resolveVar.ts CHANGED
@@ -16,6 +16,6 @@
16
16
  /** The computed value of a `var(--x)` string. Non-var strings pass through untouched. */
17
17
  export function resolveVar(value: string, context: Element = document.documentElement): string {
18
18
  if (typeof window === 'undefined' || !value.startsWith('var(--')) return value;
19
- const name = value.slice(4, -1).split(',')[0].trim();
19
+ const name = value.slice(4, -1).split(',')[0]!.trim();
20
20
  return getComputedStyle(context).getPropertyValue(name).trim() || value;
21
21
  }
@@ -40,7 +40,7 @@ const clamp = (v: number, lo: number, hi: number): number => Math.min(Math.max(v
40
40
  * for a donut arc that lands at mid-angle, mid-radius — the middle of the visible band — where the
41
41
  * box centre would land in the hole, or outside the wedge entirely on a small segment. Anything
42
42
  * without a length (rects) falls back to its box, which for those IS the mark. */
43
- function shapeCentre(el: SVGGraphicsElement): { x: number; y: number } {
43
+ export function shapeCentre(el: SVGGraphicsElement): { x: number; y: number } {
44
44
  const path = el as SVGPathElement;
45
45
  if (typeof path.getTotalLength === 'function') {
46
46
  const total = path.getTotalLength();
@@ -63,6 +63,31 @@ function shapeCentre(el: SVGGraphicsElement): { x: number; y: number } {
63
63
 
64
64
  export type SnapMode = 'crosshair' | 'mark';
65
65
 
66
+ /** Where the panel lands, in viewport pixels: beside its anchor, then held EDGE inside the viewport. */
67
+ export function tipPlacement(
68
+ mode: SnapMode,
69
+ anchor: { x: number; y: number },
70
+ box: { x: number; y: number; width: number },
71
+ tip: { width: number; height: number },
72
+ viewport: { width: number; height: number },
73
+ ): { left: number; top: number } {
74
+ // Which way the panel opens, and the two modes want opposite answers. A crosshair spans the
75
+ // plot, so the empty half is the one the line is NOT in: open away from it, which also puts the
76
+ // panel away from the pointer. A mark sits somewhere in the chart with the rest of the chart
77
+ // around it, so opening inward would lay the panel over the other marks — over the donut's hole
78
+ // and its central label, in the case that made this obvious. Open outward instead.
79
+ const cx = box.x + box.width / 2;
80
+ const toRight = mode === 'crosshair' ? anchor.x <= cx : anchor.x > cx;
81
+ const left = toRight ? anchor.x + GAP : anchor.x - tip.width - GAP;
82
+ // A crosshair panel rides at the top of the plot: fixed, so it never drifts vertically with a
83
+ // pointer whose y means nothing to the reading. A mark panel centres on its own shape.
84
+ const top = mode === 'crosshair' ? box.y + GAP : anchor.y - tip.height / 2;
85
+ return {
86
+ left: clamp(left, EDGE, viewport.width - tip.width - EDGE),
87
+ top: clamp(top, EDGE, viewport.height - tip.height - EDGE),
88
+ };
89
+ }
90
+
66
91
  /** Installs the snap on a frame. `enabled` is read live so a frame can hand positioning back to
67
92
  * Unovis (the `followCursor` escape hatch) without tearing anything down. */
68
93
  export function useTooltipSnap(
@@ -104,24 +129,17 @@ export function useTooltipSnap(
104
129
  const anchor = anchorFor(host);
105
130
  if (!anchor) return;
106
131
 
107
- const w = tip.offsetWidth;
108
- const h = tip.offsetHeight;
109
- const box = host.getBoundingClientRect();
110
- // Which way the panel opens, and the two modes want opposite answers. A crosshair spans the
111
- // plot, so the empty half is the one the line is NOT in: open away from it, which also puts the
112
- // panel away from the pointer. A mark sits somewhere in the chart with the rest of the chart
113
- // around it, so opening inward would lay the panel over the other marks — over the donut's hole
114
- // and its central label, in the case that made this obvious. Open outward instead.
115
- const cx = box.x + box.width / 2;
116
- const toRight = mode === 'crosshair' ? anchor.x <= cx : anchor.x > cx;
117
- const left = toRight ? anchor.x + GAP : anchor.x - w - GAP;
118
- // A crosshair panel rides at the top of the plot: fixed, so it never drifts vertically with a
119
- // pointer whose y means nothing to the reading. A mark panel centres on its own shape.
120
- const top = mode === 'crosshair' ? box.y + GAP : anchor.y - h / 2;
132
+ const { left, top } = tipPlacement(
133
+ mode,
134
+ anchor,
135
+ host.getBoundingClientRect(),
136
+ { width: tip.offsetWidth, height: tip.offsetHeight },
137
+ { width: window.innerWidth, height: window.innerHeight },
138
+ );
121
139
 
122
140
  writing = true;
123
- tip.style.left = `${clamp(left, EDGE, window.innerWidth - w - EDGE)}px`;
124
- tip.style.top = `${clamp(top, EDGE, window.innerHeight - h - EDGE)}px`;
141
+ tip.style.left = `${left}px`;
142
+ tip.style.top = `${top}px`;
125
143
  // Unovis positions by `bottom` when the tooltip is not attached to body; clear it so our `top`
126
144
  // is the only vertical rule in play whichever container the frame ends up with.
127
145
  tip.style.bottom = 'unset';
@@ -1,3 +1,8 @@
1
+ // FSparkline's geometry, kept out of the SFC so it can be tested without a DOM.
2
+
3
+ /** A reading that can be drawn. Null is a gap, and NaN or ±Infinity has no place on a scale. */
4
+ const drawable = (v: number | null | undefined): v is number => Number.isFinite(v);
5
+
1
6
  /**
2
7
  * The frame a sparkline's line marks are plotted in: the floor and ceiling of everything drawn,
3
8
  * shared by the series and its comparison, or null when there is nothing to draw so the container
@@ -16,10 +21,65 @@ export function sparklineDomain(
16
21
  compare: (number | null)[] | undefined,
17
22
  area: boolean,
18
23
  ): { floor: number; domain: [number, number] | null } {
19
- const vals = [...data, ...(compare ?? [])].filter((v): v is number => typeof v === 'number');
24
+ const vals = [...data, ...(compare ?? [])].filter(drawable);
20
25
  if (!vals.length) return { floor: 0, domain: null };
21
26
  const lo = Math.min(...vals);
22
27
  const hi = Math.max(...vals);
23
28
  const floor = area ? lo : 0;
24
29
  return { floor, domain: [lo - floor, hi - floor] };
25
30
  }
31
+
32
+ export interface SparklineBarDomain {
33
+ lo: number;
34
+ span: number;
35
+ }
36
+
37
+ /**
38
+ * The bars' range, as its lowest value and its span. Bars are measured from zero, not from the
39
+ * data's own floor like the line: a bar reads as a LENGTH, and a bar that starts anywhere but zero
40
+ * has a length no longer proportional to its value. The range therefore always contains zero, which
41
+ * also puts negative readings below the baseline instead of quietly flipping them. A range with no
42
+ * span is given a span of 1, so an all-zero series divides by something.
43
+ */
44
+ export function sparklineBarDomain(data: (number | null)[]): SparklineBarDomain {
45
+ const vals = data.filter(drawable);
46
+ const lo = Math.min(0, ...vals);
47
+ const hi = Math.max(0, ...vals);
48
+ return { lo, span: hi - lo || 1 };
49
+ }
50
+
51
+ /** One bar's bottom offset and height as CSS percentages, from zero to the reading, or null for a gap. */
52
+ export function sparklineBar(
53
+ v: number | null,
54
+ { lo, span }: SparklineBarDomain,
55
+ ): { bottom: string; height: string } | null {
56
+ if (!drawable(v)) return null;
57
+ const zero = (0 - lo) / span;
58
+ const value = (v - lo) / span;
59
+ const from = Math.min(zero, value);
60
+ const to = Math.max(zero, value);
61
+ return {
62
+ bottom: `${from * 100}%`,
63
+ // A reading of zero keeps a hairline. Zero is a real measurement and a gap is the absence of
64
+ // one; collapsing the bar to nothing would render the two identically.
65
+ height: `max(${(to - from) * 100}%, 1px)`,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Where the endpoint dot sits: the last drawable reading's distance down the frame, as a
71
+ * percentage, or null when there is no reading to mark. A frame with no span puts the dot at 50%,
72
+ * the middle of the height, where the flat line itself is drawn.
73
+ */
74
+ export function sparklineEndpoint(
75
+ data: (number | null)[],
76
+ { floor, domain }: { floor: number; domain: [number, number] | null },
77
+ ): number | null {
78
+ for (let i = data.length - 1; i >= 0; i--) {
79
+ const v = data[i];
80
+ if (!drawable(v)) continue;
81
+ const [lo, hi] = domain ?? [0, 0];
82
+ return hi === lo ? 50 : (1 - (v - floor - lo) / (hi - lo)) * 100;
83
+ }
84
+ return null;
85
+ }
@@ -1,4 +1,4 @@
1
- // The cell colour contract, shared by FStatusTrack and FCalendarGrid.
1
+ // The cell colour contract, shared by FStatusTrack, FCalendarGrid, FDottedMap and FLondonMap.
2
2
  //
3
3
  // Cell components answer two different questions with the same rectangle: which STATE a bucket was
4
4
  // in, and how MUCH happened in it. Both resolve a datum to a colour and need a legend, so the
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // Empty is a third outcome, not a zero and not a first state: a bucket with no reading and a bucket
8
8
  // reading zero are different claims, and colouring them alike asserts something the data does not.
9
- import { computed, unref, type MaybeRef } from 'vue';
9
+ import { computed, unref, type ComputedRef, type MaybeRef } from 'vue';
10
10
  import type { BulletLegendItemInterface } from '@unovis/ts';
11
11
  import { sequentialVar } from './useChartPalette';
12
12
  import { DATAVIZ_SEQUENTIAL } from './engine';
@@ -46,9 +46,12 @@ export interface CellScaleOptions<D> {
46
46
  export interface CellScale<D> {
47
47
  colorOf: (d: D | null | undefined) => string;
48
48
  isEmpty: (d: D | null | undefined) => boolean;
49
- legend: CellLegend;
49
+ /** The descriptor FCellLegend draws, as `scale.legend.value`. */
50
+ legend: ComputedRef<CellLegend>;
50
51
  /** The resolved intensity domain — read it back when a caller wants to label the ends. */
51
- domain: [number, number];
52
+ domain: ComputedRef<[number, number]>;
53
+ /** The ramp sampled into `steps` stops, low → high, reversed in dark mode. */
54
+ swatches: ComputedRef<string[]>;
52
55
  }
53
56
 
54
57
  /** The fill for a bucket with no reading. A token, so it flips with the mode like everything else. */
@@ -70,7 +73,7 @@ const prefersInvertedRamp = (): boolean => {
70
73
  return root.classList.contains('dark') || root.dataset.theme === 'dark';
71
74
  };
72
75
 
73
- export function useCellScale<D>(options: CellScaleOptions<D>) {
76
+ export function useCellScale<D>(options: CellScaleOptions<D>): CellScale<D> {
74
77
  const stepsOf = () => Math.max(2, unref(options.steps) ?? 5);
75
78
  // Read so the sampling recomputes on a colour-mode flip; the value itself is not used.
76
79
  const themeVersion = useThemeVersion();
@@ -118,9 +121,9 @@ export function useCellScale<D>(options: CellScaleOptions<D>) {
118
121
  const stops = swatches.value;
119
122
  // A flat domain has no gradient to express — everything sits at the top stop rather than
120
123
  // dividing by zero.
121
- if (hi <= lo) return stops[stops.length - 1];
124
+ if (hi <= lo) return stops[stops.length - 1]!;
122
125
  const t = Math.min(1, Math.max(0, (v - lo) / (hi - lo)));
123
- return stops[Math.min(stops.length - 1, Math.floor(t * stops.length))];
126
+ return stops[Math.min(stops.length - 1, Math.floor(t * stops.length))]!;
124
127
  }
125
128
  const states = unref(options.states) ?? {};
126
129
  const key = options.state!(d as D) as string;