@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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,89 @@
1
+ # Changelog
2
+
3
+ Every @humanforest package shares one version, so one entry covers all seven packages. Newest first.
4
+
5
+ ## 0.4.0 — 2026-09-15
6
+
7
+ ### Features
8
+
9
+ - **docs**: the llms mirror prints a section kicker under its heading
10
+ - **frames**: FFitFrame fits a slot that sizes itself
11
+ - **charts**: a sparkline's area fades from its line to the floor
12
+ - **charts**: the calendar month rule boxes each month in a toned line
13
+ - **docs**: the llms mirror heads a non-prop table by its own column
14
+ - **docs**: the llms mirror names the module a page destructures
15
+ - **docs**: the llms mirror renders section loops
16
+ - **docs**: the llms mirror resolves plain strings a page imports
17
+ - **docs**: DemoStage puts a demo's controls above the specimen
18
+ - **docs**: the llms mirror names each code tab in a ProseCodeGroup
19
+ - **docs**: the llms mirror renders guidance cards and imported arrays
20
+
21
+ ### Fixes
22
+
23
+ - **ui**: the scroll fade follows content that changes after mount
24
+ - **ui**: map drawing places a corner only for a press on the map
25
+ - **ui**: the scale bar measures at the centre and hides off the globe
26
+ - **ui**: geolocate stays active through a location error while tracking
27
+ - **ui**: map search names only a rendered row in aria-activedescendant
28
+ - **ui**: a context menu that reopens elsewhere takes focus again
29
+ - **ui**: map overlays add layers after the frame, not during placement
30
+ - **ui**: the map context menu wires nothing on a non-interactive map
31
+ - **ui**: map search rings kept focus and never sends a pair to suggest
32
+ - **ui**: fullscreen reads nothing without a fullscreen API
33
+ - **ui**: geolocate is a plain action with track off
34
+ - **ui**: coordinates read Mapbox's 512px world and name the copied pair
35
+ - **docs**: chord, treemap and sankey demos keep their heights
36
+ - **charts**: resolveVar leaves the document alone without a DOM
37
+ - **docs**: the battery-swap stage keeps its start edge reachable
38
+ - **docs**: foundations and layout diagrams fit a phone
39
+ - **docs**: device frames scale down to fit a phone's pane
40
+ - **docs**: component specimens fit a phone's preview pane
41
+ - **filter**: the chip's number segments drop their steppers
42
+ - **docs**: the table page's pager drops its arrows on a phone
43
+ - **filter**: a chip wider than its bar wraps its segments
44
+ - **tokens**: the display role steps down to 4xl below 40rem
45
+ - **tokens**: long inline code wraps instead of overflowing its column
46
+ - **docs**: long inline code and prop types wrap instead of widening main
47
+ - **ui**: a vehicle mark's badge outline follows its edge
48
+ - **ui**: ramp swatches abut and round only the ramp's ends
49
+ - **docs**: a context switch keeps a page's live demos mounted
50
+ - **ui**: a bound popup closes on a second click and unbinds on unmount
51
+ - **ui**: a marker is a button only when it acts as one
52
+ - **ui**: FMap applies every padding side and logs each map error
53
+ - **ui**: a track slice no longer bridges a signal gap
54
+ - **ui**: track playback keeps the playhead when the speed changes
55
+ - **ui**: balance maps paint by magnitude, and legends label exactly
56
+ - **charts**: the tooltip snap looks for its panel on pointer entry
57
+ - **ui**: unavailable reasons name a state by its label
58
+ - **ui**: zones ignore a kind they do not know
59
+ - **ui**: a selected vehicle takes its new state on resync
60
+ - **docs**: a cross-page anchored link lands on its target
61
+ - **ui**: useMapReady never runs for a caller that has unmounted
62
+ - **tokens**: area fills keep their opacity under data-theme dark
63
+ - **charts**: crosshair dots sit on stacked band tops
64
+ - **charts**: a fitted London map labels only the areas it draws
65
+ - **charts**: a missing reading breaks a line sparkline
66
+ - **charts**: a dotted map's missing values add nothing
67
+ - **charts**: a selectable London map is reachable at every size
68
+ - **charts**: cell tracks and grids take one Tab stop, props read live
69
+ - **docs**: urgent vehicles draw on top of the fleet
70
+ - **ui**: alt shortcuts select, and deleted features leave the selection
71
+ - **ui**: a crossing edit springs back, and a drag reports once
72
+ - **ui**: selected markers rise, popups are named, one button opens them
73
+ - **ui**: the coordinate tile stays copyable as the pointer moves onto it
74
+ - **ui**: the scale bar's fill is as long as the distance it names
75
+
76
+ ## 0.3.7 — 2026-09-13
77
+
78
+ ### Features
79
+
80
+ - **ui**: add FMapContextMenu, a right-click menu on the map
81
+ - **charts**: chart frames and sparklines can be named for screen readers
82
+
83
+ ### Fixes
84
+
85
+ - **charts**: CellScale types what useCellScale returns
86
+ - **charts**: an empty distribution bar is still named, as "No data"
87
+ - **charts**: a bar sparkline warns that :curve="false" is ignored
88
+ - **charts**: a sparkline leaves non-finite readings off its scale
89
+ - **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.6",
3
+ "version": "0.4.0",
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.6"
32
+ "@humanforest/tokens": "^0.4.0"
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>
@@ -1,14 +1,16 @@
1
1
  <script setup lang="ts" generic="D extends Record<string, unknown>">
