@humanforest/charts 0.3.6 → 0.4.0

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.
@@ -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,85 @@ export const indexAreas = (areas: LondonArea[]): Map<string, LondonArea> => {
73
73
  }
74
74
  return byKey;
75
75
  };
76
+
77
+ /**
78
+ * What a London map's areas take: pointer affordance (`interactive`) and a tab stop (`focusable`).
79
+ *
80
+ * Selection is an action the caller asked for, so a selectable map takes clicks and focus at every
81
+ * size and level, whatever the chrome. A tooltip is chrome: it needs chrome on, and reaches the
82
+ * keyboard only at borough level, where 33 stops is navigation rather than 704 obstacles.
83
+ */
84
+ export const areaInteraction = (map: {
85
+ level: AreaLevel;
86
+ chrome: boolean;
87
+ tooltip: boolean;
88
+ selectable: boolean;
89
+ }): { interactive: boolean; focusable: boolean } => {
90
+ const hover = map.tooltip && map.chrome;
91
+ return {
92
+ interactive: map.selectable || hover,
93
+ focusable: map.selectable || (hover && map.level === 'borough'),
94
+ };
95
+ };
96
+
97
+ /**
98
+ * One area framed to itself: the area `key` names (by code or name, either case) and a viewBox of
99
+ * its stored box, padded on every side by `pad` × its longer side. Undefined for a key the set does
100
+ * not hold, so an unknown area draws nothing rather than a stray full-London frame.
101
+ */
102
+ export const frameArea = (
103
+ areas: LondonArea[],
104
+ key: string,
105
+ pad: number,
106
+ ): { area: LondonArea; viewBox: string } | undefined => {
107
+ const area = indexAreas(areas).get(key.toLowerCase());
108
+ if (!area) return undefined;
109
+ const p = Math.max(area.w, area.h) * pad;
110
+ return { area, viewBox: `${area.x - p} ${area.y - p} ${area.w + p * 2} ${area.h + p * 2}` };
111
+ };
112
+
113
+ const FIT_PAD = 0.06;
114
+ const LABEL_EM = 9;
115
+
116
+ /**
117
+ * What a map of `set` draws, the viewBox it draws it in, and which drawn areas carry a name.
118
+ *
119
+ * `fit: 'selection'` draws only the areas whose codes `matched` holds, framed to the union of their
120
+ * bounding boxes padded by 6% of its longer side. With `fit: 'city'`, or nothing matched, it draws
121
+ * every area in the set's viewBox. `font` is the label size in viewBox units at the rendered `width`.
122
+ *
123
+ * With `labels` set, areas are named biggest bounding box first, and a label whose box would overlap
124
+ * one already placed is dropped: a neighbour's label can reach across an area that has room by its
125
+ * own box.
126
+ */
127
+ export const layoutAreas = (
128
+ set: LondonAreaSet,
129
+ map: { matched: ReadonlySet<string>; fit: 'city' | 'selection'; width: number; labels: boolean },
130
+ ): { drawn: LondonArea[]; frame: readonly number[]; font: number; labelled: LondonArea[] } => {
131
+ const fitted = map.fit === 'selection' && set.areas.some((a) => map.matched.has(a.code));
132
+ const drawn = fitted ? set.areas.filter((a) => map.matched.has(a.code)) : set.areas;
133
+ let frame: readonly number[] = set.viewBox;
134
+ if (fitted) {
135
+ const x0 = Math.min(...drawn.map((a) => a.x));
136
+ const y0 = Math.min(...drawn.map((a) => a.y));
137
+ const x1 = Math.max(...drawn.map((a) => a.x + a.w));
138
+ const y1 = Math.max(...drawn.map((a) => a.y + a.h));
139
+ const pad = Math.max(x1 - x0, y1 - y0) * FIT_PAD;
140
+ frame = [x0 - pad, y0 - pad, x1 - x0 + pad * 2, y1 - y0 + pad * 2];
141
+ }
142
+ const font = (frame[2]! / map.width) * LABEL_EM;
143
+ const labelled: LondonArea[] = [];
144
+ if (map.labels) {
145
+ const placed: { x0: number; x1: number; y0: number; y1: number }[] = [];
146
+ for (const a of [...drawn].sort((p, q) => q.w * q.h - p.w * p.h)) {
147
+ // Average glyph advance at small sizes: enough to reserve room without measuring text.
148
+ const halfW = (a.name.length * font * 0.55) / 2;
149
+ const halfH = font * 0.75;
150
+ const box = { x0: a.cx - halfW, x1: a.cx + halfW, y0: a.cy - halfH, y1: a.cy + halfH };
151
+ if (placed.some((p) => box.x0 < p.x1 && box.x1 > p.x0 && box.y0 < p.y1 && box.y1 > p.y0)) continue;
152
+ placed.push(box);
153
+ labelled.push(a);
154
+ }
155
+ }
156
+ return { drawn, frame, font, labelled };
157
+ };
package/src/londonGrid.ts CHANGED
@@ -65,3 +65,30 @@ export const dotAt = (grid: LondonGrid, lat: number, lng: number): [number, numb
65
65
  };
