@optilogic/charts 1.0.0-beta.9 → 1.1.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.
Files changed (37) hide show
  1. package/dist/index.cjs +3034 -174
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +489 -192
  4. package/dist/index.d.ts +489 -192
  5. package/dist/index.js +3006 -175
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/cartesian/area-chart.tsx +177 -0
  9. package/src/cartesian/bar-chart.tsx +217 -0
  10. package/src/cartesian/composed-chart.tsx +222 -0
  11. package/src/cartesian/line-chart.tsx +159 -0
  12. package/src/cartesian/scatter-chart.tsx +158 -0
  13. package/src/cartesian/waterfall-chart.tsx +171 -0
  14. package/src/dashboard/chart-builder.tsx +310 -0
  15. package/src/dashboard/chart-renderer.tsx +250 -0
  16. package/src/dashboard/kpi-card.tsx +121 -0
  17. package/src/dashboard/scenario-comparison.tsx +235 -0
  18. package/src/dashboard/sparkline.tsx +86 -0
  19. package/src/index.ts +50 -19
  20. package/src/radial/donut-chart.tsx +135 -0
  21. package/src/radial/pie-chart.tsx +153 -0
  22. package/src/radial/radar-chart.tsx +111 -0
  23. package/src/radial/radial-bar-chart.tsx +115 -0
  24. package/src/shared/chart-container.tsx +104 -0
  25. package/src/shared/chart-legend.tsx +57 -0
  26. package/src/shared/chart-tooltip.tsx +159 -0
  27. package/src/shared/colors.ts +37 -0
  28. package/src/shared/formatters.ts +51 -0
  29. package/src/shared/types.ts +66 -0
  30. package/src/shared/use-live-data.ts +83 -0
  31. package/src/specialized/funnel-chart.tsx +93 -0
  32. package/src/specialized/gantt-chart.tsx +416 -0
  33. package/src/specialized/heatmap-chart.tsx +250 -0
  34. package/src/specialized/sankey-chart.tsx +155 -0
  35. package/src/specialized/treemap-chart.tsx +121 -0
  36. package/src/bar-chart.tsx +0 -337
  37. package/src/line-chart.tsx +0 -266
