@humanforest/charts 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/package.json +2 -2
- package/src/FCalendarGrid.vue +84 -46
- package/src/FChartFrame.vue +22 -48
- package/src/FDottedMap.vue +12 -23
- package/src/FLondonMap.vue +34 -85
- package/src/FSparkline.vue +19 -7
- package/src/FStatusTrack.vue +25 -15
- package/src/crosshairConfig.ts +27 -0
- package/src/frameIndex.ts +36 -0
- package/src/fromCategories.ts +4 -3
- package/src/londonAreas.ts +66 -0
- 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 +18 -15
- package/src/sparklineDomain.ts +9 -0
- package/src/useCellScale.ts +30 -4
- package/src/useChartPalette.ts +6 -4
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 { sparklineBar, sparklineBarDomain, sparklineDomain, sparklineEndpoint } from './sparklineDomain';
|
|
21
|
+
import { sparklineBar, sparklineBarDomain, sparklineDomain, sparklineEndpoint, sparklineY } from './sparklineDomain';
|
|
22
22
|
|
|
23
23
|
const props = withDefaults(
|
|
24
24
|
defineProps<{
|
|
@@ -47,7 +47,7 @@ const props = withDefaults(
|
|
|
47
47
|
/** The comparison line's colour. Defaults to the series colour at low opacity. */
|
|
48
48
|
compareColor?: string;
|
|
49
49
|
height?: number;
|
|
50
|
-
/** Fills under the line
|
|
50
|
+
/** Fills under the line, fading from the line down to the floor. Off by default — a plain line is the quieter default. */
|
|
51
51
|
area?: boolean;
|
|
52
52
|
/** Marks the last point, so the series has an end rather than running off the edge. */
|
|
53
53
|
endpoint?: boolean;
|
|
@@ -102,6 +102,16 @@ if (import.meta.env.DEV) {
|
|
|
102
102
|
// A theme flip changes what currentColor resolves to, and Unovis caches colours on mount.
|
|
103
103
|
const themeVersion = useThemeVersion();
|
|
104
104
|
|
|
105
|
+
// The area fades from the series colour at the line to nothing at the floor, rather than lying under
|
|
106
|
+
// it as a flat wash. 0.24 → 0 averages the 0.12 the flat fill used: the same ink, moved up to the line
|
|
107
|
+
// where the shape is.
|
|
108
|
+
//
|
|
109
|
+
// ★ THE ID IS PER INSTANCE. The stops are `currentColor`, and a gradient resolves that where it is
|
|
110
|
+
// DEFINED, not where it is used — a row of tiles sharing one id would all paint in the first tile's
|
|
111
|
+
// colour. useId() rather than a counter, for the reason in packages/ui/test/ssr-ids.static.test.ts.
|
|
112
|
+
const fadeId = `fsp-fade-${useId()}`;
|
|
113
|
+
const fadeDefs = `<linearGradient id="${fadeId}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="currentColor" stop-opacity="0.24" /><stop offset="1" stop-color="currentColor" stop-opacity="0" /></linearGradient>`;
|
|
114
|
+
|
|
105
115
|
// One row per index carrying both series, so Unovis puts them on a single shared y scale.
|
|
106
116
|
// Every accessor takes the whole Point: Unovis infers its Datum generic from the first accessor it
|
|
107
117
|
// sees, so narrowing each one to just the field it reads would pin Datum to that shape and make the
|
|
@@ -110,10 +120,11 @@ type Point = { i: number; v: number | null; c: number | null };
|
|
|
110
120
|
const points = computed<Point[]>(() =>
|
|
111
121
|
props.data.map((v, i) => ({ i, v, c: props.compare?.[i] ?? null })),
|
|
112
122
|
);
|
|
113
|
-
// Readings are plotted as their height above the frame's floor, which is 0 except under `area`.
|
|
123
|
+
// Readings are plotted as their height above the frame's floor, which is 0 except under `area`. A gap
|
|
124
|
+
// breaks the line; sparklineY says how.
|
|
114
125
|
const x = (d: Point) => d.i;
|
|
115
|
-
const y = (d: Point) => (d.v
|
|
116
|
-
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);
|
|
117
128
|
|
|
118
129
|
const hasCompare = computed(() => !!props.compare?.some((v) => v !== null && v !== undefined));
|
|
119
130
|
|
|
@@ -179,6 +190,7 @@ const endpointTop = computed(() => sparklineEndpoint(props.data, frame.value));
|
|
|
179
190
|
:margin="{ top: 2, right: endpoint ? 4 : 0, bottom: 0, left: 0 }"
|
|
180
191
|
:y-domain="frame.domain ?? undefined"
|
|
181
192
|
:duration="motionDuration('slow')"
|
|
193
|
+
:svg-defs="fadeDefs"
|
|
182
194
|
>
|
|
183
195
|
<!-- Comparison first, so the current series draws over it. Dashed and thinner: the dash says
|
|
184
196
|
"not now" without needing a legend, and holding the same hue keeps the pair readable for a
|
|
@@ -193,7 +205,7 @@ const endpointTop = computed(() => sparklineEndpoint(props.data, frame.value));
|
|
|
193
205
|
:opacity="0.45"
|
|
194
206
|
:curve-type="curve ? CurveType.MonotoneX : CurveType.Linear"
|
|
195
207
|
/>
|
|
196
|
-
<VisArea v-if="area" :x="x" :y="y" :baseline="0" :color="
|
|
208
|
+
<VisArea v-if="area" :x="x" :y="y" :baseline="0" :color="`url(#${fadeId})`" :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear" />
|
|
197
209
|
<VisLine :x="x" :y="y" :color="color" :line-width="1.5" :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear" />
|
|
198
210
|
</VisXYContainer>
|
|
199
211
|
<span
|
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
|
+
}
|
|
@@ -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 —
|
package/src/londonAreas.ts
CHANGED
|
@@ -74,6 +74,26 @@ export const indexAreas = (areas: LondonArea[]): Map<string, LondonArea> => {
|
|
|
74
74
|
return byKey;
|
|
75
75
|
};
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* What a London map's areas take: pointer affordance (`interactive`) and a tab stop (`focusable`).
|
|
79
|
+
*
|
|
80
|
+
* Selection is an action the caller asked for, so a selectable map takes clicks and focus at every
|
|
81
|
+
* size and level, whatever the chrome. A tooltip is chrome: it needs chrome on, and reaches the
|
|
82
|
+
* keyboard only at borough level, where 33 stops is navigation rather than 704 obstacles.
|
|
83
|
+
*/
|
|
84
|
+
export const areaInteraction = (map: {
|
|
85
|
+
level: AreaLevel;
|
|
86
|
+
chrome: boolean;
|
|
87
|
+
tooltip: boolean;
|
|
88
|
+
selectable: boolean;
|
|
89
|
+
}): { interactive: boolean; focusable: boolean } => {
|
|
90
|
+
const hover = map.tooltip && map.chrome;
|
|
91
|
+
return {
|
|
92
|
+
interactive: map.selectable || hover,
|
|
93
|
+
focusable: map.selectable || (hover && map.level === 'borough'),
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
77
97
|
/**
|
|
78
98
|
* One area framed to itself: the area `key` names (by code or name, either case) and a viewBox of
|
|
79
99
|
* its stored box, padded on every side by `pad` × its longer side. Undefined for a key the set does
|
|
@@ -89,3 +109,49 @@ export const frameArea = (
|
|
|
89
109
|
const p = Math.max(area.w, area.h) * pad;
|
|
90
110
|
return { area, viewBox: `${area.x - p} ${area.y - p} ${area.w + p * 2} ${area.h + p * 2}` };
|
|
91
111
|
};
|
|
112
|
+
|
|
113
|
+
const FIT_PAD = 0.06;
|
|
114
|
+
const LABEL_EM = 9;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* What a map of `set` draws, the viewBox it draws it in, and which drawn areas carry a name.
|
|
118
|
+
*
|
|
119
|
+
* `fit: 'selection'` draws only the areas whose codes `matched` holds, framed to the union of their
|
|
120
|
+
* bounding boxes padded by 6% of its longer side. With `fit: 'city'`, or nothing matched, it draws
|
|
121
|
+
* every area in the set's viewBox. `font` is the label size in viewBox units at the rendered `width`.
|
|
122
|
+
*
|
|
123
|
+
* With `labels` set, areas are named biggest bounding box first, and a label whose box would overlap
|
|
124
|
+
* one already placed is dropped: a neighbour's label can reach across an area that has room by its
|
|
125
|
+
* own box.
|
|
126
|
+
*/
|
|
127
|
+
export const layoutAreas = (
|
|
128
|
+
set: LondonAreaSet,
|
|
129
|
+
map: { matched: ReadonlySet<string>; fit: 'city' | 'selection'; width: number; labels: boolean },
|
|
130
|
+
): { drawn: LondonArea[]; frame: readonly number[]; font: number; labelled: LondonArea[] } => {
|
|
131
|
+
const fitted = map.fit === 'selection' && set.areas.some((a) => map.matched.has(a.code));
|
|
132
|
+
const drawn = fitted ? set.areas.filter((a) => map.matched.has(a.code)) : set.areas;
|
|
133
|
+
let frame: readonly number[] = set.viewBox;
|
|
134
|
+
if (fitted) {
|
|
135
|
+
const x0 = Math.min(...drawn.map((a) => a.x));
|
|
136
|
+
const y0 = Math.min(...drawn.map((a) => a.y));
|
|
137
|
+
const x1 = Math.max(...drawn.map((a) => a.x + a.w));
|
|
138
|
+
const y1 = Math.max(...drawn.map((a) => a.y + a.h));
|
|
139
|
+
const pad = Math.max(x1 - x0, y1 - y0) * FIT_PAD;
|
|
140
|
+
frame = [x0 - pad, y0 - pad, x1 - x0 + pad * 2, y1 - y0 + pad * 2];
|
|
141
|
+
}
|
|
142
|
+
const font = (frame[2]! / map.width) * LABEL_EM;
|
|
143
|
+
const labelled: LondonArea[] = [];
|
|
144
|
+
if (map.labels) {
|
|
145
|
+
const placed: { x0: number; x1: number; y0: number; y1: number }[] = [];
|
|
146
|
+
for (const a of [...drawn].sort((p, q) => q.w * q.h - p.w * p.h)) {
|
|
147
|
+
// Average glyph advance at small sizes: enough to reserve room without measuring text.
|
|
148
|
+
const halfW = (a.name.length * font * 0.55) / 2;
|
|
149
|
+
const halfH = font * 0.75;
|
|
150
|
+
const box = { x0: a.cx - halfW, x1: a.cx + halfW, y0: a.cy - halfH, y1: a.cy + halfH };
|
|
151
|
+
if (placed.some((p) => box.x0 < p.x1 && box.x1 > p.x0 && box.y0 < p.y1 && box.y1 > p.y0)) continue;
|
|
152
|
+
placed.push(box);
|
|
153
|
+
labelled.push(a);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { drawn, frame, font, labelled };
|
|
157
|
+
};
|
package/src/londonGrid.ts
CHANGED
|
@@ -65,3 +65,30 @@ export const dotAt = (grid: LondonGrid, lat: number, lng: number): [number, numb
|
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
export const dotKey = (col: number, row: number) => `${col},${row}`;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Sums points into their dot. A point off the grid, or on a cell not in `land`, is dropped and
|
|
71
|
+
* counted. Without `value` each point counts one; a null, undefined or non-finite return adds nothing.
|
|
72
|
+
*/
|
|
73
|
+
export const binPoints = <P>(
|
|
74
|
+
grid: LondonGrid,
|
|
75
|
+
land: Set<string>,
|
|
76
|
+
points: readonly P[],
|
|
77
|
+
lat: (p: P) => number,
|
|
78
|
+
lng: (p: P) => number,
|
|
79
|
+
value?: (p: P) => number | null | undefined,
|
|
80
|
+
) => {
|
|
81
|
+
const totals = new Map<string, number>();
|
|
82
|
+
let dropped = 0;
|
|
83
|
+
for (const p of points) {
|
|
84
|
+
const at = dotAt(grid, lat(p), lng(p));
|
|
85
|
+
const key = at && dotKey(at[0], at[1]);
|
|
86
|
+
if (!key || !land.has(key)) {
|
|
87
|
+
dropped++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const v = value ? value(p) : 1;
|
|
91
|
+
totals.set(key, (totals.get(key) ?? 0) + (typeof v === 'number' && Number.isFinite(v) ? v : 0));
|
|
92
|
+
}
|
|
93
|
+
return { totals, dropped };
|
|
94
|
+
};
|
package/src/presets.ts
CHANGED
|
@@ -22,8 +22,9 @@ export const axisY: Partial<AxisConfigInterface<unknown>> = Object.freeze({
|
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
/** Legend: currently empty. A bare `<VisBulletLegend :items="…">` already renders the house look —
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* the adapter sets its label colour and the --vis-font-family its type falls back to, Unovis's
|
|
26
|
+
* own --vis-legend-* defaults set the sizes and spacing, and no call site repeats any config. The
|
|
27
|
+
* object exists so shared legend config has one home when it appears. */
|
|
27
28
|
export const legendDefaults: Partial<BulletLegendConfigInterface> = Object.freeze({});
|
|
28
29
|
|
|
29
30
|
/** Tooltip: above-right of the anchor, held clear of the pointer.
|
|
@@ -34,7 +35,8 @@ export const legendDefaults: Partial<BulletLegendConfigInterface> = Object.freez
|
|
|
34
35
|
* track the pointer whatever is set here. And pinning anchors to the hovered element's BOUNDING
|
|
35
36
|
* BOX, so it only reads as "next to what I hovered" when the box is the mark: right for a bar,
|
|
36
37
|
* wrong for a donut arc or a treemap group, whose boxes are far larger than the shape under the
|
|
37
|
-
* cursor. Hence
|
|
38
|
+
* cursor. Hence both frames set followCursor back to true, and by default useTooltipSnap then
|
|
39
|
+
* places the panel.
|
|
38
40
|
*
|
|
39
41
|
* The shifts are what keep the pointer off the panel on that tracking path. Unovis adds a fixed
|
|
40
42
|
* 5px margin, which puts the tooltip's corner under the cursor's own hotspot; these open that to a
|
|
@@ -73,7 +75,8 @@ export function houseTooltip(): Partial<TooltipConfigInterface> {
|
|
|
73
75
|
|
|
74
76
|
/** Treemap tile shape. Unovis ships 2px corners and a 2px gap, which reads as a raw partition;
|
|
75
77
|
* the house tile is a rounded slab with room to breathe, matching the card radius family. The
|
|
76
|
-
* gap
|
|
78
|
+
* gap is the separator: the adapter draws no tile stroke, and inside a group the gap shows the
|
|
79
|
+
* group tile beneath.
|
|
77
80
|
*
|
|
78
81
|
* Typed structurally rather than as `Partial<TreemapConfigInterface<…>>`: v-binding a config
|
|
79
82
|
* object carrying the Datum generic pins it to whatever that object declares, and every accessor
|
|
@@ -93,19 +96,19 @@ export const treemapDefaults: {
|
|
|
93
96
|
tileBorderRadius: 12,
|
|
94
97
|
tilePadding: 4,
|
|
95
98
|
// The strip a group reserves for its own heading. It has to clear the heading type the adapter
|
|
96
|
-
// sets on internal labels — too small and the
|
|
99
|
+
// sets on internal labels — too small and the group's own top tiles cover its label.
|
|
97
100
|
tilePaddingTop: 26,
|
|
98
|
-
// Unovis draws group and leaf tiles as flat sibling rects
|
|
99
|
-
// radius of its own. This flag is the only thing that marks
|
|
100
|
-
// tiles with no children), which is what lets the adapter give them the concentric inner
|
|
101
|
-
// Its other effect is a pointer cursor on leaves
|
|
101
|
+
// Unovis draws group and leaf tiles as flat sibling rects under one corner rule, so nesting has
|
|
102
|
+
// no radius of its own. This flag is the only thing that marks leaf tiles in the DOM (it adds a
|
|
103
|
+
// class to tiles with no children), which is what lets the adapter give them the concentric inner
|
|
104
|
+
// radius. Its other effect is a pointer cursor on leaves.
|
|
102
105
|
showTileClickAffordance: true,
|
|
103
106
|
// Plain inset from the tile edge, independent of the radius: the adapter squares the label clip,
|
|
104
107
|
// so a label is no longer sliced by its own clip corner and these do not have to dodge an arc.
|
|
105
108
|
labelOffsetX: 8,
|
|
106
109
|
labelOffsetY: 6,
|
|
107
110
|
// Leaf label type. Unovis buckets leaves into three sizes by sqrt(value) against the largest
|
|
108
|
-
// leaf, and its own 8/12/22 steps +4 then +10 — so two
|
|
111
|
+
// leaf, and its own 8/12/22 steps +4 then +10 — so two leaves of similar size can land either
|
|
109
112
|
// side of the top threshold and differ by nearly double. These are evenly spaced and top out
|
|
110
113
|
// level with the group heading, which stays distinct on weight rather than size.
|
|
111
114
|
tileLabelSmallFontSize: 12,
|
|
@@ -117,11 +120,10 @@ export const treemapDefaults: {
|
|
|
117
120
|
* disc cut into wedges; the house rings are separated slabs with the same rounded feel as the
|
|
118
121
|
* treemap tile and the donut arc.
|
|
119
122
|
*
|
|
120
|
-
* cornerRadius is the one number to be careful with.
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* donut's 4 is the comparable figure at its own 28px arc width.
|
|
123
|
+
* cornerRadius is the one number to be careful with. d3's arc generator, which draws both donuts,
|
|
124
|
+
* limits it to half the ring's width and shrinks it further on a narrow segment. 6 is the largest
|
|
125
|
+
* value that stays clean on the thinnest ring this chart is meant to carry; the flat donut's 4 is
|
|
126
|
+
* the comparable figure at its own 28px arc width.
|
|
125
127
|
*
|
|
126
128
|
* Typed structurally rather than as `Partial<NestedDonutConfigInterface<…>>` for the same reason
|
|
127
129
|
* treemapDefaults is: v-binding a config object carrying the Datum generic pins it, and every
|
|
@@ -132,9 +134,8 @@ export const nestedDonutDefaults: {
|
|
|
132
134
|
showBackground: boolean;
|
|
133
135
|
} = Object.freeze({
|
|
134
136
|
cornerRadius: 6,
|
|
135
|
-
// The ring gap. Segments
|
|
136
|
-
//
|
|
137
|
-
// the stroke defines each segment's edge against its neighbours in the same ring.
|
|
137
|
+
// The ring gap. Segments in a ring have no gap between them, so the adapter's segment stroke
|
|
138
|
+
// separates those; this padding separates the rings.
|
|
138
139
|
layerPadding: 4,
|
|
139
140
|
// The track behind the segments. Our rings always sum to the whole, so a visible track only ever
|
|
140
141
|
// surfaces in the gaps, where any opaque colour is wrong the moment the chart sits on a surface
|
package/src/resolveVar.ts
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
// Read fresh on every call: the point is to defeat a cache, so adding one here would restore the
|
|
14
14
|
// bug. Callers should invoke this inside a render that re-runs on `useThemeVersion`.
|
|
15
15
|
|
|
16
|
-
/** The computed value of a `var(--x)` string
|
|
17
|
-
|
|
16
|
+
/** The computed value of a `var(--x)` string, read off `context` or the root element. Non-var
|
|
17
|
+
* strings, and every string outside the browser, pass through untouched. */
|
|
18
|
+
export function resolveVar(value: string, context?: Element): string {
|
|
18
19
|
if (typeof window === 'undefined' || !value.startsWith('var(--')) return value;
|
|
19
20
|
const name = value.slice(4, -1).split(',')[0]!.trim();
|
|
20
|
-
return getComputedStyle(context).getPropertyValue(name).trim() || value;
|
|
21
|
+
return getComputedStyle(context ?? document.documentElement).getPropertyValue(name).trim() || value;
|
|
21
22
|
}
|