66
66
 
67
67
  export const dotKey = (col: number, row: number) => `${col},${row}`;
68
+
69
+ /**
70
+ * Sums points into their dot. A point off the grid, or on a cell not in `land`, is dropped and
71
+ * counted. Without `value` each point counts one; a null, undefined or non-finite return adds nothing.
72
+ */
73
+ export const binPoints = <P>(
74
+ grid: LondonGrid,
75
+ land: Set<string>,
76
+ points: readonly P[],
77
+ lat: (p: P) => number,
78
+ lng: (p: P) => number,
79
+ value?: (p: P) => number | null | undefined,
80
+ ) => {
81
+ const totals = new Map<string, number>();
82
+ let dropped = 0;
83
+ for (const p of points) {
84
+ const at = dotAt(grid, lat(p), lng(p));
85
+ const key = at && dotKey(at[0], at[1]);
86
+ if (!key || !land.has(key)) {
87
+ dropped++;
88
+ continue;
89
+ }
90
+ const v = value ? value(p) : 1;
91
+ totals.set(key, (totals.get(key) ?? 0) + (typeof v === 'number' && Number.isFinite(v) ? v : 0));
92
+ }
93
+ return { totals, dropped };
94
+ };
package/src/presets.ts CHANGED
@@ -22,8 +22,9 @@ export const axisY: Partial<AxisConfigInterface<unknown>> = Object.freeze({
22
22
  });
23
23
 
24
24
  /** Legend: currently empty. A bare `<VisBulletLegend :items="…">` already renders the house look —
25
- * spacing and type come from the adapter's --vis-legend-* custom properties, and no call site
26
- * repeats any config. The object exists so shared legend config has one home when it appears. */
25
+ * the adapter sets its label colour and the --vis-font-family its type falls back to, Unovis's
26
+ * own --vis-legend-* defaults set the sizes and spacing, and no call site repeats any config. The
27
+ * object exists so shared legend config has one home when it appears. */
27
28
  export const legendDefaults: Partial<BulletLegendConfigInterface> = Object.freeze({});
28
29
 