@@ -0,0 +1,83 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ export interface UseLiveDataConfig<T> {
4
+ /** Initial data snapshot */
5
+ initial?: T[];
6
+ /** Maximum number of data points to retain (ring buffer) */
7
+ maxPoints?: number;
8
+ /** Polling interval in ms (only used when mode is "poll") */
9
+ interval?: number;
10
+ /** Data fetch function for poll mode */
11
+ fetcher?: () => Promise<T[]>;
12
+ /** Start in paused state */
13
+ paused?: boolean;
14
+ }
15
+
16
+ export interface UseLiveDataReturn<T> {
17
+ data: T[];
18
+ /** Push new data points */
19
+ push: (...items: T[]) => void;
20
+ /** Replace all data at once */
21
+ replace: (items: T[]) => void;
22
+ isPaused: boolean;
23
+ pause: () => void;
24
+ resume: () => void;
25
+ }
26
+
27
+ export function useLiveData<T>(
28
+ config: UseLiveDataConfig<T> = {},
29
+ ): UseLiveDataReturn<T> {
30
+ const {
31
+ initial = [],
32
+ maxPoints = 200,
33
+ interval = 2000,
34
+ fetcher,
35
+ paused: initialPaused = false,
36
+ } = config;
37
+
38
+ const [data, setData] = useState<T[]>(initial);
39
+ const [isPaused, setIsPaused] = useState(initialPaused);
40
+ const bufferRef = useRef<T[]>(initial);
41
+
42
+ const push = useCallback(
43
+ (...items: T[]) => {
44
+ bufferRef.current = [...bufferRef.current, ...items].slice(-maxPoints);
45
+ setData(bufferRef.current);
46
+ },
47
+ [maxPoints],
48
+ );
49
+
50
+ const replace = useCallback((items: T[]) => {
51
+ bufferRef.current = items;
52
+ setData(items);
53
+ }, []);
54
+
55
+ const pause = useCallback(() => setIsPaused(true), []);
56
+ const resume = useCallback(() => setIsPaused(false), []);
57
+
58
+ useEffect(() => {
59
+ if (!fetcher || isPaused) return;
60
+
61
+ let cancelled = false;
62
+ const tick = async () => {
63
+ try {
64
+ const result = await fetcher();
65
+ if (!cancelled) {
66
+ bufferRef.current = result.slice(-maxPoints);
67
+ setData(bufferRef.current);
68
+ }
69
+ } catch {
70
+ // Silently skip failed polls
71
+ }
72
+ };
73
+
74
+ tick();
75
+ const id = setInterval(tick, interval);
76
+ return () => {
77
+ cancelled = true;
78
+ clearInterval(id);
79
+ };
80
+ }, [fetcher, interval, isPaused, maxPoints]);
81
+
82
+ return { data, push, replace, isPaused, pause, resume };
83
+ }
@@ -0,0 +1,93 @@
1
+ import * as React from "react";
2
+ import {
3
+ FunnelChart as RechartsFunnelChart,
4
+ Funnel,
5
+ Cell,
6
+ Tooltip,
7
+ LabelList,
8
+ } from "recharts";
9
+ import { getChartColor } from "../shared/colors";
10
+ import { resolveTooltipProps } from "../shared/chart-tooltip";
11
+ import { ChartContainer } from "../shared/chart-container";
12
+ import type { BaseChartProps, ChartTooltipProp } from "../shared/types";
13
+
14
+ export interface FunnelDatum {
15
+ name: string;
16
+ value: number;
17
+ color?: string;
18
+ }
19
+
20
+ export interface FunnelChartProps extends BaseChartProps {
21
+ data: FunnelDatum[];
22
+ tooltip?: ChartTooltipProp;
23
+ showLabels?: boolean;
24
+ loading?: boolean;
25
+ }
26
+
27
+ const FunnelChart = React.forwardRef<HTMLDivElement, FunnelChartProps>(
28
+ (
29
+ {
30
+ data,
31
+ tooltip = true,
32
+ showLabels = true,
33
+ animate = true,
34
+ live = false,
35
+ className,
36
+ height = "100%",
37
+ margin = { top: 8, right: 8, left: 8, bottom: 8 },
38
+ loading,
39
+ },
40
+ ref,
41
+ ) => {
42
+ const tooltipProps = resolveTooltipProps(tooltip);
43
+ const isAnimated = live ? false : animate;
44
+
45
+ return (
46
+ <ChartContainer
47
+ ref={ref}
48
+ className={className}
49
+ height={height}
50
+ loading={loading}
51
+ empty={!data?.length}
52
+ >
53
+ <RechartsFunnelChart margin={margin}>
54
+ <Funnel
55
+ dataKey="value"
56
+ data={data}
57
+ isAnimationActive={isAnimated}
58
+ animationDuration={isAnimated ? 400 : 0}
59
+ >
60
+ {data.map((entry, index) => (
61
+ <Cell
62
+ key={entry.name}
63
+ fill={getChartColor(index, entry.color)}
64
+ stroke="hsl(var(--card))"
65
+ strokeWidth={2}
66
+ />
67
+ ))}
68
+ {showLabels && (
69
+ <LabelList
70
+ dataKey="name"
71
+ position="center"
72
+ fill="#ffffff"
73
+ fontSize={13}
74
+ fontWeight={600}
75
+ style={{
76
+ textShadow: "0 1px 3px rgba(0,0,0,0.4)",
77
+ paintOrder: "stroke",
78
+ stroke: "rgba(0,0,0,0.2)",
79
+ strokeWidth: 2,
80
+ strokeLinejoin: "round",
81
+ }}
82
+ />
83
+ )}
84
+ </Funnel>
85
+ {tooltipProps && <Tooltip {...tooltipProps} />}
86
+ </RechartsFunnelChart>
87
+ </ChartContainer>
88
+ );
89
+ },
90
+ );
91
+
92
+ FunnelChart.displayName = "FunnelChart";
93
+ export { FunnelChart };
@@ -0,0 +1,416 @@
1
+ import * as React from "react";
2
+ import { cn } from "@optilogic/core";
3
+ import { getChartColor } from "../shared/colors";
4
+
5
+ export interface GanttTask {
6
+ id: string;
7
+ name: string;
8
+ start: Date;
9
+ end: Date;
10
+ /** 0–100 completion percentage */
11
+ progress?: number;
12
+ group?: string;
13
+ color?: string;
14
+ /** Task IDs this task depends on (renders dependency arrows) */
15
+ dependencies?: string[];
16
+ }
17
+
18
+ export interface GanttMilestone {
19
+ id: string;
20
+ name: string;
21
+ date: Date;
22
+ color?: string;
23
+ }
24
+
25
+ export type GanttTimeScale = "day" | "week" | "month";
26
+
27
+ export interface GanttChartProps {
28
+ tasks: GanttTask[];
29
+ milestones?: GanttMilestone[];
30
+ timeScale?: GanttTimeScale;
31
+ className?: string;
32
+ height?: number | string;
33
+ rowHeight?: number;
34
+ labelWidth?: number;
35
+ onTaskClick?: (task: GanttTask) => void;
36
+ }
37
+
38
+ const ROW_HEIGHT_DEFAULT = 36;
39
+ const LABEL_WIDTH_DEFAULT = 160;
40
+ const HEADER_HEIGHT = 32;
41
+
42
+ function getTimeBounds(tasks: GanttTask[], milestones: GanttMilestone[]) {
43
+ let min = Infinity;
44
+ let max = -Infinity;
45
+ for (const t of tasks) {
46
+ if (t.start.getTime() < min) min = t.start.getTime();
47
+ if (t.end.getTime() > max) max = t.end.getTime();
48
+ }
49
+ for (const m of milestones) {
50
+ if (m.date.getTime() < min) min = m.date.getTime();
51
+ if (m.date.getTime() > max) max = m.date.getTime();
52
+ }
53
+ const pad = (max - min) * 0.05 || 86400000;
54
+ return { min: min - pad, max: max + pad, span: max - min + 2 * pad };
55
+ }
56
+
57
+ function generateTicks(
58
+ min: number,
59
+ max: number,
60
+ scale: GanttTimeScale,
61
+ ): { pos: number; label: string }[] {
62
+ const ticks: { pos: number; label: string }[] = [];
63
+ const d = new Date(min);
64
+
65
+ if (scale === "day") {
66
+ d.setHours(0, 0, 0, 0);
67
+ while (d.getTime() <= max) {
68
+ ticks.push({
69
+ pos: d.getTime(),
70
+ label: d.toLocaleDateString(undefined, { month: "short", day: "numeric" }),
71
+ });
72
+ d.setDate(d.getDate() + 1);
73
+ }
74
+ } else if (scale === "week") {
75
+ d.setHours(0, 0, 0, 0);
76
+ d.setDate(d.getDate() - d.getDay());
77
+ while (d.getTime() <= max) {
78
+ ticks.push({
79
+ pos: d.getTime(),
80
+ label: d.toLocaleDateString(undefined, { month: "short", day: "numeric" }),
81
+ });
82
+ d.setDate(d.getDate() + 7);
83
+ }
84
+ } else {
85
+ d.setDate(1);
86
+ d.setHours(0, 0, 0, 0);
87
+ while (d.getTime() <= max) {
88
+ ticks.push({
89
+ pos: d.getTime(),
90
+ label: d.toLocaleDateString(undefined, { month: "short", year: "2-digit" }),
91
+ });
92
+ d.setMonth(d.getMonth() + 1);
93
+ }
94
+ }
95
+ return ticks;
96
+ }
97
+
98
+ const GanttChart = React.forwardRef<HTMLDivElement, GanttChartProps>(
99
+ function GanttChart(
100
+ {
101
+ tasks,
102
+ milestones = [],
103
+ timeScale = "day",
104
+ className,
105
+ height = 400,
106
+ rowHeight = ROW_HEIGHT_DEFAULT,
107
+ labelWidth = LABEL_WIDTH_DEFAULT,
108
+ onTaskClick,
109
+ },
110
+ ref,
111
+ ) {
112
+ const containerRef = React.useRef<HTMLDivElement>(null);
113
+ const [hoveredId, setHoveredId] = React.useState<string | null>(null);
114
+ const [tooltipInfo, setTooltipInfo] = React.useState<{
115
+ task: GanttTask;
116
+ x: number;
117
+ y: number;
118
+ } | null>(null);
119
+
120
+ const groups = React.useMemo(() => {
121
+ const map = new Map<string, GanttTask[]>();
122
+ for (const t of tasks) {
123
+ const g = t.group ?? "";
124
+ if (!map.has(g)) map.set(g, []);
125
+ map.get(g)!.push(t);
126
+ }
127
+ return map;
128
+ }, [tasks]);
129
+
130
+ const orderedTasks = React.useMemo(() => {
131
+ const result: GanttTask[] = [];
132
+ for (const group of groups.values()) {
133
+ result.push(...group.sort((a, b) => a.start.getTime() - b.start.getTime()));
134
+ }
135
+ return result;
136
+ }, [groups]);
137
+
138
+ const taskIndex = React.useMemo(() => {
139
+ const m = new Map<string, number>();
140
+ orderedTasks.forEach((t, i) => m.set(t.id, i));
141
+ return m;
142
+ }, [orderedTasks]);
143
+
144
+ const bounds = React.useMemo(
145
+ () => getTimeBounds(tasks, milestones),
146
+ [tasks, milestones],
147
+ );
148
+
149
+ const ticks = React.useMemo(
150
+ () => generateTicks(bounds.min, bounds.max, timeScale),
151
+ [bounds, timeScale],
152
+ );
153
+
154
+ const chartHeight =
155
+ typeof height === "number" ? height : undefined;
156
+ const svgHeight = HEADER_HEIGHT + orderedTasks.length * rowHeight + 8;
157
+
158
+ function xOf(time: number, chartWidth: number) {
159
+ return ((time - bounds.min) / bounds.span) * chartWidth;
160
+ }
161
+
162
+ return (
163
+ <div
164
+ ref={(node) => {
165
+ (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
166
+ if (typeof ref === "function") ref(node);
167
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
168
+ }}
169
+ className={cn("w-full overflow-auto relative", className)}
170
+ style={{ height }}
171
+ >
172
+ <svg
173
+ width="100%"
174
+ height={Math.max(svgHeight, chartHeight ?? svgHeight)}
175
+ style={{ minWidth: labelWidth + 400 }}
176
+ >
177
+ {/* Label background */}
178
+ <rect
179
+ x={0}
180
+ y={0}
181
+ width={labelWidth}
182
+ height={svgHeight}
183
+ fill="hsl(var(--card))"
184
+ />
185
+ <line
186
+ x1={labelWidth}
187
+ y1={0}
188
+ x2={labelWidth}
189
+ y2={svgHeight}
190
+ stroke="hsl(var(--border))"
191
+ />
192
+
193
+ {/* Time axis header */}
194
+ <g>
195
+ {ticks.map((tick, i) => {
196
+ const x = labelWidth + xOf(tick.pos, 1000);
197
+ return (
198
+ <g key={i}>
199
+ <line
200
+ x1={x}
201
+ y1={HEADER_HEIGHT}
202
+ x2={x}
203
+ y2={svgHeight}
204
+ stroke="hsl(var(--divider))"
205
+ strokeDasharray="2 4"
206
+ />
207
+ <text
208
+ x={x + 4}
209
+ y={HEADER_HEIGHT - 10}
210
+ fontSize={9}
211
+ fill="hsl(var(--muted-foreground))"
212
+ >
213
+ {tick.label}
214
+ </text>
215
+ </g>
216
+ );
217
+ })}
218
+ </g>
219
+
220
+ {/* Rows */}
221
+ {orderedTasks.map((task, i) => {
222
+ const y = HEADER_HEIGHT + i * rowHeight;
223
+ const barX = labelWidth + xOf(task.start.getTime(), 1000);
224
+ const barW = xOf(task.end.getTime(), 1000) - xOf(task.start.getTime(), 1000);
225
+ const barH = rowHeight * 0.55;
226
+ const barY = y + (rowHeight - barH) / 2;
227
+ const color = task.color ?? getChartColor(i);
228
+ const isHovered = hoveredId === task.id;
229
+ const progress = task.progress ?? 0;
230
+
231
+ return (
232
+ <g
233
+ key={task.id}
234
+ onMouseEnter={(e) => {
235
+ setHoveredId(task.id);
236
+ const rect = containerRef.current?.getBoundingClientRect();
237
+ setTooltipInfo({
238
+ task,
239
+ x: e.clientX - (rect?.left ?? 0) + (containerRef.current?.scrollLeft ?? 0),
240
+ y: e.clientY - (rect?.top ?? 0) + (containerRef.current?.scrollTop ?? 0),
241
+ });
242
+ }}
243
+ onMouseLeave={() => {
244
+ setHoveredId(null);
245
+ setTooltipInfo(null);
246
+ }}
247
+ onClick={() => onTaskClick?.(task)}
248
+ style={{ cursor: onTaskClick ? "pointer" : "default" }}
249
+ >
250
+ {/* Row stripe */}
251
+ {i % 2 === 0 && (
252
+ <rect
253
+ x={0}
254
+ y={y}
255
+ width="100%"
256
+ height={rowHeight}
257
+ fill="hsl(var(--muted))"
258
+ fillOpacity={0.3}
259
+ />
260
+ )}
261
+ {/* Label */}
262
+ <text
263
+ x={12}
264
+ y={y + rowHeight / 2}
265
+ dominantBaseline="central"
266
+ fontSize={11}
267
+ fill="hsl(var(--foreground))"
268
+ fontWeight={isHovered ? 600 : 400}
269
+ >
270
+ {task.name.length > labelWidth / 8
271
+ ? `${task.name.slice(0, Math.floor(labelWidth / 8))}…`
272
+ : task.name}
273
+ </text>
274
+ {/* Background bar */}
275
+ <rect
276
+ x={barX}
277
+ y={barY}
278
+ width={Math.max(barW, 2)}
279
+ height={barH}
280
+ rx={3}
281
+ fill={color}
282
+ fillOpacity={0.25}
283
+ stroke={isHovered ? color : "none"}
284
+ strokeWidth={isHovered ? 1.5 : 0}
285
+ />
286
+ {/* Progress fill */}
287
+ {progress > 0 && (
288
+ <rect
289
+ x={barX}
290
+ y={barY}
291
+ width={Math.max((barW * progress) / 100, 2)}
292
+ height={barH}
293
+ rx={3}
294
+ fill={color}
295
+ fillOpacity={0.85}
296
+ />
297
+ )}
298
+ </g>
299
+ );
300
+ })}
301
+
302
+ {/* Dependency arrows */}
303
+ {orderedTasks.map((task) =>
304
+ (task.dependencies ?? []).map((depId) => {
305
+ const fromIdx = taskIndex.get(depId);
306
+ if (fromIdx === undefined) return null;
307
+ const toIdx = taskIndex.get(task.id)!;
308
+ const fromTask = orderedTasks[fromIdx];
309
+ const fromEndX = labelWidth + xOf(fromTask.end.getTime(), 1000);
310
+ const fromY = HEADER_HEIGHT + fromIdx * rowHeight + rowHeight / 2;
311
+ const toStartX = labelWidth + xOf(task.start.getTime(), 1000);
312
+ const toY = HEADER_HEIGHT + toIdx * rowHeight + rowHeight / 2;
313
+ const midX = (fromEndX + toStartX) / 2;
314
+
315
+ return (
316
+ <g key={`${depId}-${task.id}`}>
317
+ <path
318
+ d={`M${fromEndX},${fromY} C${midX},${fromY} ${midX},${toY} ${toStartX},${toY}`}
319
+ fill="none"
320
+ stroke="hsl(var(--muted-foreground))"
321
+ strokeWidth={1}
322
+ strokeDasharray="3 2"
323
+ markerEnd="url(#arrow)"
324
+ />
325
+ </g>
326
+ );
327
+ }),
328
+ )}
329
+
330
+ {/* Milestones */}
331
+ {milestones.map((m, i) => {
332
+ const x = labelWidth + xOf(m.date.getTime(), 1000);
333
+ return (
334
+ <g key={m.id}>
335
+ <line
336
+ x1={x}
337
+ y1={HEADER_HEIGHT}
338
+ x2={x}
339
+ y2={svgHeight}
340
+ stroke={m.color ?? "hsl(var(--warning))"}
341
+ strokeWidth={1.5}
342
+ strokeDasharray="4 3"
343
+ />
344
+ <polygon
345
+ points={`${x},${HEADER_HEIGHT - 2} ${x + 5},${HEADER_HEIGHT + 6} ${x},${HEADER_HEIGHT + 14} ${x - 5},${HEADER_HEIGHT + 6}`}
346
+ fill={m.color ?? "hsl(var(--warning))"}
347
+ />
348
+ </g>
349
+ );
350
+ })}
351
+
352
+ {/* Arrow marker definition */}
353
+ <defs>
354
+ <marker
355
+ id="arrow"
356
+ viewBox="0 0 6 6"
357
+ refX={6}
358
+ refY={3}
359
+ markerWidth={6}
360
+ markerHeight={6}
361
+ orient="auto-start-reverse"
362
+ >
363
+ <path
364
+ d="M 0 0 L 6 3 L 0 6 z"
365
+ fill="hsl(var(--muted-foreground))"
366
+ />
367
+ </marker>
368
+ </defs>
369
+ </svg>
370
+
371
+ {/* Tooltip */}
372
+ {tooltipInfo && (
373
+ <div
374
+ style={{
375
+ position: "absolute",
376
+ left: tooltipInfo.x + 12,
377
+ top: tooltipInfo.y - 10,
378
+ background: "hsl(var(--card))",
379
+ border: "1px solid hsl(var(--border))",
380
+ borderRadius: 6,
381
+ padding: "6px 10px",
382
+ fontSize: 11,
383
+ lineHeight: 1.5,
384
+ boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
385
+ pointerEvents: "none",
386
+ zIndex: 50,
387
+ maxWidth: 220,
388
+ }}
389
+ >
390
+ <div
391
+ style={{
392
+ fontWeight: 600,
393
+ color: "hsl(var(--foreground))",
394
+ marginBottom: 2,
395
+ }}
396
+ >
397
+ {tooltipInfo.task.name}
398
+ </div>
399
+ <div style={{ color: "hsl(var(--muted-foreground))" }}>
400
+ {tooltipInfo.task.start.toLocaleDateString()} –{" "}
401
+ {tooltipInfo.task.end.toLocaleDateString()}
402
+ </div>
403
+ {tooltipInfo.task.progress != null && (
404
+ <div style={{ color: "hsl(var(--muted-foreground))" }}>
405
+ Progress: {tooltipInfo.task.progress}%
406
+ </div>
407
+ )}
408
+ </div>
409
+ )}
410
+ </div>
411
+ );
412
+ },
413
+ );
414
+
415
+ GanttChart.displayName = "GanttChart";
416
+ export { GanttChart };