@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,250 @@
1
+ import * as React from "react";
2
+ import { cn } from "@optilogic/core";
3
+
4
+ export interface HeatmapCell {
5
+ x: string | number;
6
+ y: string | number;
7
+ value: number;
8
+ }
9
+
10
+ export interface HeatmapColorScale {
11
+ min: string;
12
+ mid?: string;
13
+ max: string;
14
+ }
15
+
16
+ export interface HeatmapChartProps {
17
+ data: HeatmapCell[];
18
+ xLabels: string[];
19
+ yLabels: string[];
20
+ colorScale?: HeatmapColorScale;
21
+ cellSize?: number;
22
+ showValues?: boolean;
23
+ valueFormatter?: (value: number) => string;
24
+ className?: string;
25
+ height?: number | string;
26
+ loading?: boolean;
27
+ onCellClick?: (cell: HeatmapCell) => void;
28
+ }
29
+
30
+ const DEFAULT_SCALE: HeatmapColorScale = {
31
+ min: "hsl(var(--chart-9))",
32
+ mid: "hsl(var(--chart-4))",
33
+ max: "hsl(var(--chart-7))",
34
+ };
35
+
36
+ function interpolateColor(t: number, scale: HeatmapColorScale): string {
37
+ if (!scale.mid) {
38
+ return `color-mix(in srgb, ${scale.min} ${(1 - t) * 100}%, ${scale.max})`;
39
+ }
40
+ if (t <= 0.5) {
41
+ const t2 = t * 2;
42
+ return `color-mix(in srgb, ${scale.min} ${(1 - t2) * 100}%, ${scale.mid})`;
43
+ }
44
+ const t2 = (t - 0.5) * 2;
45
+ return `color-mix(in srgb, ${scale.mid} ${(1 - t2) * 100}%, ${scale.max})`;
46
+ }
47
+
48
+ const HeatmapChart = React.forwardRef<HTMLDivElement, HeatmapChartProps>(
49
+ function HeatmapChart(
50
+ {
51
+ data,
52
+ xLabels,
53
+ yLabels,
54
+ colorScale = DEFAULT_SCALE,
55
+ cellSize = 40,
56
+ showValues = true,
57
+ valueFormatter,
58
+ className,
59
+ height,
60
+ loading,
61
+ onCellClick,
62
+ },
63
+ ref,
64
+ ) {
65
+ const containerRef = React.useRef<HTMLDivElement>(null);
66
+ const [hoveredCell, setHoveredCell] = React.useState<HeatmapCell | null>(null);
67
+ const [tooltipPos, setTooltipPos] = React.useState({ x: 0, y: 0 });
68
+
69
+ const { valueMap, minVal, maxVal } = React.useMemo(() => {
70
+ const map = new Map<string, number>();
71
+ let lo = Infinity;
72
+ let hi = -Infinity;
73
+ for (const cell of data) {
74
+ map.set(`${cell.x}|${cell.y}`, cell.value);
75
+ if (cell.value < lo) lo = cell.value;
76
+ if (cell.value > hi) hi = cell.value;
77
+ }
78
+ return { valueMap: map, minVal: lo, maxVal: hi };
79
+ }, [data]);
80
+
81
+ const normalize = (v: number) =>
82
+ maxVal === minVal ? 0.5 : (v - minVal) / (maxVal - minVal);
83
+
84
+ const labelColWidth = 80;
85
+ const svgWidth = labelColWidth + xLabels.length * cellSize;
86
+ const svgHeight = 28 + yLabels.length * cellSize;
87
+
88
+ if (loading) {
89
+ return (
90
+ <div
91
+ ref={ref}
92
+ className={cn("w-full flex items-center justify-center", className)}
93
+ style={{ height: height ?? svgHeight }}
94
+ >
95
+ <div
96
+ className="animate-pulse"
97
+ style={{
98
+ width: "80%",
99
+ height: "60%",
100
+ borderRadius: 6,
101
+ background: "hsl(var(--muted))",
102
+ }}
103
+ />
104
+ </div>
105
+ );
106
+ }
107
+
108
+ return (
109
+ <div
110
+ ref={(node) => {
111
+ (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
112
+ if (typeof ref === "function") ref(node);
113
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
114
+ }}
115
+ className={cn("w-full overflow-auto relative", className)}
116
+ style={{ height }}
117
+ >
118
+ <svg width={svgWidth} height={svgHeight}>
119
+ {/* X-axis labels */}
120
+ {xLabels.map((label, i) => (
121
+ <text
122
+ key={`x-${i}`}
123
+ x={labelColWidth + i * cellSize + cellSize / 2}
124
+ y={14}
125
+ textAnchor="middle"
126
+ fontSize={9}
127
+ fill="hsl(var(--muted-foreground))"
128
+ >
129
+ {label}
130
+ </text>
131
+ ))}
132
+
133
+ {/* Y-axis labels + cells */}
134
+ {yLabels.map((yLabel, yi) => (
135
+ <g key={`row-${yi}`}>
136
+ <text
137
+ x={labelColWidth - 8}
138
+ y={28 + yi * cellSize + cellSize / 2}
139
+ textAnchor="end"
140
+ dominantBaseline="central"
141
+ fontSize={9}
142
+ fill="hsl(var(--muted-foreground))"
143
+ >
144
+ {yLabel}
145
+ </text>
146
+ {xLabels.map((xLabel, xi) => {
147
+ const key = `${xLabel}|${yLabel}`;
148
+ const val = valueMap.get(key);
149
+ const t = val != null ? normalize(val) : 0;
150
+ const cx = labelColWidth + xi * cellSize;
151
+ const cy = 28 + yi * cellSize;
152
+ const cellData: HeatmapCell = { x: xLabel, y: yLabel, value: val ?? 0 };
153
+
154
+ return (
155
+ <g
156
+ key={key}
157
+ onMouseEnter={(e) => {
158
+ setHoveredCell(cellData);
159
+ const rect = containerRef.current?.getBoundingClientRect();
160
+ setTooltipPos({
161
+ x: e.clientX - (rect?.left ?? 0) + (containerRef.current?.scrollLeft ?? 0),
162
+ y: e.clientY - (rect?.top ?? 0) + (containerRef.current?.scrollTop ?? 0),
163
+ });
164
+ }}
165
+ onMouseLeave={() => setHoveredCell(null)}
166
+ onClick={() => onCellClick?.(cellData)}
167
+ style={{ cursor: onCellClick ? "pointer" : "default" }}
168
+ >
169
+ <rect
170
+ x={cx + 1}
171
+ y={cy + 1}
172
+ width={cellSize - 2}
173
+ height={cellSize - 2}
174
+ rx={3}
175
+ fill={
176
+ val != null
177
+ ? interpolateColor(t, colorScale)
178
+ : "hsl(var(--muted))"
179
+ }
180
+ fillOpacity={val != null ? 0.85 : 0.3}
181
+ stroke={
182
+ hoveredCell?.x === xLabel && hoveredCell?.y === yLabel
183
+ ? "hsl(var(--foreground))"
184
+ : "none"
185
+ }
186
+ strokeWidth={1.5}
187
+ />
188
+ {showValues && val != null && cellSize >= 32 && (
189
+ <text
190
+ x={cx + cellSize / 2}
191
+ y={cy + cellSize / 2}
192
+ textAnchor="middle"
193
+ dominantBaseline="central"
194
+ fontSize={Math.min(10, cellSize / 4.5)}
195
+ fill="hsl(var(--foreground))"
196
+ fontWeight={500}
197
+ style={{ pointerEvents: "none" }}
198
+ >
199
+ {valueFormatter ? valueFormatter(val) : val.toLocaleString()}
200
+ </text>
201
+ )}
202
+ </g>
203
+ );
204
+ })}
205
+ </g>
206
+ ))}
207
+ </svg>
208
+
209
+ {/* Tooltip */}
210
+ {hoveredCell && (
211
+ <div
212
+ style={{
213
+ position: "absolute",
214
+ left: tooltipPos.x + 12,
215
+ top: tooltipPos.y - 10,
216
+ background: "hsl(var(--card))",
217
+ border: "1px solid hsl(var(--border))",
218
+ borderRadius: 6,
219
+ padding: "6px 10px",
220
+ fontSize: 11,
221
+ lineHeight: 1.5,
222
+ boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
223
+ pointerEvents: "none",
224
+ zIndex: 50,
225
+ maxWidth: 200,
226
+ }}
227
+ >
228
+ <div style={{ color: "hsl(var(--muted-foreground))" }}>
229
+ {String(hoveredCell.x)} / {String(hoveredCell.y)}
230
+ </div>
231
+ <div
232
+ style={{
233
+ fontWeight: 600,
234
+ color: "hsl(var(--foreground))",
235
+ fontVariantNumeric: "tabular-nums",
236
+ }}
237
+ >
238
+ {valueFormatter
239
+ ? valueFormatter(hoveredCell.value)
240
+ : hoveredCell.value.toLocaleString()}
241
+ </div>
242
+ </div>
243
+ )}
244
+ </div>
245
+ );
246
+ },
247
+ );
248
+
249
+ HeatmapChart.displayName = "HeatmapChart";
250
+ export { HeatmapChart };
@@ -0,0 +1,155 @@
1
+ import * as React from "react";
2
+ import { Sankey, Tooltip, Layer, Rectangle } from "recharts";
3
+ import { getChartColor } from "../shared/colors";
4
+ import { resolveTooltipProps } from "../shared/chart-tooltip";
5
+ import { ChartContainer } from "../shared/chart-container";
6
+ import type { BaseChartProps, ChartTooltipProp } from "../shared/types";
7
+
8
+ export interface SankeyNode {
9
+ name: string;
10
+ color?: string;
11
+ }
12
+
13
+ export interface SankeyLink {
14
+ source: number;
15
+ target: number;
16
+ value: number;
17
+ }
18
+
19
+ export interface SankeyChartData {
20
+ nodes: SankeyNode[];
21
+ links: SankeyLink[];
22
+ }
23
+
24
+ export interface SankeyChartProps extends BaseChartProps {
25
+ data: SankeyChartData;
26
+ nodeWidth?: number;
27
+ nodePadding?: number;
28
+ tooltip?: ChartTooltipProp;
29
+ loading?: boolean;
30
+ }
31
+
32
+ interface SankeyNodeRendererProps {
33
+ x: number;
34
+ y: number;
35
+ width: number;
36
+ height: number;
37
+ index: number;
38
+ payload: { name: string; color?: string };
39
+ }
40
+
41
+ function SankeyNodeRenderer({
42
+ x,
43
+ y,
44
+ width,
45
+ height,
46
+ index,
47
+ payload,
48
+ }: SankeyNodeRendererProps) {
49
+ const fill = payload.color ?? getChartColor(index);
50
+ return (
51
+ <Layer key={`node-${index}`}>
52
+ <Rectangle
53
+ x={x}
54
+ y={y}
55
+ width={width}
56
+ height={height}
57
+ fill={fill}
58
+ fillOpacity={0.9}
59
+ />
60
+ <text
61
+ x={x + width + 6}
62
+ y={y + height / 2}
63
+ textAnchor="start"
64
+ dominantBaseline="central"
65
+ fontSize={10}
66
+ fill="hsl(var(--foreground))"
67
+ >
68
+ {payload.name}
69
+ </text>
70
+ </Layer>
71
+ );
72
+ }
73
+
74
+ interface SankeyLinkRendererProps {
75
+ sourceX: number;
76
+ sourceY: number;
77
+ sourceControlX: number;
78
+ targetX: number;
79
+ targetY: number;
80
+ targetControlX: number;
81
+ linkWidth: number;
82
+ index: number;
83
+ payload: { source: { color?: string }; target: unknown };
84
+ }
85
+
86
+ function SankeyLinkRenderer({
87
+ sourceX,
88
+ sourceY,
89
+ sourceControlX,
90
+ targetX,
91
+ targetY,
92
+ targetControlX,
93
+ linkWidth,
94
+ index,
95
+ payload,
96
+ }: SankeyLinkRendererProps) {
97
+ const sourceColor =
98
+ (payload.source as { color?: string }).color ?? getChartColor(index);
99
+ return (
100
+ <Layer key={`link-${index}`}>
101
+ <path
102
+ d={`
103
+ M${sourceX},${sourceY}
104
+ C${sourceControlX},${sourceY} ${targetControlX},${targetY} ${targetX},${targetY}
105
+ `}
106
+ fill="none"
107
+ stroke={sourceColor}
108
+ strokeWidth={linkWidth}
109
+ strokeOpacity={0.3}
110
+ />
111
+ </Layer>
112
+ );
113
+ }
114
+
115
+ const SankeyChart = React.forwardRef<HTMLDivElement, SankeyChartProps>(
116
+ (
117
+ {
118
+ data,
119
+ nodeWidth = 10,
120
+ nodePadding = 20,
121
+ tooltip = true,
122
+ className,
123
+ height = "100%",
124
+ margin = { top: 8, right: 80, left: 8, bottom: 8 },
125
+ loading,
126
+ },
127
+ ref,
128
+ ) => {
129
+ const tooltipProps = resolveTooltipProps(tooltip);
130
+
131
+ return (
132
+ <ChartContainer
133
+ ref={ref}
134
+ className={className}
135
+ height={height}
136
+ loading={loading}
137
+ empty={!data?.nodes?.length}
138
+ >
139
+ <Sankey
140
+ data={data}
141
+ nodeWidth={nodeWidth}
142
+ nodePadding={nodePadding}
143
+ margin={margin}
144
+ node={(SankeyNodeRenderer as never)}
145
+ link={(SankeyLinkRenderer as never)}
146
+ >
147
+ {tooltipProps && <Tooltip {...tooltipProps} />}
148
+ </Sankey>
149
+ </ChartContainer>
150
+ );
151
+ },
152
+ );
153
+
154
+ SankeyChart.displayName = "SankeyChart";
155
+ export { SankeyChart };
@@ -0,0 +1,121 @@
1
+ import * as React from "react";
2
+ import { Treemap, Tooltip } from "recharts";
3
+ import { getChartColor } from "../shared/colors";
4
+ import { resolveTooltipProps } from "../shared/chart-tooltip";
5
+ import { ChartContainer } from "../shared/chart-container";
6
+ import type { BaseChartProps, ChartTooltipProp } from "../shared/types";
7
+
8
+ export interface TreemapDatum {
9
+ name: string;
10
+ value?: number;
11
+ color?: string;
12
+ children?: TreemapDatum[];
13
+ }
14
+
15
+ export interface TreemapChartProps extends BaseChartProps {
16
+ data: TreemapDatum[];
17
+ dataKey?: string;
18
+ aspectRatio?: number;
19
+ tooltip?: ChartTooltipProp;
20
+ loading?: boolean;
21
+ }
22
+
23
+ interface TreemapContentProps {
24
+ x: number;
25
+ y: number;
26
+ width: number;
27
+ height: number;
28
+ index: number;
29
+ name: string;
30
+ depth: number;
31
+ color?: string;
32
+ }
33
+
34
+ function TreemapContent({
35
+ x,
36
+ y,
37
+ width,
38
+ height,
39
+ index,
40
+ name,
41
+ depth,
42
+ color,
43
+ }: TreemapContentProps) {
44
+ const fill = color ?? getChartColor(index);
45
+ const showLabel = width > 40 && height > 20;
46
+
47
+ return (
48
+ <g>
49
+ <rect
50
+ x={x}
51
+ y={y}
52
+ width={width}
53
+ height={height}
54
+ fill={fill}
55
+ fillOpacity={depth === 1 ? 0.85 : 0.6}
56
+ stroke="hsl(var(--card))"
57
+ strokeWidth={2}
58
+ rx={2}
59
+ />
60
+ {showLabel && (
61
+ <text
62
+ x={x + width / 2}
63
+ y={y + height / 2}
64
+ textAnchor="middle"
65
+ dominantBaseline="central"
66
+ fontSize={Math.min(11, width / 6)}
67
+ fill="hsl(var(--card-foreground))"
68
+ fontWeight={500}
69
+ style={{ pointerEvents: "none" }}
70
+ >
71
+ {name.length > width / 7 ? `${name.slice(0, Math.floor(width / 7))}…` : name}
72
+ </text>
73
+ )}
74
+ </g>
75
+ );
76
+ }
77
+
78
+ const TreemapChart = React.forwardRef<HTMLDivElement, TreemapChartProps>(
79
+ (
80
+ {
81
+ data,
82
+ dataKey = "value",
83
+ aspectRatio = 4 / 3,
84
+ tooltip = true,
85
+ animate = true,
86
+ live = false,
87
+ className,
88
+ height = "100%",
89
+ margin,
90
+ loading,
91
+ },
92
+ ref,
93
+ ) => {
94
+ const tooltipProps = resolveTooltipProps(tooltip);
95
+ const isAnimated = live ? false : animate;
96
+
97
+ return (
98
+ <ChartContainer
99
+ ref={ref}
100
+ className={className}
101
+ height={height}
102
+ loading={loading}
103
+ empty={!data?.length}
104
+ >
105
+ <Treemap
106
+ data={data}
107
+ dataKey={dataKey}
108
+ aspectRatio={aspectRatio}
109
+ isAnimationActive={isAnimated}
110
+ animationDuration={isAnimated ? 400 : 0}
111
+ content={<TreemapContent x={0} y={0} width={0} height={0} index={0} name="" depth={1} />}
112
+ >
113
+ {tooltipProps && <Tooltip {...tooltipProps} />}
114
+ </Treemap>
115
+ </ChartContainer>
116
+ );
117
+ },
118
+ );
119
+
120
+ TreemapChart.displayName = "TreemapChart";
121
+ export { TreemapChart };