29
30
  /** Tooltip: above-right of the anchor, held clear of the pointer.
@@ -34,7 +35,8 @@ export const legendDefaults: Partial<BulletLegendConfigInterface> = Object.freez
34
35
  * track the pointer whatever is set here. And pinning anchors to the hovered element's BOUNDING
35
36
  * BOX, so it only reads as "next to what I hovered" when the box is the mark: right for a bar,
36
37
  * wrong for a donut arc or a treemap group, whose boxes are far larger than the shape under the
37
- * cursor. Hence FChartFrameSingle, which carries exactly those marks, defaults the other way.
38
+ * cursor. Hence both frames set followCursor back to true, and by default useTooltipSnap then
39
+ * places the panel.
38
40
  *
39
41
  * The shifts are what keep the pointer off the panel on that tracking path. Unovis adds a fixed
40
42
  * 5px margin, which puts the tooltip's corner under the cursor's own hotspot; these open that to a
@@ -73,7 +75,8 @@ export function houseTooltip(): Partial<TooltipConfigInterface> {
73
75
 
74
76
  /** Treemap tile shape. Unovis ships 2px corners and a 2px gap, which reads as a raw partition;
75
77
  * the house tile is a rounded slab with room to breathe, matching the card radius family. The
76
- * gap works with the adapter's tile stroke — padding separates, stroke defines the edge.
78
+ * gap is the separator: the adapter draws no tile stroke, and inside a group the gap shows the
79
+ * group tile beneath.
77
80
  *
78
81
  * Typed structurally rather than as `Partial<TreemapConfigInterface<…>>`: v-binding a config
79
82
  * object carrying the Datum generic pins it to whatever that object declares, and every accessor
@@ -93,19 +96,19 @@ export const treemapDefaults: {
93
96
  tileBorderRadius: 12,
94
97
  tilePadding: 4,
95
98
  // The strip a group reserves for its own heading. It has to clear the heading type the adapter
96
- // sets on internal labels — too small and the zone name is cut off by its own group.
99
+ // sets on internal labels — too small and the group's own top tiles cover its label.
97
100
  tilePaddingTop: 26,
98
- // Unovis draws group and leaf tiles as flat sibling rects with one shared rx, so nesting has no
99
- // radius of its own. This flag is the only thing that marks leaves in the DOM (it adds a class to
100
- // tiles with no children), which is what lets the adapter give them the concentric inner radius.
101
- // Its other effect is a pointer cursor on leaves — correct here, since leaves carry the tooltip.
101
+ // Unovis draws group and leaf tiles as flat sibling rects under one corner rule, so nesting has
102
+ // no radius of its own. This flag is the only thing that marks leaf tiles in the DOM (it adds a
103
+ // class to tiles with no children), which is what lets the adapter give them the concentric inner
104
+ // radius. Its other effect is a pointer cursor on leaves.
102
105
  showTileClickAffordance: true,
103
106
  // Plain inset from the tile edge, independent of the radius: the adapter squares the label clip,
104
107
  // so a label is no longer sliced by its own clip corner and these do not have to dodge an arc.
105
108
  labelOffsetX: 8,
106
109
  labelOffsetY: 6,
107
110
  // Leaf label type. Unovis buckets leaves into three sizes by sqrt(value) against the largest
108
- // leaf, and its own 8/12/22 steps +4 then +10 — so two stations of similar size can land either
111
+ // leaf, and its own 8/12/22 steps +4 then +10 — so two leaves of similar size can land either
109
112
  // side of the top threshold and differ by nearly double. These are evenly spaced and top out
110
113
  // level with the group heading, which stays distinct on weight rather than size.
111
114
  tileLabelSmallFontSize: 12,
@@ -117,11 +120,10 @@ export const treemapDefaults: {
117
120
  * disc cut into wedges; the house rings are separated slabs with the same rounded feel as the
118
121
  * treemap tile and the donut arc.
119
122
  *
120
- * cornerRadius is the one number to be careful with. The flat donut clamps it to half the arc
121
- * width, so an over-large value degrades gracefully there — the nested donut has NO such clamp,
122
- * so a radius that suits the outer ring will visibly deform a short inner segment. 6 is the
123
- * largest value that stays clean on the thinnest ring this chart is meant to carry; the flat
124
- * donut's 4 is the comparable figure at its own 28px arc width.
123
+ * cornerRadius is the one number to be careful with. d3's arc generator, which draws both donuts,
124
+ * limits it to half the ring's width and shrinks it further on a narrow segment. 6 is the largest
125
+ * value that stays clean on the thinnest ring this chart is meant to carry; the flat donut's 4 is
126
+ * the comparable figure at its own 28px arc width.
125
127
  *
126
128
  * Typed structurally rather than as `Partial<NestedDonutConfigInterface<…>>` for the same reason
127
129
  * treemapDefaults is: v-binding a config object carrying the Datum generic pins it, and every
@@ -132,9 +134,8 @@ export const nestedDonutDefaults: {
132
134
  showBackground: boolean;
133
135
  } = Object.freeze({
134
136
  cornerRadius: 6,
135
- // The ring gap. Segments carry a stroke rather than a real gap, so this works with the adapter's
136
- // segment stroke the way tilePadding works with the treemap's — padding separates the rings,
137
- // the stroke defines each segment's edge against its neighbours in the same ring.
137
+ // The ring gap. Segments in a ring have no gap between them, so the adapter's segment stroke
138
+ // separates those; this padding separates the rings.
138
139
  layerPadding: 4,
139
140
  // The track behind the segments. Our rings always sum to the whole, so a visible track only ever
140
141
  // surfaces in the gaps, where any opaque colour is wrong the moment the chart sits on a surface
package/src/resolveVar.ts CHANGED
@@ -13,9 +13,10 @@
13
13
  // Read fresh on every call: the point is to defeat a cache, so adding one here would restore the
14
14
  // bug. Callers should invoke this inside a render that re-runs on `useThemeVersion`.
15
15
 
16
- /** The computed value of a `var(--x)` string. Non-var strings pass through untouched. */
17
- export function resolveVar(value: string, context: Element = document.documentElement): string {
16
+ /** The computed value of a `var(--x)` string, read off `context` or the root element. Non-var
17
+ * strings, and every string outside the browser, pass through untouched. */
18
+ export function resolveVar(value: string, context?: Element): string {
18
19
  if (typeof window === 'undefined' || !value.startsWith('var(--')) return value;
19
20
  const name = value.slice(4, -1).split(',')[0]!.trim();
20
- return getComputedStyle(context).getPropertyValue(name).trim() || value;
21
+ return getComputedStyle(context ?? document.documentElement).getPropertyValue(name).trim() || value;
21
22
  }
@@ -0,0 +1,26 @@
1
+ // Roving focus for the cell components: a track or a grid is one Tab stop, and the keys move it.
2
+ // Cells are one flat run in reading order; the component maps an index back to its element.
3
+
4
+ /**
5
+ * The index focus moves to for a key, or null when the key is not a move. ArrowLeft and ArrowRight
6
+ * step by `across`, ArrowUp and ArrowDown by `down` (0 leaves them to the page), and Home and End
7
+ * jump to `first` and `last`. A step that would leave the run stays put. A modified key is never a
8
+ * move, so browser shortcuts such as Cmd+ArrowLeft keep working.
9
+ */
10
+ export function rovingIndex(
11
+ event: Pick<KeyboardEvent, 'key' | 'altKey' | 'ctrlKey' | 'metaKey'>,
12
+ index: number,
13
+ first: number,
14
+ last: number,
15
+ across = 1,
16
+ down = 0,
17
+ ): number | null {
18
+ if (event.altKey || event.ctrlKey || event.metaKey) return null;
19
+ if (event.key === 'Home') return first;
20
+ if (event.key === 'End') return last;
21
+ const steps: Record<string, number> = { ArrowRight: across, ArrowLeft: -across, ArrowDown: down, ArrowUp: -down };
22
+ const step = steps[event.key];
23
+ if (!step) return null;
24
+ const next = index + step;
25
+ return next < first || next > last ? index : next;
26
+ }
@@ -13,9 +13,11 @@
13
13
  // Two anchors, matching how the two frames are read:
14
14
  // crosshair — the crosshair line's x, panel held at the top of the plot. It steps as the
15
15
  // crosshair snaps between rows and holds still in between, which is the whole point.
16
- // mark — the centroid of the hovered shape, panel just outside it on the side with room.
17
- // Sampled along the outline rather than taken from the box, so an arc anchors at its
18
- // own mid-angle instead of at the corner of a box it never touches.
16
+ // mark — the centroid of the hovered shape, panel on the side away from the frame's centre
17
+ // with its near edge 12px from the centroid, then held 8px inside the viewport, so it
18
+ // overlaps the outer half of any mark wider than 24px. The centroid is sampled along
19
+ // the outline rather than taken from the box, so an arc anchors at its own mid-angle
20
+ // instead of at the corner of a box it never touches.
19
21
  import { onBeforeUnmount, onMounted, type Ref } from 'vue';
20
22
  import { Crosshair } from '@unovis/ts';
21
23
 
@@ -36,11 +38,12 @@ export function nextTipId(): string {
36
38
 
37
39
  const clamp = (v: number, lo: number, hi: number): number => Math.min(Math.max(v, lo), hi);
38
40
 
39
- /** Centre of a shape, in viewport coordinates. Paths are sampled along their outline and averaged:
40
- * for a donut arc that lands at mid-angle, mid-radius — the middle of the visible band — where the
41
- * box centre would land in the hole, or outside the wedge entirely on a small segment. Anything
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 } {
41
+ /** Centre of a shape, in viewport coordinates. Every SVGGeometryElement, rects included, is sampled
42
+ * along its outline and averaged: for a donut arc that lands at mid-angle, mid-radius — the middle
43
+ * of the visible band — where the box centre would land in the hole, or outside the wedge entirely
44
+ * on a small segment. An element with no outline to measure (a group, text), a zero length or no
45
+ * screen matrix falls back to its box. */
46
+ export function shapeCentre(el: SVGGraphicsElement): { x: number; y: number } {
44
47
  const path = el as SVGPathElement;
45
48
  if (typeof path.getTotalLength === 'function') {
46
49
  const total = path.getTotalLength();
@@ -63,6 +66,31 @@ function shapeCentre(el: SVGGraphicsElement): { x: number; y: number } {
63
66
 
64
67
  export type SnapMode = 'crosshair' | 'mark';
65
68
 
69
+ /** Where the panel lands, in viewport pixels: beside its anchor, then held EDGE inside the viewport. */
70
+ export function tipPlacement(
71
+ mode: SnapMode,
72
+ anchor: { x: number; y: number },
73
+ box: { x: number; y: number; width: number },
74
+ tip: { width: number; height: number },
75
+ viewport: { width: number; height: number },
76
+ ): { left: number; top: number } {
77
+ // Which way the panel opens, and the two modes want opposite answers. A crosshair spans the
78
+ // plot, so the empty half is the one the line is NOT in: open away from it, which also puts the
79
+ // panel away from the pointer. A mark sits somewhere in the chart with the rest of the chart
80
+ // around it, so opening inward would lay the panel over the other marks — over the donut's hole
81
+ // and its central label, in the case that made this obvious. Open outward instead.
82
+ const cx = box.x + box.width / 2;
83
+ const toRight = mode === 'crosshair' ? anchor.x <= cx : anchor.x > cx;
84
+ const left = toRight ? anchor.x + GAP : anchor.x - tip.width - GAP;
85
+ // A crosshair panel rides at the top of the plot: fixed, so it never drifts vertically with a
86
+ // pointer whose y means nothing to the reading. A mark panel centres on its own shape.
87
+ const top = mode === 'crosshair' ? box.y + GAP : anchor.y - tip.height / 2;
88
+ return {
89
+ left: clamp(left, EDGE, viewport.width - tip.width - EDGE),
90
+ top: clamp(top, EDGE, viewport.height - tip.height - EDGE),
91
+ };
92
+ }
93
+
66
94
  /** Installs the snap on a frame. `enabled` is read live so a frame can hand positioning back to
67
95
  * Unovis (the `followCursor` escape hatch) without tearing anything down. */
68
96
  export function useTooltipSnap(
@@ -77,7 +105,6 @@ export function useTooltipSnap(
77
105
  // Guards the observer against our own writes, which would otherwise re-enter it forever.
78
106
  let writing = false;
79
107
  let observer: MutationObserver | undefined;
80
- let frame = 0;
81
108
 
82
109
  function onPointerMove(e: Event): void {
83
110
  const t = e.target;
@@ -104,37 +131,31 @@ export function useTooltipSnap(
104
131
  const anchor = anchorFor(host);
105
132
  if (!anchor) return;
106
133
 
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;
134
+ const { left, top } = tipPlacement(
135
+ mode,
136
+ anchor,
137
+ host.getBoundingClientRect(),
138
+ { width: tip.offsetWidth, height: tip.offsetHeight },
139
+ { width: window.innerWidth, height: window.innerHeight },
140
+ );
121
141
 
122
142
  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`;
143
+ tip.style.left = `${left}px`;
144
+ tip.style.top = `${top}px`;
125
145
  // Unovis positions by `bottom` when the tooltip is not attached to body; clear it so our `top`
126
146
  // is the only vertical rule in play whichever container the frame ends up with.
127
147
  tip.style.bottom = 'unset';
128
148
  writing = false;
129
149
  }
130
150
 
151
+ // The panel is looked up each time the pointer enters the chart, the only time it can show.
152
+ // VisTooltip creates it after mount, a remount (a theme or palette change) replaces it, and a
153
+ // frame without a tooltip has none. A panel replaced while the pointer is inside is picked up on
154
+ // the next entry.
131
155
  function attach(): void {
156
+ observer?.disconnect();
132
157
  const tip = document.querySelector<HTMLElement>(`[data-forest-tip="${tipId}"]`);
133
- if (!tip) {
134
- // VisTooltip creates its element on its own schedule; wait for it rather than racing mount.
135
- frame = requestAnimationFrame(attach);
136
- return;
137
- }
158
+ if (!tip) return;
138
159
  observer = new MutationObserver(() => {
139
160
  if (!writing) reposition(tip);
140
161
  });
@@ -143,12 +164,12 @@ export function useTooltipSnap(
143
164
 
144
165
  onMounted(() => {
145
166
  root.value?.addEventListener('pointermove', onPointerMove, { capture: true });
146
- attach();
167
+ root.value?.addEventListener('pointerenter', attach);
147
168
  });
148
169
 
149
170
  onBeforeUnmount(() => {
150
- cancelAnimationFrame(frame);
151
171
  observer?.disconnect();
152
172
  root.value?.removeEventListener('pointermove', onPointerMove, { capture: true });
173
+ root.value?.removeEventListener('pointerenter', attach);
153
174
  });
154
175
  }
@@ -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,74 @@ 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
+ }
86
+
87
+ /**
88
+ * A reading's y on the line marks: its height above the frame's floor, or undefined when there is no
89
+ * reading to draw, so the line breaks there. Undefined, not null: Unovis' line tests each point with
90
+ * the global isFinite, which passes a null and plots it at zero.
91
+ */
92
+ export function sparklineY(v: number | null | undefined, floor: number): number | undefined {
93
+ return drawable(v) ? v - floor : undefined;
94
+ }
@@ -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';
@@ -29,7 +29,7 @@ export interface CellLegend {
29
29
  }
30
30
 
31
31
  export interface CellScaleOptions<D> {
32
- mode: 'state' | 'intensity';
32
+ mode: MaybeRef<'state' | 'intensity'>;
33
33
  /** State mode. Same shape as `categories` elsewhere, so useChartPalette output feeds it as-is. */
34
34
  states?: MaybeRef<Record<string, BulletLegendItemInterface> | undefined>;
35
35
  /** Intensity mode. */
@@ -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();
@@ -102,7 +105,7 @@ export function useCellScale<D>(options: CellScaleOptions<D>) {
102
105
 
103
106
  const isEmpty = (d: D | null | undefined): boolean => {
104
107
  if (d === null || d === undefined) return true;
105
- if (options.mode === 'intensity') {
108
+ if (unref(options.mode) === 'intensity') {
106
109
  const v = options.value?.(d);
107
110
  return typeof v !== 'number' || !Number.isFinite(v);
108
111
  }
@@ -112,7 +115,7 @@ export function useCellScale<D>(options: CellScaleOptions<D>) {
112
115
 
113
116
  const resolve = (d: D | null | undefined): string => {
114
117
  if (isEmpty(d)) return CELL_EMPTY_COLOR;
115
- if (options.mode === 'intensity') {
118
+ if (unref(options.mode) === 'intensity') {
116
119
  const [lo, hi] = domain.value;
117
120
  const v = options.value!(d as D) as number;
118
121
  const stops = swatches.value;
@@ -131,7 +134,7 @@ export function useCellScale<D>(options: CellScaleOptions<D>) {
131
134
  };
132
135
 
133
136
  const legend = computed<CellLegend>(() => {
134
- if (options.mode === 'intensity') {
137
+ if (unref(options.mode) === 'intensity') {
135
138
  return {
136
139
  mode: 'intensity',
137
140
  entries: swatches.value.map((swatch, i) => ({ key: String(i), swatch })),
@@ -153,3 +156,29 @@ export function useCellScale<D>(options: CellScaleOptions<D>) {
153
156
 
154
157
  return { colorOf: resolve, isEmpty, legend, domain, swatches };
155
158
  }
159
+
160
+ /** The colour props FStatusTrack and FCalendarGrid share. */
161
+ export interface CellScaleProps<D> {
162
+ data: readonly D[];
163
+ mode: 'state' | 'intensity';
164
+ states?: Record<string, BulletLegendItemInterface>;
165
+ state?: (d: D) => string | null | undefined;
166
+ value?: (d: D) => number | null | undefined;
167
+ ramp?: SequentialRamp;
168
+ domain?: [number, number];
169
+ steps: number;
170
+ }
171
+
172
+ /** useCellScale over a component's colour props, every one read live, the accessors included. */
173
+ export function useCellScaleProps<D>(props: CellScaleProps<D>): CellScale<D> {
174
+ return useCellScale<D>({
175
+ mode: computed(() => props.mode),
176
+ states: computed(() => props.states),
177
+ ramp: computed(() => props.ramp ?? ('forest' as SequentialRamp)),
178
+ domain: computed(() => props.domain),
179
+ steps: computed(() => props.steps),
180
+ data: computed(() => props.data),
181
+ value: (d) => props.value?.(d),
182
+ state: (d) => props.state?.(d),
183
+ });
184
+ }
@@ -12,7 +12,7 @@ export interface SeriesDef { key: string; name: string }
12
12
  // There is deliberately NO generic `categorical()` — the generic name used to return the VIVID set,
13
13
  // which meant the one palette that is not colour-blind-safe owned the most authoritative-sounding name.
14
14
  const SAFE = Object.keys(DATAVIZ.light); // 1..6 — brand-led CVD-safe, consumed by index
15
- const VIVID = DATAVIZ_CATEGORICAL.names; // green, amber, red, … (first 6 = the curated 6)
15
+ const VIVID = DATAVIZ_CATEGORICAL.names; // green, amber, sky, … (first 6 = the curated 6)
16
16
  const PAIRS = Object.keys(DATAVIZ_PAIRS.light); // forest-deep, forest-soft, warm-deep, … (family-grouped)
17
17
 
18
18
  export const safeVar = (i: number) => `var(--dataviz-categorical-safe-${SAFE[i % SAFE.length]})`;
@@ -37,13 +37,15 @@ const build = (series: SeriesDef[], color: (i: number) => string): Categories =>
37
37
  export function useChartPalette() {
38
38
  return {
39
39
  /** The 6-colour brand-led CVD-safe set — what the Unovis adapter already ships on --vis-color0..5.
40
- * The default: colour alone separates the series, so it needs no second channel. */
40
+ * The default: the six stay separable on colour alone under all three simulated dichromacies,
41
+ * and a second cue (direct labels, dash or marker) covers what the simulation cannot, such as a
42
+ * photocopy or a projector. A legend keyed by colour is not that cue. */
41
43
  cvdSafe: (series: SeriesDef[]) => build(series, safeVar),
42
44
  /** Vivid categorical (palette order). ≤6 where you can; 7–12 are label-only.
43
- * NOT CVD-safe — the caller MUST add a second channel (legend, direct labels, dash or marker). */
45
+ * NOT CVD-safe — the caller MUST add a second channel (direct labels, dash or marker). */
44
46
  vivid: (series: SeriesDef[]) => build(series, vividVar),
45
47
  /** Brand pairs — 3 brand hues × deep/soft, family-grouped. Brand-forward work: decks, marketing.
46
- * NOT CVD-safe — the caller MUST add a second channel (legend, direct labels, dash or marker). */
48
+ * NOT CVD-safe — the caller MUST add a second channel (direct labels, dash or marker). */
47
49
  pairs: (series: SeriesDef[]) => build(series, pairsVar),
48
50
  /** One of the three sets, chosen at runtime. For the docs' palette picker — production code
49
51
  * should name the set it means, so the CVD contract above is visible at the call site. */