@optilogic/charts 1.0.0-beta.9 → 1.0.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 +3034 -174
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +489 -192
- package/dist/index.d.ts +489 -192
- package/dist/index.js +3006 -175
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/cartesian/area-chart.tsx +177 -0
- package/src/cartesian/bar-chart.tsx +217 -0
- package/src/cartesian/composed-chart.tsx +222 -0
- package/src/cartesian/line-chart.tsx +159 -0
- package/src/cartesian/scatter-chart.tsx +158 -0
- package/src/cartesian/waterfall-chart.tsx +171 -0
- package/src/dashboard/chart-builder.tsx +310 -0
- package/src/dashboard/chart-renderer.tsx +250 -0
- package/src/dashboard/kpi-card.tsx +121 -0
- package/src/dashboard/scenario-comparison.tsx +235 -0
- package/src/dashboard/sparkline.tsx +86 -0
- package/src/index.ts +50 -19
- package/src/radial/donut-chart.tsx +135 -0
- package/src/radial/pie-chart.tsx +153 -0
- package/src/radial/radar-chart.tsx +111 -0
- package/src/radial/radial-bar-chart.tsx +115 -0
- package/src/shared/chart-container.tsx +104 -0
- package/src/shared/chart-legend.tsx +57 -0
- package/src/shared/chart-tooltip.tsx +159 -0
- package/src/shared/colors.ts +37 -0
- package/src/shared/formatters.ts +51 -0
- package/src/shared/types.ts +66 -0
- package/src/shared/use-live-data.ts +83 -0
- package/src/specialized/funnel-chart.tsx +93 -0
- package/src/specialized/gantt-chart.tsx +416 -0
- package/src/specialized/heatmap-chart.tsx +250 -0
- package/src/specialized/sankey-chart.tsx +155 -0
- package/src/specialized/treemap-chart.tsx +121 -0
- package/src/bar-chart.tsx +0 -337
- package/src/line-chart.tsx +0 -266
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
RadarChart as RechartsRadarChart,
|
|
4
|
+
Radar,
|
|
5
|
+
PolarGrid,
|
|
6
|
+
PolarAngleAxis,
|
|
7
|
+
PolarRadiusAxis,
|
|
8
|
+
Tooltip,
|
|
9
|
+
Legend,
|
|
10
|
+
} from "recharts";
|
|
11
|
+
import { getChartColor } from "../shared/colors";
|
|
12
|
+
import { resolveTooltipProps } from "../shared/chart-tooltip";
|
|
13
|
+
import { ChartLegendContent, resolveLegendConfig } from "../shared/chart-legend";
|
|
14
|
+
import { ChartContainer } from "../shared/chart-container";
|
|
15
|
+
import type { BaseChartProps, ChartLegend, ChartTooltipProp } from "../shared/types";
|
|
16
|
+
|
|
17
|
+
export interface RadarChartSeries {
|
|
18
|
+
dataKey: string;
|
|
19
|
+
name: string;
|
|
20
|
+
color?: string;
|
|
21
|
+
fillOpacity?: number;
|
|
22
|
+
strokeWidth?: number;
|
|
23
|
+
/** Use dashed stroke for baseline/reference series */
|
|
24
|
+
strokeDasharray?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RadarChartProps extends BaseChartProps {
|
|
28
|
+
data: Record<string, unknown>[];
|
|
29
|
+
series: RadarChartSeries[];
|
|
30
|
+
/** Key in data used for the angle axis labels */
|
|
31
|
+
categoryKey: string;
|
|
32
|
+
domain?: [number, number];
|
|
33
|
+
legend?: boolean | ChartLegend;
|
|
34
|
+
tooltip?: ChartTooltipProp;
|
|
35
|
+
loading?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const RadarChart = React.forwardRef<HTMLDivElement, RadarChartProps>(
|
|
39
|
+
(
|
|
40
|
+
{
|
|
41
|
+
data,
|
|
42
|
+
series,
|
|
43
|
+
categoryKey,
|
|
44
|
+
domain,
|
|
45
|
+
legend = false,
|
|
46
|
+
tooltip = true,
|
|
47
|
+
animate = true,
|
|
48
|
+
live = false,
|
|
49
|
+
className,
|
|
50
|
+
height = "100%",
|
|
51
|
+
margin = { top: 8, right: 8, left: 8, bottom: 8 },
|
|
52
|
+
loading,
|
|
53
|
+
},
|
|
54
|
+
ref,
|
|
55
|
+
) => {
|
|
56
|
+
const legendConfig = resolveLegendConfig(legend);
|
|
57
|
+
const tooltipProps = resolveTooltipProps(tooltip);
|
|
58
|
+
const isAnimated = live ? false : animate;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<ChartContainer
|
|
62
|
+
ref={ref}
|
|
63
|
+
className={className}
|
|
64
|
+
height={height}
|
|
65
|
+
loading={loading}
|
|
66
|
+
empty={!data?.length}
|
|
67
|
+
>
|
|
68
|
+
<RechartsRadarChart data={data} margin={margin} cx="50%" cy="50%">
|
|
69
|
+
<PolarGrid stroke="hsl(var(--divider))" />
|
|
70
|
+
<PolarAngleAxis
|
|
71
|
+
dataKey={categoryKey}
|
|
72
|
+
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
|
73
|
+
/>
|
|
74
|
+
<PolarRadiusAxis
|
|
75
|
+
domain={domain}
|
|
76
|
+
tick={{ fontSize: 9, fill: "hsl(var(--muted-foreground))" }}
|
|
77
|
+
axisLine={false}
|
|
78
|
+
/>
|
|
79
|
+
{tooltipProps && <Tooltip {...tooltipProps} />}
|
|
80
|
+
{legendConfig && (
|
|
81
|
+
<Legend
|
|
82
|
+
verticalAlign={legendConfig.position ?? "bottom"}
|
|
83
|
+
align={legendConfig.align ?? "center"}
|
|
84
|
+
content={<ChartLegendContent />}
|
|
85
|
+
/>
|
|
86
|
+
)}
|
|
87
|
+
{series.map((s, index) => {
|
|
88
|
+
const color = getChartColor(index, s.color);
|
|
89
|
+
return (
|
|
90
|
+
<Radar
|
|
91
|
+
key={s.dataKey}
|
|
92
|
+
name={s.name}
|
|
93
|
+
dataKey={s.dataKey}
|
|
94
|
+
stroke={color}
|
|
95
|
+
strokeWidth={s.strokeWidth ?? 2}
|
|
96
|
+
strokeDasharray={s.strokeDasharray}
|
|
97
|
+
fill={color}
|
|
98
|
+
fillOpacity={s.fillOpacity ?? 0.15}
|
|
99
|
+
isAnimationActive={isAnimated}
|
|
100
|
+
animationDuration={isAnimated ? 400 : 0}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
})}
|
|
104
|
+
</RechartsRadarChart>
|
|
105
|
+
</ChartContainer>
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
RadarChart.displayName = "RadarChart";
|
|
111
|
+
export { RadarChart };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
RadialBarChart as RechartsRadialBarChart,
|
|
4
|
+
RadialBar,
|
|
5
|
+
Tooltip,
|
|
6
|
+
Legend,
|
|
7
|
+
} from "recharts";
|
|
8
|
+
import { getChartColor } from "../shared/colors";
|
|
9
|
+
import { resolveTooltipProps } from "../shared/chart-tooltip";
|
|
10
|
+
import { ChartLegendContent, resolveLegendConfig } from "../shared/chart-legend";
|
|
11
|
+
import { ChartContainer } from "../shared/chart-container";
|
|
12
|
+
import type { BaseChartProps, ChartLegend, ChartTooltipProp } from "../shared/types";
|
|
13
|
+
|
|
14
|
+
export interface RadialBarDatum {
|
|
15
|
+
name: string;
|
|
16
|
+
value: number;
|
|
17
|
+
fill?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RadialBarChartProps extends BaseChartProps {
|
|
21
|
+
data: RadialBarDatum[];
|
|
22
|
+
innerRadius?: number | string;
|
|
23
|
+
outerRadius?: number | string;
|
|
24
|
+
startAngle?: number;
|
|
25
|
+
endAngle?: number;
|
|
26
|
+
legend?: boolean | ChartLegend;
|
|
27
|
+
tooltip?: ChartTooltipProp;
|
|
28
|
+
/** Show value labels on each bar */
|
|
29
|
+
showLabels?: boolean;
|
|
30
|
+
loading?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const RadialBarChart = React.forwardRef<HTMLDivElement, RadialBarChartProps>(
|
|
34
|
+
(
|
|
35
|
+
{
|
|
36
|
+
data,
|
|
37
|
+
innerRadius = "30%",
|
|
38
|
+
outerRadius = "90%",
|
|
39
|
+
startAngle = 180,
|
|
40
|
+
endAngle = 0,
|
|
41
|
+
legend = false,
|
|
42
|
+
tooltip = true,
|
|
43
|
+
showLabels = true,
|
|
44
|
+
animate = true,
|
|
45
|
+
live = false,
|
|
46
|
+
className,
|
|
47
|
+
height = "100%",
|
|
48
|
+
margin = { top: 8, right: 8, left: 8, bottom: 8 },
|
|
49
|
+
loading,
|
|
50
|
+
},
|
|
51
|
+
ref,
|
|
52
|
+
) => {
|
|
53
|
+
const legendConfig = resolveLegendConfig(legend);
|
|
54
|
+
const tooltipProps = resolveTooltipProps(tooltip);
|
|
55
|
+
const isAnimated = live ? false : animate;
|
|
56
|
+
|
|
57
|
+
const coloredData = React.useMemo(
|
|
58
|
+
() =>
|
|
59
|
+
data.map((d, i) => ({
|
|
60
|
+
...d,
|
|
61
|
+
fill: d.fill ?? getChartColor(i),
|
|
62
|
+
})),
|
|
63
|
+
[data],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<ChartContainer
|
|
68
|
+
ref={ref}
|
|
69
|
+
className={className}
|
|
70
|
+
height={height}
|
|
71
|
+
loading={loading}
|
|
72
|
+
empty={!data?.length}
|
|
73
|
+
>
|
|
74
|
+
<RechartsRadialBarChart
|
|
75
|
+
data={coloredData}
|
|
76
|
+
innerRadius={innerRadius}
|
|
77
|
+
outerRadius={outerRadius}
|
|
78
|
+
startAngle={startAngle}
|
|
79
|
+
endAngle={endAngle}
|
|
80
|
+
margin={margin}
|
|
81
|
+
cx="50%"
|
|
82
|
+
cy="50%"
|
|
83
|
+
>
|
|
84
|
+
<RadialBar
|
|
85
|
+
dataKey="value"
|
|
86
|
+
background={{ fill: "hsl(var(--muted))" }}
|
|
87
|
+
isAnimationActive={isAnimated}
|
|
88
|
+
animationDuration={isAnimated ? 400 : 0}
|
|
89
|
+
label={
|
|
90
|
+
showLabels
|
|
91
|
+
? {
|
|
92
|
+
position: "insideStart",
|
|
93
|
+
fill: "hsl(var(--foreground))",
|
|
94
|
+
fontSize: 10,
|
|
95
|
+
fontWeight: 600,
|
|
96
|
+
}
|
|
97
|
+
: false
|
|
98
|
+
}
|
|
99
|
+
/>
|
|
100
|
+
{tooltipProps && <Tooltip {...tooltipProps} />}
|
|
101
|
+
{legendConfig && (
|
|
102
|
+
<Legend
|
|
103
|
+
verticalAlign={legendConfig.position ?? "bottom"}
|
|
104
|
+
align={legendConfig.align ?? "center"}
|
|
105
|
+
content={<ChartLegendContent />}
|
|
106
|
+
/>
|
|
107
|
+
)}
|
|
108
|
+
</RechartsRadialBarChart>
|
|
109
|
+
</ChartContainer>
|
|
110
|
+
);
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
RadialBarChart.displayName = "RadialBarChart";
|
|
115
|
+
export { RadialBarChart };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { ResponsiveContainer } from "recharts";
|
|
3
|
+
import { cn } from "@optilogic/core";
|
|
4
|
+
|
|
5
|
+
export interface ChartContainerProps {
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
className?: string;
|
|
8
|
+
height?: number | string;
|
|
9
|
+
loading?: boolean;
|
|
10
|
+
empty?: boolean;
|
|
11
|
+
emptyMessage?: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const ChartContainer = React.forwardRef<
|
|
17
|
+
HTMLDivElement,
|
|
18
|
+
ChartContainerProps
|
|
19
|
+
>(function ChartContainer(
|
|
20
|
+
{
|
|
21
|
+
children,
|
|
22
|
+
className,
|
|
23
|
+
height = "100%",
|
|
24
|
+
loading,
|
|
25
|
+
empty,
|
|
26
|
+
emptyMessage = "No data available",
|
|
27
|
+
title,
|
|
28
|
+
description,
|
|
29
|
+
},
|
|
30
|
+
ref,
|
|
31
|
+
) {
|
|
32
|
+
return (
|
|
33
|
+
<div ref={ref} className={cn("w-full", className)} style={{ height }}>
|
|
34
|
+
{(title || description) && (
|
|
35
|
+
<div style={{ marginBottom: 8 }}>
|
|
36
|
+
{title && (
|
|
37
|
+
<div
|
|
38
|
+
style={{
|
|
39
|
+
fontSize: 13,
|
|
40
|
+
fontWeight: 600,
|
|
41
|
+
color: "hsl(var(--foreground))",
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
{title}
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
{description && (
|
|
48
|
+
<div
|
|
49
|
+
style={{
|
|
50
|
+
fontSize: 11,
|
|
51
|
+
color: "hsl(var(--muted-foreground))",
|
|
52
|
+
marginTop: 2,
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
{description}
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
)}
|
|
60
|
+
{loading ? (
|
|
61
|
+
<div
|
|
62
|
+
style={{
|
|
63
|
+
width: "100%",
|
|
64
|
+
height: title || description ? "calc(100% - 40px)" : "100%",
|
|
65
|
+
display: "flex",
|
|
66
|
+
alignItems: "center",
|
|
67
|
+
justifyContent: "center",
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<div
|
|
71
|
+
className="animate-pulse"
|
|
72
|
+
style={{
|
|
73
|
+
width: "80%",
|
|
74
|
+
height: "60%",
|
|
75
|
+
borderRadius: 6,
|
|
76
|
+
background: "hsl(var(--muted))",
|
|
77
|
+
}}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
) : empty ? (
|
|
81
|
+
<div
|
|
82
|
+
style={{
|
|
83
|
+
width: "100%",
|
|
84
|
+
height: title || description ? "calc(100% - 40px)" : "100%",
|
|
85
|
+
display: "flex",
|
|
86
|
+
alignItems: "center",
|
|
87
|
+
justifyContent: "center",
|
|
88
|
+
color: "hsl(var(--muted-foreground))",
|
|
89
|
+
fontSize: 12,
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
{emptyMessage}
|
|
93
|
+
</div>
|
|
94
|
+
) : (
|
|
95
|
+
<ResponsiveContainer
|
|
96
|
+
width="100%"
|
|
97
|
+
height={title || description ? "calc(100% - 40px)" : "100%"}
|
|
98
|
+
>
|
|
99
|
+
{children as React.ReactElement}
|
|
100
|
+
</ResponsiveContainer>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { ChartLegend } from "./types";
|
|
3
|
+
|
|
4
|
+
interface LegendPayloadItem {
|
|
5
|
+
value: string;
|
|
6
|
+
color: string;
|
|
7
|
+
dataKey?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ChartLegendContentProps {
|
|
11
|
+
payload?: LegendPayloadItem[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const ChartLegendContent = React.memo(function ChartLegendContent({
|
|
15
|
+
payload,
|
|
16
|
+
}: ChartLegendContentProps) {
|
|
17
|
+
if (!payload?.length) return null;
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
style={{
|
|
22
|
+
display: "flex",
|
|
23
|
+
flexWrap: "wrap",
|
|
24
|
+
gap: "8px 16px",
|
|
25
|
+
fontSize: 11,
|
|
26
|
+
lineHeight: 1,
|
|
27
|
+
padding: "0 0 4px",
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
{payload.map((entry, i) => (
|
|
31
|
+
<span
|
|
32
|
+
key={entry.dataKey ?? i}
|
|
33
|
+
style={{ display: "flex", alignItems: "center", gap: 5 }}
|
|
34
|
+
>
|
|
35
|
+
<span
|
|
36
|
+
style={{
|
|
37
|
+
width: 8,
|
|
38
|
+
height: 8,
|
|
39
|
+
borderRadius: "50%",
|
|
40
|
+
background: entry.color,
|
|
41
|
+
flexShrink: 0,
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
<span style={{ color: "hsl(var(--muted-foreground))" }}>
|
|
45
|
+
{entry.value}
|
|
46
|
+
</span>
|
|
47
|
+
</span>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export function resolveLegendConfig(legend: boolean | ChartLegend | undefined) {
|
|
54
|
+
if (legend === false || legend === undefined) return null;
|
|
55
|
+
if (legend === true) return { position: "top" as const, align: "right" as const };
|
|
56
|
+
return legend;
|
|
57
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { TooltipProps } from "recharts";
|
|
3
|
+
import type { ChartTooltipConfig } from "./types";
|
|
4
|
+
|
|
5
|
+
interface ChartTooltipPayloadItem {
|
|
6
|
+
name: string;
|
|
7
|
+
value: number;
|
|
8
|
+
color: string;
|
|
9
|
+
dataKey: string;
|
|
10
|
+
payload: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ChartTooltipContentProps
|
|
14
|
+
extends TooltipProps<number, string> {
|
|
15
|
+
config?: ChartTooltipConfig;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const ChartTooltipContent = React.memo(function ChartTooltipContent({
|
|
19
|
+
active,
|
|
20
|
+
payload,
|
|
21
|
+
label,
|
|
22
|
+
config,
|
|
23
|
+
}: ChartTooltipContentProps) {
|
|
24
|
+
if (!active || !payload?.length) return null;
|
|
25
|
+
|
|
26
|
+
const items = payload as unknown as ChartTooltipPayloadItem[];
|
|
27
|
+
|
|
28
|
+
const formattedLabel = config?.labelFormatter
|
|
29
|
+
? config.labelFormatter(String(label))
|
|
30
|
+
: String(label ?? "");
|
|
31
|
+
|
|
32
|
+
const total = config?.showTotal
|
|
33
|
+
? items.reduce((sum, item) => sum + (Number(item.value) || 0), 0)
|
|
34
|
+
: null;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
style={{
|
|
39
|
+
background: "hsl(var(--card))",
|
|
40
|
+
border: "1px solid hsl(var(--border))",
|
|
41
|
+
borderRadius: 6,
|
|
42
|
+
padding: "8px 10px",
|
|
43
|
+
fontSize: 11,
|
|
44
|
+
lineHeight: 1.5,
|
|
45
|
+
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
|
46
|
+
minWidth: 120,
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
{formattedLabel && (
|
|
50
|
+
<div
|
|
51
|
+
style={{
|
|
52
|
+
color: "hsl(var(--foreground))",
|
|
53
|
+
fontWeight: 600,
|
|
54
|
+
marginBottom: 4,
|
|
55
|
+
borderBottom: "1px solid hsl(var(--border))",
|
|
56
|
+
paddingBottom: 4,
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
{formattedLabel}
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
{items.map((item, i) => {
|
|
63
|
+
const formatted = config?.formatter
|
|
64
|
+
? config.formatter(Number(item.value), item.name)
|
|
65
|
+
: String(item.value);
|
|
66
|
+
const displayValue = Array.isArray(formatted) ? formatted[0] : formatted;
|
|
67
|
+
const displayName = Array.isArray(formatted) ? formatted[1] : item.name;
|
|
68
|
+
|
|
69
|
+
let delta: React.ReactNode = null;
|
|
70
|
+
if (config?.showDelta && config.baseline) {
|
|
71
|
+
const baselineValue = Number(
|
|
72
|
+
item.payload?.[config.baseline] ?? 0,
|
|
73
|
+
);
|
|
74
|
+
if (baselineValue !== 0) {
|
|
75
|
+
const diff = Number(item.value) - baselineValue;
|
|
76
|
+
const pct = ((diff / baselineValue) * 100).toFixed(1);
|
|
77
|
+
const sign = diff >= 0 ? "+" : "";
|
|
78
|
+
const color =
|
|
79
|
+
diff > 0
|
|
80
|
+
? "hsl(var(--success))"
|
|
81
|
+
: diff < 0
|
|
82
|
+
? "hsl(var(--destructive))"
|
|
83
|
+
: "hsl(var(--muted-foreground))";
|
|
84
|
+
delta = (
|
|
85
|
+
<span style={{ color, marginLeft: 4, fontSize: 10 }}>
|
|
86
|
+
{diff >= 0 ? "▲" : "▼"} {sign}{pct}%
|
|
87
|
+
</span>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
key={item.dataKey ?? i}
|
|
95
|
+
style={{
|
|
96
|
+
display: "flex",
|
|
97
|
+
alignItems: "center",
|
|
98
|
+
justifyContent: "space-between",
|
|
99
|
+
gap: 12,
|
|
100
|
+
padding: "1px 0",
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|
104
|
+
<span
|
|
105
|
+
style={{
|
|
106
|
+
width: 8,
|
|
107
|
+
height: 8,
|
|
108
|
+
borderRadius: "50%",
|
|
109
|
+
background: item.color,
|
|
110
|
+
flexShrink: 0,
|
|
111
|
+
}}
|
|
112
|
+
/>
|
|
113
|
+
<span style={{ color: "hsl(var(--muted-foreground))" }}>
|
|
114
|
+
{displayName}
|
|
115
|
+
</span>
|
|
116
|
+
</span>
|
|
117
|
+
<span
|
|
118
|
+
style={{
|
|
119
|
+
fontWeight: 600,
|
|
120
|
+
color: "hsl(var(--foreground))",
|
|
121
|
+
fontVariantNumeric: "tabular-nums",
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
{displayValue}
|
|
125
|
+
{delta}
|
|
126
|
+
</span>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
})}
|
|
130
|
+
{total != null && (
|
|
131
|
+
<div
|
|
132
|
+
style={{
|
|
133
|
+
borderTop: "1px solid hsl(var(--border))",
|
|
134
|
+
marginTop: 4,
|
|
135
|
+
paddingTop: 4,
|
|
136
|
+
display: "flex",
|
|
137
|
+
justifyContent: "space-between",
|
|
138
|
+
fontWeight: 600,
|
|
139
|
+
color: "hsl(var(--foreground))",
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
<span>Total</span>
|
|
143
|
+
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
|
144
|
+
{config?.formatter ? config.formatter(total, "Total") : total}
|
|
145
|
+
</span>
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
/** Resolve the tooltip prop into Recharts Tooltip props */
|
|
153
|
+
export function resolveTooltipProps(tooltip: boolean | ChartTooltipConfig | undefined) {
|
|
154
|
+
if (tooltip === false || tooltip === undefined) return null;
|
|
155
|
+
if (tooltip === true) return { content: <ChartTooltipContent /> };
|
|
156
|
+
return {
|
|
157
|
+
content: <ChartTooltipContent config={tooltip} />,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Theme-aware chart colors from CSS variables */
|
|
2
|
+
export const CHART_COLORS = [
|
|
3
|
+
"hsl(var(--chart-1))",
|
|
4
|
+
"hsl(var(--chart-2))",
|
|
5
|
+
"hsl(var(--chart-3))",
|
|
6
|
+
"hsl(var(--chart-4))",
|
|
7
|
+
"hsl(var(--chart-5))",
|
|
8
|
+
"hsl(var(--chart-6))",
|
|
9
|
+
"hsl(var(--chart-7))",
|
|
10
|
+
"hsl(var(--chart-8))",
|
|
11
|
+
"hsl(var(--chart-9))",
|
|
12
|
+
"hsl(var(--chart-10))",
|
|
13
|
+
"hsl(var(--chart-11))",
|
|
14
|
+
"hsl(var(--chart-12))",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export function getChartColor(index: number, custom?: string): string {
|
|
18
|
+
if (custom) return custom;
|
|
19
|
+
return CHART_COLORS[index % CHART_COLORS.length] ?? "hsl(var(--chart-1))";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type SemanticColorName =
|
|
23
|
+
| "positive"
|
|
24
|
+
| "negative"
|
|
25
|
+
| "neutral"
|
|
26
|
+
| "warning";
|
|
27
|
+
|
|
28
|
+
const SEMANTIC_COLORS: Record<SemanticColorName, string> = {
|
|
29
|
+
positive: "hsl(var(--success))",
|
|
30
|
+
negative: "hsl(var(--destructive))",
|
|
31
|
+
neutral: "hsl(var(--muted-foreground))",
|
|
32
|
+
warning: "hsl(var(--warning))",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function getSemanticColor(name: SemanticColorName): string {
|
|
36
|
+
return SEMANTIC_COLORS[name];
|
|
37
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const compactThresholds: [number, string][] = [
|
|
2
|
+
[1_000_000_000, "B"],
|
|
3
|
+
[1_000_000, "M"],
|
|
4
|
+
[1_000, "K"],
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
export function formatCompact(value: number, decimals = 1): string {
|
|
8
|
+
for (const [threshold, suffix] of compactThresholds) {
|
|
9
|
+
if (Math.abs(value) >= threshold) {
|
|
10
|
+
return `${(value / threshold).toFixed(decimals)}${suffix}`;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return value.toLocaleString();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatCurrency(
|
|
17
|
+
value: number,
|
|
18
|
+
currency = "USD",
|
|
19
|
+
decimals?: number,
|
|
20
|
+
): string {
|
|
21
|
+
if (Math.abs(value) >= 1_000_000) {
|
|
22
|
+
return `$${formatCompact(value, decimals ?? 1)}`;
|
|
23
|
+
}
|
|
24
|
+
return value.toLocaleString("en-US", {
|
|
25
|
+
style: "currency",
|
|
26
|
+
currency,
|
|
27
|
+
minimumFractionDigits: decimals ?? 0,
|
|
28
|
+
maximumFractionDigits: decimals ?? 0,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatPercent(value: number, decimals = 1): string {
|
|
33
|
+
return `${value.toFixed(decimals)}%`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatUnit(value: number, unit: string, decimals = 1): string {
|
|
37
|
+
return `${value.toLocaleString(undefined, { maximumFractionDigits: decimals })} ${unit}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function formatDuration(seconds: number): string {
|
|
41
|
+
if (seconds < 60) return `${Math.round(seconds)}s`;
|
|
42
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
|
|
43
|
+
if (seconds < 86400) {
|
|
44
|
+
const h = Math.floor(seconds / 3600);
|
|
45
|
+
const m = Math.round((seconds % 3600) / 60);
|
|
46
|
+
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
|
47
|
+
}
|
|
48
|
+
const d = Math.floor(seconds / 86400);
|
|
49
|
+
const h = Math.round((seconds % 86400) / 3600);
|
|
50
|
+
return h > 0 ? `${d}d ${h}h` : `${d}d`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** X-axis configuration shared across cartesian charts */
|
|
2
|
+
export interface ChartXAxis {
|
|
3
|
+
dataKey: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
tickFormatter?: (value: unknown) => string;
|
|
6
|
+
minTickGap?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Y-axis configuration shared across cartesian charts */
|
|
10
|
+
export interface ChartYAxis {
|
|
11
|
+
domain?: [
|
|
12
|
+
number | "auto" | "dataMin" | "dataMax",
|
|
13
|
+
number | "auto" | "dataMin" | "dataMax",
|
|
14
|
+
];
|
|
15
|
+
label?: string;
|
|
16
|
+
tickFormatter?: (value: unknown) => string;
|
|
17
|
+
width?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Grid configuration */
|
|
21
|
+
export interface ChartGrid {
|
|
22
|
+
vertical?: boolean;
|
|
23
|
+
horizontal?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Legend configuration */
|
|
27
|
+
export interface ChartLegend {
|
|
28
|
+
position?: "top" | "bottom";
|
|
29
|
+
align?: "left" | "center" | "right";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Chart margin */
|
|
33
|
+
export interface ChartMargin {
|
|
34
|
+
top?: number;
|
|
35
|
+
right?: number;
|
|
36
|
+
bottom?: number;
|
|
37
|
+
left?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Tooltip value formatter */
|
|
41
|
+
export type TooltipFormatter = (
|
|
42
|
+
value: number,
|
|
43
|
+
name: string,
|
|
44
|
+
) => string | [string, string];
|
|
45
|
+
|
|
46
|
+
/** Tooltip configuration (pass true for defaults, false to disable) */
|
|
47
|
+
export interface ChartTooltipConfig {
|
|
48
|
+
formatter?: TooltipFormatter;
|
|
49
|
+
labelFormatter?: (label: string) => string;
|
|
50
|
+
showTotal?: boolean;
|
|
51
|
+
showDelta?: boolean;
|
|
52
|
+
baseline?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type ChartTooltipProp = boolean | ChartTooltipConfig;
|
|
56
|
+
|
|
57
|
+
/** Common props shared by all charts */
|
|
58
|
+
export interface BaseChartProps {
|
|
59
|
+
className?: string;
|
|
60
|
+
height?: number | string;
|
|
61
|
+
margin?: ChartMargin;
|
|
62
|
+
animate?: boolean;
|
|
63
|
+
tooltip?: ChartTooltipProp;
|
|
64
|
+
/** Disables animation and optimises for frequent data updates */
|
|
65
|
+
live?: boolean;
|
|
66
|
+
}
|