@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
package/LICENSE
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Forest Design System — Licence
|
|
2
|
+
Copyright (c) 2026 HumanForest Ltd. All rights reserved.
|
|
3
|
+
|
|
4
|
+
This software is published openly so that people building Forest products can install it
|
|
5
|
+
without access to its source repository. It is not open-source software.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
1. GRANT
|
|
9
|
+
|
|
10
|
+
You may install, use, copy and modify this software, and incorporate it into applications,
|
|
11
|
+
prototypes, documents and presentations, for the purpose of building or operating Forest
|
|
12
|
+
products, services and communications.
|
|
13
|
+
|
|
14
|
+
This grant is free of charge and requires no separate agreement. It runs to anyone who
|
|
15
|
+
obtains the software from a public registry.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
2. RESERVED
|
|
19
|
+
|
|
20
|
+
All rights not granted above are reserved. In particular, you may not:
|
|
21
|
+
|
|
22
|
+
(a) redistribute this software, modified or unmodified, as a design system, component
|
|
23
|
+
library, template or theme of your own;
|
|
24
|
+
|
|
25
|
+
(b) use the HumanForest and Forest names, logos, wordmarks, sub-brand marks or any
|
|
26
|
+
confusingly similar mark. Nothing here grants any trademark right. The brand marks
|
|
27
|
+
included in these packages are provided to render Forest's own products correctly,
|
|
28
|
+
not as artwork you may adopt;
|
|
29
|
+
|
|
30
|
+
(c) represent your product as being made, endorsed or approved by HumanForest.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
3. THIRD-PARTY MATERIAL
|
|
34
|
+
|
|
35
|
+
Parts of these packages are not HumanForest's to license, and the grant in section 1 does
|
|
36
|
+
not extend to them. Each keeps its own terms:
|
|
37
|
+
|
|
38
|
+
TYPEFACES — Mohr and GT Haptik are licensed to HumanForest by their foundries and are NOT
|
|
39
|
+
sublicensed to you. No font binary is included in any published package. The stylesheets
|
|
40
|
+
reference https://assets.forest.bike/fonts/, which serves them to those entitled to use
|
|
41
|
+
them. If you are not covered by Forest's licences, substitute your own faces. JetBrains
|
|
42
|
+
Mono is licensed under the SIL Open Font License 1.1 and may be used under those terms.
|
|
43
|
+
|
|
44
|
+
GLYPH OUTLINES — packages/tokens ships `glyphs/glyphs.json`, letterforms of the above
|
|
45
|
+
typefaces outlined as vector paths so sub-brand lockups can be composed at runtime. It is
|
|
46
|
+
covered by the same foundry terms as the typefaces themselves, not by section 1.
|
|
47
|
+
|
|
48
|
+
DEVICE ARTWORK — packages/frames ships device renders from Wikimedia Commons under the
|
|
49
|
+
Creative Commons Attribution-ShareAlike 4.0 International licence
|
|
50
|
+
(https://creativecommons.org/licenses/by-sa/4.0/). They are unmodified. Attribution is a
|
|
51
|
+
condition of that licence: see ATTRIBUTION.md in that package, and keep it with the art
|
|
52
|
+
wherever the art travels. Works derived from the artwork must themselves be CC BY-SA 4.0.
|
|
53
|
+
|
|
54
|
+
DEPENDENCIES — third-party packages installed alongside these keep their own licences.
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
4. NO WARRANTY
|
|
58
|
+
|
|
59
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
60
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
|
61
|
+
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
|
62
|
+
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
63
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
64
|
+
DEALINGS IN THE SOFTWARE.
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
Questions about use beyond this grant: design@forest.bike
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@humanforest/charts",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"src"
|
|
7
|
+
],
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./useChartPalette": "./src/useChartPalette.ts",
|
|
11
|
+
"./presets": "./src/presets.ts",
|
|
12
|
+
"./fromCategories": "./src/fromCategories.ts",
|
|
13
|
+
"./forestTooltip": "./src/forestTooltip.ts",
|
|
14
|
+
"./useChartRepaintKey": "./src/useChartRepaintKey.ts"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"vue": "^3.5",
|
|
18
|
+
"@unovis/vue": "^1.6.7",
|
|
19
|
+
"@nuxt/ui": "^4"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@unovis/ts": "^1.6.7",
|
|
23
|
+
"vue": "^3.5.22"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@humanforest/tokens": "^0.1.0"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"license": "SEE LICENSE IN LICENSE"
|
|
32
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// A single London borough as a styleable graphic — a glyph, not a chart. Where FLondonMap fills
|
|
3
|
+
// shapes from data, this just hands you one shape, framed to itself, filled with currentColor. So
|
|
4
|
+
// it colours like an icon: `class="text-primary"`, size with a width, add a border in CSS.
|
|
5
|
+
//
|
|
6
|
+
// Deliberately does one thing. No data, no ramp, no labels, no wards, no selection — the moment a
|
|
7
|
+
// caller wants any of those they want FLondonMap. Keeping this presentational is the whole point.
|
|
8
|
+
import { computed } from 'vue';
|
|
9
|
+
import { LONDON_BOROUGHS, indexAreas } from './londonAreas';
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(
|
|
12
|
+
defineProps<{
|
|
13
|
+
/** Which borough: an ONS code (E09…) or a name, matched case-insensitively. */
|
|
14
|
+
borough: string;
|
|
15
|
+
/** Breathing room around the shape, as a fraction of its longer side. */
|
|
16
|
+
pad?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Assistive text. With it the graphic is announced as an image; without it it is decorative and
|
|
19
|
+
* hidden from the accessibility tree — the same call an icon makes.
|
|
20
|
+
*/
|
|
21
|
+
label?: string;
|
|
22
|
+
}>(),
|
|
23
|
+
{ pad: 0.04 },
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// Boroughs only — the ward set is loaded on demand elsewhere, and a glyph has no reason to pull it.
|
|
27
|
+
const area = computed(() => indexAreas(LONDON_BOROUGHS.areas).get(props.borough.toLowerCase()));
|
|
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
|
+
});
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<svg
|
|
41
|
+
v-if="area"
|
|
42
|
+
:viewBox="viewBox"
|
|
43
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
44
|
+
:role="label ? 'img' : undefined"
|
|
45
|
+
:aria-label="label"
|
|
46
|
+
:aria-hidden="label ? undefined : 'true'"
|
|
47
|
+
>
|
|
48
|
+
<!-- currentColor so a text-* class paints the fill; non-scaling-stroke keeps any border the
|
|
49
|
+
caller adds one hairline whatever the rendered size. -->
|
|
50
|
+
<path :d="area.d" fill="currentColor" vector-effect="non-scaling-stroke" />
|
|
51
|
+
</svg>
|
|
52
|
+
</template>
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
<script setup lang="ts" generic="D extends Record<string, unknown>">
|
|
2
|
+
// Days as cells: weeks are columns, weekdays are rows. A year of ride volume, or a year of one
|
|
3
|
+
// station's daily state. Colour resolution is useCellScale's; this component owns the calendar.
|
|
4
|
+
//
|
|
5
|
+
// The grid is built from the date window, not from the data, so a day with no datum is a real empty
|
|
6
|
+
// cell in the right place rather than a missing column. Days are bucketed by local calendar date —
|
|
7
|
+
// a timestamp is floored to its day before lookup, so two events on one day land together.
|
|
8
|
+
import { computed, ref } from 'vue';
|
|
9
|
+
import type { BulletLegendItemInterface } from '@unovis/ts';
|
|
10
|
+
import { useCellScale, CELL_EMPTY_COLOR, type SequentialRamp } from './useCellScale';
|
|
11
|
+
import { CELL_SIZES, orElse, type CellSize } from './cellSize';
|
|
12
|
+
import FCellLegend from './FCellLegend.vue';
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(
|
|
15
|
+
defineProps<{
|
|
16
|
+
data: D[];
|
|
17
|
+
/** The day a datum belongs to. Any Date-parseable value. */
|
|
18
|
+
date: (d: D) => Date | number | string;
|
|
19
|
+
mode?: 'state' | 'intensity';
|
|
20
|
+
states?: Record<string, BulletLegendItemInterface>;
|
|
21
|
+
state?: (d: D) => string | null | undefined;
|
|
22
|
+
value?: (d: D) => number | null | undefined;
|
|
23
|
+
ramp?: SequentialRamp;
|
|
24
|
+
/** Intensity domain. Two grids compared side by side must both set it. */
|
|
25
|
+
domain?: [number, number];
|
|
26
|
+
steps?: number;
|
|
27
|
+
/** Window bounds. Default: the first and last dates present in the data. */
|
|
28
|
+
start?: Date;
|
|
29
|
+
end?: Date;
|
|
30
|
+
/** 0 = weeks start Sunday (default), 1 = Monday. */
|
|
31
|
+
weekStart?: 0 | 1;
|
|
32
|
+
/** Size step. Sets the cell dimensions, and whether labels, legend and tooltips default on:
|
|
33
|
+
* xs and sm are too small to carry chrome, so it defaults off there. Any explicit prop wins. */
|
|
34
|
+
size?: CellSize;
|
|
35
|
+
/** Forces the size step's chrome decision either way — labels, legend and tooltips together.
|
|
36
|
+
* `monthLabels`, `weekdayLabels` and `legend` still win over it individually. */
|
|
37
|
+
chrome?: boolean;
|
|
38
|
+
cellSize?: number;
|
|
39
|
+
cellGap?: number;
|
|
40
|
+
radius?: number;
|
|
41
|
+
monthLabels?: boolean;
|
|
42
|
+
/** Rule along the month boundary. It steps day by day, so a month opening midweek is cut where
|
|
43
|
+
* it actually opens rather than at the column edge. */
|
|
44
|
+
monthSeparators?: boolean;
|
|
45
|
+
weekdayLabels?: boolean;
|
|
46
|
+
legend?: boolean;
|
|
47
|
+
/** Hover and focus text. Given one, cells become focusable buttons and get the house bubble.
|
|
48
|
+
* Suppressed at xs and sm, where the target is too small to hit reliably. */
|
|
49
|
+
tooltip?: (d: D | undefined, date: Date) => string;
|
|
50
|
+
ariaLabel?: string;
|
|
51
|
+
}>(),
|
|
52
|
+
{
|
|
53
|
+
mode: 'intensity',
|
|
54
|
+
weekStart: 0,
|
|
55
|
+
size: 'md',
|
|
56
|
+
monthSeparators: false,
|
|
57
|
+
steps: 5,
|
|
58
|
+
/* Vue casts an ABSENT Boolean-typed prop to false, so without these the size step could never
|
|
59
|
+
switch chrome on: `orElse` would read that false as a deliberate "off" at every size. An
|
|
60
|
+
explicit undefined default is what keeps "not passed" distinguishable from "passed false". */
|
|
61
|
+
monthLabels: undefined,
|
|
62
|
+
weekdayLabels: undefined,
|
|
63
|
+
legend: undefined,
|
|
64
|
+
chrome: undefined,
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Three tiers, narrowest first: an individual prop beats `chrome`, which beats the size step.
|
|
69
|
+
const spec = computed(() => CELL_SIZES[props.size]);
|
|
70
|
+
const cellSize = computed(() => orElse(props.cellSize, spec.value.cell));
|
|
71
|
+
const cellGap = computed(() => orElse(props.cellGap, spec.value.gap));
|
|
72
|
+
const radius = computed(() => orElse(props.radius, spec.value.radius));
|
|
73
|
+
const showChrome = computed(() => orElse(props.chrome, spec.value.chrome));
|
|
74
|
+
const showMonthLabels = computed(() => orElse(props.monthLabels, showChrome.value));
|
|
75
|
+
const showWeekdayLabels = computed(() => orElse(props.weekdayLabels, showChrome.value));
|
|
76
|
+
const showLegend = computed(() => orElse(props.legend, showChrome.value));
|
|
77
|
+
const interactive = computed(() => !!props.tooltip && showChrome.value);
|
|
78
|
+
|
|
79
|
+
/** Local midnight — the calendar day is what a reader means, not the UTC instant. */
|
|
80
|
+
const floorDay = (v: Date | number | string): Date => {
|
|
81
|
+
const d = new Date(v);
|
|
82
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
83
|
+
};
|
|
84
|
+
const keyOf = (d: Date) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
|
85
|
+
|
|
86
|
+
// Last datum wins for a day. Summing would silently change the reading into something the caller
|
|
87
|
+
// never asked for; duplicates are the caller's to resolve.
|
|
88
|
+
const byDay = computed(() => {
|
|
89
|
+
const m = new Map<string, D>();
|
|
90
|
+
for (const d of props.data) m.set(keyOf(floorDay(props.date(d))), d);
|
|
91
|
+
return m;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const bounds = computed<[Date, Date]>(() => {
|
|
95
|
+
if (props.start && props.end) return [floorDay(props.start), floorDay(props.end)];
|
|
96
|
+
let lo = Infinity;
|
|
97
|
+
let hi = -Infinity;
|
|
98
|
+
for (const d of props.data) {
|
|
99
|
+
const t = floorDay(props.date(d)).getTime();
|
|
100
|
+
if (t < lo) lo = t;
|
|
101
|
+
if (t > hi) hi = t;
|
|
102
|
+
}
|
|
103
|
+
const today = floorDay(new Date());
|
|
104
|
+
const start = props.start ?? (Number.isFinite(lo) ? new Date(lo) : today);
|
|
105
|
+
const end = props.end ?? (Number.isFinite(hi) ? new Date(hi) : today);
|
|
106
|
+
return [floorDay(start), floorDay(end)];
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/** Days back to the week boundary, so column 0 is a full week and rows line up with weekdays. */
|
|
110
|
+
const leadingOffset = (d: Date) => (d.getDay() - props.weekStart + 7) % 7;
|
|
111
|
+
|
|
112
|
+
const weeks = computed<{ date: Date; inRange: boolean }[][]>(() => {
|
|
113
|
+
const [start, end] = bounds.value;
|
|
114
|
+
const cursor = new Date(start);
|
|
115
|
+
cursor.setDate(cursor.getDate() - leadingOffset(start));
|
|
116
|
+
const out: { date: Date; inRange: boolean }[][] = [];
|
|
117
|
+
while (cursor <= end) {
|
|
118
|
+
const week: { date: Date; inRange: boolean }[] = [];
|
|
119
|
+
for (let i = 0; i < 7; i++) {
|
|
120
|
+
const day = new Date(cursor);
|
|
121
|
+
week.push({ date: day, inRange: day >= start && day <= end });
|
|
122
|
+
// setDate, not +86400000: a clocks-back day is 25 hours, so adding a fixed day and
|
|
123
|
+
// re-flooring lands on the same midnight and the walk never terminates.
|
|
124
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
125
|
+
}
|
|
126
|
+
out.push(week);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const scale = useCellScale<D>({
|
|
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
|
+
});
|
|
141
|
+
|
|
142
|
+
const datumFor = (d: Date) => byDay.value.get(keyOf(d));
|
|
143
|
+
const fillFor = (cell: { date: Date; inRange: boolean }) =>
|
|
144
|
+
cell.inRange ? scale.colorOf(datumFor(cell.date) ?? null) : 'transparent';
|
|
145
|
+
|
|
146
|
+
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
147
|
+
/** Mon, Wed, Fri only — labelling all seven crowds an 11px row. */
|
|
148
|
+
const weekdayRows = computed(() =>
|
|
149
|
+
Array.from({ length: 7 }, (_, r) => {
|
|
150
|
+
const dayIndex = (r + props.weekStart) % 7;
|
|
151
|
+
return [1, 3, 5].includes(dayIndex) ? WEEKDAYS[dayIndex] : '';
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const monthLabel = (d: Date) => d.toLocaleDateString('en-GB', { month: 'short' });
|
|
156
|
+
|
|
157
|
+
const pitch = computed(() => cellSize.value + cellGap.value);
|
|
158
|
+
const gridWidth = computed(() => weeks.value.length * pitch.value - cellGap.value);
|
|
159
|
+
const gridHeight = computed(() => 7 * pitch.value - cellGap.value);
|
|
160
|
+
|
|
161
|
+
// Where each month opens, in grid coordinates. One source for both the label and the rule: the
|
|
162
|
+
// label used to be placed on the week whose FIRST day fell in the new month, which puts it up to
|
|
163
|
+
// six days late whenever a month opens midweek.
|
|
164
|
+
const monthStarts = computed(() => {
|
|
165
|
+
const out: { wi: number; di: number; label: string; first: boolean }[] = [];
|
|
166
|
+
let prevMonth: number | null = null;
|
|
167
|
+
weeks.value.forEach((week, wi) => {
|
|
168
|
+
week.forEach((cell, di) => {
|
|
169
|
+
if (!cell.inRange) return;
|
|
170
|
+
const m = cell.date.getMonth();
|
|
171
|
+
if (prevMonth === null) out.push({ wi, di, label: monthLabel(cell.date), first: true });
|
|
172
|
+
else if (m !== prevMonth) out.push({ wi, di, label: monthLabel(cell.date), first: false });
|
|
173
|
+
prevMonth = m;
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
return out;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// The boundary between two months, as one continuous path each.
|
|
180
|
+
//
|
|
181
|
+
// Time runs down a column then into the next, so a month that opens midweek does NOT split its
|
|
182
|
+
// column into left and right: the days above the opening row in every LATER column already belong
|
|
183
|
+
// to the new month. The frontier is therefore a Z — down the right edge of the opening column to
|
|
184
|
+
// the opening row, one column left, then down to the bottom — not a per-cell outline. A month
|
|
185
|
+
// opening on the week-start row degenerates to a single straight vertical.
|
|
186
|
+
//
|
|
187
|
+
// Coordinates land on gap midpoints (w * pitch - cellGap/2), so the rule sits centred between two
|
|
188
|
+
// cells rather than against either one, and one path per boundary welds its own corners.
|
|
189
|
+
const monthPaths = computed(() => {
|
|
190
|
+
if (!props.monthSeparators) return '';
|
|
191
|
+
const half = cellGap.value / 2;
|
|
192
|
+
return monthStarts.value
|
|
193
|
+
.filter((m) => !m.first)
|
|
194
|
+
.map(({ wi, di }) => {
|
|
195
|
+
const xLeft = wi * pitch.value - half;
|
|
196
|
+
if (di === 0) return `M${xLeft},0V${gridHeight.value}`;
|
|
197
|
+
const xRight = (wi + 1) * pitch.value - half;
|
|
198
|
+
const yCut = di * pitch.value - half;
|
|
199
|
+
return `M${xRight},0V${yCut}H${xLeft}V${gridHeight.value}`;
|
|
200
|
+
})
|
|
201
|
+
.join(' ');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const dateLabel = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
205
|
+
const labelFor = (cell: { date: Date }) => props.tooltip?.(datumFor(cell.date), cell.date) ?? '';
|
|
206
|
+
|
|
207
|
+
const gridLabel = computed(() => {
|
|
208
|
+
const [start, end] = bounds.value;
|
|
209
|
+
return props.ariaLabel ?? `Daily values from ${dateLabel(start)} to ${dateLabel(end)}`;
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// One UTooltip for the grid, re-anchored to the hovered cell through `reference`. A year is 371
|
|
213
|
+
// cells; a tooltip root per cell would be 371 of them, so the anchor moves instead.
|
|
214
|
+
//
|
|
215
|
+
// `tipOpen` owns visibility; the text and the anchor are left alone on close. Clearing them
|
|
216
|
+
// together with the flag emptied the bubble mid-exit-animation and dropped the anchor back to the
|
|
217
|
+
// trigger, so leaving a segment flashed a blank pill at the container's centre for ~80ms.
|
|
218
|
+
//
|
|
219
|
+
// Anchored to the cell, not the pointer: a keyboard focus has no cursor to follow, and a bubble
|
|
220
|
+
// that tracks the pointer inside an 11px target only jitters.
|
|
221
|
+
const tip = ref<{ text: string; el: Element | null }>({ text: '', el: null });
|
|
222
|
+
const tipOpen = ref(false);
|
|
223
|
+
const showTip = (event: Event, text: string) => {
|
|
224
|
+
tip.value = { text, el: event.currentTarget as Element };
|
|
225
|
+
tipOpen.value = true;
|
|
226
|
+
};
|
|
227
|
+
const hideTip = () => {
|
|
228
|
+
tipOpen.value = false;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const hasEmpty = computed(() =>
|
|
232
|
+
weeks.value.some((w) => w.some((c) => c.inRange && scale.isEmpty(datumFor(c.date) ?? null))),
|
|
233
|
+
);
|
|
234
|
+
</script>
|
|
235
|
+
|
|
236
|
+
<template>
|
|
237
|
+
<div class="flex w-full flex-col gap-3">
|
|
238
|
+
<div class="overflow-x-auto">
|
|
239
|
+
<div class="flex" :style="{ gap: `${cellGap}px` }">
|
|
240
|
+
<!-- Weekday gutter. justify-end pins the rows to the grid's bottom, so they line up
|
|
241
|
+
whether or not a month-label row sits above. -->
|
|
242
|
+
<div
|
|
243
|
+
v-if="showWeekdayLabels"
|
|
244
|
+
class="flex shrink-0 flex-col justify-end"
|
|
245
|
+
:style="{ gap: `${cellGap}px` }"
|
|
246
|
+
>
|
|
247
|
+
<span
|
|
248
|
+
v-for="(w, r) in weekdayRows"
|
|
249
|
+
:key="r"
|
|
250
|
+
class="type-caption flex items-center text-dimmed"
|
|
251
|
+
:style="{ height: `${cellSize}px`, lineHeight: `${cellSize}px` }"
|
|
252
|
+
>{{ w }}</span>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<div class="flex flex-col" :style="{ gap: `${cellGap}px` }">
|
|
256
|
+
<!-- Labels sit in their own row, positioned by column rather than living inside one, so
|
|
257
|
+
a long month name cannot widen its week and shove the cells apart. -->
|
|
258
|
+
<div
|
|
259
|
+
v-if="showMonthLabels"
|
|
260
|
+
class="relative h-5"
|
|
261
|
+
:style="{ width: `${gridWidth}px` }"
|
|
262
|
+
aria-hidden="true"
|
|
263
|
+
>
|
|
264
|
+
<span
|
|
265
|
+
v-for="m in monthStarts"
|
|
266
|
+
:key="`${m.wi}-${m.label}`"
|
|
267
|
+
class="type-caption absolute top-0 whitespace-nowrap text-dimmed"
|
|
268
|
+
:style="{ left: `${m.wi * pitch}px` }"
|
|
269
|
+
>{{ m.label }}</span>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<UTooltip :text="tip.text" :reference="tip.el" :open="tipOpen">
|
|
273
|
+
<div class="relative" :role="interactive ? 'group' : 'img'" :aria-label="gridLabel">
|
|
274
|
+
<div class="flex" :style="{ gap: `${cellGap}px` }">
|
|
275
|
+
<div v-for="(week, wi) in weeks" :key="wi" class="flex flex-col" :style="{ gap: `${cellGap}px` }">
|
|
276
|
+
<component
|
|
277
|
+
:is="interactive && cell.inRange ? 'button' : 'div'"
|
|
278
|
+
v-for="(cell, di) in week"
|
|
279
|
+
:key="di"
|
|
280
|
+
:type="interactive && cell.inRange ? 'button' : undefined"
|
|
281
|
+
class="transition-[filter] duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ui-primary)]"
|
|
282
|
+
:class="interactive && cell.inRange ? 'cursor-pointer hover:brightness-110' : ''"
|
|
283
|
+
:style="{
|
|
284
|
+
width: `${cellSize}px`,
|
|
285
|
+
height: `${cellSize}px`,
|
|
286
|
+
borderRadius: `${radius}px`,
|
|
287
|
+
background: fillFor(cell),
|
|
288
|
+
}"
|
|
289
|
+
:aria-label="interactive && cell.inRange ? labelFor(cell) : undefined"
|
|
290
|
+
:aria-hidden="interactive && cell.inRange ? undefined : 'true'"
|
|
291
|
+
@mouseenter="interactive && cell.inRange && showTip($event, labelFor(cell))"
|
|
292
|
+
@mouseleave="hideTip"
|
|
293
|
+
@focus="interactive && cell.inRange && showTip($event, labelFor(cell))"
|
|
294
|
+
@blur="hideTip"
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
<!-- One path for every boundary, over the cells. pointer-events none so it never takes
|
|
300
|
+
a hover from the cell beneath; crispEdges so a 1px rule stays 1px off-integer.
|
|
301
|
+
Dimmed text rather than a border token: the border tokens sit around 1.5:1 on the
|
|
302
|
+
page and vanish between saturated cells. -->
|
|
303
|
+
|
|
304
|
+
<svg
|
|
305
|
+
v-if="monthSeparators"
|
|
306
|
+
class="pointer-events-none absolute left-0 top-0 overflow-visible"
|
|
307
|
+
:width="gridWidth"
|
|
308
|
+
:height="gridHeight"
|
|
309
|
+
aria-hidden="true"
|
|
310
|
+
>
|
|
311
|
+
<path
|
|
312
|
+
:d="monthPaths"
|
|
313
|
+
fill="none"
|
|
314
|
+
stroke-width="1"
|
|
315
|
+
shape-rendering="crispEdges"
|
|
316
|
+
style="stroke: var(--ui-text-dimmed)"
|
|
317
|
+
/>
|
|
318
|
+
</svg>
|
|
319
|
+
</div>
|
|
320
|
+
</UTooltip>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
<FCellLegend
|
|
326
|
+
v-if="showLegend"
|
|
327
|
+
:legend="scale.legend.value"
|
|
328
|
+
:show-empty="hasEmpty"
|
|
329
|
+
:empty-color="CELL_EMPTY_COLOR"
|
|
330
|
+
/>
|
|
331
|
+
</div>
|
|
332
|
+
</template>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The legend both cell components share. Two shapes from one contract: named entries for state
|
|
3
|
+
// mode, an unnamed Less → More ramp for intensity.
|
|
4
|
+
//
|
|
5
|
+
// The empty swatch appears only when the data actually has a gap. A legend that always advertised
|
|
6
|
+
// "no data" would teach readers to look for a category that is usually absent.
|
|
7
|
+
import type { CellLegend } from './useCellScale';
|
|
8
|
+
|
|
9
|
+
withDefaults(
|
|
10
|
+
defineProps<{
|
|
11
|
+
legend: CellLegend;
|
|
12
|
+
showEmpty?: boolean;
|
|
13
|
+
emptyColor?: string;
|
|
14
|
+
}>(),
|
|
15
|
+
{ showEmpty: false, emptyColor: 'var(--ui-bg-accented)' },
|
|
16
|
+
);
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
21
|
+
<template v-if="legend.mode === 'state'">
|
|
22
|
+
<span v-for="e in legend.entries" :key="e.key" class="flex items-center gap-1.5">
|
|
23
|
+
<span
|
|
24
|
+
class="size-2.5 rounded-full"
|
|
25
|
+
:style="{ background: e.swatch }"
|
|
26
|
+
aria-hidden="true"
|
|
27
|
+
/>
|
|
28
|
+
<span class="type-caption text-default">{{ e.name }}</span>
|
|
29
|
+
</span>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<span v-else class="flex items-center gap-1.5">
|
|
33
|
+
<span class="type-caption text-dimmed">{{ legend.bounds?.[0] }}</span>
|
|
34
|
+
<span class="flex gap-0.5" aria-hidden="true">
|
|
35
|
+
<span
|
|
36
|
+
v-for="e in legend.entries"
|
|
37
|
+
:key="e.key"
|
|
38
|
+
class="size-2.5 rounded-[2px]"
|
|
39
|
+
:style="{ background: e.swatch }"
|
|
40
|
+
/>
|
|
41
|
+
</span>
|
|
42
|
+
<span class="type-caption text-dimmed">{{ legend.bounds?.[1] }}</span>
|
|
43
|
+
</span>
|
|
44
|
+
|
|
45
|
+
<span v-if="showEmpty" class="flex items-center gap-1.5">
|
|
46
|
+
<span
|
|
47
|
+
class="size-2.5 rounded-[2px] ring-1 ring-[var(--ui-border)] ring-inset"
|
|
48
|
+
:style="{ background: emptyColor }"
|
|
49
|
+
aria-hidden="true"
|
|
50
|
+
/>
|
|
51
|
+
<span class="type-caption text-dimmed">No data</span>
|
|
52
|
+
</span>
|
|
53
|
+
</div>
|
|
54
|
+
</template>
|