@r0hitsharma/dashboard-kit 0.12.0-rohit-fork-ci.1
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/README.md +150 -0
- package/dist/charting-interaction.d.ts +47 -0
- package/dist/charting-interaction.js +112 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +10 -0
- package/dist/interaction.d.ts +43 -0
- package/dist/interaction.js +41 -0
- package/dist/registry.d.ts +37 -0
- package/dist/registry.js +220 -0
- package/dist/renderer.d.ts +38 -0
- package/dist/renderer.js +140 -0
- package/dist/schema.d.ts +138 -0
- package/dist/schema.js +25 -0
- package/dist/validate.d.ts +104 -0
- package/dist/validate.js +174 -0
- package/package.json +61 -0
- package/src/charting-interaction.test.ts +95 -0
- package/src/charting-interaction.tsx +199 -0
- package/src/index.ts +57 -0
- package/src/interaction.ts +89 -0
- package/src/registry.tsx +410 -0
- package/src/renderer.tsx +275 -0
- package/src/schema.ts +148 -0
- package/src/validate.test.ts +257 -0
- package/src/validate.ts +233 -0
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# @r0hitsharma/dashboard-kit
|
|
2
|
+
|
|
3
|
+
A generic, declarative dashboard engine. You describe a dashboard as data — a
|
|
4
|
+
`DashboardSpec` manifest — and the engine renders it: a recursive layout tree of
|
|
5
|
+
resizable splits and widgets, a flat widget registry, typed data bindings, and
|
|
6
|
+
declared cross-widget interaction. It ships only the ENGINE and a small set of
|
|
7
|
+
GENERIC adapters over `@r0hitsharma/design-system` and
|
|
8
|
+
`@r0hitsharma/charting`; every domain-specific widget, data source, and
|
|
9
|
+
component binding is the consumer's to register.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```jsonc
|
|
14
|
+
// peerDependencies
|
|
15
|
+
"@r0hitsharma/dashboard-kit": "*",
|
|
16
|
+
"@r0hitsharma/design-system": "*",
|
|
17
|
+
"@r0hitsharma/charting": "*",
|
|
18
|
+
"react": "^19",
|
|
19
|
+
"react-dom": "^19"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import {
|
|
26
|
+
DashboardRenderer,
|
|
27
|
+
type DashboardSpec,
|
|
28
|
+
type DashboardDataSources,
|
|
29
|
+
} from '@r0hitsharma/dashboard-kit';
|
|
30
|
+
|
|
31
|
+
const spec: DashboardSpec = {
|
|
32
|
+
version: 1,
|
|
33
|
+
title: 'Overview',
|
|
34
|
+
layout: {
|
|
35
|
+
type: 'split',
|
|
36
|
+
direction: 'column',
|
|
37
|
+
children: [
|
|
38
|
+
{ type: 'widget', ref: 'kpis' },
|
|
39
|
+
{
|
|
40
|
+
type: 'split',
|
|
41
|
+
direction: 'row',
|
|
42
|
+
children: [
|
|
43
|
+
{ type: 'widget', ref: 'trend', size: 2 },
|
|
44
|
+
{ type: 'widget', ref: 'table', size: 1 },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
widgets: {
|
|
50
|
+
kpis: {
|
|
51
|
+
id: 'kpis',
|
|
52
|
+
component: 'statRow',
|
|
53
|
+
title: 'Key metrics',
|
|
54
|
+
dataBinding: { source: 'metrics', fields: { label: 'name', value: 'value' } },
|
|
55
|
+
interaction: { reads: ['highlightedKey'] },
|
|
56
|
+
},
|
|
57
|
+
trend: {
|
|
58
|
+
id: 'trend',
|
|
59
|
+
component: 'lineChart',
|
|
60
|
+
title: 'Trend',
|
|
61
|
+
dataBinding: { source: 'series', fields: {} },
|
|
62
|
+
props: { xField: 'x', series: [{ key: 'A', field: 'a' }] },
|
|
63
|
+
},
|
|
64
|
+
table: {
|
|
65
|
+
id: 'table',
|
|
66
|
+
component: 'table',
|
|
67
|
+
title: 'Rows',
|
|
68
|
+
dataBinding: { source: 'metrics', fields: { rowKey: 'name' } },
|
|
69
|
+
props: {
|
|
70
|
+
columns: [
|
|
71
|
+
{ accessorKey: 'name', header: 'Name' },
|
|
72
|
+
{ accessorKey: 'value', header: 'Value', render: 'number' },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
interaction: { writes: ['highlightedKey'] },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const dataSources: DashboardDataSources = {
|
|
81
|
+
metrics: [
|
|
82
|
+
{ name: 'Alpha', value: 42 },
|
|
83
|
+
{ name: 'Beta', value: 17 },
|
|
84
|
+
],
|
|
85
|
+
series: [
|
|
86
|
+
{ x: 'Mon', a: 3 },
|
|
87
|
+
{ x: 'Tue', a: 5 },
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export function Example() {
|
|
92
|
+
return <DashboardRenderer spec={spec} dataSources={dataSources} />;
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Clicking a `table` row writes `highlightedKey`; the `statRow` reads it and
|
|
97
|
+
emphasizes the matching tile — cross-highlighting driven entirely by the
|
|
98
|
+
manifest's declared `interaction`.
|
|
99
|
+
|
|
100
|
+
## The manifest
|
|
101
|
+
|
|
102
|
+
- `layout` — a recursive tree of `split` (row/column, optionally `resizable`)
|
|
103
|
+
and `widget` (a `ref` into `widgets`) nodes. Positioning only; independent of
|
|
104
|
+
what each leaf renders.
|
|
105
|
+
- `widgets` — a flat, id-keyed map of widget nodes: `{ component, props,
|
|
106
|
+
dataBinding, interaction, thresholds }`.
|
|
107
|
+
- `dataBinding` — `{ source, fields }`: a data-source id plus a channel→field
|
|
108
|
+
mapping (Vega-Lite style, not a query string).
|
|
109
|
+
- `interaction` — `reads` / `writes` shared-selection keys, plus `agentWritable`
|
|
110
|
+
(default-deny agent exposure; see below).
|
|
111
|
+
|
|
112
|
+
## Extending the registry
|
|
113
|
+
|
|
114
|
+
Register your own component keys → adapters and merge them over the defaults:
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
import { DashboardRenderer, mergeRegistries, type RegistryComponent } from '@r0hitsharma/dashboard-kit';
|
|
118
|
+
|
|
119
|
+
const Gauge: RegistryComponent = ({ widget, data }) => /* ... */;
|
|
120
|
+
const registry = mergeRegistries({ gauge: Gauge });
|
|
121
|
+
|
|
122
|
+
<DashboardRenderer spec={spec} dataSources={ds} registry={registry} />;
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The default registry provides `note`, `stat`, `statRow`, `table`, `lineChart`,
|
|
126
|
+
and `areaChart`.
|
|
127
|
+
|
|
128
|
+
## Validation
|
|
129
|
+
|
|
130
|
+
`validateDashboardSpec(candidate, { knownComponents })` runs a zod structural
|
|
131
|
+
pass plus referential passes (unknown refs, orphaned widgets, a widget `id` that
|
|
132
|
+
disagrees with its registry key, resizable column-split without a height, an
|
|
133
|
+
`agentWritable` key that isn't writable, optional unknown-component check). It
|
|
134
|
+
never throws — an agent-submitted patch is untrusted input, so it returns
|
|
135
|
+
`{ ok, issues }` and the renderer shows an annotated rejection rather than
|
|
136
|
+
blanking. `DashboardRenderer` runs it by default;
|
|
137
|
+
pass `skipValidation` for a spec you author and trust.
|
|
138
|
+
|
|
139
|
+
`collectAgentWritableKeys(spec)` is the other half of the `agentWritable`
|
|
140
|
+
contract: it returns the set of keys the manifest actually permits an agent to
|
|
141
|
+
write, so a host app's agent-facing tools can refuse anything outside it.
|
|
142
|
+
|
|
143
|
+
## Interaction
|
|
144
|
+
|
|
145
|
+
By default the renderer uses a self-contained local interaction store, enough
|
|
146
|
+
for a standalone dashboard. To cross-filter a synced chart group, back
|
|
147
|
+
interaction with charting's real per-key store via `useChartingInteraction`
|
|
148
|
+
(see `DESIGN.md`).
|
|
149
|
+
|
|
150
|
+
See `DESIGN.md` for the full contract and design rationale.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type InteractionKey } from '@r0hitsharma/charting';
|
|
2
|
+
import { type ReactElement } from 'react';
|
|
3
|
+
import type { InteractionContextValue } from './interaction.js';
|
|
4
|
+
/**
|
|
5
|
+
* Adapts a manifest's free-form interaction vocabulary onto charting's REAL
|
|
6
|
+
* per-key store (`DashboardInteractionProvider` / `useDashboardInteraction` /
|
|
7
|
+
* `useInteractionValue`), so a declarative dashboard cross-filters the same
|
|
8
|
+
* synced chart group a hand-built one would.
|
|
9
|
+
*
|
|
10
|
+
* The reconciliation this file owns: a manifest names keys in an FDC3-shaped
|
|
11
|
+
* vocabulary (`highlightedAsset`, `selectedTimeRange`) while charting's store
|
|
12
|
+
* is typed to `highlightedKey` / `timeRange` / `hoveredTimestamp`. The key map
|
|
13
|
+
* below is the single, declared place that translation happens — not a rename
|
|
14
|
+
* of either side. Consumers can pass their own map to extend the vocabulary.
|
|
15
|
+
*
|
|
16
|
+
* ── Why a bridge rather than calling `useDashboardInteraction` in each widget
|
|
17
|
+
* Charting's `DashboardInteractionContext` value changes identity on every
|
|
18
|
+
* pointer move (`hoveredTimestamp` lives in it), so anything that consumes it
|
|
19
|
+
* re-renders at hover frequency. {@link InteractionSync} is the SOLE consumer:
|
|
20
|
+
* a null-rendering component that republishes the mapped keys into a tiny
|
|
21
|
+
* per-key store. A widget that declared `interaction.reads: ['highlightedAsset']`
|
|
22
|
+
* then re-renders when the highlight changes and at no other time — matching
|
|
23
|
+
* the discipline of charting's own `useInteractionValue`.
|
|
24
|
+
*/
|
|
25
|
+
export type InteractionKeyMap = Record<string, InteractionKey>;
|
|
26
|
+
/**
|
|
27
|
+
* Default manifest-key -> charting-key aliases. Native charting keys map to
|
|
28
|
+
* themselves so a manifest may also name them directly.
|
|
29
|
+
*/
|
|
30
|
+
export declare const DEFAULT_INTERACTION_KEY_MAP: InteractionKeyMap;
|
|
31
|
+
export type ChartingInteraction = {
|
|
32
|
+
/** The generic surface to hand the renderer's `interaction` prop. */
|
|
33
|
+
interaction: InteractionContextValue;
|
|
34
|
+
/**
|
|
35
|
+
* Render ONCE inside a `SyncedChartGroup` / `DashboardInteractionProvider`.
|
|
36
|
+
* It renders nothing observable; it is the sole subscriber to the
|
|
37
|
+
* high-frequency charting context and republishes the mapped keys into the
|
|
38
|
+
* per-key store that {@link interaction} reads.
|
|
39
|
+
*/
|
|
40
|
+
InteractionSync: () => ReactElement;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Builds a charting-backed {@link InteractionContextValue}. Call it above a
|
|
44
|
+
* `SyncedChartGroup`, hand `interaction` to `DashboardRenderer`, and render
|
|
45
|
+
* `<InteractionSync />` inside the group.
|
|
46
|
+
*/
|
|
47
|
+
export declare function useChartingInteraction(keyMap?: InteractionKeyMap): ChartingInteraction;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useDashboardInteraction, } from '@r0hitsharma/charting';
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, } from 'react';
|
|
4
|
+
/**
|
|
5
|
+
* Default manifest-key -> charting-key aliases. Native charting keys map to
|
|
6
|
+
* themselves so a manifest may also name them directly.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_INTERACTION_KEY_MAP = {
|
|
9
|
+
highlightedAsset: 'highlightedKey',
|
|
10
|
+
highlightedKey: 'highlightedKey',
|
|
11
|
+
selectedTimeRange: 'timeRange',
|
|
12
|
+
timeRange: 'timeRange',
|
|
13
|
+
hoveredTimestamp: 'hoveredTimestamp',
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Module-scope, not a closure created per `useChartingInteraction` call:
|
|
17
|
+
* hooks nested inside a `useMemo`/`useCallback` are ambiguous to the rules-of
|
|
18
|
+
* -hooks check (it can't tell they belong to a separate component render, not
|
|
19
|
+
* the enclosing hook's), so this needs to be a real, statically top-level
|
|
20
|
+
* component. Everything it needs comes in as props instead of a closure.
|
|
21
|
+
*
|
|
22
|
+
* `keyMap` is a plain prop, not a ref synced from a parent effect: React
|
|
23
|
+
* flushes child effects before parent effects, so a ref updated by
|
|
24
|
+
* `useChartingInteraction`'s own effect would still hold the PREVIOUS
|
|
25
|
+
* `keyMap` when this component's effects below run in the commit where it
|
|
26
|
+
* actually changed. A prop is simply current already.
|
|
27
|
+
*/
|
|
28
|
+
function ChartingInteractionSync({ keyMap, writerRef, publish, }) {
|
|
29
|
+
const chart = useDashboardInteraction();
|
|
30
|
+
// Every manifest alias that resolves to `highlightedKey` gets the
|
|
31
|
+
// charting value republished under it; likewise for the other keys.
|
|
32
|
+
const aliasesFor = useCallback((chartingKey) => Object.entries(keyMap)
|
|
33
|
+
.filter(([, target]) => target === chartingKey)
|
|
34
|
+
.map(([alias]) => alias), [keyMap]);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
for (const alias of aliasesFor('highlightedKey')) {
|
|
37
|
+
publish(alias, chart.highlightedKey ?? undefined);
|
|
38
|
+
}
|
|
39
|
+
}, [chart.highlightedKey, publish, aliasesFor]);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
for (const alias of aliasesFor('timeRange')) {
|
|
42
|
+
publish(alias, chart.timeRange ?? undefined);
|
|
43
|
+
}
|
|
44
|
+
}, [chart.timeRange, publish, aliasesFor]);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
for (const alias of aliasesFor('hoveredTimestamp')) {
|
|
47
|
+
publish(alias, chart.hoveredTimestamp ?? undefined);
|
|
48
|
+
}
|
|
49
|
+
}, [chart.hoveredTimestamp, publish, aliasesFor]);
|
|
50
|
+
// The write side, held in a ref so `interaction.write` stays
|
|
51
|
+
// identity-stable for every consumer.
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
writerRef.current = (key, value) => {
|
|
54
|
+
switch (keyMap[key]) {
|
|
55
|
+
case 'highlightedKey':
|
|
56
|
+
chart.setHighlightedKey(value == null ? null : String(value));
|
|
57
|
+
return;
|
|
58
|
+
case 'timeRange':
|
|
59
|
+
chart.setTimeRange(value ?? null);
|
|
60
|
+
return;
|
|
61
|
+
case 'hoveredTimestamp':
|
|
62
|
+
chart.setHoveredTimestamp(value == null ? null : Number(value));
|
|
63
|
+
return;
|
|
64
|
+
default:
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}, [chart, keyMap, writerRef]);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Builds a charting-backed {@link InteractionContextValue}. Call it above a
|
|
73
|
+
* `SyncedChartGroup`, hand `interaction` to `DashboardRenderer`, and render
|
|
74
|
+
* `<InteractionSync />` inside the group.
|
|
75
|
+
*/
|
|
76
|
+
export function useChartingInteraction(keyMap = DEFAULT_INTERACTION_KEY_MAP) {
|
|
77
|
+
const valuesRef = useRef(new Map());
|
|
78
|
+
const listenersRef = useRef(new Map());
|
|
79
|
+
const writerRef = useRef(() => { });
|
|
80
|
+
const read = useCallback((key) => valuesRef.current.get(key), []);
|
|
81
|
+
const write = useCallback((key, value) => {
|
|
82
|
+
writerRef.current(key, value);
|
|
83
|
+
}, []);
|
|
84
|
+
const subscribe = useCallback((key, onChange) => {
|
|
85
|
+
let listeners = listenersRef.current.get(key);
|
|
86
|
+
if (!listeners) {
|
|
87
|
+
listeners = new Set();
|
|
88
|
+
listenersRef.current.set(key, listeners);
|
|
89
|
+
}
|
|
90
|
+
listeners.add(onChange);
|
|
91
|
+
return () => {
|
|
92
|
+
listeners.delete(onChange);
|
|
93
|
+
};
|
|
94
|
+
}, []);
|
|
95
|
+
const publish = useCallback((key, value) => {
|
|
96
|
+
if (Object.is(valuesRef.current.get(key), value))
|
|
97
|
+
return;
|
|
98
|
+
valuesRef.current.set(key, value);
|
|
99
|
+
for (const listener of listenersRef.current.get(key) ?? [])
|
|
100
|
+
listener();
|
|
101
|
+
}, []);
|
|
102
|
+
const interaction = useMemo(() => ({ read, write, subscribe }), [read, write, subscribe]);
|
|
103
|
+
// A stable function identity, so React doesn't unmount/remount
|
|
104
|
+
// `ChartingInteractionSync` (and its charting subscription) across the
|
|
105
|
+
// dashboard's renders — as long as `keyMap` doesn't churn identity either.
|
|
106
|
+
// Pass a memoized `keyMap` (module-scoped, like the default, or your own
|
|
107
|
+
// `useMemo`); an inline object here remounts the subscription every render,
|
|
108
|
+
// the same discipline `usePlayback`'s `source` and `useDataTable`'s
|
|
109
|
+
// `columns` already require of their callers.
|
|
110
|
+
const InteractionSync = useCallback(() => (_jsx(ChartingInteractionSync, { keyMap: keyMap, writerRef: writerRef, publish: publish })), [publish, keyMap]);
|
|
111
|
+
return { interaction, InteractionSync };
|
|
112
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { DashboardSpec, DashboardDataSources, DataBinding, LayoutDirection, LayoutNode, SplitLayoutNode, ThresholdRule, ThresholdSeverity, WidgetInteraction, WidgetLayoutNode, WidgetNode, WidgetTableColumn, } from './schema.js';
|
|
2
|
+
export { DashboardRenderer, renderLayoutNode, type DashboardRendererProps, } from './renderer.js';
|
|
3
|
+
export { DEFAULT_REGISTRY, UnknownWidget, mergeRegistries, type ComponentRegistry, type RegistryComponent, type RegistryComponentProps, } from './registry.js';
|
|
4
|
+
export { collectAgentWritableKeys, dashboardSpecSchema, validateDashboardSpec, type ManifestIssue, type ManifestValidation, type ValidateOptions, } from './validate.js';
|
|
5
|
+
export { useInteractionField, useLocalInteraction, type InteractionContextValue, } from './interaction.js';
|
|
6
|
+
export { DEFAULT_INTERACTION_KEY_MAP, useChartingInteraction, type ChartingInteraction, type InteractionKeyMap, } from './charting-interaction.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The recursive renderer.
|
|
2
|
+
export { DashboardRenderer, renderLayoutNode, } from './renderer.js';
|
|
3
|
+
// The string -> component registry mechanism + the default generic adapters.
|
|
4
|
+
export { DEFAULT_REGISTRY, UnknownWidget, mergeRegistries, } from './registry.js';
|
|
5
|
+
// Structural + referential manifest validation, and the agent-exposure query.
|
|
6
|
+
export { collectAgentWritableKeys, dashboardSpecSchema, validateDashboardSpec, } from './validate.js';
|
|
7
|
+
// The generic interaction surface: the local store + the per-key read hook.
|
|
8
|
+
export { useInteractionField, useLocalInteraction, } from './interaction.js';
|
|
9
|
+
// The adapter onto charting's real per-key interaction store.
|
|
10
|
+
export { DEFAULT_INTERACTION_KEY_MAP, useChartingInteraction, } from './charting-interaction.js';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interaction surface a manifest's widgets read and write, addressed by
|
|
3
|
+
* the free-form string keys a `WidgetNode.interaction.reads`/`writes` names.
|
|
4
|
+
*
|
|
5
|
+
* This is deliberately a small, transport-agnostic `{read, write, subscribe}`
|
|
6
|
+
* contract rather than charting's concrete `DashboardInteractionApi` (which is
|
|
7
|
+
* typed to a fixed set of keys: `timeRange`, `hoveredTimestamp`, `filters`,
|
|
8
|
+
* `highlightedKey`). A manifest names its OWN vocabulary (`highlightedAsset`,
|
|
9
|
+
* `selectedTimeRange`, ...); {@link module:charting-interaction} is the adapter
|
|
10
|
+
* that maps that vocabulary onto charting's real per-key store. Keeping the
|
|
11
|
+
* engine's contract generic is what lets a consumer back interaction with
|
|
12
|
+
* charting, with their own store, or with the built-in local store below —
|
|
13
|
+
* without the engine caring which.
|
|
14
|
+
*/
|
|
15
|
+
export type InteractionContextValue = {
|
|
16
|
+
read: (key: string) => unknown;
|
|
17
|
+
write: (key: string, value: unknown) => void;
|
|
18
|
+
/**
|
|
19
|
+
* Optional per-key change subscription. When present, widgets reach values
|
|
20
|
+
* through {@link useInteractionField} rather than calling `read` during
|
|
21
|
+
* render, so only the widgets bound to a changed key re-render — the same
|
|
22
|
+
* hover-perf discipline charting's own per-key store enforces.
|
|
23
|
+
*/
|
|
24
|
+
subscribe?: (key: string, onChange: () => void) => () => void;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* A self-contained interaction store backed by React state — the default the
|
|
28
|
+
* renderer falls back to when no `interaction` prop is supplied. Correct and
|
|
29
|
+
* cheap for a standalone manifest (a story, a preview) that isn't synced to a
|
|
30
|
+
* live chart group; for chart-synced interaction, pass the adapter from
|
|
31
|
+
* {@link module:charting-interaction} instead.
|
|
32
|
+
*/
|
|
33
|
+
export declare function useLocalInteraction(): InteractionContextValue;
|
|
34
|
+
/**
|
|
35
|
+
* Reads ONE interaction key, subscribing to just that key when the provider
|
|
36
|
+
* supports it. `key` may be `undefined` (a widget that declares no
|
|
37
|
+
* `interaction.reads`), in which case this is a no-op returning `undefined`.
|
|
38
|
+
*
|
|
39
|
+
* Named `useInteractionField` (not `useInteractionValue`) so a consumer file
|
|
40
|
+
* can import both this and charting's own `useInteractionValue` without a name
|
|
41
|
+
* collision.
|
|
42
|
+
*/
|
|
43
|
+
export declare function useInteractionField(interaction: InteractionContextValue, key: string | undefined): unknown;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* A self-contained interaction store backed by React state — the default the
|
|
4
|
+
* renderer falls back to when no `interaction` prop is supplied. Correct and
|
|
5
|
+
* cheap for a standalone manifest (a story, a preview) that isn't synced to a
|
|
6
|
+
* live chart group; for chart-synced interaction, pass the adapter from
|
|
7
|
+
* {@link module:charting-interaction} instead.
|
|
8
|
+
*/
|
|
9
|
+
export function useLocalInteraction() {
|
|
10
|
+
const [, setState] = useState({});
|
|
11
|
+
// Keep `read`/`write` identity-stable so the context value doesn't change
|
|
12
|
+
// for reasons other than a real state change.
|
|
13
|
+
const stateRef = useRef({});
|
|
14
|
+
const read = useCallback((key) => stateRef.current[key], []);
|
|
15
|
+
const write = useCallback((key, value) => {
|
|
16
|
+
if (Object.is(stateRef.current[key], value))
|
|
17
|
+
return;
|
|
18
|
+
stateRef.current = { ...stateRef.current, [key]: value };
|
|
19
|
+
setState(stateRef.current);
|
|
20
|
+
}, []);
|
|
21
|
+
return useMemo(() => ({ read, write }), [read, write]);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Reads ONE interaction key, subscribing to just that key when the provider
|
|
25
|
+
* supports it. `key` may be `undefined` (a widget that declares no
|
|
26
|
+
* `interaction.reads`), in which case this is a no-op returning `undefined`.
|
|
27
|
+
*
|
|
28
|
+
* Named `useInteractionField` (not `useInteractionValue`) so a consumer file
|
|
29
|
+
* can import both this and charting's own `useInteractionValue` without a name
|
|
30
|
+
* collision.
|
|
31
|
+
*/
|
|
32
|
+
export function useInteractionField(interaction, key) {
|
|
33
|
+
const { read, subscribe } = interaction;
|
|
34
|
+
const subscribeToKey = useCallback((onChange) => {
|
|
35
|
+
if (!key || !subscribe)
|
|
36
|
+
return () => { };
|
|
37
|
+
return subscribe(key, onChange);
|
|
38
|
+
}, [key, subscribe]);
|
|
39
|
+
const getSnapshot = useCallback(() => (key ? read(key) : undefined), [key, read]);
|
|
40
|
+
return useSyncExternalStore(subscribeToKey, getSnapshot, getSnapshot);
|
|
41
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type JSX } from 'react';
|
|
2
|
+
import { type InteractionContextValue } from './interaction.js';
|
|
3
|
+
import type { WidgetNode } from './schema.js';
|
|
4
|
+
/**
|
|
5
|
+
* The string -> component registry. A manifest's `WidgetNode.component` names a
|
|
6
|
+
* key here; the resolved adapter owns translating that widget's
|
|
7
|
+
* `dataBinding.fields` mapping + `props` into a real component's prop shape, so
|
|
8
|
+
* the manifest stays declarative (field names, not JSX).
|
|
9
|
+
*
|
|
10
|
+
* The registry is an EXTENSIBLE MECHANISM: {@link DEFAULT_REGISTRY} ships a
|
|
11
|
+
* small set of generic adapters over public design-system + charting
|
|
12
|
+
* components, and a consumer merges in their own domain adapters with
|
|
13
|
+
* {@link mergeRegistries} (or replaces it wholesale). The engine itself knows
|
|
14
|
+
* nothing about any specific widget.
|
|
15
|
+
*/
|
|
16
|
+
export type RegistryComponentProps = {
|
|
17
|
+
widget: WidgetNode;
|
|
18
|
+
data: Record<string, unknown>[];
|
|
19
|
+
interaction: InteractionContextValue;
|
|
20
|
+
};
|
|
21
|
+
export type RegistryComponent = (props: RegistryComponentProps) => JSX.Element;
|
|
22
|
+
export type ComponentRegistry = Record<string, RegistryComponent>;
|
|
23
|
+
/**
|
|
24
|
+
* The default registry: a small set of GENERIC adapters over public
|
|
25
|
+
* design-system + charting components. Nothing domain-specific — a consumer
|
|
26
|
+
* merges in their own component keys with {@link mergeRegistries}.
|
|
27
|
+
*/
|
|
28
|
+
export declare const DEFAULT_REGISTRY: ComponentRegistry;
|
|
29
|
+
/** Merges consumer adapters over the defaults (consumer keys win on collision). */
|
|
30
|
+
export declare function mergeRegistries(...registries: ComponentRegistry[]): ComponentRegistry;
|
|
31
|
+
/**
|
|
32
|
+
* Inline marker rendered in place of a widget whose ref or component key does
|
|
33
|
+
* not resolve — one bad manifest entry never blanks the whole dashboard.
|
|
34
|
+
*/
|
|
35
|
+
export declare function UnknownWidget({ label }: {
|
|
36
|
+
label: string;
|
|
37
|
+
}): JSX.Element;
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Axis, AreaSeries, Grid, LineSeries, Tooltip, XYChart, chartTheme, } from '@r0hitsharma/charting';
|
|
3
|
+
import { Badge, DataTable, Sparkline, StatRow, StatTile, useDataTable, } from '@r0hitsharma/design-system';
|
|
4
|
+
import { useCallback, useRef, useState, } from 'react';
|
|
5
|
+
import { useInteractionField, } from './interaction.js';
|
|
6
|
+
const lastRecord = (data) => (data.length > 0 ? data[data.length - 1] : {});
|
|
7
|
+
const toNumber = (value) => typeof value === 'number' ? value : Number(value ?? Number.NaN);
|
|
8
|
+
function resolveThresholdTone(value, thresholds) {
|
|
9
|
+
if (value === undefined || !thresholds)
|
|
10
|
+
return 'default';
|
|
11
|
+
for (const rule of thresholds) {
|
|
12
|
+
const hit = (rule.op === 'gte' && value >= rule.value) ||
|
|
13
|
+
(rule.op === 'lte' && value <= rule.value) ||
|
|
14
|
+
(rule.op === 'gt' && value > rule.value) ||
|
|
15
|
+
(rule.op === 'lt' && value < rule.value) ||
|
|
16
|
+
(rule.op === 'eq' && value === rule.value);
|
|
17
|
+
if (hit)
|
|
18
|
+
return rule.severity === 'success' ? 'success' : 'critical';
|
|
19
|
+
}
|
|
20
|
+
return 'default';
|
|
21
|
+
}
|
|
22
|
+
/** Fallback width used only before the first ResizeObserver measurement. */
|
|
23
|
+
const FALLBACK_CHART_WIDTH = 560;
|
|
24
|
+
/**
|
|
25
|
+
* Sizes a chart's SVG `width` (a hard pixel dimension, not CSS) to whatever
|
|
26
|
+
* width its container is granted, via a `ResizeObserver` on a callback ref. An
|
|
27
|
+
* explicit `widget.props.width` still wins.
|
|
28
|
+
*/
|
|
29
|
+
function useChartWidth(explicitWidth) {
|
|
30
|
+
const [measured, setMeasured] = useState(FALLBACK_CHART_WIDTH);
|
|
31
|
+
const observerRef = useRef(null);
|
|
32
|
+
const ref = useCallback((el) => {
|
|
33
|
+
observerRef.current?.disconnect();
|
|
34
|
+
observerRef.current = null;
|
|
35
|
+
if (explicitWidth != null || !el)
|
|
36
|
+
return;
|
|
37
|
+
const observer = new ResizeObserver((entries) => {
|
|
38
|
+
const width = entries[0]?.contentRect.width;
|
|
39
|
+
if (width && width > 0)
|
|
40
|
+
setMeasured(Math.floor(width));
|
|
41
|
+
});
|
|
42
|
+
observer.observe(el);
|
|
43
|
+
observerRef.current = observer;
|
|
44
|
+
}, [explicitWidth]);
|
|
45
|
+
return { ref, width: explicitWidth ?? measured };
|
|
46
|
+
}
|
|
47
|
+
const CHART_MARGIN = { top: 16, right: 24, bottom: 32, left: 48 };
|
|
48
|
+
const noteStyle = {
|
|
49
|
+
color: 'var(--colors-text-muted)',
|
|
50
|
+
fontSize: '0.875rem',
|
|
51
|
+
lineHeight: 1.6,
|
|
52
|
+
margin: 0,
|
|
53
|
+
};
|
|
54
|
+
/** A free-form note paragraph, text supplied via `props.text`. */
|
|
55
|
+
const NoteWidget = ({ widget }) => (_jsx("p", { style: noteStyle, children: String(widget.props?.text ?? '') }));
|
|
56
|
+
/**
|
|
57
|
+
* Formats a numeric value per `props.format`. Non-numeric input falls through
|
|
58
|
+
* to its string form (or an em dash).
|
|
59
|
+
*/
|
|
60
|
+
function formatStatValue(value, format) {
|
|
61
|
+
const numeric = toNumber(value);
|
|
62
|
+
if (!Number.isFinite(numeric))
|
|
63
|
+
return String(value ?? '—');
|
|
64
|
+
switch (format) {
|
|
65
|
+
case 'percent':
|
|
66
|
+
return `${(numeric * 100).toFixed(1)}%`;
|
|
67
|
+
case 'ratio':
|
|
68
|
+
return numeric.toFixed(2);
|
|
69
|
+
case 'currency':
|
|
70
|
+
return `$${numeric.toLocaleString('en-US', { maximumFractionDigits: 0 })}`;
|
|
71
|
+
default:
|
|
72
|
+
return String(numeric);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** A single stat tile; value/tone resolved from the bound source + thresholds. */
|
|
76
|
+
const StatWidget = ({ widget, data }) => {
|
|
77
|
+
const fields = widget.dataBinding?.fields ?? {};
|
|
78
|
+
const record = lastRecord(data);
|
|
79
|
+
const rawValue = fields.value ? record[fields.value] : undefined;
|
|
80
|
+
const numeric = toNumber(rawValue);
|
|
81
|
+
const tone = resolveThresholdTone(Number.isFinite(numeric) ? numeric : undefined, widget.thresholds);
|
|
82
|
+
return (_jsx(StatTile, { label: widget.props?.label ?? widget.title ?? widget.id, value: formatStatValue(rawValue, widget.props?.format), sub: widget.props?.sub, tone: tone }));
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* A row of stat tiles, one per bound record. Reads its first declared
|
|
86
|
+
* interaction key (`interaction.reads[0]`) as a highlighted-row key: the tile
|
|
87
|
+
* whose label field matches gets a `success` tone, proving `reads` is wired.
|
|
88
|
+
*/
|
|
89
|
+
const StatRowWidget = ({ widget, data, interaction }) => {
|
|
90
|
+
const fields = widget.dataBinding?.fields ?? {};
|
|
91
|
+
const highlightKey = widget.interaction?.reads?.[0];
|
|
92
|
+
const highlighted = useInteractionField(interaction, highlightKey);
|
|
93
|
+
return (_jsx(StatRow, { children: data.map((record, index) => {
|
|
94
|
+
const label = fields.label ? String(record[fields.label]) : `#${index}`;
|
|
95
|
+
const value = fields.value ? record[fields.value] : undefined;
|
|
96
|
+
const isHighlighted = highlighted != null && String(highlighted) === label;
|
|
97
|
+
return (_jsx(StatTile, { label: label, value: formatStatValue(value, widget.props?.format), sub: fields.sub ? String(record[fields.sub] ?? '') : undefined, tone: isHighlighted ? 'success' : 'default' }, label));
|
|
98
|
+
}) }));
|
|
99
|
+
};
|
|
100
|
+
function renderCell(column, value) {
|
|
101
|
+
switch (column.render) {
|
|
102
|
+
case 'number':
|
|
103
|
+
return typeof value === 'number'
|
|
104
|
+
? value.toLocaleString('en-US', { maximumFractionDigits: 2 })
|
|
105
|
+
: String(value ?? '—');
|
|
106
|
+
case 'currency':
|
|
107
|
+
return typeof value === 'number'
|
|
108
|
+
? `$${value.toLocaleString('en-US', { maximumFractionDigits: 0 })}`
|
|
109
|
+
: String(value ?? '—');
|
|
110
|
+
case 'percent':
|
|
111
|
+
return typeof value === 'number'
|
|
112
|
+
? `${value.toFixed(1)}%`
|
|
113
|
+
: String(value ?? '—');
|
|
114
|
+
case 'badge': {
|
|
115
|
+
const palette = value === 'low' || value === 'success'
|
|
116
|
+
? 'green'
|
|
117
|
+
: value === 'high' || value === 'critical'
|
|
118
|
+
? 'red'
|
|
119
|
+
: 'amber';
|
|
120
|
+
return (_jsx(Badge, { colorPalette: palette, variant: "subtle", children: String(value ?? '—') }));
|
|
121
|
+
}
|
|
122
|
+
case 'sparkline':
|
|
123
|
+
return Array.isArray(value) ? (_jsx(Sparkline, { data: value, width: 96, height: 28, area: true })) : ('—');
|
|
124
|
+
default:
|
|
125
|
+
return String(value ?? '—');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* A data table with declarative columns (`props.columns`). Writes its first
|
|
130
|
+
* declared interaction key (`interaction.writes[0]`) on row click — the
|
|
131
|
+
* clicked row's key value — proving `writes` is wired; the same key read by a
|
|
132
|
+
* sibling widget drives cross-highlighting.
|
|
133
|
+
*/
|
|
134
|
+
const TableWidget = ({ widget, data, interaction }) => {
|
|
135
|
+
const specColumns = widget.props?.columns ?? [];
|
|
136
|
+
const rowKeyField = widget.dataBinding?.fields?.rowKey ?? specColumns[0]?.accessorKey;
|
|
137
|
+
const writeKey = widget.interaction?.writes?.[0];
|
|
138
|
+
const highlightKey = widget.interaction?.reads?.[0] ?? writeKey;
|
|
139
|
+
const selected = useInteractionField(interaction, highlightKey);
|
|
140
|
+
const columns = specColumns.map((column) => ({
|
|
141
|
+
accessorKey: column.accessorKey,
|
|
142
|
+
header: column.header,
|
|
143
|
+
cell: (info) => renderCell(column, info.getValue()),
|
|
144
|
+
}));
|
|
145
|
+
const table = useDataTable(data, columns);
|
|
146
|
+
const getRowKey = useCallback((row) => rowKeyField ? String(row[rowKeyField]) : '', [rowKeyField]);
|
|
147
|
+
return (_jsx(DataTable, { table: table, isLoading: false, getRowKey: getRowKey, selectedRowKey: selected != null ? String(selected) : undefined, onRowClick: writeKey && rowKeyField
|
|
148
|
+
? (row) => interaction.write(writeKey, row[rowKeyField])
|
|
149
|
+
: undefined }));
|
|
150
|
+
};
|
|
151
|
+
const chartWrapStyle = {
|
|
152
|
+
width: '100%',
|
|
153
|
+
minWidth: 0,
|
|
154
|
+
overflowX: 'auto',
|
|
155
|
+
};
|
|
156
|
+
function readSeries(widget) {
|
|
157
|
+
const raw = widget.props?.series;
|
|
158
|
+
if (Array.isArray(raw))
|
|
159
|
+
return raw;
|
|
160
|
+
// Fall back to a single series bound through `dataBinding.fields.value`.
|
|
161
|
+
const field = widget.dataBinding?.fields?.value;
|
|
162
|
+
return field ? [{ key: widget.title ?? widget.id, field }] : [];
|
|
163
|
+
}
|
|
164
|
+
function makeChartWidget(kind) {
|
|
165
|
+
return function ChartWidget({ widget, data }) {
|
|
166
|
+
const explicitWidth = widget.props?.width;
|
|
167
|
+
const height = widget.props?.height ?? 260;
|
|
168
|
+
const { ref, width } = useChartWidth(explicitWidth);
|
|
169
|
+
const xField = widget.props?.xField ?? 'x';
|
|
170
|
+
const series = readSeries(widget);
|
|
171
|
+
const label = widget.props?.ariaLabel ??
|
|
172
|
+
widget.title ??
|
|
173
|
+
`${kind} chart`;
|
|
174
|
+
if (data.length === 0 || series.length === 0) {
|
|
175
|
+
return _jsx("div", { ref: ref, style: chartWrapStyle });
|
|
176
|
+
}
|
|
177
|
+
const xAccessor = (d) => d[xField];
|
|
178
|
+
return (_jsx("div", { ref: ref, style: chartWrapStyle, role: "img", "aria-label": label, children: _jsxs(XYChart, { theme: chartTheme, width: width, height: height, margin: CHART_MARGIN, xScale: { type: 'band', paddingInner: 0.3 }, yScale: { type: 'linear', nice: true }, children: [_jsx(Grid, { columns: false, numTicks: 4 }), _jsx(Axis, { orientation: "bottom", numTicks: 4 }), _jsx(Axis, { orientation: "left", numTicks: 4 }), series.map((s) => kind === 'line' ? (_jsx(LineSeries, { dataKey: s.key, data: data, xAccessor: xAccessor, yAccessor: (d) => toNumber(d[s.field]) }, s.key)) : (_jsx(AreaSeries, { dataKey: s.key, data: data, xAccessor: xAccessor, yAccessor: (d) => toNumber(d[s.field]), fillOpacity: 0.3 }, s.key))), _jsx(Tooltip, { snapTooltipToDatumX: true, snapTooltipToDatumY: true, showSeriesGlyphs: true, renderTooltip: ({ tooltipData }) => {
|
|
179
|
+
const datum = tooltipData?.nearestDatum?.datum;
|
|
180
|
+
return datum ? String(datum[xField]) : null;
|
|
181
|
+
} })] }) }));
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The default registry: a small set of GENERIC adapters over public
|
|
186
|
+
* design-system + charting components. Nothing domain-specific — a consumer
|
|
187
|
+
* merges in their own component keys with {@link mergeRegistries}.
|
|
188
|
+
*/
|
|
189
|
+
export const DEFAULT_REGISTRY = {
|
|
190
|
+
note: NoteWidget,
|
|
191
|
+
stat: StatWidget,
|
|
192
|
+
statRow: StatRowWidget,
|
|
193
|
+
table: TableWidget,
|
|
194
|
+
lineChart: makeChartWidget('line'),
|
|
195
|
+
areaChart: makeChartWidget('area'),
|
|
196
|
+
};
|
|
197
|
+
/** Merges consumer adapters over the defaults (consumer keys win on collision). */
|
|
198
|
+
export function mergeRegistries(...registries) {
|
|
199
|
+
return Object.assign({}, DEFAULT_REGISTRY, ...registries);
|
|
200
|
+
}
|
|
201
|
+
const unknownStyle = {
|
|
202
|
+
display: 'inline-flex',
|
|
203
|
+
alignItems: 'center',
|
|
204
|
+
gap: '0.375rem',
|
|
205
|
+
padding: '0.25rem 0.5rem',
|
|
206
|
+
borderRadius: '0.375rem',
|
|
207
|
+
borderWidth: '1px',
|
|
208
|
+
borderStyle: 'dashed',
|
|
209
|
+
borderColor: 'var(--colors-border-critical, currentColor)',
|
|
210
|
+
color: 'var(--colors-text-critical, currentColor)',
|
|
211
|
+
fontFamily: 'var(--fonts-mono, monospace)',
|
|
212
|
+
fontSize: '0.75rem',
|
|
213
|
+
};
|
|
214
|
+
/**
|
|
215
|
+
* Inline marker rendered in place of a widget whose ref or component key does
|
|
216
|
+
* not resolve — one bad manifest entry never blanks the whole dashboard.
|
|
217
|
+
*/
|
|
218
|
+
export function UnknownWidget({ label }) {
|
|
219
|
+
return (_jsxs("span", { style: unknownStyle, role: "status", children: ["unknown widget: ", label] }));
|
|
220
|
+
}
|