@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/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
|
+
"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.
|
|
32
|
+
"@humanforest/tokens": "^0.4.0"
|
|
28
33
|
},
|
|
29
34
|
"publishConfig": {
|
|
30
35
|
"access": "public"
|
package/src/FBoroughShape.vue
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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="
|
|
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>
|
package/src/FCalendarGrid.vue
CHANGED
|
@@ -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
|
|
3
|
-
//
|
|
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 {
|
|
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
|
-
/**
|
|
43
|
-
*
|
|
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
|
-
*
|
|
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 =
|
|
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
|
-
//
|
|
162
|
-
//
|
|
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;
|
|
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
|
|
162
|
+
week.forEach((cell) => {
|
|
169
163
|
if (!cell.inRange) return;
|
|
170
164
|
const m = cell.date.getMonth();
|
|
171
|
-
if (
|
|
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
|
-
//
|
|
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
|
|
184
|
-
// the opening row, one column left, then down to the bottom
|
|
185
|
-
//
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
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-
|
|
354
|
+
style="stroke: var(--ui-text-toned)"
|
|
317
355
|
/>
|
|
318
356
|
</svg>
|
|
319
357
|
</div>
|
package/src/FChartFrame.vue
CHANGED
|
@@ -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
|
-
|
|
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).
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
const
|
|
104
|
-
|
|
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
|
|
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="
|
|
177
|
-
<VisAxis v-if="axisY !== false" v-bind="
|
|
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
|
-
|
|
187
|
-
|
|
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
|
|
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"
|
package/src/FDistributionBar.vue
CHANGED
|
@@ -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
|
-
|
|
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
|
|