2
- // Days as cells: weeks are columns, weekdays are rows. A year of ride volume, or a year of one
3
- // station's daily state. Colour resolution is useCellScale's; this component owns the calendar.
2
+ // Days as cells: weeks are columns, weekdays are rows. A year of trip volume, or a year of one
3
+ // parking place's daily state. Colour resolution is useCellScale's; this component owns the
4
+ // calendar.
4
5
  //
5
6
  // The grid is built from the date window, not from the data, so a day with no datum is a real empty
6
7
  // cell in the right place rather than a missing column. Days are bucketed by local calendar date —
7
8
  // a timestamp is floored to its day before lookup, so two events on one day land together.
8
9
  import { computed, ref } from 'vue';
9
10
  import type { BulletLegendItemInterface } from '@unovis/ts';
10
- import { useCellScale, CELL_EMPTY_COLOR, type SequentialRamp } from './useCellScale';
11
+ import { useCellScaleProps, CELL_EMPTY_COLOR, type SequentialRamp } from './useCellScale';
11
12
  import { CELL_SIZES, orElse, type CellSize } from './cellSize';
13
+ import { rovingIndex } from './rovingFocus';
12
14
  import FCellLegend from './FCellLegend.vue';
13
15
 
14
16
  const props = withDefaults(
@@ -39,13 +41,15 @@ const props = withDefaults(
39
41
  cellGap?: number;
40
42
  radius?: number;
41
43
  monthLabels?: boolean;
42
- /** Rule along the month boundary. It steps day by day, so a month opening midweek is cut where
43
- * it actually opens rather than at the column edge. */
44
+ /** Box around each month. It steps day by day, so a month opening midweek is cut where it
45
+ * actually opens rather than at the column edge. */
44
46
  monthSeparators?: boolean;
45
47
  weekdayLabels?: boolean;
46
48
  legend?: boolean;
47
- /** Hover and focus text. Given one, cells become focusable buttons and get the house bubble.
48
- * Suppressed at xs and sm, where the target is too small to hit reliably. */
49
+ /** Hover and focus text. Given one, cells become focusable buttons and get the house bubble; the
50
+ * grid is one Tab stop, ArrowUp and ArrowDown step a day, ArrowLeft and ArrowRight a week, and
51
+ * Home and End jump to the first and last day. Suppressed at xs and sm, where the target is too
52
+ * small to hit reliably. */
49
53
  tooltip?: (d: D | undefined, date: Date) => string;
50
54
  ariaLabel?: string;
51
55
  }>(),
@@ -128,16 +132,7 @@ const weeks = computed<{ date: Date; inRange: boolean }[][]>(() => {
128
132
  return out;
129
133
  });
130
134
 
131
- const scale = useCellScale<D>({
132
- mode: props.mode,
133
- states: computed(() => props.states),
134
- ramp: computed(() => props.ramp ?? ('forest' as SequentialRamp)),
135
- domain: computed(() => props.domain),
136
- steps: computed(() => props.steps),
137
- data: computed(() => props.data),
138
- value: props.value,
139
- state: props.state,
140
- });
135
+ const scale = useCellScaleProps<D>(props);
141
136
 
142
137
  const datumFor = (d: Date) => byDay.value.get(keyOf(d));
143
138
  const fillFor = (cell: { date: Date; inRange: boolean }) =>
@@ -158,47 +153,58 @@ const pitch = computed(() => cellSize.value + cellGap.value);
158
153
  const gridWidth = computed(() => weeks.value.length * pitch.value - cellGap.value);
159
154
  const gridHeight = computed(() => 7 * pitch.value - cellGap.value);
160
155
 
161
- // Where each month opens, in grid coordinates. One source for both the label and the rule: the
162
- // label used to be placed on the week whose FIRST day fell in the new month, which puts it up to
163
- // six days late whenever a month opens midweek.
156
+ // The column each month opens in. The label used to be placed on the week whose FIRST day fell in
157
+ // the new month, which puts it up to six days late whenever a month opens midweek.
164
158
  const monthStarts = computed(() => {
165
- const out: { wi: number; di: number; label: string; first: boolean }[] = [];
159
+ const out: { wi: number; label: string }[] = [];
166
160
  let prevMonth: number | null = null;
167
161
  weeks.value.forEach((week, wi) => {
168
- week.forEach((cell, di) => {
162
+ week.forEach((cell) => {
169
163
  if (!cell.inRange) return;
170
164
  const m = cell.date.getMonth();
171
- if (prevMonth === null) out.push({ wi, di, label: monthLabel(cell.date), first: true });
172
- else if (m !== prevMonth) out.push({ wi, di, label: monthLabel(cell.date), first: false });
165
+ if (m !== prevMonth) out.push({ wi, label: monthLabel(cell.date) });
173
166
  prevMonth = m;
174
167
  });
175
168
  });
176
169
  return out;
177
170
  });
178
171
 
179
- // The boundary between two months, as one continuous path each.
172
+ // A box around each month: an edge wherever a day's neighbour — a row up or down, a week left or
173
+ // right — is another month or outside the window. That one test closes every box top and bottom,
174
+ // steps it round a partial first or last week, and draws the frontier between two months.
180
175
  //
181
176
  // Time runs down a column then into the next, so a month that opens midweek does NOT split its
182
177
  // column into left and right: the days above the opening row in every LATER column already belong
183
- // to the new month. The frontier is therefore a Z — down the right edge of the opening column to
184
- // the opening row, one column left, then down to the bottom — not a per-cell outline. A month
185
- // opening on the week-start row degenerates to a single straight vertical.
178
+ // to the new month. The frontier comes out as a Z — down the right edge of the opening column to
179
+ // the opening row, one column left, then down to the bottom. A month opening on the week-start row
180
+ // gets a straight vertical. A frontier is emitted from both sides; the two segments coincide.
186
181
  //
187
182
  // Coordinates land on gap midpoints (w * pitch - cellGap/2), so the rule sits centred between two
188
- // cells rather than against either one, and one path per boundary welds its own corners.
183
+ // cells rather than against either one. Square caps weld the corners between segments.
189
184
  const monthPaths = computed(() => {
190
185
  if (!props.monthSeparators) return '';
186
+ const p = pitch.value;
191
187
  const half = cellGap.value / 2;
192
- return monthStarts.value
193
- .filter((m) => !m.first)
194
- .map(({ wi, di }) => {
195
- const xLeft = wi * pitch.value - half;
196
- if (di === 0) return `M${xLeft},0V${gridHeight.value}`;
197
- const xRight = (wi + 1) * pitch.value - half;
198
- const yCut = di * pitch.value - half;
199
- return `M${xRight},0V${yCut}H${xLeft}V${gridHeight.value}`;
200
- })
201
- .join(' ');
188
+ const monthAt = (wi: number, di: number) => {
189
+ const cell = weeks.value[wi]?.[di];
190
+ return cell?.inRange ? cell.date.getMonth() : -1;
191
+ };
192
+ const d: string[] = [];
193
+ weeks.value.forEach((week, wi) => {
194
+ week.forEach((cell, di) => {
195
+ if (!cell.inRange) return;
196
+ const m = cell.date.getMonth();
197
+ const x0 = wi * p - half;
198
+ const x1 = x0 + p;
199
+ const y0 = di * p - half;
200
+ const y1 = y0 + p;
201
+ if (monthAt(wi, di - 1) !== m) d.push(`M${x0},${y0}H${x1}`);
202
+ if (monthAt(wi, di + 1) !== m) d.push(`M${x0},${y1}H${x1}`);
203
+ if (monthAt(wi - 1, di) !== m) d.push(`M${x0},${y0}V${y1}`);
204
+ if (monthAt(wi + 1, di) !== m) d.push(`M${x1},${y0}V${y1}`);
205
+ });
206
+ });
207
+ return d.join('');
202
208
  });
203
209
 
204
210
  const dateLabel = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
@@ -228,6 +234,30 @@ const hideTip = () => {
228
234
  tipOpen.value = false;
229
235
  };
230
236
 
237
+ // Roving focus: the grid is one Tab stop, held by the day focused last. A cell's flat index is
238
+ // week * 7 + weekday and the in-range days are one unbroken run of it, so the arrow keys step a day
239
+ // (up, down) or a week (left, right) along that run, and Home and End jump to its ends.
240
+ const focusRange = computed<[number, number]>(() => {
241
+ const cells = weeks.value.flat();
242
+ const first = cells.findIndex((c) => c.inRange);
243
+ return [first, first + cells.filter((c) => c.inRange).length - 1];
244
+ });
245
+ const focusIndex = ref(0);
246
+ const tabStop = computed(() => Math.min(Math.max(focusIndex.value, focusRange.value[0]), focusRange.value[1]));
247
+ const onFocus = (event: Event, index: number, cell: { date: Date }) => {
248
+ focusIndex.value = index;
249
+ showTip(event, labelFor(cell));
250
+ };
251
+ const onKeydown = (event: KeyboardEvent, index: number) => {
252
+ const [first, last] = focusRange.value;
253
+ const next = rovingIndex(event, index, first, last, 7, 1);
254
+ if (next === null) return;
255
+ event.preventDefault();
256
+ // The in-range days are the grid's only buttons, in flat-index order.
257
+ const buttons = (event.currentTarget as Element).closest('[role="group"]')?.querySelectorAll('button');
258
+ buttons?.[next - first]?.focus();
259
+ };
260
+
231
261
  const hasEmpty = computed(() =>
232
262
  weeks.value.some((w) => w.some((c) => c.inRange && scale.isEmpty(datumFor(c.date) ?? null))),
233
263
  );
@@ -235,7 +265,12 @@ const hasEmpty = computed(() =>
235
265
 
236
266
  <template>
237
267
  <div class="flex w-full flex-col gap-3">
238
- <div class="overflow-x-auto">
268
+ <!-- The month boxes sit half a gap outside the cells, past this scroller's clip. Padding out and
269
+ margin back in gives them room without moving the grid. -->
270
+ <div
271
+ class="overflow-x-auto"
272
+ :style="monthSeparators ? { padding: `${cellGap}px`, margin: `-${cellGap}px` } : undefined"
273
+ >
239
274
  <div class="flex" :style="{ gap: `${cellGap}px` }">
240
275
  <!-- Weekday gutter. justify-end pins the rows to the grid's bottom, so they line up
241
276
  whether or not a month-label row sits above. -->
@@ -288,18 +323,20 @@ const hasEmpty = computed(() =>
288
323
  }"
289
324
  :aria-label="interactive && cell.inRange ? labelFor(cell) : undefined"
290
325
  :aria-hidden="interactive && cell.inRange ? undefined : 'true'"
326
+ :tabindex="interactive && cell.inRange ? (wi * 7 + di === tabStop ? 0 : -1) : undefined"
291
327
  @mouseenter="interactive && cell.inRange && showTip($event, labelFor(cell))"
292
328
  @mouseleave="hideTip"
293
- @focus="interactive && cell.inRange && showTip($event, labelFor(cell))"
329
+ @focus="interactive && cell.inRange && onFocus($event, wi * 7 + di, cell)"
294
330
  @blur="hideTip"
331
+ @keydown="interactive && cell.inRange && onKeydown($event, wi * 7 + di)"
295
332
  />
296
333
  </div>
297
334
  </div>
298
335
 
299
- <!-- One path for every boundary, over the cells. pointer-events none so it never takes
300
- a hover from the cell beneath; crispEdges so a 1px rule stays 1px off-integer.
301
- Dimmed text rather than a border token: the border tokens sit around 1.5:1 on the
302
- page and vanish between saturated cells. -->
336
+ <!-- One path for every box, over the cells. pointer-events none so it never takes a
337
+ hover from the cell beneath; crispEdges so a 1px rule stays 1px off-integer.
338
+ Toned text rather than a border token: the border tokens sit around 1.5:1 on the
339
+ page and vanish between saturated cells, and dimmed was still too faint. -->
303
340
 
304
341
  <svg
305
342
  v-if="monthSeparators"
@@ -312,8 +349,9 @@ const hasEmpty = computed(() =>
312
349
  :d="monthPaths"
313
350
  fill="none"
314
351
  stroke-width="1"
352
+ stroke-linecap="square"
315
353
  shape-rendering="crispEdges"
316
- style="stroke: var(--ui-text-dimmed)"
354
+ style="stroke: var(--ui-text-toned)"
317
355
  />
318
356
  </svg>
319
357
  </div>
@@ -10,20 +10,18 @@ import { Position } from '@unovis/ts';
10
10
  import type {
11
11
  AxisConfigInterface,
12
12
  BulletLegendItemInterface,
13
+ Crosshair,
13
14
  TooltipConfigInterface,
14
15
  XYContainerConfigInterface,
15
16
  } from '@unovis/ts';
16
17
  // Relative file imports (not the package barrel): the barrel exports this component, and a
17
18
  // self-import through it would create a module cycle.
18
- import { fromCategories } from './fromCategories';
19
+ import { chartLabel, fromCategories } from './fromCategories';
20
+ import { crosshairConfig } from './crosshairConfig';
19
21
  import { forestTooltip, type ForestTooltipOptions } from './forestTooltip';
20
22
  import { motionDuration } from './motionDuration';
21
- import {
22
- axisX as axisXPreset,
23
- axisY as axisYPreset,
24
- tooltipDefaults,
25
- tooltipContainer,
26
- } from './presets';
23
+ import { indexAxes, indexContainer } from './frameIndex';
24
+ import { tooltipDefaults, tooltipContainer } from './presets';
27
25
  import { useThemeVersion } from './useThemeVersion';
28
26
  import { useTooltipSnap, nextTipId } from './snapTooltip';
29
27
 
@@ -58,9 +56,9 @@ const props = withDefaults(
58
56
  crosshair?: boolean;
59
57
  /** Title/value formatting for the house crosshair template. */
60
58
  format?: ForestTooltipOptions<Datum>;
61
- /** The mark runs horizontally (set `orientation` on the mark itself). The frame's index
62
- * machinery — tick pinning, the edge gutter, grid-line defaults — follows the category
63
- * dimension onto y; formatters stay yours to place per axis. */
59
+ /** The mark runs horizontally (set `orientation` on the mark itself). Tick pinning and the
60
+ * edge gutter move onto y, the category dimension, and the grid lines onto x; formatters stay
61
+ * yours to place per axis. */
64
62
  horizontal?: boolean;
65
63
  legend?: boolean;
66
64
  /** Where the legend sits. `bottom` (default) renders centred under the chart, caption-style;
@@ -70,6 +68,8 @@ const props = withDefaults(
70
68
  duration?: number;
71
69
  /** Extra signals that must remount the chart (theme flips and palette identity are automatic). */
72
70
  repaintOn?: unknown[];
71
+ /** The chart's accessible name. Defaults to its series, as "Chart: Trips, Utilisation". */
72
+ ariaLabel?: string;
73
73
  }>(),
74
74
  {
75
75
  height: 280,
@@ -96,43 +96,20 @@ defineSlots<{
96
96
 
97
97
  const acc = computed(() => fromCategories<Datum>(props.categories, props.data, props.xKey));
98
98
 
99
- // Index charts get tick positions at the row indices — without this the axis invents fractional
100
- // ticks between rows. The pin sits on the category axis: x normally, y when `horizontal` (Unovis
101
- // swaps which scale carries the index). Grid lines follow the value axis the same way. An xKey
102
- // chart scales its own axis.
103
- const indexTicks = computed(() =>
104
- props.xKey ? undefined : { tickValues: props.data.map((_, i) => i) },
105
- );
106
- const xAxisConfig = computed<Partial<AxisConfigInterface<Datum>>>(() => ({
107
- ...axisXPreset,
108
- ...(props.horizontal ? { gridLine: true } : indexTicks.value),
109
- ...(props.axisX || {}),
110
- }));
111
- const yAxisConfig = computed<Partial<AxisConfigInterface<Datum>>>(() => ({
112
- ...axisYPreset,
113
- ...(props.horizontal ? { gridLine: false, ...indexTicks.value } : {}),
114
- ...(props.axisY || {}),
115
- }));
116
-
117
- // Index charts also get half a row of outer gutter: the category domain extends past the first
118
- // and last rows so edge marks (bars especially) stop sitting flush against the plot edges. Ticks
119
- // stay on the row indices, and the gutter follows the category dimension under `horizontal`. An
120
- // xKey chart plots a real field and keeps the library's exact-extent default; an explicit
121
- // `container` domain overrides this like any other container key.
122
- const containerConfig = computed<Partial<XYContainerConfigInterface<Datum>>>(() => {
123
- const gutter: [number, number] = [-0.5, props.data.length - 0.5];
124
- return {
125
- ...(props.xKey || props.data.length === 0
126
- ? {}
127
- : props.horizontal
128
- ? { yDomain: gutter }
129
- : { xDomain: gutter }),
130
- ...(props.container || {}),
131
- };
132
- });
99
+ // Index charts pin the category axis ticks to the row indices, extend its domain half a row past
100
+ // each end, and leave the grid to the value axis; `horizontal` moves the ticks and the gutter onto
101
+ // y and the grid onto x. The rules live in frameIndex.ts. The container keeps a computed of its
102
+ // own: VisXYContainer updates on every new config object, where VisAxis compares its props deeply.
103
+ const axes = computed(() => indexAxes<Datum>(props));
104
+ const containerConfig = computed(() => indexContainer<Datum>(props));
133
105
 
134
106
  const tipFn = computed(() => forestTooltip<Datum>(props.categories, props.format));
135
107
 
108
+ const crosshairRef = ref<{ component?: Crosshair<Datum> }>();
109
+ const crosshairProps = computed(() =>
110
+ crosshairConfig(acc.value, () => crosshairRef.value?.component?.accessors),
111
+ );
112
+
136
113
  // The panel is snapped to the datum by useTooltipSnap below, which rewrites its position after
137
114
  // Unovis places it. What Unovis is configured to do here is therefore only what shows when the
138
115
  // snap stands down (`followCursor`), plus the show/hide and content it always owns: pointer
@@ -164,7 +141,12 @@ const repaintKey = computed(() =>
164
141
  </script>
165
142
 
166
143
  <template>
167
- <div ref="rootEl" style="display: flex; flex-direction: column; gap: 0.75rem">
144
+ <div
145
+ ref="rootEl"
146
+ role="img"
147
+ :aria-label="chartLabel(categories, ariaLabel)"
148
+ style="display: flex; flex-direction: column; gap: 0.75rem"
149
+ >
168
150
  <VisXYContainer
169
151
  :key="repaintKey"
170
152
  :data="data"
@@ -173,8 +155,8 @@ const repaintKey = computed(() =>
173
155
  v-bind="{ ...containerConfig, ...$attrs }"
174
156
  >
175
157
  <slot name="mark" :data="data" :x="acc.xAccessor" :y="acc.yAccessors" :colors="acc.colors" />
176
- <VisAxis v-if="axisX !== false" v-bind="xAxisConfig" />
177
- <VisAxis v-if="axisY !== false" v-bind="yAxisConfig" />
158
+ <VisAxis v-if="axisX !== false" v-bind="axes.axisX" />
159
+ <VisAxis v-if="axisY !== false" v-bind="axes.axisY" />
178
160
  <template v-if="tooltip !== false">
179
161
  <!-- duration 0 is deliberate, and load-bearing twice over. Unovis animates the crosshair
180
162
  line's x through a transition, so it eases toward the row the pointer just snapped to
@@ -183,9 +165,8 @@ const repaintKey = computed(() =>
183
165
  tooltip's anchor. Easing it would put the panel on the wrong side of the plot. -->
184
166
  <VisCrosshair
185
167
  v-if="crosshair"
186
- :x="acc.xAccessor"
187
- :y="acc.yAccessors"
188
- :color="acc.colors"
168
+ ref="crosshairRef"
169
+ v-bind="crosshairProps"
189
170
  :template="tipFn"
190
171
  :duration="0"
191
172
  />
@@ -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