@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 +89 -0
- package/package.json +8 -3
- package/src/FBoroughShape.vue +6 -14
- package/src/FCalendarGrid.vue +84 -46
- package/src/FChartFrame.vue +31 -50
- package/src/FChartFrameSingle.vue +10 -2
- package/src/FDistributionBar.vue +2 -11
- package/src/FDottedMap.vue +12 -23
- package/src/FLondonMap.vue +34 -85
- package/src/FSparkline.vue +46 -48
- package/src/FStatusTrack.vue +25 -15
- package/src/crosshairConfig.ts +27 -0
- package/src/distribution.ts +12 -0
- package/src/frameIndex.ts +36 -0
- package/src/fromCategories.ts +17 -3
- package/src/londonAreas.ts +85 -3
- package/src/londonGrid.ts +27 -0
- package/src/presets.ts +19 -18
- package/src/resolveVar.ts +4 -3
- package/src/rovingFocus.ts +26 -0
- package/src/snapTooltip.ts +53 -32
- package/src/sparklineDomain.ts +70 -1
- package/src/useCellScale.ts +38 -9
- package/src/useChartPalette.ts +6 -4
package/src/FDottedMap.vue
CHANGED
|
@@ -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
|
|
7
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
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:
|
|
159
|
-
//
|
|
160
|
-
//
|
|
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
|
|
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.`;
|
package/src/FLondonMap.vue
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
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="
|
|
373
|
-
:key="`t-${
|
|
374
|
-
:x="
|
|
375
|
-
:y="
|
|
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="
|
|
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
|
-
>{{
|
|
332
|
+
>{{ area.name }}</text>
|
|
384
333
|
</g>
|
|
385
334
|
</svg>
|
|
386
335
|
</UTooltip>
|
package/src/FSparkline.vue
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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 =
|
|
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
|
|
107
|
-
const yCompare = (d: Point) => (d.c
|
|
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:
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
|
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'"
|
|
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="
|
|
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 &&
|
|
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"
|
package/src/FStatusTrack.vue
CHANGED
|
@@ -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
|
|
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 {
|
|
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 =
|
|
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 &&
|
|
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
|
+
}
|
package/src/distribution.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/fromCategories.ts
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
+
}
|