@optilogic/charts 1.0.0-beta.8 → 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,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 };
|