@humanforest/charts 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/package.json +2 -2
- package/src/FCalendarGrid.vue +84 -46
- package/src/FChartFrame.vue +22 -48
- package/src/FDottedMap.vue +12 -23
- package/src/FLondonMap.vue +34 -85
- package/src/FSparkline.vue +19 -7
- package/src/FStatusTrack.vue +25 -15
- package/src/crosshairConfig.ts +27 -0
- package/src/frameIndex.ts +36 -0
- package/src/fromCategories.ts +4 -3
- package/src/londonAreas.ts +66 -0
- package/src/londonGrid.ts +27 -0
- package/src/presets.ts +19 -18
- package/src/resolveVar.ts +4 -3
- package/src/rovingFocus.ts +26 -0
- package/src/snapTooltip.ts +18 -15
- package/src/sparklineDomain.ts +9 -0
- package/src/useCellScale.ts +30 -4
- package/src/useChartPalette.ts +6 -4
|
@@ -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,10 +38,11 @@ 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
|
-
*
|
|
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. */
|
|
43
46
|
export function shapeCentre(el: SVGGraphicsElement): { x: number; y: number } {
|
|
44
47
|
const path = el as SVGPathElement;
|
|
45
48
|
if (typeof path.getTotalLength === 'function') {
|
|
@@ -102,7 +105,6 @@ export function useTooltipSnap(
|
|
|
102
105
|
// Guards the observer against our own writes, which would otherwise re-enter it forever.
|
|
103
106
|
let writing = false;
|
|
104
107
|
let observer: MutationObserver | undefined;
|
|
105
|
-
let frame = 0;
|
|
106
108
|
|
|
107
109
|
function onPointerMove(e: Event): void {
|
|
108
110
|
const t = e.target;
|
|
@@ -146,13 +148,14 @@ export function useTooltipSnap(
|
|
|
146
148
|
writing = false;
|
|
147
149
|
}
|
|
148
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.
|
|
149
155
|
function attach(): void {
|
|
156
|
+
observer?.disconnect();
|
|
150
157
|
const tip = document.querySelector<HTMLElement>(`[data-forest-tip="${tipId}"]`);
|
|
151
|
-
if (!tip)
|
|
152
|
-
// VisTooltip creates its element on its own schedule; wait for it rather than racing mount.
|
|
153
|
-
frame = requestAnimationFrame(attach);
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
158
|
+
if (!tip) return;
|
|
156
159
|
observer = new MutationObserver(() => {
|
|
157
160
|
if (!writing) reposition(tip);
|
|
158
161
|
});
|
|
@@ -161,12 +164,12 @@ export function useTooltipSnap(
|
|
|
161
164
|
|
|
162
165
|
onMounted(() => {
|
|
163
166
|
root.value?.addEventListener('pointermove', onPointerMove, { capture: true });
|
|
164
|
-
attach
|
|
167
|
+
root.value?.addEventListener('pointerenter', attach);
|
|
165
168
|
});
|
|
166
169
|
|
|
167
170
|
onBeforeUnmount(() => {
|
|
168
|
-
cancelAnimationFrame(frame);
|
|
169
171
|
observer?.disconnect();
|
|
170
172
|
root.value?.removeEventListener('pointermove', onPointerMove, { capture: true });
|
|
173
|
+
root.value?.removeEventListener('pointerenter', attach);
|
|
171
174
|
});
|
|
172
175
|
}
|
package/src/sparklineDomain.ts
CHANGED
|
@@ -83,3 +83,12 @@ export function sparklineEndpoint(
|
|
|
83
83
|
}
|
|
84
84
|
return null;
|
|
85
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
|
@@ -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. */
|
|
@@ -105,7 +105,7 @@ export function useCellScale<D>(options: CellScaleOptions<D>): CellScale<D> {
|
|
|
105
105
|
|
|
106
106
|
const isEmpty = (d: D | null | undefined): boolean => {
|
|
107
107
|
if (d === null || d === undefined) return true;
|
|
108
|
-
if (options.mode === 'intensity') {
|
|
108
|
+
if (unref(options.mode) === 'intensity') {
|
|
109
109
|
const v = options.value?.(d);
|
|
110
110
|
return typeof v !== 'number' || !Number.isFinite(v);
|
|
111
111
|
}
|
|
@@ -115,7 +115,7 @@ export function useCellScale<D>(options: CellScaleOptions<D>): CellScale<D> {
|
|
|
115
115
|
|
|
116
116
|
const resolve = (d: D | null | undefined): string => {
|
|
117
117
|
if (isEmpty(d)) return CELL_EMPTY_COLOR;
|
|
118
|
-
if (options.mode === 'intensity') {
|
|
118
|
+
if (unref(options.mode) === 'intensity') {
|
|
119
119
|
const [lo, hi] = domain.value;
|
|
120
120
|
const v = options.value!(d as D) as number;
|
|
121
121
|
const stops = swatches.value;
|
|
@@ -134,7 +134,7 @@ export function useCellScale<D>(options: CellScaleOptions<D>): CellScale<D> {
|
|
|
134
134
|
};
|
|
135
135
|
|
|
136
136
|
const legend = computed<CellLegend>(() => {
|
|
137
|
-
if (options.mode === 'intensity') {
|
|
137
|
+
if (unref(options.mode) === 'intensity') {
|
|
138
138
|
return {
|
|
139
139
|
mode: 'intensity',
|
|
140
140
|
entries: swatches.value.map((swatch, i) => ({ key: String(i), swatch })),
|
|
@@ -156,3 +156,29 @@ export function useCellScale<D>(options: CellScaleOptions<D>): CellScale<D> {
|
|
|
156
156
|
|
|
157
157
|
return { colorOf: resolve, isEmpty, legend, domain, swatches };
|
|
158
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. */
|