@humanforest/charts 0.3.6 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -0
- package/package.json +8 -3
- package/src/FBoroughShape.vue +6 -14
- package/src/FCalendarGrid.vue +84 -46
- package/src/FChartFrame.vue +31 -50
- package/src/FChartFrameSingle.vue +10 -2
- package/src/FDistributionBar.vue +2 -11
- package/src/FDottedMap.vue +12 -23
- package/src/FLondonMap.vue +34 -85
- package/src/FSparkline.vue +46 -48
- package/src/FStatusTrack.vue +25 -15
- package/src/crosshairConfig.ts +27 -0
- package/src/distribution.ts +12 -0
- package/src/frameIndex.ts +36 -0
- package/src/fromCategories.ts +17 -3
- package/src/londonAreas.ts +85 -3
- package/src/londonGrid.ts +27 -0
- package/src/presets.ts +19 -18
- package/src/resolveVar.ts +4 -3
- package/src/rovingFocus.ts +26 -0
- package/src/snapTooltip.ts +53 -32
- package/src/sparklineDomain.ts +70 -1
- package/src/useCellScale.ts +38 -9
- package/src/useChartPalette.ts +6 -4
package/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,85 @@ export const indexAreas = (areas: LondonArea[]): Map<string, LondonArea> => {
|
|
|
73
73
|
}
|
|
74
74
|
return byKey;
|
|
75
75
|
};
|
|
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
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One area framed to itself: the area `key` names (by code or name, either case) and a viewBox of
|
|
99
|
+
* its stored box, padded on every side by `pad` × its longer side. Undefined for a key the set does
|
|
100
|
+
* not hold, so an unknown area draws nothing rather than a stray full-London frame.
|
|
101
|
+
*/
|
|
102
|
+
export const frameArea = (
|
|
103
|
+
areas: LondonArea[],
|
|
104
|
+
key: string,
|
|
105
|
+
pad: number,
|
|
106
|
+
): { area: LondonArea; viewBox: string } | undefined => {
|
|
107
|
+
const area = indexAreas(areas).get(key.toLowerCase());
|
|
108
|
+
if (!area) return undefined;
|
|
109
|
+
const p = Math.max(area.w, area.h) * pad;
|
|
110
|
+
return { area, viewBox: `${area.x - p} ${area.y - p} ${area.w + p * 2} ${area.h + p * 2}` };
|
|
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
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Roving focus for the cell components: a track or a grid is one Tab stop, and the keys move it.
|
|
2
|
+
// Cells are one flat run in reading order; the component maps an index back to its element.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The index focus moves to for a key, or null when the key is not a move. ArrowLeft and ArrowRight
|
|
6
|
+
* step by `across`, ArrowUp and ArrowDown by `down` (0 leaves them to the page), and Home and End
|
|
7
|
+
* jump to `first` and `last`. A step that would leave the run stays put. A modified key is never a
|
|
8
|
+
* move, so browser shortcuts such as Cmd+ArrowLeft keep working.
|
|
9
|
+
*/
|
|
10
|
+
export function rovingIndex(
|
|
11
|
+
event: Pick<KeyboardEvent, 'key' | 'altKey' | 'ctrlKey' | 'metaKey'>,
|
|
12
|
+
index: number,
|
|
13
|
+
first: number,
|
|
14
|
+
last: number,
|
|
15
|
+
across = 1,
|
|
16
|
+
down = 0,
|
|
17
|
+
): number | null {
|
|
18
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return null;
|
|
19
|
+
if (event.key === 'Home') return first;
|
|
20
|
+
if (event.key === 'End') return last;
|
|
21
|
+
const steps: Record<string, number> = { ArrowRight: across, ArrowLeft: -across, ArrowDown: down, ArrowUp: -down };
|
|
22
|
+
const step = steps[event.key];
|
|
23
|
+
if (!step) return null;
|
|
24
|
+
const next = index + step;
|
|
25
|
+
return next < first || next > last ? index : next;
|
|
26
|
+
}
|
package/src/snapTooltip.ts
CHANGED
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
// Two anchors, matching how the two frames are read:
|
|
14
14
|
// crosshair — the crosshair line's x, panel held at the top of the plot. It steps as the
|
|
15
15
|
// crosshair snaps between rows and holds still in between, which is the whole point.
|
|
16
|
-
// mark — the centroid of the hovered shape, panel
|
|
17
|
-
//
|
|
18
|
-
//
|
|
16
|
+
// mark — the centroid of the hovered shape, panel on the side away from the frame's centre
|
|
17
|
+
// with its near edge 12px from the centroid, then held 8px inside the viewport, so it
|
|
18
|
+
// overlaps the outer half of any mark wider than 24px. The centroid is sampled along
|
|
19
|
+
// the outline rather than taken from the box, so an arc anchors at its own mid-angle
|
|
20
|
+
// instead of at the corner of a box it never touches.
|
|
19
21
|
import { onBeforeUnmount, onMounted, type Ref } from 'vue';
|
|
20
22
|
import { Crosshair } from '@unovis/ts';
|
|
21
23
|
|
|
@@ -36,11 +38,12 @@ export function nextTipId(): string {
|
|
|
36
38
|
|
|
37
39
|
const clamp = (v: number, lo: number, hi: number): number => Math.min(Math.max(v, lo), hi);
|
|
38
40
|
|
|
39
|
-
/** Centre of a shape, in viewport coordinates.
|
|
40
|
-
* for a donut arc that lands at mid-angle, mid-radius — the middle
|
|
41
|
-
* box centre would land in the hole, or outside the wedge entirely
|
|
42
|
-
*
|
|
43
|
-
|
|
41
|
+
/** Centre of a shape, in viewport coordinates. Every SVGGeometryElement, rects included, is sampled
|
|
42
|
+
* along its outline and averaged: for a donut arc that lands at mid-angle, mid-radius — the middle
|
|
43
|
+
* of the visible band — where the box centre would land in the hole, or outside the wedge entirely
|
|
44
|
+
* on a small segment. An element with no outline to measure (a group, text), a zero length or no
|
|
45
|
+
* screen matrix falls back to its box. */
|
|
46
|
+
export function shapeCentre(el: SVGGraphicsElement): { x: number; y: number } {
|
|
44
47
|
const path = el as SVGPathElement;
|
|
45
48
|
if (typeof path.getTotalLength === 'function') {
|
|
46
49
|
const total = path.getTotalLength();
|
|
@@ -63,6 +66,31 @@ function shapeCentre(el: SVGGraphicsElement): { x: number; y: number } {
|
|
|
63
66
|
|
|
64
67
|
export type SnapMode = 'crosshair' | 'mark';
|
|
65
68
|
|
|
69
|
+
/** Where the panel lands, in viewport pixels: beside its anchor, then held EDGE inside the viewport. */
|
|
70
|
+
export function tipPlacement(
|
|
71
|
+
mode: SnapMode,
|
|
72
|
+
anchor: { x: number; y: number },
|
|
73
|
+
box: { x: number; y: number; width: number },
|
|
74
|
+
tip: { width: number; height: number },
|
|
75
|
+
viewport: { width: number; height: number },
|
|
76
|
+
): { left: number; top: number } {
|
|
77
|
+
// Which way the panel opens, and the two modes want opposite answers. A crosshair spans the
|
|
78
|
+
// plot, so the empty half is the one the line is NOT in: open away from it, which also puts the
|
|
79
|
+
// panel away from the pointer. A mark sits somewhere in the chart with the rest of the chart
|
|
80
|
+
// around it, so opening inward would lay the panel over the other marks — over the donut's hole
|
|
81
|
+
// and its central label, in the case that made this obvious. Open outward instead.
|
|
82
|
+
const cx = box.x + box.width / 2;
|
|
83
|
+
const toRight = mode === 'crosshair' ? anchor.x <= cx : anchor.x > cx;
|
|
84
|
+
const left = toRight ? anchor.x + GAP : anchor.x - tip.width - GAP;
|
|
85
|
+
// A crosshair panel rides at the top of the plot: fixed, so it never drifts vertically with a
|
|
86
|
+
// pointer whose y means nothing to the reading. A mark panel centres on its own shape.
|
|
87
|
+
const top = mode === 'crosshair' ? box.y + GAP : anchor.y - tip.height / 2;
|
|
88
|
+
return {
|
|
89
|
+
left: clamp(left, EDGE, viewport.width - tip.width - EDGE),
|
|
90
|
+
top: clamp(top, EDGE, viewport.height - tip.height - EDGE),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
66
94
|
/** Installs the snap on a frame. `enabled` is read live so a frame can hand positioning back to
|
|
67
95
|
* Unovis (the `followCursor` escape hatch) without tearing anything down. */
|
|
68
96
|
export function useTooltipSnap(
|
|
@@ -77,7 +105,6 @@ export function useTooltipSnap(
|
|
|
77
105
|
// Guards the observer against our own writes, which would otherwise re-enter it forever.
|
|
78
106
|
let writing = false;
|
|
79
107
|
let observer: MutationObserver | undefined;
|
|
80
|
-
let frame = 0;
|
|
81
108
|
|
|
82
109
|
function onPointerMove(e: Event): void {
|
|
83
110
|
const t = e.target;
|
|
@@ -104,37 +131,31 @@ export function useTooltipSnap(
|
|
|
104
131
|
const anchor = anchorFor(host);
|
|
105
132
|
if (!anchor) return;
|
|
106
133
|
|
|
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;
|
|
134
|
+
const { left, top } = tipPlacement(
|
|
135
|
+
mode,
|
|
136
|
+
anchor,
|
|
137
|
+
host.getBoundingClientRect(),
|
|
138
|
+
{ width: tip.offsetWidth, height: tip.offsetHeight },
|
|
139
|
+
{ width: window.innerWidth, height: window.innerHeight },
|
|
140
|
+
);
|
|
121
141
|
|
|
122
142
|
writing = true;
|
|
123
|
-
tip.style.left = `${
|
|
124
|
-
tip.style.top = `${
|
|
143
|
+
tip.style.left = `${left}px`;
|
|
144
|
+
tip.style.top = `${top}px`;
|
|
125
145
|
// Unovis positions by `bottom` when the tooltip is not attached to body; clear it so our `top`
|
|
126
146
|
// is the only vertical rule in play whichever container the frame ends up with.
|
|
127
147
|
tip.style.bottom = 'unset';
|
|
128
148
|
writing = false;
|
|
129
149
|
}
|
|
130
150
|
|
|
151
|
+
// The panel is looked up each time the pointer enters the chart, the only time it can show.
|
|
152
|
+
// VisTooltip creates it after mount, a remount (a theme or palette change) replaces it, and a
|
|
153
|
+
// frame without a tooltip has none. A panel replaced while the pointer is inside is picked up on
|
|
154
|
+
// the next entry.
|
|
131
155
|
function attach(): void {
|
|
156
|
+
observer?.disconnect();
|
|
132
157
|
const tip = document.querySelector<HTMLElement>(`[data-forest-tip="${tipId}"]`);
|
|
133
|
-
if (!tip)
|
|
134
|
-
// VisTooltip creates its element on its own schedule; wait for it rather than racing mount.
|
|
135
|
-
frame = requestAnimationFrame(attach);
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
158
|
+
if (!tip) return;
|
|
138
159
|
observer = new MutationObserver(() => {
|
|
139
160
|
if (!writing) reposition(tip);
|
|
140
161
|
});
|
|
@@ -143,12 +164,12 @@ export function useTooltipSnap(
|
|
|
143
164
|
|
|
144
165
|
onMounted(() => {
|
|
145
166
|
root.value?.addEventListener('pointermove', onPointerMove, { capture: true });
|
|
146
|
-
attach
|
|
167
|
+
root.value?.addEventListener('pointerenter', attach);
|
|
147
168
|
});
|
|
148
169
|
|
|
149
170
|
onBeforeUnmount(() => {
|
|
150
|
-
cancelAnimationFrame(frame);
|
|
151
171
|
observer?.disconnect();
|
|
152
172
|
root.value?.removeEventListener('pointermove', onPointerMove, { capture: true });
|
|
173
|
+
root.value?.removeEventListener('pointerenter', attach);
|
|
153
174
|
});
|
|
154
175
|
}
|
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,74 @@ 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
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A reading's y on the line marks: its height above the frame's floor, or undefined when there is no
|
|
89
|
+
* reading to draw, so the line breaks there. Undefined, not null: Unovis' line tests each point with
|
|
90
|
+
* the global isFinite, which passes a null and plots it at zero.
|
|
91
|
+
*/
|
|
92
|
+
export function sparklineY(v: number | null | undefined, floor: number): number | undefined {
|
|
93
|
+
return drawable(v) ? v - floor : undefined;
|
|
94
|
+
}
|
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';
|
|
@@ -29,7 +29,7 @@ export interface CellLegend {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export interface CellScaleOptions<D> {
|
|
32
|
-
mode: 'state' | 'intensity'
|
|
32
|
+
mode: MaybeRef<'state' | 'intensity'>;
|
|
33
33
|
/** State mode. Same shape as `categories` elsewhere, so useChartPalette output feeds it as-is. */
|
|
34
34
|
states?: MaybeRef<Record<string, BulletLegendItemInterface> | undefined>;
|
|
35
35
|
/** Intensity mode. */
|
|
@@ -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();
|
|
@@ -102,7 +105,7 @@ export function useCellScale<D>(options: CellScaleOptions<D>) {
|
|
|
102
105
|
|
|
103
106
|
const isEmpty = (d: D | null | undefined): boolean => {
|
|
104
107
|
if (d === null || d === undefined) return true;
|
|
105
|
-
if (options.mode === 'intensity') {
|
|
108
|
+
if (unref(options.mode) === 'intensity') {
|
|
106
109
|
const v = options.value?.(d);
|
|
107
110
|
return typeof v !== 'number' || !Number.isFinite(v);
|
|
108
111
|
}
|
|
@@ -112,7 +115,7 @@ export function useCellScale<D>(options: CellScaleOptions<D>) {
|
|
|
112
115
|
|
|
113
116
|
const resolve = (d: D | null | undefined): string => {
|
|
114
117
|
if (isEmpty(d)) return CELL_EMPTY_COLOR;
|
|
115
|
-
if (options.mode === 'intensity') {
|
|
118
|
+
if (unref(options.mode) === 'intensity') {
|
|
116
119
|
const [lo, hi] = domain.value;
|
|
117
120
|
const v = options.value!(d as D) as number;
|
|
118
121
|
const stops = swatches.value;
|
|
@@ -131,7 +134,7 @@ export function useCellScale<D>(options: CellScaleOptions<D>) {
|
|
|
131
134
|
};
|
|
132
135
|
|
|
133
136
|
const legend = computed<CellLegend>(() => {
|
|
134
|
-
if (options.mode === 'intensity') {
|
|
137
|
+
if (unref(options.mode) === 'intensity') {
|
|
135
138
|
return {
|
|
136
139
|
mode: 'intensity',
|
|
137
140
|
entries: swatches.value.map((swatch, i) => ({ key: String(i), swatch })),
|
|
@@ -153,3 +156,29 @@ export function useCellScale<D>(options: CellScaleOptions<D>) {
|
|
|
153
156
|
|
|
154
157
|
return { colorOf: resolve, isEmpty, legend, domain, swatches };
|
|
155
158
|
}
|
|
159
|
+
|
|
160
|
+
/** The colour props FStatusTrack and FCalendarGrid share. */
|
|
161
|
+
export interface CellScaleProps<D> {
|
|
162
|
+
data: readonly D[];
|
|
163
|
+
mode: 'state' | 'intensity';
|
|
164
|
+
states?: Record<string, BulletLegendItemInterface>;
|
|
165
|
+
state?: (d: D) => string | null | undefined;
|
|
166
|
+
value?: (d: D) => number | null | undefined;
|
|
167
|
+
ramp?: SequentialRamp;
|
|
168
|
+
domain?: [number, number];
|
|
169
|
+
steps: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** useCellScale over a component's colour props, every one read live, the accessors included. */
|
|
173
|
+
export function useCellScaleProps<D>(props: CellScaleProps<D>): CellScale<D> {
|
|
174
|
+
return useCellScale<D>({
|
|
175
|
+
mode: computed(() => props.mode),
|
|
176
|
+
states: computed(() => props.states),
|
|
177
|
+
ramp: computed(() => props.ramp ?? ('forest' as SequentialRamp)),
|
|
178
|
+
domain: computed(() => props.domain),
|
|
179
|
+
steps: computed(() => props.steps),
|
|
180
|
+
data: computed(() => props.data),
|
|
181
|
+
value: (d) => props.value?.(d),
|
|
182
|
+
state: (d) => props.state?.(d),
|
|
183
|
+
});
|
|
184
|
+
}
|
package/src/useChartPalette.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface SeriesDef { key: string; name: string }
|
|
|
12
12
|
// There is deliberately NO generic `categorical()` — the generic name used to return the VIVID set,
|
|
13
13
|
// which meant the one palette that is not colour-blind-safe owned the most authoritative-sounding name.
|
|
14
14
|
const SAFE = Object.keys(DATAVIZ.light); // 1..6 — brand-led CVD-safe, consumed by index
|
|
15
|
-
const VIVID = DATAVIZ_CATEGORICAL.names; // green, amber,
|
|
15
|
+
const VIVID = DATAVIZ_CATEGORICAL.names; // green, amber, sky, … (first 6 = the curated 6)
|
|
16
16
|
const PAIRS = Object.keys(DATAVIZ_PAIRS.light); // forest-deep, forest-soft, warm-deep, … (family-grouped)
|
|
17
17
|
|
|
18
18
|
export const safeVar = (i: number) => `var(--dataviz-categorical-safe-${SAFE[i % SAFE.length]})`;
|
|
@@ -37,13 +37,15 @@ const build = (series: SeriesDef[], color: (i: number) => string): Categories =>
|
|
|
37
37
|
export function useChartPalette() {
|
|
38
38
|
return {
|
|
39
39
|
/** The 6-colour brand-led CVD-safe set — what the Unovis adapter already ships on --vis-color0..5.
|
|
40
|
-
* The default:
|
|
40
|
+
* The default: the six stay separable on colour alone under all three simulated dichromacies,
|
|
41
|
+
* and a second cue (direct labels, dash or marker) covers what the simulation cannot, such as a
|
|
42
|
+
* photocopy or a projector. A legend keyed by colour is not that cue. */
|
|
41
43
|
cvdSafe: (series: SeriesDef[]) => build(series, safeVar),
|
|
42
44
|
/** Vivid categorical (palette order). ≤6 where you can; 7–12 are label-only.
|
|
43
|
-
* NOT CVD-safe — the caller MUST add a second channel (
|
|
45
|
+
* NOT CVD-safe — the caller MUST add a second channel (direct labels, dash or marker). */
|
|
44
46
|
vivid: (series: SeriesDef[]) => build(series, vividVar),
|
|
45
47
|
/** Brand pairs — 3 brand hues × deep/soft, family-grouped. Brand-forward work: decks, marketing.
|
|
46
|
-
* NOT CVD-safe — the caller MUST add a second channel (
|
|
48
|
+
* NOT CVD-safe — the caller MUST add a second channel (direct labels, dash or marker). */
|
|
47
49
|
pairs: (series: SeriesDef[]) => build(series, pairsVar),
|
|
48
50
|
/** One of the three sets, chosen at runtime. For the docs' palette picker — production code
|
|
49
51
|
* should name the set it means, so the CVD contract above is visible at the call site. */
|