@humanforest/charts 0.3.7 → 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.
@@ -15,6 +15,8 @@ import {
15
15
  LONDON_BOROUGHS,
16
16
  loadLondonWards,
17
17
  indexAreas,
18
+ areaInteraction,
19
+ layoutAreas,
18
20
  type AreaLevel,
19
21
  type LondonArea,
20
22
  type LondonAreaSet,
@@ -54,7 +56,7 @@ const props = withDefaults(
54
56
  * city is a zoom; dropping the city while keeping the frame is a handful of shapes adrift in an
55
57
  * empty London-sized box. Neither is a picture anybody asked for.
56
58
  *
57
- * ⚠ WHAT IT COSTS: without the other areas there is no coastline, no neighbour and no river to
59
+ * ⚠ WHAT IT COSTS: without the other areas there is no neighbour and no river to
58
60
  * locate the selection against, so it only reads if the selection is itself a familiar shape. A
59
61
  * contiguous service area survives it; three boroughs that do not touch do not. The areas keep
60
62
  * their true relative position either way — this is still the map's coordinate space, which is
@@ -68,7 +70,7 @@ const props = withDefaults(
68
70
  size?: CellSize;
69
71
  /** Forces the size step's chrome decision. Individual props still win over it. */
70
72
  chrome?: boolean;
71
- /** Area names at their centroids. Off for wards at any size — 704 labels is a texture. */
73
+ /** Names of the drawn areas at their centroids. Off for wards at any size — 704 labels is a texture. */
72
74
  labels?: boolean;
73
75
  legend?: boolean;
74
76
  /** Hover text. It also puts boroughs on the keyboard; wards stay off it unless selectable. */
@@ -130,18 +132,16 @@ const showLabels = computed(() =>
130
132
  orElse(props.labels, showChrome.value && props.level === 'borough'),
131
133
  );
132
134
  const showBoroughOutline = computed(() => orElse(props.boroughOutline, props.level === 'ward'));
133
- const interactive = computed(() => (!!props.tooltip || props.selectable) && showChrome.value);
134
-
135
- // Focus is not given out as freely as hover.
136
- //
137
- // Selection is a real action, so a selectable map is keyboard-reachable however many areas it has —
138
- // the caller asked for it and there is no other way to perform it. A tooltip is not an action, and
139
- // at ward level it would buy 704 tab stops for text the summary on the wrapper already carries. So
140
- // tooltip alone reaches the keyboard at borough level, where 33 stops is navigation, and not at
141
- // ward level, where it would only be an obstacle.
142
- const focusable = computed(
143
- () => interactive.value && (props.selectable || props.level === 'borough'),
135
+ const reach = computed(() =>
136
+ areaInteraction({
137
+ level: props.level,
138
+ chrome: showChrome.value,
139
+ tooltip: !!props.tooltip,
140
+ selectable: props.selectable,
141
+ }),
144
142
  );
143
+ const interactive = computed(() => reach.value.interactive);
144
+ const focusable = computed(() => reach.value.focusable);
145
145
 
146
146
  // Strokes invert with the mode rather than matching the page.
147
147
  //
@@ -176,35 +176,20 @@ const cells = computed<AreaDatum[]>(() =>
176
176
  set.value.areas.map((area) => ({ area, datum: byArea.value.get(area.code) })),
177
177
  );
178
178
 
179
- /*
180
- ── `fit: selection` ────────────────────────────────────────────────────────────────────────────
181
-
182
- ★★ THE SUBSET AND THE FRAME ARE ONE DECISION, and both are derived from the SAME list — the cells
183
- that carry a datum. Deriving them separately is how a map ends up framed to one set and drawn from
184
- another.
185
-
186
- ⚠ `cells` ITSELF IS NOT FILTERED, and that is deliberate: the ramp's domain, the empty-state check
187
- and the summary all read it, and a domain computed from the drawn subset would re-scale the colours
188
- every time the frame changed. What the caller asked for is a different VIEW of the same reading.
189
- */
190
- const FIT_PAD = 0.06;
191
- const fitted = computed(() => props.fit === 'selection' && cells.value.some((c) => c.datum !== undefined));
192
- const drawn = computed(() => (fitted.value ? cells.value.filter((c) => c.datum !== undefined) : cells.value));
193
- /*
194
- The union of the drawn areas' stored bounding boxes — the same arithmetic FBoroughShape does for
195
- one, so nothing here parses a path. The pad is a fraction of the union's LONGER side, so a wide
196
- selection and a tall one get the same visual margin rather than the same number of user units.
197
- */
198
- const frame = computed<readonly number[]>(() => {
199
- if (!fitted.value) return set.value.viewBox;
200
- const areas = drawn.value.map((c) => c.area);
201
- const x0 = Math.min(...areas.map((a) => a.x));
202
- const y0 = Math.min(...areas.map((a) => a.y));
203
- const x1 = Math.max(...areas.map((a) => a.x + a.w));
204
- const y1 = Math.max(...areas.map((a) => a.y + a.h));
205
- const pad = Math.max(x1 - x0, y1 - y0) * FIT_PAD;
206
- return [x0 - pad, y0 - pad, x1 - x0 + pad * 2, y1 - y0 + pad * 2];
207
- });
179
+ // The drawn areas, the frame and the labels all come from layoutAreas. `cells` itself is not
180
+ // filtered: the empty-state check and the default label read the whole level.
181
+ const layout = computed(() =>
182
+ layoutAreas(set.value, {
183
+ matched: new Set(byArea.value.keys()),
184
+ fit: props.fit,
185
+ width: width.value,
186
+ labels: showLabels.value,
187
+ }),
188
+ );
189
+ const drawn = computed<AreaDatum[]>(() =>
190
+ layout.value.drawn.map((area) => ({ area, datum: byArea.value.get(area.code) })),
191
+ );
192
+ const frame = computed(() => layout.value.frame);
208
193
 
209
194
  // Unlike the dotted map, empty here IS a third outcome and stays that way. A borough with no row is
210
195
  // a borough nobody measured — the areas are given by the geography, not by the data, so absence is
@@ -214,7 +199,7 @@ const spaced = (v: number) =>
214
199
  props.scale === 'sqrt' ? Math.sqrt(v) : props.scale === 'log' ? Math.log1p(v) : v;
215
200
 
216
201
  const scale = useCellScale<AreaDatum>({
217
- mode: props.mode,
202
+ mode: computed(() => props.mode),
218
203
  states: computed(() => props.states),
219
204
  ramp: computed(() => props.ramp ?? ('forest' as SequentialRamp)),
220
205
  domain: computed(() =>
@@ -265,42 +250,6 @@ const mapLabel = computed(() => {
265
250
 
266
251
  const labelFor = (cell: AreaDatum) =>
267
252
  props.tooltip?.(cell.area, cell.datum) ?? `${cell.area.name}, no data`;
268
-
269
- // Labels are placed largest-area-first, and one is dropped when it would collide.
270
- //
271
- // Fitting each label to its own shape is not enough. The inner boroughs are the smallest and the
272
- // longest-named — Hammersmith and Fulham, Kensington and Chelsea and the City all meet inside a few
273
- // hundred pixels — and even where a shape has room by its own bounding box, its neighbour's label
274
- // reaches across and overlaps it. So the test is against the labels already placed, not against the
275
- // geometry: work down from the biggest area, keep a label only where its box is still clear, and let
276
- // the tooltip name whatever had to be dropped.
277
- const LABEL_EM = 9;
278
- /** The size labels actually render at, in viewBox units — the size the boxes have to reserve for. */
279
- const labelFont = computed(() => strokeScale.value * LABEL_EM);
280
-
281
- const labelBox = (area: LondonArea, font: number) => {
282
- // Average glyph advance for this typeface at small sizes. Close enough to reserve the right room
283
- // without measuring text, which would mean a layout pass per label.
284
- const halfW = (area.name.length * font * 0.55) / 2;
285
- const halfH = font * 0.75;
286
- return { x0: area.cx - halfW, x1: area.cx + halfW, y0: area.cy - halfH, y1: area.cy + halfH };
287
- };
288
-
289
- const labelled = computed<AreaDatum[]>(() => {
290
- if (!showLabels.value) return [];
291
- const placed: ReturnType<typeof labelBox>[] = [];
292
- const out: AreaDatum[] = [];
293
- const font = labelFont.value;
294
- // Biggest first, so when two labels compete the one with more map behind it wins.
295
- const order = [...cells.value].sort((a, b) => b.area.w * b.area.h - a.area.w * a.area.h);
296
- for (const cell of order) {
297
- const box = labelBox(cell.area, font);
298
- if (placed.some((p) => box.x0 < p.x1 && box.x1 > p.x0 && box.y0 < p.y1 && box.y1 > p.y0)) continue;
299
- placed.push(box);
300
- out.push(cell);
301
- }
302
- return out;
303
- });
304
253
  </script>
305
254
 
306
255
  <template>
@@ -369,18 +318,18 @@ const labelled = computed<AreaDatum[]>(() => {
369
318
  <!-- Labels last so nothing paints over them, and out of the pointer's way. -->
370
319
  <g class="pointer-events-none">
371
320
  <text
372
- v-for="cell in labelled"
373
- :key="`t-${cell.area.code}`"
374
- :x="cell.area.cx"
375
- :y="cell.area.cy"
321
+ v-for="area in layout.labelled"
322
+ :key="`t-${area.code}`"
323
+ :x="area.cx"
324
+ :y="area.cy"
376
325
  text-anchor="middle"
377
326
  dominant-baseline="middle"
378
- :font-size="labelFont"
327
+ :font-size="layout.font"
379
328
  fill="var(--ui-text-highlighted)"
380
329
  :stroke-width="strokeScale * 2.5"
381
330
  stroke="var(--ui-bg)"
382
331
  paint-order="stroke"
383
- >{{ cell.area.name }}</text>
332
+ >{{ area.name }}</text>
384
333
  </g>
385
334
  </svg>
386
335
  </UTooltip>
@@ -11,14 +11,14 @@
11
11
  // on a multi-segment line — the slope-ratio work everyone cites measured isolated segment PAIRS —
12
12
  // so the default follows Tufte's word-sized graphic and is set to sit with a line of text rather
13
13
  // than from any optimal-slope claim.
14
- import { computed, watchEffect } from 'vue';
14
+ import { computed, useId, watchEffect } from 'vue';
15
15
  import { VisXYContainer, VisLine, VisArea } from '@unovis/vue';
16
16
  // CurveType members carry the same string values ('monotoneX', 'linear'); TypeScript will not accept
17
17
  // the bare literals for a string enum, so use the members.
18
18
  import { CurveType } from '@unovis/ts';
19
19
  import { motionDuration } from './motionDuration';
20
20
  import { useThemeVersion } from './useThemeVersion';
21
- import { sparklineBar, sparklineBarDomain, sparklineDomain, sparklineEndpoint } from './sparklineDomain';
21
+ import { sparklineBar, sparklineBarDomain, sparklineDomain, sparklineEndpoint, sparklineY } from './sparklineDomain';
22
22
 
23
23
  const props = withDefaults(
24
24
  defineProps<{
@@ -47,7 +47,7 @@ const props = withDefaults(
47
47
  /** The comparison line's colour. Defaults to the series colour at low opacity. */
48
48
  compareColor?: string;
49
49
  height?: number;
50
- /** Fills under the line at low opacity. Off by default — a plain line is the quieter default. */
50
+ /** Fills under the line, fading from the line down to the floor. Off by default — a plain line is the quieter default. */
51
51
  area?: boolean;
52
52
  /** Marks the last point, so the series has an end rather than running off the edge. */
53
53
  endpoint?: boolean;
@@ -102,6 +102,16 @@ if (import.meta.env.DEV) {
102
102
  // A theme flip changes what currentColor resolves to, and Unovis caches colours on mount.
103
103
  const themeVersion = useThemeVersion();
104
104
 
105
+ // The area fades from the series colour at the line to nothing at the floor, rather than lying under
106
+ // it as a flat wash. 0.24 → 0 averages the 0.12 the flat fill used: the same ink, moved up to the line
107
+ // where the shape is.
108
+ //
109
+ // ★ THE ID IS PER INSTANCE. The stops are `currentColor`, and a gradient resolves that where it is
110
+ // DEFINED, not where it is used — a row of tiles sharing one id would all paint in the first tile's
111
+ // colour. useId() rather than a counter, for the reason in packages/ui/test/ssr-ids.static.test.ts.
112
+ const fadeId = `fsp-fade-${useId()}`;
113
+ const fadeDefs = `<linearGradient id="${fadeId}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="currentColor" stop-opacity="0.24" /><stop offset="1" stop-color="currentColor" stop-opacity="0" /></linearGradient>`;
114
+
105
115
  // One row per index carrying both series, so Unovis puts them on a single shared y scale.
106
116
  // Every accessor takes the whole Point: Unovis infers its Datum generic from the first accessor it
107
117
  // sees, so narrowing each one to just the field it reads would pin Datum to that shape and make the
@@ -110,10 +120,11 @@ type Point = { i: number; v: number | null; c: number | null };
110
120
  const points = computed<Point[]>(() =>
111
121
  props.data.map((v, i) => ({ i, v, c: props.compare?.[i] ?? null })),
112
122
  );
113
- // Readings are plotted as their height above the frame's floor, which is 0 except under `area`.
123
+ // Readings are plotted as their height above the frame's floor, which is 0 except under `area`. A gap
124
+ // breaks the line; sparklineY says how.
114
125
  const x = (d: Point) => d.i;
115
- const y = (d: Point) => (d.v === null ? null : d.v - frame.value.floor);
116
- const yCompare = (d: Point) => (d.c === null ? null : d.c - frame.value.floor);
126
+ const y = (d: Point) => sparklineY(d.v, frame.value.floor);
127
+ const yCompare = (d: Point) => sparklineY(d.c, frame.value.floor);
117
128
 
118
129
  const hasCompare = computed(() => !!props.compare?.some((v) => v !== null && v !== undefined));
119
130
 
@@ -179,6 +190,7 @@ const endpointTop = computed(() => sparklineEndpoint(props.data, frame.value));
179
190
  :margin="{ top: 2, right: endpoint ? 4 : 0, bottom: 0, left: 0 }"
180
191
  :y-domain="frame.domain ?? undefined"
181
192
  :duration="motionDuration('slow')"
193
+ :svg-defs="fadeDefs"
182
194
  >
183
195
  <!-- Comparison first, so the current series draws over it. Dashed and thinner: the dash says
184
196
  "not now" without needing a legend, and holding the same hue keeps the pair readable for a
@@ -193,7 +205,7 @@ const endpointTop = computed(() => sparklineEndpoint(props.data, frame.value));
193
205
  :opacity="0.45"
194
206
  :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear"
195
207
  />
196
- <VisArea v-if="area" :x="x" :y="y" :baseline="0" :color="color" :opacity="0.12" :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear" />
208
+ <VisArea v-if="area" :x="x" :y="y" :baseline="0" :color="`url(#${fadeId})`" :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear" />
197
209
  <VisLine :x="x" :y="y" :color="color" :line-width="1.5" :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear" />
198
210
  </VisXYContainer>
199
211
  <span
@@ -1,14 +1,15 @@
1
1
  <script setup lang="ts" generic="D extends Record<string, unknown>">
2
2
  // A row of segments, one per bucket, each carrying a state or an intensity. One asset per track:
3
- // a dock's health across ninety days, a bike's week. Colour resolution is useCellScale's; this
4
- // component is layout, labels and the interaction surface.
3
+ // a vehicle's state across ninety days, a parking place's week. Colour resolution is
4
+ // useCellScale's; this component is layout, labels and the interaction surface.
5
5
  //
6
6
  // Plain DOM rather than SVG — the segments are a flex row, so the browser does the layout, and an
7
7
  // interactive segment can be a real <button> that focuses and announces itself.
8
8
  import { computed, ref } from 'vue';
9
9
  import type { BulletLegendItemInterface } from '@unovis/ts';
10
- import { useCellScale, CELL_EMPTY_COLOR, type SequentialRamp } from './useCellScale';
10
+ import { useCellScaleProps, CELL_EMPTY_COLOR, type SequentialRamp } from './useCellScale';
11
11
  import { CELL_SIZES, orElse, type CellSize } from './cellSize';
12
+ import { rovingIndex } from './rovingFocus';
12
13
  import FCellLegend from './FCellLegend.vue';
13
14
 
14
15
  const props = withDefaults(
@@ -41,7 +42,8 @@ const props = withDefaults(
41
42
  legend?: boolean;
42
43
  /** Start and end captions under the track, e.g. ['90 days ago', 'Today']. */
43
44
  caption?: [string, string];
44
- /** Hover and focus text. Given one, segments become focusable buttons and get the house bubble.
45
+ /** Hover and focus text. Given one, segments become focusable buttons and get the house bubble;
46
+ * the track is one Tab stop, and ArrowLeft, ArrowRight, Home and End move between segments.
45
47
  * Suppressed at xs and sm, where a segment is too thin to hit reliably. */
46
48
  tooltip?: (d: D, i: number) => string;
47
49
  /** Sentence for assistive tech describing the whole track. */
@@ -67,16 +69,7 @@ const showChrome = computed(() => orElse(props.chrome, spec.value.chrome));
67
69
  const showLegend = computed(() => orElse(props.legend, showChrome.value));
68
70
  const interactive = computed(() => !!props.tooltip && showChrome.value);
69
71
 
70
- const scale = useCellScale<D>({
71
- mode: props.mode,
72
- states: computed(() => props.states),
73
- ramp: computed(() => props.ramp ?? ('forest' as SequentialRamp)),
74
- domain: computed(() => props.domain),
75
- steps: computed(() => props.steps),
76
- data: computed(() => props.data),
77
- value: props.value,
78
- state: props.state,
79
- });
72
+ const scale = useCellScaleProps<D>(props);
80
73
 
81
74
  // The label an assistive reader gets per segment. Without a tooltip the segments are decorative and
82
75
  // the track's own label carries the meaning, so this is only consulted when one is given.
@@ -103,6 +96,21 @@ const showTip = (event: Event, text: string) => {
103
96
  const hideTip = () => {
104
97
  tipOpen.value = false;
105
98
  };
99
+
100
+ // Roving focus: the track is one Tab stop, held by the segment focused last, and the arrow keys,
101
+ // Home and End move it.
102
+ const focusIndex = ref(0);
103
+ const tabStop = computed(() => Math.min(focusIndex.value, props.data.length - 1));
104
+ const onFocus = (event: Event, d: D, i: number) => {
105
+ focusIndex.value = i;
106
+ showTip(event, labelFor(d, i));
107
+ };
108
+ const onKeydown = (event: KeyboardEvent, i: number) => {
109
+ const next = rovingIndex(event, i, 0, props.data.length - 1);
110
+ if (next === null) return;
111
+ event.preventDefault();
112
+ ((event.currentTarget as Element).parentElement?.children[next] as HTMLElement | undefined)?.focus();
113
+ };
106
114
  </script>
107
115
 
108
116
  <template>
@@ -126,10 +134,12 @@ const hideTip = () => {
126
134
  :style="{ background: scale.colorOf(d), borderRadius: `${radius}px` }"
127
135
  :aria-label="interactive ? labelFor(d, i) : undefined"
128
136
  :aria-hidden="interactive ? undefined : 'true'"
137
+ :tabindex="interactive ? (i === tabStop ? 0 : -1) : undefined"
129
138
  @mouseenter="interactive && showTip($event, labelFor(d, i))"
130
139
  @mouseleave="hideTip"
131
- @focus="interactive && showTip($event, labelFor(d, i))"
140
+ @focus="interactive && onFocus($event, d, i)"
132
141
  @blur="hideTip"
142
+ @keydown="interactive && onKeydown($event, i)"
133
143
  />
134
144
  </div>
135
145
  </UTooltip>
@@ -0,0 +1,27 @@
1
+ // What FChartFrame binds onto VisCrosshair for its circles: a colour, and no x or y.
2
+ //
3
+ // Unovis's Crosshair reads its accessors either all from its own config or all from what
4
+ // XYContainer derives from the marks, and only that derivation knows which marks stack. So with
5
+ // no x or y here, a stacked VisArea or VisStackedBar puts each dot at its band's top, and a line
6
+ // or single-accessor area puts it at the value.
7
+ //
8
+ // That derivation lists one accessor per mark, stacked marks first, so a series' colour by dot
9
+ // index goes wrong once marks repeat a series (an area under its own line) or a line comes before
10
+ // a stack. Each dot takes the colour of the series its accessor belongs to instead; an accessor
11
+ // that belongs to no series keeps the colour at its index.
12
+ import type { CrosshairAccessors, CrosshairConfigInterface } from '@unovis/ts';
13
+ import type { ChartAccessors } from './fromCategories';
14
+
15
+ export function crosshairConfig<Datum>(
16
+ acc: Pick<ChartAccessors<Datum>, 'yAccessors' | 'colors'>,
17
+ /** The crosshair's accessors as it resolves them at draw time. */
18
+ resolved: () => Pick<CrosshairAccessors<Datum>, 'y' | 'yStacked'> | undefined,
19
+ ): Pick<CrosshairConfigInterface<Datum>, 'color'> {
20
+ return {
21
+ color: (_d: Datum, i: number) => {
22
+ const { y, yStacked } = resolved() ?? {};
23
+ const series = (acc.yAccessors as unknown[]).indexOf([...(yStacked ?? []), ...(y ?? [])][i]);
24
+ return acc.colors[series === -1 ? i % acc.colors.length : series];
25
+ },
26
+ };
27
+ }
@@ -0,0 +1,36 @@
1
+ // The index-chart rules FChartFrame lays under a call site's own container and axis config.
2
+ //
3
+ // With no xKey the frame plots the row index, so the category axis — x, or y when `horizontal` —
4
+ // takes ticks at the row indices and half a row of gutter past the first and last rows, and the
5
+ // grid follows the value axis. An xKey chart plots a real field and keeps the library's exact
6
+ // extent. The call site's own keys are spread last, so an explicit domain or tickValues replaces
7
+ // the index default.
8
+ import type { AxisConfigInterface, XYContainerConfigInterface } from '@unovis/ts';
9
+ import { axisX as axisXPreset, axisY as axisYPreset } from './presets';
10
+
11
+ type IndexInput = { data: readonly unknown[]; xKey?: string; horizontal?: boolean };
12
+
13
+ /** Both axis configs: the house preset, then the index ticks and grid default, then the call site's keys. */
14
+ export function indexAxes<Datum>(
15
+ p: IndexInput & {
16
+ axisX?: Partial<AxisConfigInterface<Datum>> | false;
17
+ axisY?: Partial<AxisConfigInterface<Datum>> | false;
18
+ },
19
+ ): { axisX: Partial<AxisConfigInterface<Datum>>; axisY: Partial<AxisConfigInterface<Datum>> } {
20
+ const ticks = p.xKey ? undefined : { tickValues: p.data.map((_, i) => i) };
21
+ return {
22
+ axisX: { ...axisXPreset, ...(p.horizontal ? { gridLine: true } : ticks), ...(p.axisX || {}) },
23
+ axisY: { ...axisYPreset, ...(p.horizontal ? { gridLine: false, ...ticks } : {}), ...(p.axisY || {}) },
24
+ };
25
+ }
26
+
27
+ /** The container config: the half-row gutter on the category domain, then the call site's keys. */
28
+ export function indexContainer<Datum>(
29
+ p: IndexInput & { container?: Partial<XYContainerConfigInterface<Datum>> },
30
+ ): Partial<XYContainerConfigInterface<Datum>> {
31
+ const gutter: [number, number] = [-0.5, p.data.length - 0.5];
32
+ return {
33
+ ...(p.xKey || p.data.length === 0 ? {} : p.horizontal ? { yDomain: gutter } : { xDomain: gutter }),
34
+ ...(p.container || {}),
35
+ };
36
+ }
@@ -8,9 +8,10 @@ export interface ChartAccessors<Datum> {
8
8
  /** Ordered legend entries for `<VisBulletLegend :items>`; multi-colour series show their first colour. */
9
9
  legendItems: BulletLegendItemInterface[];
10
10
  /**
11
- * One accessor per series key, in category order. Values pass through untouched — a null or
12
- * undefined cell stays null/undefined (Unovis' NumericAccessor accepts both and renders the
13
- * gap); coercing would plot missing data as zero.
11
+ * One accessor per series key, in category order. Values pass through untouched, so a cell is
12
+ * drawn as Unovis draws it: on a VisLine an undefined, NaN, Infinity or non-numeric cell breaks
13
+ * the line (see fallbackValue and interpolateMissingData), and a null plots as zero. Coercing
14
+ * would plot every missing cell as zero.
14
15
  */
15
16
  yAccessors: Array<(d: Datum) => number | null | undefined>;
16
17
  /** X accessor: the row's `xKey` value when one is given, otherwise the array index —
@@ -74,6 +74,26 @@ export const indexAreas = (areas: LondonArea[]): Map<string, LondonArea> => {
74
74
  return byKey;
75
75
  };
76
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
+
77
97
  /**
78
98
  * One area framed to itself: the area `key` names (by code or name, either case) and a viewBox of
79
99
  * its stored box, padded on every side by `pad` × its longer side. Undefined for a key the set does
@@ -89,3 +109,49 @@ export const frameArea = (
89
109
  const p = Math.max(area.w, area.h) * pad;
90
110
  return { area, viewBox: `${area.x - p} ${area.y - p} ${area.w + p * 2} ${area.h + p * 2}` };
91
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
  }