@humanforest/charts 0.1.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/LICENSE +67 -0
- package/package.json +32 -0
- package/src/FBoroughShape.vue +52 -0
- package/src/FCalendarGrid.vue +332 -0
- package/src/FCellLegend.vue +54 -0
- package/src/FChartFrame.vue +201 -0
- package/src/FChartFrameSingle.vue +91 -0
- package/src/FChartLegend.vue +26 -0
- package/src/FDistributionBar.vue +224 -0
- package/src/FDottedMap.vue +335 -0
- package/src/FLondonMap.vue +399 -0
- package/src/FSparkline.vue +198 -0
- package/src/FStatusTrack.vue +151 -0
- package/src/cellSize.ts +46 -0
- package/src/distribution.ts +99 -0
- package/src/engine.ts +15 -0
- package/src/forestTooltip.ts +110 -0
- package/src/fromCategories.ts +43 -0
- package/src/index.ts +87 -0
- package/src/londonAreas.ts +75 -0
- package/src/londonGrid.ts +67 -0
- package/src/motionDuration.ts +49 -0
- package/src/presets.ts +143 -0
- package/src/resolveVar.ts +21 -0
- package/src/snapTooltip.ts +154 -0
- package/src/tooltipStandIn.ts +58 -0
- package/src/useCellScale.ts +155 -0
- package/src/useChartPalette.ts +61 -0
- package/src/useChartRepaintKey.ts +22 -0
- package/src/useThemeVersion.ts +17 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
<script setup lang="ts" generic="P extends Record<string, unknown>">
|
|
2
|
+
// London as a grid of dots, coloured by how many points land in each. Third of the cell components:
|
|
3
|
+
// FStatusTrack puts cells in a sequence, FCalendarGrid puts them on a date grid, this puts them on a
|
|
4
|
+
// geography. Colour resolution is useCellScale's; this component owns the binning and the layout.
|
|
5
|
+
//
|
|
6
|
+
// It is not a map. There is no pan, no zoom and no projection at read time — a dot is roughly a
|
|
7
|
+
// square kilometre and locates nothing. Real geography is Mapbox's job. This answers "where in
|
|
8
|
+
// London" at a glance, with no tiles and no network.
|
|
9
|
+
import { computed, ref, shallowRef, watchEffect } from 'vue';
|
|
10
|
+
import { useCellScale, type SequentialRamp } from './useCellScale';
|
|
11
|
+
import { CELL_SIZES, orElse, type CellSize } from './cellSize';
|
|
12
|
+
import {
|
|
13
|
+
LONDON_GRID,
|
|
14
|
+
RIVER_COLOR,
|
|
15
|
+
loadLondonGrid,
|
|
16
|
+
dotAt,
|
|
17
|
+
dotKey,
|
|
18
|
+
type DotDensity,
|
|
19
|
+
type LondonGrid,
|
|
20
|
+
} from './londonGrid';
|
|
21
|
+
import FCellLegend from './FCellLegend.vue';
|
|
22
|
+
|
|
23
|
+
interface DotInfo {
|
|
24
|
+
col: number;
|
|
25
|
+
row: number;
|
|
26
|
+
value: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const props = withDefaults(
|
|
30
|
+
defineProps<{
|
|
31
|
+
points: P[];
|
|
32
|
+
lat: (p: P) => number;
|
|
33
|
+
lng: (p: P) => number;
|
|
34
|
+
/** Summed per dot as `value(p) ?? 1`, so the default reading is a count of points. */
|
|
35
|
+
value?: (p: P) => number | null | undefined;
|
|
36
|
+
ramp?: SequentialRamp;
|
|
37
|
+
/** Intensity domain, in the caller's units. Two maps compared side by side must both set it. */
|
|
38
|
+
domain?: [number, number];
|
|
39
|
+
steps?: number;
|
|
40
|
+
/** How a dot's total is spaced along the ramp. See `spaced` below for why this exists. */
|
|
41
|
+
scale?: 'linear' | 'sqrt' | 'log';
|
|
42
|
+
/** How many dots the map is sampled into. Separate from `size`, which is how big it is drawn:
|
|
43
|
+
* a coarse grid at lg is big dots, a fine grid at sm is a texture. Anything but the default is
|
|
44
|
+
* fetched on use. */
|
|
45
|
+
density?: DotDensity;
|
|
46
|
+
/** Size step. Sets the dot dimensions, and whether legend and tooltips default on:
|
|
47
|
+
* xs and sm are too small to carry chrome, so it defaults off there. Any explicit prop wins. */
|
|
48
|
+
size?: CellSize;
|
|
49
|
+
/** Forces the size step's chrome decision either way. `legend` still wins over it. */
|
|
50
|
+
chrome?: boolean;
|
|
51
|
+
dotSize?: number;
|
|
52
|
+
dotGap?: number;
|
|
53
|
+
/** Corner radius. Defaults to half the dot, which is a circle; 0 gives squares. */
|
|
54
|
+
radius?: number;
|
|
55
|
+
legend?: boolean;
|
|
56
|
+
/** Paint the Thames. Off, its cells are ordinary land — they take data and are coloured like
|
|
57
|
+
* any other dot, rather than leaving a gap where the channel was. */
|
|
58
|
+
river?: boolean;
|
|
59
|
+
/** Hover text. Suppressed at xs and sm, where the target is too small to hit reliably. */
|
|
60
|
+
tooltip?: (info: DotInfo) => string;
|
|
61
|
+
ariaLabel?: string;
|
|
62
|
+
}>(),
|
|
63
|
+
{
|
|
64
|
+
density: 'fine',
|
|
65
|
+
size: 'md',
|
|
66
|
+
steps: 5,
|
|
67
|
+
scale: 'linear',
|
|
68
|
+
/* Vue casts an ABSENT Boolean-typed prop to false, so without these the size step could never
|
|
69
|
+
switch chrome on: `orElse` would read that false as a deliberate "off" at every size. */
|
|
70
|
+
legend: undefined,
|
|
71
|
+
chrome: undefined,
|
|
72
|
+
river: true,
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Three tiers, narrowest first: an individual prop beats `chrome`, which beats the size step.
|
|
77
|
+
const spec = computed(() => CELL_SIZES[props.size]);
|
|
78
|
+
|
|
79
|
+
// Size sets the rendered width; the dot size follows from it and the density.
|
|
80
|
+
//
|
|
81
|
+
// Fixing the dot in pixels made the two settings the same setting — a denser grid simply drew a
|
|
82
|
+
// bigger map, so density could not be varied without resizing the chart. Deriving the dot from the
|
|
83
|
+
// width means a coarse grid at lg is big dots and a fine grid at lg is small ones, both filling the
|
|
84
|
+
// same frame. The widths match the London map step for step so the two sit at the same scale.
|
|
85
|
+
const MAP_WIDTH: Record<CellSize, number> = { xs: 192, sm: 383, md: 575, lg: 767 };
|
|
86
|
+
/** How much of each cell the dot fills; the remainder is the gap. */
|
|
87
|
+
const DOT_FILL = 0.78;
|
|
88
|
+
// Left fractional. Rounding the dot and the gap to whole pixels made the rendered width miss its
|
|
89
|
+
// target by up to ten per cent, and differently per density — which is the coupling this was meant
|
|
90
|
+
// to remove. These are path coordinates, so a fraction costs nothing.
|
|
91
|
+
const pitchFor = computed(() => MAP_WIDTH[props.size] / grid.value.width);
|
|
92
|
+
const dotSize = computed(() => orElse(props.dotSize, pitchFor.value * DOT_FILL));
|
|
93
|
+
const dotGap = computed(() => orElse(props.dotGap, pitchFor.value * (1 - DOT_FILL)));
|
|
94
|
+
const radius = computed(() => orElse(props.radius, dotSize.value / 2));
|
|
95
|
+
const showChrome = computed(() => orElse(props.chrome, spec.value.chrome));
|
|
96
|
+
const showLegend = computed(() => orElse(props.legend, showChrome.value));
|
|
97
|
+
const interactive = computed(() => !!props.tooltip && showChrome.value);
|
|
98
|
+
|
|
99
|
+
// The bundled resolution stands in while another loads, so changing density never blanks the map —
|
|
100
|
+
// the silhouette is the same either way, only how finely it is sampled changes.
|
|
101
|
+
const loaded = shallowRef<LondonGrid>(LONDON_GRID);
|
|
102
|
+
watchEffect(() => {
|
|
103
|
+
const want = props.density;
|
|
104
|
+
void loadLondonGrid(want).then((g) => {
|
|
105
|
+
if (props.density === want) loaded.value = g;
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
const grid = computed(() => loaded.value);
|
|
109
|
+
|
|
110
|
+
// With the river off, its cells become ordinary land rather than disappearing.
|
|
111
|
+
//
|
|
112
|
+
// The build step files them separately, so hiding the painting alone left a white channel through
|
|
113
|
+
// the middle of the map — "do not draw the Thames" read as "cut a hole where it was". They also have
|
|
114
|
+
// to rejoin the binning, or they would render as a guaranteed-zero stripe: points landing on them
|
|
115
|
+
// are dropped while they count as river.
|
|
116
|
+
const landDots = computed(() =>
|
|
117
|
+
props.river ? grid.value.dots : [...grid.value.dots, ...grid.value.river],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const land = computed(() => new Set(landDots.value.map(([c, r]) => dotKey(c, r))));
|
|
121
|
+
|
|
122
|
+
// Points are summed into their dot. A point outside the boundary, or on the river, has nowhere to
|
|
123
|
+
// land — those are counted rather than quietly absorbed, so a caller feeding the wrong city or the
|
|
124
|
+
// wrong coordinate order can see it. The river is drawn but is not a bucket: a ride does not start
|
|
125
|
+
// in the Thames, and letting one land there would put a reading on a dot that cannot have one.
|
|
126
|
+
const binned = computed(() => {
|
|
127
|
+
const totals = new Map<string, number>();
|
|
128
|
+
let dropped = 0;
|
|
129
|
+
for (const p of props.points) {
|
|
130
|
+
const at = dotAt(grid.value, props.lat(p), props.lng(p));
|
|
131
|
+
const key = at && dotKey(at[0], at[1]);
|
|
132
|
+
if (!key || !land.value.has(key)) {
|
|
133
|
+
dropped++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
totals.set(key, (totals.get(key) ?? 0) + (props.value?.(p) ?? 1));
|
|
137
|
+
}
|
|
138
|
+
return { totals, dropped };
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Every land dot gets a cell, whether or not a point reached it.
|
|
142
|
+
//
|
|
143
|
+
// This is where the family's "empty is a third outcome" rule inverts, and inverting it is what keeps
|
|
144
|
+
// the reading honest here. On a calendar a day with no datum might not have been measured; on a
|
|
145
|
+
// density map the coverage is complete by construction, so a dot with no points means zero events
|
|
146
|
+
// happened there. Colouring that as "no data" would assert something the data does not. Unhit dots
|
|
147
|
+
// therefore take the ramp's low stop, and the legend carries no empty swatch.
|
|
148
|
+
const cells = computed<DotInfo[]>(() =>
|
|
149
|
+
landDots.value.map(([col, row]) => ({
|
|
150
|
+
col,
|
|
151
|
+
row,
|
|
152
|
+
value: binned.value.totals.get(dotKey(col, row)) ?? 0,
|
|
153
|
+
})),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// How a total is spaced along the ramp.
|
|
157
|
+
//
|
|
158
|
+
// Density is heavy-tailed, and linearly spacing a heavy tail produces a blank map: with a typical
|
|
159
|
+
// week of rides the median dot holds 1 and the busiest holds over a hundred, so five equal buckets
|
|
160
|
+
// put roughly ninety per cent of the city in the palest one and the picture says nothing. Compress
|
|
161
|
+
// the tail and the structure appears. Nothing is compressed by default — a transform the caller did
|
|
162
|
+
// not ask for would quietly change what the ramp claims — but a density map usually wants one.
|
|
163
|
+
const spaced = (v: number) =>
|
|
164
|
+
props.scale === 'sqrt' ? Math.sqrt(v) : props.scale === 'log' ? Math.log1p(v) : v;
|
|
165
|
+
|
|
166
|
+
// The domain prop is in the caller's units, so it is spaced too — otherwise setting one would
|
|
167
|
+
// silently mean something different from leaving it off.
|
|
168
|
+
const spacedDomain = computed<[number, number] | undefined>(() =>
|
|
169
|
+
props.domain ? [spaced(props.domain[0]), spaced(props.domain[1])] : undefined,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const scale = useCellScale<DotInfo>({
|
|
173
|
+
mode: 'intensity',
|
|
174
|
+
ramp: computed(() => props.ramp ?? ('forest' as SequentialRamp)),
|
|
175
|
+
domain: spacedDomain,
|
|
176
|
+
steps: computed(() => props.steps),
|
|
177
|
+
data: cells,
|
|
178
|
+
value: (d) => spaced(d.value),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const pitch = computed(() => dotSize.value + dotGap.value);
|
|
182
|
+
const width = computed(() => grid.value.width * pitch.value - dotGap.value);
|
|
183
|
+
const height = computed(() => grid.value.height * pitch.value - dotGap.value);
|
|
184
|
+
|
|
185
|
+
// Dots are drawn as one path per colour, not one element per dot.
|
|
186
|
+
//
|
|
187
|
+
// At this density a rect each means four and a half thousand nodes per map, and a page showing
|
|
188
|
+
// several sizes of the same map put thirty thousand in one document — enough that the browser spends
|
|
189
|
+
// its time in layout rather than paint. Grouping by fill takes that to about seven nodes, because a
|
|
190
|
+
// path can hold any number of disjoint subpaths and every dot sharing a bucket also shares a fill.
|
|
191
|
+
const subpath = (col: number, row: number): string => {
|
|
192
|
+
const x = col * pitch.value;
|
|
193
|
+
const y = row * pitch.value;
|
|
194
|
+
const s = dotSize.value;
|
|
195
|
+
const r = Math.min(radius.value, s / 2);
|
|
196
|
+
if (r <= 0) return `M${x},${y}h${s}v${s}h${-s}z`;
|
|
197
|
+
// A radius of half the dot is a circle, which is the default and the common case.
|
|
198
|
+
if (r >= s / 2) return `M${x},${y + r}a${r},${r} 0 1,0 ${s},0a${r},${r} 0 1,0 ${-s},0`;
|
|
199
|
+
const m = s - 2 * r;
|
|
200
|
+
return `M${x + r},${y}h${m}a${r},${r} 0 0,1 ${r},${r}v${m}a${r},${r} 0 0,1 ${-r},${r}h${-m}a${r},${r} 0 0,1 ${-r},${-r}v${-m}a${r},${r} 0 0,1 ${r},${-r}z`;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** One entry per distinct fill, in ramp order so the layer list is stable across renders. */
|
|
204
|
+
const layers = computed(() => {
|
|
205
|
+
const byFill = new Map<string, string[]>();
|
|
206
|
+
for (const cell of cells.value) {
|
|
207
|
+
const fill = scale.colorOf(cell);
|
|
208
|
+
const parts = byFill.get(fill);
|
|
209
|
+
if (parts) parts.push(subpath(cell.col, cell.row));
|
|
210
|
+
else byFill.set(fill, [subpath(cell.col, cell.row)]);
|
|
211
|
+
}
|
|
212
|
+
return [...byFill].map(([fill, parts]) => ({ fill, d: parts.join('') }));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const riverPath = computed(() =>
|
|
216
|
+
props.river ? grid.value.river.map(([c, r]) => subpath(c, r)).join('') : '',
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
/** Cells by grid position, so a pointer lands on its dot by arithmetic rather than by hit-testing. */
|
|
220
|
+
const byPosition = computed(() => {
|
|
221
|
+
const m = new Map<string, DotInfo>();
|
|
222
|
+
for (const c of cells.value) m.set(dotKey(c.col, c.row), c);
|
|
223
|
+
return m;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const mapLabel = computed(() => {
|
|
227
|
+
if (props.ariaLabel) return props.ariaLabel;
|
|
228
|
+
// The busiest dot in the caller's units. scale.domain is in spaced units and would report a
|
|
229
|
+
// logarithm as though it were a ride count.
|
|
230
|
+
const hi = props.domain?.[1] ?? cells.value.reduce((m, c) => (c.value > m ? c.value : m), 0);
|
|
231
|
+
const placed = props.points.length - binned.value.dropped;
|
|
232
|
+
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.`;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Anchored to the dot rather than the pointer. Dots are not focusable and deliberately so: promoting
|
|
236
|
+
// four thousand of them to buttons the way FCalendarGrid promotes 365 cells would put four thousand
|
|
237
|
+
// tab stops in the page. The summary on the wrapper carries the reading for anyone not using a
|
|
238
|
+
// pointer.
|
|
239
|
+
// The dot has no element of its own to anchor to — it is painted into a merged path — so the
|
|
240
|
+
// tooltip is given a VIRTUAL reference: floating-ui only ever asks an anchor for its bounding box,
|
|
241
|
+
// and here that box is the arithmetic the hit-testing already does.
|
|
242
|
+
const tip = ref<{ text: string; box: { x: number; y: number; w: number; h: number } | null }>({
|
|
243
|
+
text: '',
|
|
244
|
+
box: null,
|
|
245
|
+
});
|
|
246
|
+
const tipOpen = ref(false);
|
|
247
|
+
const tipReference = computed(() =>
|
|
248
|
+
tip.value.box
|
|
249
|
+
? { getBoundingClientRect: () => new DOMRect(tip.value.box!.x, tip.value.box!.y, tip.value.box!.w, tip.value.box!.h) }
|
|
250
|
+
: null,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
// One listener on an overlay, rather than one per dot. The grid is regular, so the dot under the
|
|
254
|
+
// pointer is arithmetic — which is both cheaper than four thousand hit targets and more accurate,
|
|
255
|
+
// because the gaps between dots resolve to their dot instead of falling through to nothing.
|
|
256
|
+
const trackPointer = (event: MouseEvent) => {
|
|
257
|
+
const box = (event.currentTarget as SVGRectElement).getBoundingClientRect();
|
|
258
|
+
// The svg is scaled to fit its container, so client pixels are converted through the viewBox
|
|
259
|
+
// rather than assumed to be user units.
|
|
260
|
+
const scaleX = width.value / box.width;
|
|
261
|
+
const col = Math.floor(((event.clientX - box.left) * scaleX) / pitch.value);
|
|
262
|
+
const row = Math.floor(((event.clientY - box.top) * (height.value / box.height)) / pitch.value);
|
|
263
|
+
const cell = byPosition.value.get(dotKey(col, row));
|
|
264
|
+
if (!cell) {
|
|
265
|
+
tipOpen.value = false;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// Anchored to the dot, not the pointer: inside a 5px target a bubble that follows the cursor
|
|
269
|
+
// only jitters.
|
|
270
|
+
const scaleY = height.value / box.height;
|
|
271
|
+
tip.value = {
|
|
272
|
+
text: props.tooltip!(cell),
|
|
273
|
+
box: {
|
|
274
|
+
x: box.left + (col * pitch.value) / scaleX,
|
|
275
|
+
y: box.top + (row * pitch.value) / scaleY,
|
|
276
|
+
w: pitch.value / scaleX,
|
|
277
|
+
h: pitch.value / scaleY,
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
tipOpen.value = true;
|
|
281
|
+
};
|
|
282
|
+
const hideTip = () => {
|
|
283
|
+
tipOpen.value = false;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
defineExpose({
|
|
287
|
+
/** Points that fell outside the landmass — off the grid entirely, or on the river. */
|
|
288
|
+
outOfBounds: computed(() => binned.value.dropped),
|
|
289
|
+
});
|
|
290
|
+
</script>
|
|
291
|
+
|
|
292
|
+
<template>
|
|
293
|
+
<div class="flex w-full flex-col gap-3">
|
|
294
|
+
<div class="overflow-x-auto">
|
|
295
|
+
<!-- max-width and height are inline rather than utilities: Tailwind does not scan this
|
|
296
|
+
package, so a class used only here is never generated and the rule silently does
|
|
297
|
+
nothing. width and height stay attributes for the intrinsic size, and height:auto is
|
|
298
|
+
what keeps the aspect once max-width takes over in a container narrower than the map. -->
|
|
299
|
+
<UTooltip :text="tip.text" :reference="tipReference" :open="tipOpen">
|
|
300
|
+
<svg
|
|
301
|
+
:width="width"
|
|
302
|
+
:height="height"
|
|
303
|
+
:viewBox="`0 0 ${width} ${height}`"
|
|
304
|
+
role="img"
|
|
305
|
+
:aria-label="mapLabel"
|
|
306
|
+
class="mx-auto block"
|
|
307
|
+
style="max-width: 100%; height: auto"
|
|
308
|
+
>
|
|
309
|
+
<!-- The river first, so a land dot wins any overlap. It carries no data and takes no hover:
|
|
310
|
+
the Thames is a label on the map, not a bucket with a reading. -->
|
|
311
|
+
<path v-if="riverPath" :d="riverPath" :fill="RIVER_COLOR" />
|
|
312
|
+
|
|
313
|
+
<path v-for="layer in layers" :key="layer.fill" :d="layer.d" :fill="layer.fill" />
|
|
314
|
+
|
|
315
|
+
<!-- One transparent target over the whole grid. The dot under the pointer is computed, so
|
|
316
|
+
the map keeps its tooltip without giving every dot its own node. -->
|
|
317
|
+
<rect
|
|
318
|
+
v-if="interactive"
|
|
319
|
+
:width="width"
|
|
320
|
+
:height="height"
|
|
321
|
+
fill="transparent"
|
|
322
|
+
@mousemove="trackPointer"
|
|
323
|
+
@mouseleave="hideTip"
|
|
324
|
+
/>
|
|
325
|
+
</svg>
|
|
326
|
+
</UTooltip>
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
<!-- Centred with the map above it. The legend component itself is shared with the track and the
|
|
330
|
+
calendar, which are left-aligned, so the alignment belongs here rather than in it. -->
|
|
331
|
+
<div v-if="showLegend" class="flex justify-center">
|
|
332
|
+
<FCellLegend :legend="scale.legend.value" />
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
</template>
|