@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.
@@ -3,8 +3,8 @@
3
3
  // FStatusTrack puts cells in a sequence, FCalendarGrid puts them on a date grid, this puts them on a
4
4
  // geography. Colour resolution is useCellScale's; this component owns the binning and the layout.
5
5
  //
6
- // It is not a map. There is no pan, no zoom and no projection at read time — a dot is roughly a
7
- // square kilometre and locates nothing. Real geography is Mapbox's job. This answers "where in
6
+ // It is not a map. There is no pan, no zoom and no projection at read time — a dot on the default
7
+ // grid covers about 0.37 km² and locates nothing. Real geography is Mapbox's job. This answers "where in
8
8
  // London" at a glance, with no tiles and no network.
9
9
  import { computed, ref, shallowRef, watchEffect } from 'vue';
10
10
  import { useCellScale, type SequentialRamp } from './useCellScale';
@@ -13,7 +13,7 @@ import {
13
13
  LONDON_GRID,
14
14
  RIVER_COLOR,
15
15
  loadLondonGrid,
16
- dotAt,
16
+ binPoints,
17
17
  dotKey,
18
18
  type DotDensity,
19
19
  type LondonGrid,
@@ -31,7 +31,7 @@ const props = withDefaults(
31
31
  points: P[];
32
32
  lat: (p: P) => number;
33
33
  lng: (p: P) => number;
34
- /** Summed per dot as `value(p) ?? 1`, so the default reading is a count of points. */
34
+ /** Summed per dot. Unset, each point counts one; a null, undefined or non-finite return adds nothing. */
35
35
  value?: (p: P) => number | null | undefined;
36
36
  ramp?: SequentialRamp;
37
37
  /** Intensity domain, in the caller's units. Two maps compared side by side must both set it. */
@@ -121,22 +121,11 @@ const land = computed(() => new Set(landDots.value.map(([c, r]) => dotKey(c, r))
121
121
 
122
122
  // Points are summed into their dot. A point outside the boundary, or on the river, has nowhere to
123
123
  // land — those are counted rather than quietly absorbed, so a caller feeding the wrong city or the
124
- // wrong coordinate order can see it. The river is drawn but is not a bucket: a ride does not start
124
+ // wrong coordinate order can see it. The river is drawn but is not a bucket: a trip does not start
125
125
  // in the Thames, and letting one land there would put a reading on a dot that cannot have one.
126
- const binned = computed(() => {
127
- const totals = new Map<string, number>();
128
- let dropped = 0;
129
- for (const p of props.points) {
130
- const at = dotAt(grid.value, props.lat(p), props.lng(p));
131
- const key = at && dotKey(at[0], at[1]);
132
- if (!key || !land.value.has(key)) {
133
- dropped++;
134
- continue;
135
- }
136
- totals.set(key, (totals.get(key) ?? 0) + (props.value?.(p) ?? 1));
137
- }
138
- return { totals, dropped };
139
- });
126
+ const binned = computed(() =>
127
+ binPoints(grid.value, land.value, props.points, props.lat, props.lng, props.value),
128
+ );
140
129
 
141
130
  // Every land dot gets a cell, whether or not a point reached it.
142
131
  //
@@ -155,9 +144,9 @@ const cells = computed<DotInfo[]>(() =>
155
144
 
156
145
  // How a total is spaced along the ramp.
157
146
  //
158
- // Density is heavy-tailed, and linearly spacing a heavy tail produces a blank map: with a typical
159
- // week of rides the median dot holds 1 and the busiest holds over a hundred, so five equal buckets
160
- // put roughly ninety per cent of the city in the palest one and the picture says nothing. Compress
147
+ // Density is heavy-tailed, and linearly spacing a heavy tail produces a blank map: in a week of
148
+ // trip starts the median dot can hold 1 and the busiest 50, so five equal buckets put over ninety
149
+ // per cent of the city in the palest one and the picture says nothing. Compress
161
150
  // the tail and the structure appears. Nothing is compressed by default — a transform the caller did
162
151
  // not ask for would quietly change what the ramp claims — but a density map usually wants one.
163
152
  const spaced = (v: number) =>
@@ -226,7 +215,7 @@ const byPosition = computed(() => {
226
215
  const mapLabel = computed(() => {
227
216
  if (props.ariaLabel) return props.ariaLabel;
228
217
  // The busiest dot in the caller's units. scale.domain is in spaced units and would report a
229
- // logarithm as though it were a ride count.
218
+ // logarithm as though it were a count.
230
219
  const hi = props.domain?.[1] ?? cells.value.reduce((m, c) => (c.value > m ? c.value : m), 0);
231
220
  const placed = props.points.length - binned.value.dropped;
232
221
  return `London, as a grid of dots shaded by density. The busiest holds ${hi.toLocaleString('en-GB')}, across ${placed.toLocaleString('en-GB')} of ${props.points.length.toLocaleString('en-GB')} points.`;
@@ -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 { sparklineDomain } 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;
@@ -74,9 +74,15 @@ const props = withDefaults(
74
74
  * counts belong to them.
75
75
  *
76
76
  * `compare`, `area`, `endpoint` and `curve` are line-only; a dev-mode warning fires if one is
77
- * passed alongside `bar` rather than being silently dropped.
77
+ * set alongside `bar` rather than being silently dropped. `curve` is on by default, so for it
78
+ * the warning is for `:curve="false"`.
78
79
  */
79
80
  mark?: 'line' | 'bar';
81
+ /**
82
+ * The graphic's accessible name. With it the sparkline is announced as an image; without it it is
83
+ * decorative and hidden from the accessibility tree, the reading being the figure beside it.
84
+ */
85
+ ariaLabel?: string;
80
86
  }>(),
81
87
  { height: 32, color: 'var(--ui-primary)', area: false, endpoint: false, fill: false, curve: true, mark: 'line', compare: undefined, compareColor: undefined },
82
88
  );
@@ -84,7 +90,10 @@ const props = withDefaults(
84
90
  if (import.meta.env.DEV) {
85
91
  watchEffect(() => {
86
92
  if (props.mark !== 'bar') return;
87
- const ignored = (['compare', 'area', 'endpoint'] as const).filter((k) => props[k]);
93
+ const ignored = [
94
+ ...(['compare', 'area', 'endpoint'] as const).filter((k) => props[k]),
95
+ ...(props.curve ? [] : ['curve']),
96
+ ];
88
97
  if (ignored.length)
89
98
  console.warn(`[FSparkline] \`${ignored.join('`, `')}\` ${ignored.length > 1 ? 'are' : 'is'} line-only and ignored under mark="bar".`);
90
99
  });
@@ -93,6 +102,16 @@ if (import.meta.env.DEV) {
93
102
  // A theme flip changes what currentColor resolves to, and Unovis caches colours on mount.
94
103
  const themeVersion = useThemeVersion();
95
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
+
96
115
  // One row per index carrying both series, so Unovis puts them on a single shared y scale.
97
116
  // Every accessor takes the whole Point: Unovis infers its Datum generic from the first accessor it
98
117
  // sees, so narrowing each one to just the field it reads would pin Datum to that shape and make the
@@ -101,10 +120,11 @@ type Point = { i: number; v: number | null; c: number | null };
101
120
  const points = computed<Point[]>(() =>
102
121
  props.data.map((v, i) => ({ i, v, c: props.compare?.[i] ?? null })),
103
122
  );
104
- // 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.
105
125
  const x = (d: Point) => d.i;
106
- const y = (d: Point) => (d.v === null ? null : d.v - frame.value.floor);
107
- 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);
108
128
 
109
129
  const hasCompare = computed(() => !!props.compare?.some((v) => v !== null && v !== undefined));
110
130
 
@@ -114,47 +134,24 @@ const hasCompare = computed(() => !!props.compare?.some((v) => v !== null && v !
114
134
  // the floor at 0 it runs from 0 up to each reading's height above the floor.
115
135
  const frame = computed(() => sparklineDomain(props.data, props.compare, props.area));
116
136
 
117
- // Bars are the exception: they are measured from zero, not from the data's own floor. A bar reads
118
- // as a LENGTH, and a bar that starts anywhere but zero has a length no longer proportional to its
119
- // value. The bar domain therefore always contains zero, which also puts negative readings below the
120
- // baseline instead of quietly flipping them.
121
- const barDomain = computed(() => {
122
- const vals = props.data.filter((v): v is number => v !== null);
123
- const lo = Math.min(0, ...vals);
124
- const hi = Math.max(0, ...vals);
125
- return { lo, span: hi - lo || 1 };
126
- });
127
-
128
- /** Bottom offset and height as percentages, or null for a gap. */
129
- const barFor = (v: number | null) => {
130
- if (v === null) return null;
131
- const { lo, span } = barDomain.value;
132
- const zero = (0 - lo) / span;
133
- const value = (v - lo) / span;
134
- const from = Math.min(zero, value);
135
- const to = Math.max(zero, value);
136
- return {
137
- bottom: `${from * 100}%`,
138
- // A reading of zero keeps a hairline. Zero is a real measurement and a gap is the absence of
139
- // one; collapsing the bar to nothing would render the two identically.
140
- height: `max(${(to - from) * 100}%, 1px)`,
141
- };
142
- };
137
+ // Bars are the exception: measured from zero, not from the data's own floor. Why lives in
138
+ // sparklineBarDomain.
139
+ const barDomain = computed(() => sparklineBarDomain(props.data));
140
+ const barFor = (v: number | null) => sparklineBar(v, barDomain.value);
143
141
 
144
- const last = computed(() => {
145
- for (let i = props.data.length - 1; i >= 0; i--) if (props.data[i] !== null) return props.data[i] as number;
146
- return null;
147
- });
148
- // Fraction of the height the final point sits at, for the endpoint dot.
149
- const endpointTop = computed(() => {
150
- const [lo, hi] = frame.value.domain ?? [0, 0];
151
- if (last.value === null || hi === lo) return 50;
152
- return (1 - (last.value - frame.value.floor - lo) / (hi - lo)) * 100;
153
- });
142
+ // Percentage down the height for the endpoint dot, or null when there is no reading to mark.
143
+ const endpointTop = computed(() => sparklineEndpoint(props.data, frame.value));
154
144
  </script>
155
145
 
156
146
  <template>
157
- <div class="relative w-full" :class="fill ? 'h-full' : ''" :style="fill ? { minHeight: `${height}px`, color } : { height: `${height}px`, color }">
147
+ <div
148
+ class="relative w-full"
149
+ :class="fill ? 'h-full' : ''"
150
+ :style="fill ? { minHeight: `${height}px`, color } : { height: `${height}px`, color }"
151
+ :role="ariaLabel ? 'img' : undefined"
152
+ :aria-label="ariaLabel || undefined"
153
+ :aria-hidden="ariaLabel ? undefined : 'true'"
154
+ >
158
155
  <!-- Plain DOM rather than a mark: there are no axes, scales or tooltip to share with the XY
159
156
  container, so a flex row of columns is the whole implementation and costs no chart runtime.
160
157
  The 2px gap is fixed, so the bucket count sets how thin the bars get: n buckets across w
@@ -162,7 +159,7 @@ const endpointTop = computed(() => {
162
159
  2.7px — still bars; past roughly 60 the gap outweighs the bar and the row reads as a
163
160
  texture, where the measure belongs to a line instead. Each bar is rounded at both ends:
164
161
  no baseline is drawn for a square end to sit on. -->
165
- <div v-if="mark === 'bar'" class="flex w-full items-end gap-0.5" :class="fill ? 'absolute inset-0' : 'h-full'" aria-hidden="true">
162
+ <div v-if="mark === 'bar'" class="flex w-full items-end gap-0.5" :class="fill ? 'absolute inset-0' : 'h-full'">
166
163
  <span
167
164
  v-for="(v, i) in data"
168
165
  :key="i"
@@ -193,6 +190,7 @@ const endpointTop = computed(() => {
193
190
  :margin="{ top: 2, right: endpoint ? 4 : 0, bottom: 0, left: 0 }"
194
191
  :y-domain="frame.domain ?? undefined"
195
192
  :duration="motionDuration('slow')"
193
+ :svg-defs="fadeDefs"
196
194
  >
197
195
  <!-- Comparison first, so the current series draws over it. Dashed and thinner: the dash says
198
196
  "not now" without needing a legend, and holding the same hue keeps the pair readable for a
@@ -207,11 +205,11 @@ const endpointTop = computed(() => {
207
205
  :opacity="0.45"
208
206
  :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear"
209
207
  />
210
- <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" />
211
209
  <VisLine :x="x" :y="y" :color="color" :line-width="1.5" :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear" />
212
210
  </VisXYContainer>
213
211
  <span
214
- v-if="mark === 'line' && endpoint && last !== null"
212
+ v-if="mark === 'line' && endpoint && endpointTop !== null"
215
213
  class="pointer-events-none absolute right-0 size-1.5 -translate-y-1/2 rounded-full bg-current"
216
214
  :style="{ top: `${endpointTop}%` }"
217
215
  aria-hidden="true"
@@ -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
+ }
@@ -88,6 +88,18 @@ export function segmentRadius(index: number, count: number, radius: number, join
88
88
  return '0';
89
89
  }
90
90
 
91
+ /**
92
+ * The bar's sentence for assistive tech: each part with its share, then any remainder, as
93
+ * "Food 62%, Software 24%, unaccounted 14%". The bar's lengths are the only place the shares are
94
+ * stated unless `showShare` is on, so the sentence carries them regardless. A bar with nothing to
95
+ * state is "No data", because the element carrying it needs a non-empty name.
96
+ */
97
+ export function distributionLabel(shares: DistributionShares, locale = 'en-GB'): string {
98
+ const parts = shares.parts.map((p) => `${p.segment.label} ${formatShare(p.share, locale)}`);
99
+ if (shares.remainder > 0) parts.push(`unaccounted ${formatShare(shares.remainder, locale)}`);
100
+ return parts.join(', ') || 'No data';
101
+ }
102
+
91
103
  /**
92
104
  * A share as a percentage. Whole numbers: at the width this component is read at, a decimal place
93
105
  * is precision the bar itself cannot show. A non-zero share that would round to nothing is stated
@@ -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 —
@@ -41,3 +42,16 @@ export function fromCategories<Datum extends Record<string, unknown>>(
41
42
  xAccessor: xKey ? (d) => d[xKey] as number : (_d, i) => i,
42
43
  };
43
44
  }
45
+
46
+ /**
47
+ * A chart frame's accessible name: `ariaLabel` when given, otherwise the series it plots, as
48
+ * "Chart: Trips, Utilisation". With no categories it is "Chart".
49
+ */
50
+ export function chartLabel(
51
+ categories: Record<string, BulletLegendItemInterface> | undefined,
52
+ ariaLabel?: string,
53
+ ): string {
54
+ if (ariaLabel) return ariaLabel;
55
+ const names = Object.entries(categories ?? {}).map(([key, item]) => String(item.name ?? key));
56
+ return names.length ? `Chart: ${names.join(', ')}` : 'Chart';
57
+ }