@humanforest/charts 0.3.5 → 0.3.7
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 +18 -0
- package/package.json +8 -3
- package/src/FBoroughShape.vue +6 -14
- package/src/FChartFrame.vue +9 -2
- package/src/FChartFrameSingle.vue +10 -2
- package/src/FDistributionBar.vue +2 -11
- package/src/FSparkline.vue +28 -42
- package/src/distribution.ts +12 -0
- package/src/forestTooltip.ts +1 -1
- package/src/fromCategories.ts +15 -2
- package/src/londonAreas.ts +19 -3
- package/src/resolveVar.ts +1 -1
- package/src/snapTooltip.ts +35 -17
- package/src/sparklineDomain.ts +61 -1
- package/src/useCellScale.ts +10 -7
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Every @humanforest package shares one version, so one entry covers all seven packages. Newest first.
|
|
4
|
+
|
|
5
|
+
## 0.3.7 — 2026-09-13
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- **ui**: add FMapContextMenu, a right-click menu on the map
|
|
10
|
+
- **charts**: chart frames and sparklines can be named for screen readers
|
|
11
|
+
|
|
12
|
+
### Fixes
|
|
13
|
+
|
|
14
|
+
- **charts**: CellScale types what useCellScale returns
|
|
15
|
+
- **charts**: an empty distribution bar is still named, as "No data"
|
|
16
|
+
- **charts**: a bar sparkline warns that :curve="false" is ignored
|
|
17
|
+
- **charts**: a sparkline leaves non-finite readings off its scale
|
|
18
|
+
- **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.
|
|
3
|
+
"version": "0.3.7",
|
|
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.3.
|
|
32
|
+
"@humanforest/tokens": "^0.3.7"
|
|
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/FChartFrame.vue
CHANGED
|
@@ -15,7 +15,7 @@ import type {
|
|
|
15
15
|
} from '@unovis/ts';
|
|
16
16
|
// Relative file imports (not the package barrel): the barrel exports this component, and a
|
|
17
17
|
// self-import through it would create a module cycle.
|
|
18
|
-
import { fromCategories } from './fromCategories';
|
|
18
|
+
import { chartLabel, fromCategories } from './fromCategories';
|
|
19
19
|
import { forestTooltip, type ForestTooltipOptions } from './forestTooltip';
|
|
20
20
|
import { motionDuration } from './motionDuration';
|
|
21
21
|
import {
|
|
@@ -70,6 +70,8 @@ const props = withDefaults(
|
|
|
70
70
|
duration?: number;
|
|
71
71
|
/** Extra signals that must remount the chart (theme flips and palette identity are automatic). */
|
|
72
72
|
repaintOn?: unknown[];
|
|
73
|
+
/** The chart's accessible name. Defaults to its series, as "Chart: Trips, Utilisation". */
|
|
74
|
+
ariaLabel?: string;
|
|
73
75
|
}>(),
|
|
74
76
|
{
|
|
75
77
|
height: 280,
|
|
@@ -164,7 +166,12 @@ const repaintKey = computed(() =>
|
|
|
164
166
|
</script>
|
|
165
167
|
|
|
166
168
|
<template>
|
|
167
|
-
<div
|
|
169
|
+
<div
|
|
170
|
+
ref="rootEl"
|
|
171
|
+
role="img"
|
|
172
|
+
:aria-label="chartLabel(categories, ariaLabel)"
|
|
173
|
+
style="display: flex; flex-direction: column; gap: 0.75rem"
|
|
174
|
+
>
|
|
168
175
|
<VisXYContainer
|
|
169
176
|
:key="repaintKey"
|
|
170
177
|
:data="data"
|
|
@@ -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
|
|
package/src/FSparkline.vue
CHANGED
|
@@ -18,7 +18,7 @@ import { VisXYContainer, VisLine, VisArea } from '@unovis/vue';
|
|
|
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 } from './sparklineDomain';
|
|
22
22
|
|
|
23
23
|
const props = withDefaults(
|
|
24
24
|
defineProps<{
|
|
@@ -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
|
});
|
|
@@ -114,47 +123,24 @@ const hasCompare = computed(() => !!props.compare?.some((v) => v !== null && v !
|
|
|
114
123
|
// the floor at 0 it runs from 0 up to each reading's height above the floor.
|
|
115
124
|
const frame = computed(() => sparklineDomain(props.data, props.compare, props.area));
|
|
116
125
|
|
|
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
|
-
};
|
|
126
|
+
// Bars are the exception: measured from zero, not from the data's own floor. Why lives in
|
|
127
|
+
// sparklineBarDomain.
|
|
128
|
+
const barDomain = computed(() => sparklineBarDomain(props.data));
|
|
129
|
+
const barFor = (v: number | null) => sparklineBar(v, barDomain.value);
|
|
143
130
|
|
|
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
|
-
});
|
|
131
|
+
// Percentage down the height for the endpoint dot, or null when there is no reading to mark.
|
|
132
|
+
const endpointTop = computed(() => sparklineEndpoint(props.data, frame.value));
|
|
154
133
|
</script>
|
|
155
134
|
|
|
156
135
|
<template>
|
|
157
|
-
<div
|
|
136
|
+
<div
|
|
137
|
+
class="relative w-full"
|
|
138
|
+
:class="fill ? 'h-full' : ''"
|
|
139
|
+
:style="fill ? { minHeight: `${height}px`, color } : { height: `${height}px`, color }"
|
|
140
|
+
:role="ariaLabel ? 'img' : undefined"
|
|
141
|
+
:aria-label="ariaLabel || undefined"
|
|
142
|
+
:aria-hidden="ariaLabel ? undefined : 'true'"
|
|
143
|
+
>
|
|
158
144
|
<!-- Plain DOM rather than a mark: there are no axes, scales or tooltip to share with the XY
|
|
159
145
|
container, so a flex row of columns is the whole implementation and costs no chart runtime.
|
|
160
146
|
The 2px gap is fixed, so the bucket count sets how thin the bars get: n buckets across w
|
|
@@ -162,7 +148,7 @@ const endpointTop = computed(() => {
|
|
|
162
148
|
2.7px — still bars; past roughly 60 the gap outweighs the bar and the row reads as a
|
|
163
149
|
texture, where the measure belongs to a line instead. Each bar is rounded at both ends:
|
|
164
150
|
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'"
|
|
151
|
+
<div v-if="mark === 'bar'" class="flex w-full items-end gap-0.5" :class="fill ? 'absolute inset-0' : 'h-full'">
|
|
166
152
|
<span
|
|
167
153
|
v-for="(v, i) in data"
|
|
168
154
|
:key="i"
|
|
@@ -211,7 +197,7 @@ const endpointTop = computed(() => {
|
|
|
211
197
|
<VisLine :x="x" :y="y" :color="color" :line-width="1.5" :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear" />
|
|
212
198
|
</VisXYContainer>
|
|
213
199
|
<span
|
|
214
|
-
v-if="mark === 'line' && endpoint &&
|
|
200
|
+
v-if="mark === 'line' && endpoint && endpointTop !== null"
|
|
215
201
|
class="pointer-events-none absolute right-0 size-1.5 -translate-y-1/2 rounded-full bg-current"
|
|
216
202
|
:style="{ top: `${endpointTop}%` }"
|
|
217
203
|
aria-hidden="true"
|
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
|
package/src/forestTooltip.ts
CHANGED
|
@@ -97,7 +97,7 @@ export function forestTooltip<Datum extends Record<string, unknown>>(
|
|
|
97
97
|
const present = keys.filter((k) => k in d);
|
|
98
98
|
const title = opts.titleFormatter ? opts.titleFormatter(d) : Object.values(d)[0];
|
|
99
99
|
const rows: ForestTipRow[] = present.map((k, i) => {
|
|
100
|
-
const item = categories[k]
|
|
100
|
+
const item = categories[k]!;
|
|
101
101
|
const raw = d[k];
|
|
102
102
|
return {
|
|
103
103
|
swatch: typeof item.color === 'string' ? item.color : `var(--vis-color${i})`,
|
package/src/fromCategories.ts
CHANGED
|
@@ -35,9 +35,22 @@ export function fromCategories<Datum extends Record<string, unknown>>(
|
|
|
35
35
|
: Array.isArray(item.color) && typeof item.color[0] === 'string' ? item.color[0]
|
|
36
36
|
: `var(--vis-color${i})`;
|
|
37
37
|
return {
|
|
38
|
-
colors: keys.map((k, i) => colorOf(categories[k]
|
|
39
|
-
legendItems: keys.map((k, i) => ({ ...categories[k]
|
|
38
|
+
colors: keys.map((k, i) => colorOf(categories[k]!, i)),
|
|
39
|
+
legendItems: keys.map((k, i) => ({ ...categories[k]!, color: colorOf(categories[k]!, i) })),
|
|
40
40
|
yAccessors: keys.map((k) => (d: Datum) => d[k] as number | null | undefined),
|
|
41
41
|
xAccessor: xKey ? (d) => d[xKey] as number : (_d, i) => i,
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A chart frame's accessible name: `ariaLabel` when given, otherwise the series it plots, as
|
|
47
|
+
* "Chart: Trips, Utilisation". With no categories it is "Chart".
|
|
48
|
+
*/
|
|
49
|
+
export function chartLabel(
|
|
50
|
+
categories: Record<string, BulletLegendItemInterface> | undefined,
|
|
51
|
+
ariaLabel?: string,
|
|
52
|
+
): string {
|
|
53
|
+
if (ariaLabel) return ariaLabel;
|
|
54
|
+
const names = Object.entries(categories ?? {}).map(([key, item]) => String(item.name ?? key));
|
|
55
|
+
return names.length ? `Chart: ${names.join(', ')}` : 'Chart';
|
|
56
|
+
}
|
package/src/londonAreas.ts
CHANGED
|
@@ -39,9 +39,9 @@ export const LONDON_BOROUGHS = boroughs as unknown as LondonAreaSet;
|
|
|
39
39
|
/**
|
|
40
40
|
* The wards, fetched on first use.
|
|
41
41
|
*
|
|
42
|
-
* Wards are
|
|
43
|
-
* separate chunk rather than part of the barrel. The promise is cached, so concurrent callers
|
|
44
|
-
* re-renders share one request.
|
|
42
|
+
* Wards are more than four times the weight of the boroughs and most maps never draw them, so they
|
|
43
|
+
* are a separate chunk rather than part of the barrel. The promise is cached, so concurrent callers
|
|
44
|
+
* and re-renders share one request.
|
|
45
45
|
*/
|
|
46
46
|
let wardsPromise: Promise<LondonAreaSet> | null = null;
|
|
47
47
|
export const loadLondonWards = (): Promise<LondonAreaSet> => {
|
|
@@ -73,3 +73,19 @@ export const indexAreas = (areas: LondonArea[]): Map<string, LondonArea> => {
|
|
|
73
73
|
}
|
|
74
74
|
return byKey;
|
|
75
75
|
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One area framed to itself: the area `key` names (by code or name, either case) and a viewBox of
|
|
79
|
+
* its stored box, padded on every side by `pad` × its longer side. Undefined for a key the set does
|
|
80
|
+
* not hold, so an unknown area draws nothing rather than a stray full-London frame.
|
|
81
|
+
*/
|
|
82
|
+
export const frameArea = (
|
|
83
|
+
areas: LondonArea[],
|
|
84
|
+
key: string,
|
|
85
|
+
pad: number,
|
|
86
|
+
): { area: LondonArea; viewBox: string } | undefined => {
|
|
87
|
+
const area = indexAreas(areas).get(key.toLowerCase());
|
|
88
|
+
if (!area) return undefined;
|
|
89
|
+
const p = Math.max(area.w, area.h) * pad;
|
|
90
|
+
return { area, viewBox: `${area.x - p} ${area.y - p} ${area.w + p * 2} ${area.h + p * 2}` };
|
|
91
|
+
};
|
package/src/resolveVar.ts
CHANGED
|
@@ -16,6 +16,6 @@
|
|
|
16
16
|
/** The computed value of a `var(--x)` string. Non-var strings pass through untouched. */
|
|
17
17
|
export function resolveVar(value: string, context: Element = document.documentElement): string {
|
|
18
18
|
if (typeof window === 'undefined' || !value.startsWith('var(--')) return value;
|
|
19
|
-
const name = value.slice(4, -1).split(',')[0]
|
|
19
|
+
const name = value.slice(4, -1).split(',')[0]!.trim();
|
|
20
20
|
return getComputedStyle(context).getPropertyValue(name).trim() || value;
|
|
21
21
|
}
|
package/src/snapTooltip.ts
CHANGED
|
@@ -40,7 +40,7 @@ const clamp = (v: number, lo: number, hi: number): number => Math.min(Math.max(v
|
|
|
40
40
|
* for a donut arc that lands at mid-angle, mid-radius — the middle of the visible band — where the
|
|
41
41
|
* box centre would land in the hole, or outside the wedge entirely on a small segment. Anything
|
|
42
42
|
* without a length (rects) falls back to its box, which for those IS the mark. */
|
|
43
|
-
function shapeCentre(el: SVGGraphicsElement): { x: number; y: number } {
|
|
43
|
+
export function shapeCentre(el: SVGGraphicsElement): { x: number; y: number } {
|
|
44
44
|
const path = el as SVGPathElement;
|
|
45
45
|
if (typeof path.getTotalLength === 'function') {
|
|
46
46
|
const total = path.getTotalLength();
|
|
@@ -63,6 +63,31 @@ function shapeCentre(el: SVGGraphicsElement): { x: number; y: number } {
|
|
|
63
63
|
|
|
64
64
|
export type SnapMode = 'crosshair' | 'mark';
|
|
65
65
|
|
|
66
|
+
/** Where the panel lands, in viewport pixels: beside its anchor, then held EDGE inside the viewport. */
|
|
67
|
+
export function tipPlacement(
|
|
68
|
+
mode: SnapMode,
|
|
69
|
+
anchor: { x: number; y: number },
|
|
70
|
+
box: { x: number; y: number; width: number },
|
|
71
|
+
tip: { width: number; height: number },
|
|
72
|
+
viewport: { width: number; height: number },
|
|
73
|
+
): { left: number; top: number } {
|
|
74
|
+
// Which way the panel opens, and the two modes want opposite answers. A crosshair spans the
|
|
75
|
+
// plot, so the empty half is the one the line is NOT in: open away from it, which also puts the
|
|
76
|
+
// panel away from the pointer. A mark sits somewhere in the chart with the rest of the chart
|
|
77
|
+
// around it, so opening inward would lay the panel over the other marks — over the donut's hole
|
|
78
|
+
// and its central label, in the case that made this obvious. Open outward instead.
|
|
79
|
+
const cx = box.x + box.width / 2;
|
|
80
|
+
const toRight = mode === 'crosshair' ? anchor.x <= cx : anchor.x > cx;
|
|
81
|
+
const left = toRight ? anchor.x + GAP : anchor.x - tip.width - GAP;
|
|
82
|
+
// A crosshair panel rides at the top of the plot: fixed, so it never drifts vertically with a
|
|
83
|
+
// pointer whose y means nothing to the reading. A mark panel centres on its own shape.
|
|
84
|
+
const top = mode === 'crosshair' ? box.y + GAP : anchor.y - tip.height / 2;
|
|
85
|
+
return {
|
|
86
|
+
left: clamp(left, EDGE, viewport.width - tip.width - EDGE),
|
|
87
|
+
top: clamp(top, EDGE, viewport.height - tip.height - EDGE),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
/** Installs the snap on a frame. `enabled` is read live so a frame can hand positioning back to
|
|
67
92
|
* Unovis (the `followCursor` escape hatch) without tearing anything down. */
|
|
68
93
|
export function useTooltipSnap(
|
|
@@ -104,24 +129,17 @@ export function useTooltipSnap(
|
|
|
104
129
|
const anchor = anchorFor(host);
|
|
105
130
|
if (!anchor) return;
|
|
106
131
|
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// and its central label, in the case that made this obvious. Open outward instead.
|
|
115
|
-
const cx = box.x + box.width / 2;
|
|
116
|
-
const toRight = mode === 'crosshair' ? anchor.x <= cx : anchor.x > cx;
|
|
117
|
-
const left = toRight ? anchor.x + GAP : anchor.x - w - GAP;
|
|
118
|
-
// A crosshair panel rides at the top of the plot: fixed, so it never drifts vertically with a
|
|
119
|
-
// pointer whose y means nothing to the reading. A mark panel centres on its own shape.
|
|
120
|
-
const top = mode === 'crosshair' ? box.y + GAP : anchor.y - h / 2;
|
|
132
|
+
const { left, top } = tipPlacement(
|
|
133
|
+
mode,
|
|
134
|
+
anchor,
|
|
135
|
+
host.getBoundingClientRect(),
|
|
136
|
+
{ width: tip.offsetWidth, height: tip.offsetHeight },
|
|
137
|
+
{ width: window.innerWidth, height: window.innerHeight },
|
|
138
|
+
);
|
|
121
139
|
|
|
122
140
|
writing = true;
|
|
123
|
-
tip.style.left = `${
|
|
124
|
-
tip.style.top = `${
|
|
141
|
+
tip.style.left = `${left}px`;
|
|
142
|
+
tip.style.top = `${top}px`;
|
|
125
143
|
// Unovis positions by `bottom` when the tooltip is not attached to body; clear it so our `top`
|
|
126
144
|
// is the only vertical rule in play whichever container the frame ends up with.
|
|
127
145
|
tip.style.bottom = 'unset';
|
package/src/sparklineDomain.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// FSparkline's geometry, kept out of the SFC so it can be tested without a DOM.
|
|
2
|
+
|
|
3
|
+
/** A reading that can be drawn. Null is a gap, and NaN or ±Infinity has no place on a scale. */
|
|
4
|
+
const drawable = (v: number | null | undefined): v is number => Number.isFinite(v);
|
|
5
|
+
|
|
1
6
|
/**
|
|
2
7
|
* The frame a sparkline's line marks are plotted in: the floor and ceiling of everything drawn,
|
|
3
8
|
* shared by the series and its comparison, or null when there is nothing to draw so the container
|
|
@@ -16,10 +21,65 @@ export function sparklineDomain(
|
|
|
16
21
|
compare: (number | null)[] | undefined,
|
|
17
22
|
area: boolean,
|
|
18
23
|
): { floor: number; domain: [number, number] | null } {
|
|
19
|
-
const vals = [...data, ...(compare ?? [])].filter(
|
|
24
|
+
const vals = [...data, ...(compare ?? [])].filter(drawable);
|
|
20
25
|
if (!vals.length) return { floor: 0, domain: null };
|
|
21
26
|
const lo = Math.min(...vals);
|
|
22
27
|
const hi = Math.max(...vals);
|
|
23
28
|
const floor = area ? lo : 0;
|
|
24
29
|
return { floor, domain: [lo - floor, hi - floor] };
|
|
25
30
|
}
|
|
31
|
+
|
|
32
|
+
export interface SparklineBarDomain {
|
|
33
|
+
lo: number;
|
|
34
|
+
span: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The bars' range, as its lowest value and its span. Bars are measured from zero, not from the
|
|
39
|
+
* data's own floor like the line: a bar reads as a LENGTH, and a bar that starts anywhere but zero
|
|
40
|
+
* has a length no longer proportional to its value. The range therefore always contains zero, which
|
|
41
|
+
* also puts negative readings below the baseline instead of quietly flipping them. A range with no
|
|
42
|
+
* span is given a span of 1, so an all-zero series divides by something.
|
|
43
|
+
*/
|
|
44
|
+
export function sparklineBarDomain(data: (number | null)[]): SparklineBarDomain {
|
|
45
|
+
const vals = data.filter(drawable);
|
|
46
|
+
const lo = Math.min(0, ...vals);
|
|
47
|
+
const hi = Math.max(0, ...vals);
|
|
48
|
+
return { lo, span: hi - lo || 1 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One bar's bottom offset and height as CSS percentages, from zero to the reading, or null for a gap. */
|
|
52
|
+
export function sparklineBar(
|
|
53
|
+
v: number | null,
|
|
54
|
+
{ lo, span }: SparklineBarDomain,
|
|
55
|
+
): { bottom: string; height: string } | null {
|
|
56
|
+
if (!drawable(v)) return null;
|
|
57
|
+
const zero = (0 - lo) / span;
|
|
58
|
+
const value = (v - lo) / span;
|
|
59
|
+
const from = Math.min(zero, value);
|
|
60
|
+
const to = Math.max(zero, value);
|
|
61
|
+
return {
|
|
62
|
+
bottom: `${from * 100}%`,
|
|
63
|
+
// A reading of zero keeps a hairline. Zero is a real measurement and a gap is the absence of
|
|
64
|
+
// one; collapsing the bar to nothing would render the two identically.
|
|
65
|
+
height: `max(${(to - from) * 100}%, 1px)`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Where the endpoint dot sits: the last drawable reading's distance down the frame, as a
|
|
71
|
+
* percentage, or null when there is no reading to mark. A frame with no span puts the dot at 50%,
|
|
72
|
+
* the middle of the height, where the flat line itself is drawn.
|
|
73
|
+
*/
|
|
74
|
+
export function sparklineEndpoint(
|
|
75
|
+
data: (number | null)[],
|
|
76
|
+
{ floor, domain }: { floor: number; domain: [number, number] | null },
|
|
77
|
+
): number | null {
|
|
78
|
+
for (let i = data.length - 1; i >= 0; i--) {
|
|
79
|
+
const v = data[i];
|
|
80
|
+
if (!drawable(v)) continue;
|
|
81
|
+
const [lo, hi] = domain ?? [0, 0];
|
|
82
|
+
return hi === lo ? 50 : (1 - (v - floor - lo) / (hi - lo)) * 100;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
package/src/useCellScale.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The cell colour contract, shared by FStatusTrack and
|
|
1
|
+
// The cell colour contract, shared by FStatusTrack, FCalendarGrid, FDottedMap and FLondonMap.
|
|
2
2
|
//
|
|
3
3
|
// Cell components answer two different questions with the same rectangle: which STATE a bucket was
|
|
4
4
|
// in, and how MUCH happened in it. Both resolve a datum to a colour and need a legend, so the
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Empty is a third outcome, not a zero and not a first state: a bucket with no reading and a bucket
|
|
8
8
|
// reading zero are different claims, and colouring them alike asserts something the data does not.
|
|
9
|
-
import { computed, unref, type MaybeRef } from 'vue';
|
|
9
|
+
import { computed, unref, type ComputedRef, type MaybeRef } from 'vue';
|
|
10
10
|
import type { BulletLegendItemInterface } from '@unovis/ts';
|
|
11
11
|
import { sequentialVar } from './useChartPalette';
|
|
12
12
|
import { DATAVIZ_SEQUENTIAL } from './engine';
|
|
@@ -46,9 +46,12 @@ export interface CellScaleOptions<D> {
|
|
|
46
46
|
export interface CellScale<D> {
|
|
47
47
|
colorOf: (d: D | null | undefined) => string;
|
|
48
48
|
isEmpty: (d: D | null | undefined) => boolean;
|
|
49
|
-
legend
|
|
49
|
+
/** The descriptor FCellLegend draws, as `scale.legend.value`. */
|
|
50
|
+
legend: ComputedRef<CellLegend>;
|
|
50
51
|
/** The resolved intensity domain — read it back when a caller wants to label the ends. */
|
|
51
|
-
domain: [number, number]
|
|
52
|
+
domain: ComputedRef<[number, number]>;
|
|
53
|
+
/** The ramp sampled into `steps` stops, low → high, reversed in dark mode. */
|
|
54
|
+
swatches: ComputedRef<string[]>;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
/** The fill for a bucket with no reading. A token, so it flips with the mode like everything else. */
|
|
@@ -70,7 +73,7 @@ const prefersInvertedRamp = (): boolean => {
|
|
|
70
73
|
return root.classList.contains('dark') || root.dataset.theme === 'dark';
|
|
71
74
|
};
|
|
72
75
|
|
|
73
|
-
export function useCellScale<D>(options: CellScaleOptions<D>) {
|
|
76
|
+
export function useCellScale<D>(options: CellScaleOptions<D>): CellScale<D> {
|
|
74
77
|
const stepsOf = () => Math.max(2, unref(options.steps) ?? 5);
|
|
75
78
|
// Read so the sampling recomputes on a colour-mode flip; the value itself is not used.
|
|
76
79
|
const themeVersion = useThemeVersion();
|
|
@@ -118,9 +121,9 @@ export function useCellScale<D>(options: CellScaleOptions<D>) {
|
|
|
118
121
|
const stops = swatches.value;
|
|
119
122
|
// A flat domain has no gradient to express — everything sits at the top stop rather than
|
|
120
123
|
// dividing by zero.
|
|
121
|
-
if (hi <= lo) return stops[stops.length - 1]
|
|
124
|
+
if (hi <= lo) return stops[stops.length - 1]!;
|
|
122
125
|
const t = Math.min(1, Math.max(0, (v - lo) / (hi - lo)));
|
|
123
|
-
return stops[Math.min(stops.length - 1, Math.floor(t * stops.length))]
|
|
126
|
+
return stops[Math.min(stops.length - 1, Math.floor(t * stops.length))]!;
|
|
124
127
|
}
|
|
125
128
|
const states = unref(options.states) ?? {};
|
|
126
129
|
const key = options.state!(d as D) as string;
|