@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.
@@ -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
+ }
@@ -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 just outside it on the side with room.
17
- // Sampled along the outline rather than taken from the box, so an arc anchors at its
18
- // own mid-angle instead of at the corner of a box it never touches.
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. Paths are sampled along their outline and averaged:
40
- * for a donut arc that lands at mid-angle, mid-radius — the middle of the visible band — where the
41
- * box centre would land in the hole, or outside the wedge entirely on a small segment. Anything
42
- * without a length (rects) falls back to its box, which for those IS the mark. */
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
  }
@@ -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
+ }
@@ -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
+ }
@@ -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, red, … (first 6 = the curated 6)
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: colour alone separates the series, so it needs no second channel. */
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 (legend, direct labels, dash or marker). */
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 (legend, direct labels, dash or marker). */
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. */