@optilogic/charts 1.3.10 → 1.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/dist/index.cjs +353 -29
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +49 -1
- package/dist/index.d.ts +49 -1
- package/dist/index.js +355 -31
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/cartesian/area-chart.tsx +11 -0
- package/src/cartesian/bar-chart.tsx +109 -12
- package/src/cartesian/composed-chart.tsx +11 -0
- package/src/cartesian/line-chart.tsx +12 -2
- package/src/cartesian/scatter-chart.tsx +22 -0
- package/src/cartesian/waterfall-chart.tsx +11 -0
- package/src/dashboard/chart-builder.tsx +47 -5
- package/src/dashboard/chart-renderer.tsx +1 -1
- package/src/index.ts +5 -1
- package/src/radial/donut-chart.tsx +6 -0
- package/src/radial/pie-chart.tsx +6 -0
- package/src/radial/radar-chart.tsx +11 -0
- package/src/radial/radial-bar-chart.tsx +6 -0
- package/src/shared/chart-container.tsx +133 -6
- package/src/shared/formatters.ts +20 -0
- package/src/specialized/funnel-chart.tsx +6 -0
- package/src/specialized/gantt-chart.tsx +25 -0
- package/src/specialized/heatmap-chart.tsx +40 -5
- package/src/specialized/sankey-chart.tsx +17 -0
- package/src/specialized/treemap-chart.tsx +31 -1
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { ResponsiveContainer } from "recharts";
|
|
3
|
-
import { cn } from "@optilogic/core";
|
|
3
|
+
import { cn, VisuallyHidden } from "@optilogic/core";
|
|
4
|
+
|
|
5
|
+
/** Column descriptor for the screen-reader data-table fallback. */
|
|
6
|
+
export interface ChartTableColumn {
|
|
7
|
+
key: string;
|
|
8
|
+
label: string;
|
|
9
|
+
}
|
|
4
10
|
|
|
5
11
|
export interface ChartContainerProps {
|
|
6
12
|
children: React.ReactNode;
|
|
@@ -11,6 +17,42 @@ export interface ChartContainerProps {
|
|
|
11
17
|
emptyMessage?: string;
|
|
12
18
|
title?: string;
|
|
13
19
|
description?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Accessible name for the visual chart region (SC 1.1.1). Falls back to
|
|
22
|
+
* `title` when omitted.
|
|
23
|
+
*/
|
|
24
|
+
ariaLabel?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Accessible description for the chart. Falls back to `description` when
|
|
27
|
+
* omitted. Exposed to assistive tech via `aria-describedby`.
|
|
28
|
+
*/
|
|
29
|
+
ariaDescription?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Row data rendered as a visually-hidden `<table>` so screen-reader users can
|
|
32
|
+
* access the underlying numbers (SC 1.1.1, 1.3.1). Requires `tableColumns`.
|
|
33
|
+
* Accepts any object row; cells are read by `tableColumns[].key`.
|
|
34
|
+
*/
|
|
35
|
+
tableData?: ReadonlyArray<object>;
|
|
36
|
+
/** Column definitions for the data-table fallback. Requires `tableData`. */
|
|
37
|
+
tableColumns?: Array<ChartTableColumn>;
|
|
38
|
+
/**
|
|
39
|
+
* Makes each data point actionable (a "visualization link"). When provided, a
|
|
40
|
+
* keyboard- and screen-reader-reachable list of buttons is rendered — one per
|
|
41
|
+
* `tableData` row — that is visually hidden until focused (skip-link pattern),
|
|
42
|
+
* so pointer users click the chart while keyboard users tab to the same
|
|
43
|
+
* actions without an invisible-focus trap (WCAG SC 2.1.1, 2.4.7).
|
|
44
|
+
*/
|
|
45
|
+
onRowActivate?: (row: object, index: number) => void;
|
|
46
|
+
/** Accessible label for a row's action. Defaults to the first column's value. */
|
|
47
|
+
rowActionLabel?: (row: object, index: number) => string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatCell(value: unknown): string {
|
|
51
|
+
if (value == null) return "";
|
|
52
|
+
if (typeof value === "number") return value.toLocaleString();
|
|
53
|
+
if (value instanceof Date) return value.toLocaleString();
|
|
54
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
55
|
+
return String(value);
|
|
14
56
|
}
|
|
15
57
|
|
|
16
58
|
export const ChartContainer = React.forwardRef<
|
|
@@ -26,9 +68,27 @@ export const ChartContainer = React.forwardRef<
|
|
|
26
68
|
emptyMessage = "No data available",
|
|
27
69
|
title,
|
|
28
70
|
description,
|
|
71
|
+
ariaLabel,
|
|
72
|
+
ariaDescription,
|
|
73
|
+
tableData,
|
|
74
|
+
tableColumns,
|
|
75
|
+
onRowActivate,
|
|
76
|
+
rowActionLabel,
|
|
29
77
|
},
|
|
30
78
|
ref,
|
|
31
79
|
) {
|
|
80
|
+
const descriptionId = React.useId();
|
|
81
|
+
const accessibleName = ariaLabel ?? title;
|
|
82
|
+
const accessibleDescription = ariaDescription ?? description;
|
|
83
|
+
|
|
84
|
+
const hasTable =
|
|
85
|
+
!loading &&
|
|
86
|
+
!empty &&
|
|
87
|
+
!!tableData &&
|
|
88
|
+
tableData.length > 0 &&
|
|
89
|
+
!!tableColumns &&
|
|
90
|
+
tableColumns.length > 0;
|
|
91
|
+
|
|
32
92
|
return (
|
|
33
93
|
<div ref={ref} className={cn("w-full", className)} style={{ height }}>
|
|
34
94
|
{(title || description) && (
|
|
@@ -92,12 +152,79 @@ export const ChartContainer = React.forwardRef<
|
|
|
92
152
|
{emptyMessage}
|
|
93
153
|
</div>
|
|
94
154
|
) : (
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
155
|
+
<div
|
|
156
|
+
role="img"
|
|
157
|
+
aria-label={accessibleName}
|
|
158
|
+
aria-describedby={
|
|
159
|
+
accessibleDescription ? descriptionId : undefined
|
|
160
|
+
}
|
|
161
|
+
className="relative"
|
|
162
|
+
style={{
|
|
163
|
+
width: "100%",
|
|
164
|
+
height: title || description ? "calc(100% - 40px)" : "100%",
|
|
165
|
+
}}
|
|
98
166
|
>
|
|
99
|
-
{
|
|
100
|
-
|
|
167
|
+
{accessibleDescription && (
|
|
168
|
+
<VisuallyHidden as="span" id={descriptionId}>
|
|
169
|
+
{accessibleDescription}
|
|
170
|
+
</VisuallyHidden>
|
|
171
|
+
)}
|
|
172
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
173
|
+
{children as React.ReactElement}
|
|
174
|
+
</ResponsiveContainer>
|
|
175
|
+
{hasTable && (
|
|
176
|
+
<VisuallyHidden as="div">
|
|
177
|
+
<table>
|
|
178
|
+
{accessibleName && <caption>{accessibleName}</caption>}
|
|
179
|
+
<thead>
|
|
180
|
+
<tr>
|
|
181
|
+
{tableColumns.map((col) => (
|
|
182
|
+
<th key={col.key} scope="col">
|
|
183
|
+
{col.label}
|
|
184
|
+
</th>
|
|
185
|
+
))}
|
|
186
|
+
</tr>
|
|
187
|
+
</thead>
|
|
188
|
+
<tbody>
|
|
189
|
+
{tableData.map((row, rowIndex) => {
|
|
190
|
+
const record = row as Record<string, unknown>;
|
|
191
|
+
return (
|
|
192
|
+
<tr key={rowIndex}>
|
|
193
|
+
{tableColumns.map((col) => (
|
|
194
|
+
<td key={col.key}>{formatCell(record[col.key])}</td>
|
|
195
|
+
))}
|
|
196
|
+
</tr>
|
|
197
|
+
);
|
|
198
|
+
})}
|
|
199
|
+
</tbody>
|
|
200
|
+
</table>
|
|
201
|
+
</VisuallyHidden>
|
|
202
|
+
)}
|
|
203
|
+
{hasTable && onRowActivate && (
|
|
204
|
+
<nav
|
|
205
|
+
aria-label={`${accessibleName ?? "Chart"} data point links`}
|
|
206
|
+
className="sr-only focus-within:not-sr-only focus-within:absolute focus-within:z-20 focus-within:top-1 focus-within:left-1 focus-within:flex focus-within:max-h-[calc(100%-0.5rem)] focus-within:flex-col focus-within:gap-1 focus-within:overflow-auto focus-within:rounded-md focus-within:border focus-within:border-border focus-within:bg-popover focus-within:p-2 focus-within:shadow-md"
|
|
207
|
+
>
|
|
208
|
+
{tableData.map((row, rowIndex) => {
|
|
209
|
+
const record = row as Record<string, unknown>;
|
|
210
|
+
const firstKey = tableColumns[0]?.key;
|
|
211
|
+
const label =
|
|
212
|
+
rowActionLabel?.(row, rowIndex) ??
|
|
213
|
+
(firstKey ? formatCell(record[firstKey]) : `Row ${rowIndex + 1}`);
|
|
214
|
+
return (
|
|
215
|
+
<button
|
|
216
|
+
key={rowIndex}
|
|
217
|
+
type="button"
|
|
218
|
+
onClick={() => onRowActivate(row, rowIndex)}
|
|
219
|
+
className="rounded px-1 py-0.5 text-left text-sm text-foreground underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
220
|
+
>
|
|
221
|
+
{label}
|
|
222
|
+
</button>
|
|
223
|
+
);
|
|
224
|
+
})}
|
|
225
|
+
</nav>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
101
228
|
)}
|
|
102
229
|
</div>
|
|
103
230
|
);
|
package/src/shared/formatters.ts
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
import type { CellValue } from "@optilogic/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Narrow an untyped datum field (chart data is `Record<string, unknown>`) to a
|
|
5
|
+
* {@link CellValue} at the boundary, without casting. Non-primitive values that
|
|
6
|
+
* aren't `Date` become `null`.
|
|
7
|
+
*/
|
|
8
|
+
export function toCellValue(value: unknown): CellValue {
|
|
9
|
+
if (value == null) return null;
|
|
10
|
+
if (
|
|
11
|
+
typeof value === "number" ||
|
|
12
|
+
typeof value === "string" ||
|
|
13
|
+
typeof value === "boolean"
|
|
14
|
+
) {
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
if (value instanceof Date) return value;
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
1
21
|
const compactThresholds: [number, string][] = [
|
|
2
22
|
[1_000_000_000, "B"],
|
|
3
23
|
[1_000_000, "M"],
|
|
@@ -49,6 +49,12 @@ const FunnelChart = React.forwardRef<HTMLDivElement, FunnelChartProps>(
|
|
|
49
49
|
height={height}
|
|
50
50
|
loading={loading}
|
|
51
51
|
empty={!data?.length}
|
|
52
|
+
ariaLabel="Funnel chart"
|
|
53
|
+
tableData={data}
|
|
54
|
+
tableColumns={[
|
|
55
|
+
{ key: "name", label: "Stage" },
|
|
56
|
+
{ key: "value", label: "Value" },
|
|
57
|
+
]}
|
|
52
58
|
>
|
|
53
59
|
<RechartsFunnelChart margin={margin}>
|
|
54
60
|
<Funnel
|
|
@@ -265,9 +265,18 @@ const GanttChart = React.forwardRef<HTMLDivElement, GanttChartProps>(
|
|
|
265
265
|
const isHovered = hoveredId === task.id;
|
|
266
266
|
const progress = task.progress ?? 0;
|
|
267
267
|
|
|
268
|
+
const taskLabel = `${task.name}, ${task.start.toLocaleDateString()} to ${task.end.toLocaleDateString()}${
|
|
269
|
+
task.progress != null
|
|
270
|
+
? `, ${Math.round((task.progress ?? 0) * 100)}% complete`
|
|
271
|
+
: ""
|
|
272
|
+
}`;
|
|
273
|
+
|
|
268
274
|
return (
|
|
269
275
|
<g
|
|
270
276
|
key={task.id}
|
|
277
|
+
tabIndex={0}
|
|
278
|
+
role={onTaskClick ? "button" : "img"}
|
|
279
|
+
aria-label={taskLabel}
|
|
271
280
|
onMouseEnter={(e) => {
|
|
272
281
|
setHoveredId(task.id);
|
|
273
282
|
const rect = containerRef.current?.getBoundingClientRect();
|
|
@@ -281,7 +290,23 @@ const GanttChart = React.forwardRef<HTMLDivElement, GanttChartProps>(
|
|
|
281
290
|
setHoveredId(null);
|
|
282
291
|
setTooltipInfo(null);
|
|
283
292
|
}}
|
|
293
|
+
// Keyboard/focus parity with pointer interaction (SC 2.1.1).
|
|
294
|
+
onFocus={() => {
|
|
295
|
+
setHoveredId(task.id);
|
|
296
|
+
setTooltipInfo({ task, x: barX, y: barY });
|
|
297
|
+
}}
|
|
298
|
+
onBlur={() => {
|
|
299
|
+
setHoveredId(null);
|
|
300
|
+
setTooltipInfo(null);
|
|
301
|
+
}}
|
|
302
|
+
onKeyDown={(e) => {
|
|
303
|
+
if (onTaskClick && (e.key === "Enter" || e.key === " ")) {
|
|
304
|
+
e.preventDefault();
|
|
305
|
+
onTaskClick(task);
|
|
306
|
+
}
|
|
307
|
+
}}
|
|
284
308
|
onClick={() => onTaskClick?.(task)}
|
|
309
|
+
className="focus:outline-none focus-visible:[outline:2px_solid_hsl(var(--ring))]"
|
|
285
310
|
style={{ cursor: onTaskClick ? "pointer" : "default" }}
|
|
286
311
|
>
|
|
287
312
|
{/* Row stripe */}
|
|
@@ -64,6 +64,7 @@ const HeatmapChart = React.forwardRef<HTMLDivElement, HeatmapChartProps>(
|
|
|
64
64
|
) {
|
|
65
65
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
66
66
|
const [hoveredCell, setHoveredCell] = React.useState<HeatmapCell | null>(null);
|
|
67
|
+
const [focusedKey, setFocusedKey] = React.useState<string | null>(null);
|
|
67
68
|
const [tooltipPos, setTooltipPos] = React.useState({ x: 0, y: 0 });
|
|
68
69
|
|
|
69
70
|
const { valueMap, minVal, maxVal } = React.useMemo(() => {
|
|
@@ -150,10 +151,21 @@ const HeatmapChart = React.forwardRef<HTMLDivElement, HeatmapChartProps>(
|
|
|
150
151
|
const cx = labelColWidth + xi * cellSize;
|
|
151
152
|
const cy = 28 + yi * cellSize;
|
|
152
153
|
const cellData: HeatmapCell = { x: xLabel, y: yLabel, value: val ?? 0 };
|
|
154
|
+
const isInteractive = !!onCellClick;
|
|
155
|
+
const valueText =
|
|
156
|
+
val != null
|
|
157
|
+
? valueFormatter
|
|
158
|
+
? valueFormatter(val)
|
|
159
|
+
: val.toLocaleString()
|
|
160
|
+
: "no value";
|
|
161
|
+
const cellLabel = `${yLabel}, ${xLabel}: ${valueText}`;
|
|
153
162
|
|
|
154
163
|
return (
|
|
155
164
|
<g
|
|
156
165
|
key={key}
|
|
166
|
+
role={isInteractive ? "button" : "img"}
|
|
167
|
+
tabIndex={0}
|
|
168
|
+
aria-label={cellLabel}
|
|
157
169
|
onMouseEnter={(e) => {
|
|
158
170
|
setHoveredCell(cellData);
|
|
159
171
|
const rect = containerRef.current?.getBoundingClientRect();
|
|
@@ -163,8 +175,29 @@ const HeatmapChart = React.forwardRef<HTMLDivElement, HeatmapChartProps>(
|
|
|
163
175
|
});
|
|
164
176
|
}}
|
|
165
177
|
onMouseLeave={() => setHoveredCell(null)}
|
|
178
|
+
onFocus={() => {
|
|
179
|
+
setHoveredCell(cellData);
|
|
180
|
+
setFocusedKey(key);
|
|
181
|
+
setTooltipPos({
|
|
182
|
+
x: cx + cellSize / 2,
|
|
183
|
+
y: cy,
|
|
184
|
+
});
|
|
185
|
+
}}
|
|
186
|
+
onBlur={() => {
|
|
187
|
+
setHoveredCell(null);
|
|
188
|
+
setFocusedKey(null);
|
|
189
|
+
}}
|
|
166
190
|
onClick={() => onCellClick?.(cellData)}
|
|
167
|
-
|
|
191
|
+
onKeyDown={(e) => {
|
|
192
|
+
if (
|
|
193
|
+
isInteractive &&
|
|
194
|
+
(e.key === "Enter" || e.key === " ")
|
|
195
|
+
) {
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
onCellClick?.(cellData);
|
|
198
|
+
}
|
|
199
|
+
}}
|
|
200
|
+
style={{ cursor: onCellClick ? "pointer" : "default", outline: "none" }}
|
|
168
201
|
>
|
|
169
202
|
<rect
|
|
170
203
|
x={cx + 1}
|
|
@@ -179,11 +212,13 @@ const HeatmapChart = React.forwardRef<HTMLDivElement, HeatmapChartProps>(
|
|
|
179
212
|
}
|
|
180
213
|
fillOpacity={val != null ? 0.85 : 0.3}
|
|
181
214
|
stroke={
|
|
182
|
-
|
|
183
|
-
? "hsl(var(--
|
|
184
|
-
:
|
|
215
|
+
focusedKey === key
|
|
216
|
+
? "hsl(var(--ring))"
|
|
217
|
+
: hoveredCell?.x === xLabel && hoveredCell?.y === yLabel
|
|
218
|
+
? "hsl(var(--foreground))"
|
|
219
|
+
: "none"
|
|
185
220
|
}
|
|
186
|
-
strokeWidth={1.5}
|
|
221
|
+
strokeWidth={focusedKey === key ? 2.5 : 1.5}
|
|
187
222
|
/>
|
|
188
223
|
{showValues && val != null && cellSize >= 32 && (
|
|
189
224
|
<text
|
|
@@ -128,6 +128,16 @@ const SankeyChart = React.forwardRef<HTMLDivElement, SankeyChartProps>(
|
|
|
128
128
|
) => {
|
|
129
129
|
const tooltipProps = resolveTooltipProps(tooltip);
|
|
130
130
|
|
|
131
|
+
const tableData = React.useMemo(
|
|
132
|
+
() =>
|
|
133
|
+
(data?.links ?? []).map((link) => ({
|
|
134
|
+
source: data.nodes[link.source]?.name ?? String(link.source),
|
|
135
|
+
target: data.nodes[link.target]?.name ?? String(link.target),
|
|
136
|
+
value: link.value,
|
|
137
|
+
})),
|
|
138
|
+
[data],
|
|
139
|
+
);
|
|
140
|
+
|
|
131
141
|
return (
|
|
132
142
|
<ChartContainer
|
|
133
143
|
ref={ref}
|
|
@@ -135,6 +145,13 @@ const SankeyChart = React.forwardRef<HTMLDivElement, SankeyChartProps>(
|
|
|
135
145
|
height={height}
|
|
136
146
|
loading={loading}
|
|
137
147
|
empty={!data?.nodes?.length}
|
|
148
|
+
ariaLabel="Sankey diagram"
|
|
149
|
+
tableData={tableData}
|
|
150
|
+
tableColumns={[
|
|
151
|
+
{ key: "source", label: "Source" },
|
|
152
|
+
{ key: "target", label: "Target" },
|
|
153
|
+
{ key: "value", label: "Value" },
|
|
154
|
+
]}
|
|
138
155
|
>
|
|
139
156
|
<Sankey
|
|
140
157
|
data={data}
|
|
@@ -20,6 +20,27 @@ export interface TreemapChartProps extends BaseChartProps {
|
|
|
20
20
|
loading?: boolean;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Flatten a treemap tree into leaf rows for the screen-reader data table.
|
|
25
|
+
* Uses each leaf's own name/value so the numbers are exposed to assistive tech.
|
|
26
|
+
*/
|
|
27
|
+
function flattenTreemap(
|
|
28
|
+
nodes: TreemapDatum[],
|
|
29
|
+
): Array<Record<string, unknown>> {
|
|
30
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
31
|
+
const walk = (items: TreemapDatum[]) => {
|
|
32
|
+
for (const item of items) {
|
|
33
|
+
if (item.children && item.children.length > 0) {
|
|
34
|
+
walk(item.children);
|
|
35
|
+
} else {
|
|
36
|
+
rows.push({ name: item.name, value: item.value ?? null });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
walk(nodes);
|
|
41
|
+
return rows;
|
|
42
|
+
}
|
|
43
|
+
|
|
23
44
|
interface TreemapContentProps {
|
|
24
45
|
x: number;
|
|
25
46
|
y: number;
|
|
@@ -46,6 +67,8 @@ function TreemapContent({
|
|
|
46
67
|
|
|
47
68
|
return (
|
|
48
69
|
<g>
|
|
70
|
+
{/* Full, untruncated label exposed to assistive tech (SC 1.1.1). */}
|
|
71
|
+
{name && <title>{name}</title>}
|
|
49
72
|
<rect
|
|
50
73
|
x={x}
|
|
51
74
|
y={y}
|
|
@@ -86,7 +109,6 @@ const TreemapChart = React.forwardRef<HTMLDivElement, TreemapChartProps>(
|
|
|
86
109
|
live = false,
|
|
87
110
|
className,
|
|
88
111
|
height = "100%",
|
|
89
|
-
margin,
|
|
90
112
|
loading,
|
|
91
113
|
},
|
|
92
114
|
ref,
|
|
@@ -94,6 +116,8 @@ const TreemapChart = React.forwardRef<HTMLDivElement, TreemapChartProps>(
|
|
|
94
116
|
const tooltipProps = resolveTooltipProps(tooltip);
|
|
95
117
|
const isAnimated = live ? false : animate;
|
|
96
118
|
|
|
119
|
+
const tableData = React.useMemo(() => flattenTreemap(data), [data]);
|
|
120
|
+
|
|
97
121
|
return (
|
|
98
122
|
<ChartContainer
|
|
99
123
|
ref={ref}
|
|
@@ -101,6 +125,12 @@ const TreemapChart = React.forwardRef<HTMLDivElement, TreemapChartProps>(
|
|
|
101
125
|
height={height}
|
|
102
126
|
loading={loading}
|
|
103
127
|
empty={!data?.length}
|
|
128
|
+
ariaLabel="Treemap chart"
|
|
129
|
+
tableData={tableData}
|
|
130
|
+
tableColumns={[
|
|
131
|
+
{ key: "name", label: "Name" },
|
|
132
|
+
{ key: "value", label: "Value" },
|
|
133
|
+
]}
|
|
104
134
|
>
|
|
105
135
|
<Treemap
|
|
106
136
|
data={data}
|