@c2-markdown-ui/react 0.4.3 → 0.4.4
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/README.md +6 -24
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3733 -4365
- package/dist/lib/MarkdownUI.d.ts +10 -0
- package/dist/lib/MarkdownUI.d.ts.map +1 -0
- package/dist/lib/index.d.ts +4 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/widgets/Button.d.ts +15 -0
- package/dist/widgets/Button.d.ts.map +1 -0
- package/dist/widgets/ButtonGroup.d.ts +11 -0
- package/dist/widgets/ButtonGroup.d.ts.map +1 -0
- package/dist/widgets/Cascader.d.ts +34 -0
- package/dist/widgets/Cascader.d.ts.map +1 -0
- package/dist/widgets/Chart.d.ts +20 -0
- package/dist/widgets/Chart.d.ts.map +1 -0
- package/dist/widgets/DatePicker.d.ts +28 -0
- package/dist/widgets/DatePicker.d.ts.map +1 -0
- package/dist/widgets/Form.d.ts +47 -0
- package/dist/widgets/Form.d.ts.map +1 -0
- package/dist/widgets/Incomplete.d.ts +3 -0
- package/dist/widgets/Incomplete.d.ts.map +1 -0
- package/dist/widgets/MultipleChoiceQuestion.d.ts +15 -0
- package/dist/widgets/MultipleChoiceQuestion.d.ts.map +1 -0
- package/dist/widgets/NumberInput.d.ts +26 -0
- package/dist/widgets/NumberInput.d.ts.map +1 -0
- package/dist/widgets/Quiz.d.ts +37 -0
- package/dist/widgets/Quiz.d.ts.map +1 -0
- package/dist/widgets/Radio.d.ts +24 -0
- package/dist/widgets/Radio.d.ts.map +1 -0
- package/dist/widgets/Select.d.ts +15 -0
- package/dist/widgets/Select.d.ts.map +1 -0
- package/dist/widgets/SelectMulti.d.ts +20 -0
- package/dist/widgets/SelectMulti.d.ts.map +1 -0
- package/dist/widgets/ShortAnswerQuestion.d.ts +16 -0
- package/dist/widgets/ShortAnswerQuestion.d.ts.map +1 -0
- package/dist/widgets/Slider.d.ts +13 -0
- package/dist/widgets/Slider.d.ts.map +1 -0
- package/dist/widgets/Switch.d.ts +17 -0
- package/dist/widgets/Switch.d.ts.map +1 -0
- package/dist/widgets/TextInput.d.ts +11 -0
- package/dist/widgets/TextInput.d.ts.map +1 -0
- package/dist/widgets/Widget.d.ts +7 -0
- package/dist/widgets/Widget.d.ts.map +1 -0
- package/dist/widgets/chartRemoteData.d.ts +18 -0
- package/dist/widgets/chartRemoteData.d.ts.map +1 -0
- package/dist/widgets/choiceUtils.d.ts +24 -0
- package/dist/widgets/choiceUtils.d.ts.map +1 -0
- package/dist/widgets/index.d.ts +18 -0
- package/dist/widgets/index.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/lib/MarkdownUI.tsx +9 -47
- package/src/lib/widgets.css +1372 -2188
- package/src/widgets/Button.tsx +39 -39
- package/src/widgets/ButtonGroup.tsx +47 -47
- package/src/widgets/Cascader.tsx +2 -3
- package/src/widgets/Chart.tsx +237 -237
- package/src/widgets/DatePicker.tsx +434 -451
- package/src/widgets/Form.tsx +372 -512
- package/src/widgets/Incomplete.tsx +3 -3
- package/src/widgets/MultipleChoiceQuestion.tsx +82 -82
- package/src/widgets/Quiz.tsx +320 -320
- package/src/widgets/Radio.tsx +26 -57
- package/src/widgets/Select.tsx +142 -178
- package/src/widgets/SelectMulti.tsx +72 -105
- package/src/widgets/ShortAnswerQuestion.tsx +101 -101
- package/src/widgets/Slider.tsx +56 -70
- package/src/widgets/Switch.tsx +2 -14
- package/src/widgets/TextInput.tsx +38 -76
- package/src/widgets/Widget.tsx +82 -130
- package/src/widgets/choiceUtils.ts +45 -90
- package/src/widgets/index.ts +16 -17
- package/src/widgets/TreeSelect.tsx +0 -649
package/src/widgets/Button.tsx
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
|
|
3
|
-
export interface ButtonProps {
|
|
4
|
-
label?: string;
|
|
5
|
-
/** 背景色(十六进制或 CSS 颜色名) */
|
|
6
|
-
color?: string;
|
|
7
|
-
/** 字体颜色(十六进制或 CSS 颜色名) */
|
|
8
|
-
textColor?: string;
|
|
9
|
-
/** 尺寸:small / medium / large,默认 medium */
|
|
10
|
-
size?: 'small' | 'medium' | 'large';
|
|
11
|
-
/** 是否禁用 */
|
|
12
|
-
disabled?: boolean;
|
|
13
|
-
onchange: (value: string) => void;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export const Button: React.FC<ButtonProps> = ({
|
|
17
|
-
label = '按钮',
|
|
18
|
-
color,
|
|
19
|
-
textColor,
|
|
20
|
-
size = 'medium',
|
|
21
|
-
disabled = false,
|
|
22
|
-
onchange
|
|
23
|
-
}) => {
|
|
24
|
-
const style: React.CSSProperties = {};
|
|
25
|
-
if (color) style.backgroundColor = color;
|
|
26
|
-
if (textColor) style.color = textColor;
|
|
27
|
-
|
|
28
|
-
return (
|
|
29
|
-
<button
|
|
30
|
-
type="button"
|
|
31
|
-
className={`widget-button size-${size}`}
|
|
32
|
-
style={Object.keys(style).length > 0 ? style : undefined}
|
|
33
|
-
onClick={() => onchange(label)}
|
|
34
|
-
disabled={disabled}
|
|
35
|
-
>
|
|
36
|
-
{label}
|
|
37
|
-
</button>
|
|
38
|
-
);
|
|
39
|
-
};
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ButtonProps {
|
|
4
|
+
label?: string;
|
|
5
|
+
/** 背景色(十六进制或 CSS 颜色名) */
|
|
6
|
+
color?: string;
|
|
7
|
+
/** 字体颜色(十六进制或 CSS 颜色名) */
|
|
8
|
+
textColor?: string;
|
|
9
|
+
/** 尺寸:small / medium / large,默认 medium */
|
|
10
|
+
size?: 'small' | 'medium' | 'large';
|
|
11
|
+
/** 是否禁用 */
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
onchange: (value: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const Button: React.FC<ButtonProps> = ({
|
|
17
|
+
label = '按钮',
|
|
18
|
+
color,
|
|
19
|
+
textColor,
|
|
20
|
+
size = 'medium',
|
|
21
|
+
disabled = false,
|
|
22
|
+
onchange
|
|
23
|
+
}) => {
|
|
24
|
+
const style: React.CSSProperties = {};
|
|
25
|
+
if (color) style.backgroundColor = color;
|
|
26
|
+
if (textColor) style.color = textColor;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
className={`widget-button size-${size}`}
|
|
32
|
+
style={Object.keys(style).length > 0 ? style : undefined}
|
|
33
|
+
onClick={() => onchange(label)}
|
|
34
|
+
disabled={disabled}
|
|
35
|
+
>
|
|
36
|
+
{label}
|
|
37
|
+
</button>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
2
|
-
import { parseChoices, type ChoiceItem } from './choiceUtils';
|
|
3
|
-
|
|
4
|
-
export interface ButtonGroupProps {
|
|
5
|
-
choices: string[];
|
|
6
|
-
label?: string;
|
|
7
|
-
default?: string;
|
|
8
|
-
/** 是否禁用 */
|
|
9
|
-
disabled?: boolean;
|
|
10
|
-
onchange: (value: string) => void;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export const ButtonGroup: React.FC<ButtonGroupProps> = ({
|
|
14
|
-
choices,
|
|
15
|
-
label = '',
|
|
16
|
-
default: initial,
|
|
17
|
-
disabled = false,
|
|
18
|
-
onchange
|
|
19
|
-
}) => {
|
|
20
|
-
const items = parseChoices(choices);
|
|
21
|
-
const [selected, setSelected] = useState(initial ?? items[0]?.value ?? '');
|
|
22
|
-
|
|
23
|
-
const handleClick = (item: ChoiceItem) => {
|
|
24
|
-
if (disabled) return;
|
|
25
|
-
setSelected(item.value);
|
|
26
|
-
onchange(item.value);
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
return (
|
|
30
|
-
<div className="widget-button-group">
|
|
31
|
-
{label && <label>{label}</label>}
|
|
32
|
-
<div role="group" aria-label={label}>
|
|
33
|
-
{items.map((item) => (
|
|
34
|
-
<button
|
|
35
|
-
key={item.value}
|
|
36
|
-
type="button"
|
|
37
|
-
aria-pressed={selected === item.value}
|
|
38
|
-
onClick={() => handleClick(item)}
|
|
39
|
-
disabled={disabled}
|
|
40
|
-
>
|
|
41
|
-
{item.label}
|
|
42
|
-
</button>
|
|
43
|
-
))}
|
|
44
|
-
</div>
|
|
45
|
-
</div>
|
|
46
|
-
);
|
|
47
|
-
};
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { parseChoices, type ChoiceItem } from './choiceUtils';
|
|
3
|
+
|
|
4
|
+
export interface ButtonGroupProps {
|
|
5
|
+
choices: string[];
|
|
6
|
+
label?: string;
|
|
7
|
+
default?: string;
|
|
8
|
+
/** 是否禁用 */
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
onchange: (value: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const ButtonGroup: React.FC<ButtonGroupProps> = ({
|
|
14
|
+
choices,
|
|
15
|
+
label = '',
|
|
16
|
+
default: initial,
|
|
17
|
+
disabled = false,
|
|
18
|
+
onchange
|
|
19
|
+
}) => {
|
|
20
|
+
const items = parseChoices(choices);
|
|
21
|
+
const [selected, setSelected] = useState(initial ?? items[0]?.value ?? '');
|
|
22
|
+
|
|
23
|
+
const handleClick = (item: ChoiceItem) => {
|
|
24
|
+
if (disabled) return;
|
|
25
|
+
setSelected(item.value);
|
|
26
|
+
onchange(item.value);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="widget-button-group">
|
|
31
|
+
{label && <label>{label}</label>}
|
|
32
|
+
<div role="group" aria-label={label}>
|
|
33
|
+
{items.map((item) => (
|
|
34
|
+
<button
|
|
35
|
+
key={item.value}
|
|
36
|
+
type="button"
|
|
37
|
+
aria-pressed={selected === item.value}
|
|
38
|
+
onClick={() => handleClick(item)}
|
|
39
|
+
disabled={disabled}
|
|
40
|
+
>
|
|
41
|
+
{item.label}
|
|
42
|
+
</button>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
};
|
package/src/widgets/Cascader.tsx
CHANGED
|
@@ -56,15 +56,14 @@ function parseSeg(seg: string): { value: string; label: string } {
|
|
|
56
56
|
return { value: seg.trim(), label: seg.trim() };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
/** 默认路径(value 串,/ 分隔)→ 逐级 value 数组;支持 key:label
|
|
59
|
+
/** 默认路径(value 串,/ 分隔)→ 逐级 value 数组;支持 label 串回退(无 key:label 时 value=label 等价) */
|
|
60
60
|
function pathToValues(tree: CasNode[], path: string | undefined): string[] {
|
|
61
61
|
if (!path) return [];
|
|
62
62
|
const segs = path.split('/').map(s => s.trim()).filter(Boolean);
|
|
63
63
|
const values: string[] = [];
|
|
64
64
|
let level = tree;
|
|
65
65
|
for (const seg of segs) {
|
|
66
|
-
const
|
|
67
|
-
const node = level.find(n => n.value === segVal || n.value === seg || n.label === segLabel || n.label === seg);
|
|
66
|
+
const node = level.find(n => n.value === seg) ?? level.find(n => n.label === seg);
|
|
68
67
|
if (!node) return values;
|
|
69
68
|
values.push(node.value);
|
|
70
69
|
level = node.children;
|
package/src/widgets/Chart.tsx
CHANGED
|
@@ -1,238 +1,238 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import {
|
|
3
|
-
Chart as ChartJS,
|
|
4
|
-
CategoryScale,
|
|
5
|
-
LinearScale,
|
|
6
|
-
PointElement,
|
|
7
|
-
LineElement,
|
|
8
|
-
BarElement,
|
|
9
|
-
Title,
|
|
10
|
-
Tooltip,
|
|
11
|
-
Legend,
|
|
12
|
-
ArcElement,
|
|
13
|
-
LineController,
|
|
14
|
-
BarController,
|
|
15
|
-
PieController,
|
|
16
|
-
ScatterController
|
|
17
|
-
} from 'chart.js';
|
|
18
|
-
import { parseChartCsv, normalizeRemoteChartData, RemoteChartData } from './chartRemoteData';
|
|
19
|
-
|
|
20
|
-
// Register Chart.js components
|
|
21
|
-
ChartJS.register(
|
|
22
|
-
CategoryScale,
|
|
23
|
-
LinearScale,
|
|
24
|
-
PointElement,
|
|
25
|
-
LineElement,
|
|
26
|
-
BarElement,
|
|
27
|
-
Title,
|
|
28
|
-
Tooltip,
|
|
29
|
-
Legend,
|
|
30
|
-
ArcElement,
|
|
31
|
-
LineController,
|
|
32
|
-
BarController,
|
|
33
|
-
PieController,
|
|
34
|
-
ScatterController
|
|
35
|
-
);
|
|
36
|
-
|
|
37
|
-
interface Dataset {
|
|
38
|
-
label: string;
|
|
39
|
-
data: number[];
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
interface ChartProps {
|
|
43
|
-
type: "chart-line" | "chart-bar" | "chart-pie" | "chart-scatter";
|
|
44
|
-
title?: string;
|
|
45
|
-
height?: number;
|
|
46
|
-
/** 远端数据源 URL:设置后运行时 fetch 拉取(CSV 文本或 JSON),本地 CSV 作为初始/后备数据 */
|
|
47
|
-
url?: string;
|
|
48
|
-
/** 是否展示图例:未设置(undefined)时自动判断(多数据集或饼图时展示) */
|
|
49
|
-
legend?: boolean;
|
|
50
|
-
labels: string[];
|
|
51
|
-
datasets: Dataset[];
|
|
52
|
-
options?: Record<string, any>;
|
|
53
|
-
onchange: (data: any) => void;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function Chart({ type, title, height, url = '', legend, labels, datasets, options = {}, onchange }: ChartProps) {
|
|
57
|
-
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
58
|
-
const chartRef = useRef<ChartJS | null>(null);
|
|
59
|
-
|
|
60
|
-
// 远端数据:拉取成功后覆盖本地 CSV 数据
|
|
61
|
-
// 加载失败静默处理:回落本地数据,均无数据时图表区域显示灰色"暂无数据"
|
|
62
|
-
const [remoteData, setRemoteData] = useState<RemoteChartData | null>(null);
|
|
63
|
-
const [loading, setLoading] = useState(false);
|
|
64
|
-
|
|
65
|
-
// 实际渲染数据:远端优先,本地 CSV 作为初始/后备
|
|
66
|
-
const effectiveLabels = remoteData ? remoteData.labels : labels;
|
|
67
|
-
const effectiveDatasets = remoteData ? remoteData.datasets : datasets;
|
|
68
|
-
/** 无任何可渲染数据(本地与远端均为空) */
|
|
69
|
-
const isEmpty = !loading && effectiveLabels.length === 0 && effectiveDatasets.length === 0;
|
|
70
|
-
|
|
71
|
-
// 远端数据源:fetch 拉取 CSV 文本或 JSON,成功后覆盖本地数据
|
|
72
|
-
useEffect(() => {
|
|
73
|
-
if (!url) return;
|
|
74
|
-
let cancelled = false;
|
|
75
|
-
setLoading(true);
|
|
76
|
-
(async () => {
|
|
77
|
-
try {
|
|
78
|
-
const res = await fetch(url);
|
|
79
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
80
|
-
const text = await res.text();
|
|
81
|
-
if (cancelled) return;
|
|
82
|
-
let parsed: RemoteChartData | null = null;
|
|
83
|
-
const trimmed = text.trim();
|
|
84
|
-
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
85
|
-
try {
|
|
86
|
-
parsed = normalizeRemoteChartData(JSON.parse(trimmed));
|
|
87
|
-
} catch {
|
|
88
|
-
parsed = null;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
if (!parsed) parsed = parseChartCsv(text);
|
|
92
|
-
if (cancelled) return;
|
|
93
|
-
if (!parsed) throw new Error('响应无法解析为图表数据(CSV 或 JSON)');
|
|
94
|
-
setRemoteData(parsed);
|
|
95
|
-
} catch {
|
|
96
|
-
// 加载失败:静默回落本地数据
|
|
97
|
-
} finally {
|
|
98
|
-
if (!cancelled) setLoading(false);
|
|
99
|
-
}
|
|
100
|
-
})();
|
|
101
|
-
return () => { cancelled = true; };
|
|
102
|
-
}, [url]);
|
|
103
|
-
|
|
104
|
-
// Clamp height to safe bounds: 200px min, 800px max, 400px default
|
|
105
|
-
const safeHeight = Math.max(200, Math.min(800, height || 400));
|
|
106
|
-
|
|
107
|
-
const getChartType = (widgetType: string) => {
|
|
108
|
-
switch (widgetType) {
|
|
109
|
-
case 'chart-line': return 'line';
|
|
110
|
-
case 'chart-bar': return 'bar';
|
|
111
|
-
case 'chart-pie': return 'pie';
|
|
112
|
-
case 'chart-scatter': return 'scatter';
|
|
113
|
-
default: return 'line';
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const getDefaultColors = () => [
|
|
118
|
-
'
|
|
119
|
-
'
|
|
120
|
-
'
|
|
121
|
-
'
|
|
122
|
-
'
|
|
123
|
-
'
|
|
124
|
-
];
|
|
125
|
-
|
|
126
|
-
const prepareDatasets = (datasets: Dataset[], chartType: string, chartLabels: string[]) => {
|
|
127
|
-
const colors = getDefaultColors();
|
|
128
|
-
|
|
129
|
-
return datasets.map((dataset, index) => {
|
|
130
|
-
const color = colors[index % colors.length];
|
|
131
|
-
let processedData: any = dataset.data;
|
|
132
|
-
|
|
133
|
-
// For scatter plots, convert data to {x, y} format
|
|
134
|
-
if (chartType === 'scatter') {
|
|
135
|
-
processedData = dataset.data.map((value, dataIndex) => ({
|
|
136
|
-
x: dataIndex,
|
|
137
|
-
y: value
|
|
138
|
-
}));
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (chartType === 'pie') {
|
|
142
|
-
return {
|
|
143
|
-
...dataset,
|
|
144
|
-
data: processedData,
|
|
145
|
-
backgroundColor: colors.slice(0, dataset.data.length),
|
|
146
|
-
borderColor: colors.slice(0, dataset.data.length),
|
|
147
|
-
borderWidth: 1
|
|
148
|
-
};
|
|
149
|
-
} else {
|
|
150
|
-
return {
|
|
151
|
-
...dataset,
|
|
152
|
-
data: processedData,
|
|
153
|
-
borderColor: color,
|
|
154
|
-
backgroundColor: chartType === 'line' ? color + '20' : color,
|
|
155
|
-
borderWidth: 2,
|
|
156
|
-
fill: chartType === 'line' ? false : true
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
});
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
useEffect(() => {
|
|
163
|
-
if (!canvasRef.current) return;
|
|
164
|
-
|
|
165
|
-
// Destroy existing chart
|
|
166
|
-
if (chartRef.current) {
|
|
167
|
-
chartRef.current.destroy();
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const chartType = getChartType(type);
|
|
171
|
-
const preparedDatasets = prepareDatasets(effectiveDatasets, chartType, effectiveLabels);
|
|
172
|
-
|
|
173
|
-
const defaultOptions = {
|
|
174
|
-
responsive: true,
|
|
175
|
-
maintainAspectRatio: false,
|
|
176
|
-
plugins: {
|
|
177
|
-
title: {
|
|
178
|
-
display: !!title,
|
|
179
|
-
text: title || ''
|
|
180
|
-
},
|
|
181
|
-
legend: {
|
|
182
|
-
display: legend !== undefined ? legend : (effectiveDatasets.length > 1 || chartType === 'pie')
|
|
183
|
-
}
|
|
184
|
-
},
|
|
185
|
-
onClick: (_event: any, elements: any[]) => {
|
|
186
|
-
if (elements.length > 0) {
|
|
187
|
-
const element = elements[0];
|
|
188
|
-
const dataIndex = element.index;
|
|
189
|
-
const datasetIndex = element.datasetIndex;
|
|
190
|
-
|
|
191
|
-
onchange({
|
|
192
|
-
label: effectiveLabels[dataIndex],
|
|
193
|
-
value: effectiveDatasets[datasetIndex]?.data[dataIndex],
|
|
194
|
-
datasetLabel: effectiveDatasets[datasetIndex]?.label,
|
|
195
|
-
dataIndex,
|
|
196
|
-
datasetIndex
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
const mergedOptions = { ...defaultOptions, ...options };
|
|
203
|
-
|
|
204
|
-
const chartData = chartType === 'scatter'
|
|
205
|
-
? { datasets: preparedDatasets }
|
|
206
|
-
: { labels: effectiveLabels, datasets: preparedDatasets };
|
|
207
|
-
|
|
208
|
-
chartRef.current = new ChartJS(canvasRef.current, {
|
|
209
|
-
type: chartType as any,
|
|
210
|
-
data: chartData,
|
|
211
|
-
options: mergedOptions
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
// Cleanup function
|
|
215
|
-
return () => {
|
|
216
|
-
if (chartRef.current) {
|
|
217
|
-
chartRef.current.destroy();
|
|
218
|
-
chartRef.current = null;
|
|
219
|
-
}
|
|
220
|
-
};
|
|
221
|
-
}, [type, title, height, url, legend, effectiveLabels, effectiveDatasets, options, onchange]);
|
|
222
|
-
|
|
223
|
-
return (
|
|
224
|
-
<div className="widget-chart" style={{ height: `${safeHeight}px`, position: 'relative', width: '100%' }}>
|
|
225
|
-
{isEmpty && (
|
|
226
|
-
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '
|
|
227
|
-
暂无数据
|
|
228
|
-
</div>
|
|
229
|
-
)}
|
|
230
|
-
{loading && (
|
|
231
|
-
<div style={{ position: 'absolute', top: 8, right: 8, zIndex: 1, padding: '2px 8px', borderRadius: 4, background: 'rgba(0, 0, 0, 0.05)', fontSize: 12 }}>
|
|
232
|
-
数据加载中...
|
|
233
|
-
</div>
|
|
234
|
-
)}
|
|
235
|
-
<canvas ref={canvasRef} style={{ maxWidth: '100%', height: '100%' }}></canvas>
|
|
236
|
-
</div>
|
|
237
|
-
);
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Chart as ChartJS,
|
|
4
|
+
CategoryScale,
|
|
5
|
+
LinearScale,
|
|
6
|
+
PointElement,
|
|
7
|
+
LineElement,
|
|
8
|
+
BarElement,
|
|
9
|
+
Title,
|
|
10
|
+
Tooltip,
|
|
11
|
+
Legend,
|
|
12
|
+
ArcElement,
|
|
13
|
+
LineController,
|
|
14
|
+
BarController,
|
|
15
|
+
PieController,
|
|
16
|
+
ScatterController
|
|
17
|
+
} from 'chart.js';
|
|
18
|
+
import { parseChartCsv, normalizeRemoteChartData, RemoteChartData } from './chartRemoteData';
|
|
19
|
+
|
|
20
|
+
// Register Chart.js components
|
|
21
|
+
ChartJS.register(
|
|
22
|
+
CategoryScale,
|
|
23
|
+
LinearScale,
|
|
24
|
+
PointElement,
|
|
25
|
+
LineElement,
|
|
26
|
+
BarElement,
|
|
27
|
+
Title,
|
|
28
|
+
Tooltip,
|
|
29
|
+
Legend,
|
|
30
|
+
ArcElement,
|
|
31
|
+
LineController,
|
|
32
|
+
BarController,
|
|
33
|
+
PieController,
|
|
34
|
+
ScatterController
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
interface Dataset {
|
|
38
|
+
label: string;
|
|
39
|
+
data: number[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ChartProps {
|
|
43
|
+
type: "chart-line" | "chart-bar" | "chart-pie" | "chart-scatter";
|
|
44
|
+
title?: string;
|
|
45
|
+
height?: number;
|
|
46
|
+
/** 远端数据源 URL:设置后运行时 fetch 拉取(CSV 文本或 JSON),本地 CSV 作为初始/后备数据 */
|
|
47
|
+
url?: string;
|
|
48
|
+
/** 是否展示图例:未设置(undefined)时自动判断(多数据集或饼图时展示) */
|
|
49
|
+
legend?: boolean;
|
|
50
|
+
labels: string[];
|
|
51
|
+
datasets: Dataset[];
|
|
52
|
+
options?: Record<string, any>;
|
|
53
|
+
onchange: (data: any) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function Chart({ type, title, height, url = '', legend, labels, datasets, options = {}, onchange }: ChartProps) {
|
|
57
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
58
|
+
const chartRef = useRef<ChartJS | null>(null);
|
|
59
|
+
|
|
60
|
+
// 远端数据:拉取成功后覆盖本地 CSV 数据
|
|
61
|
+
// 加载失败静默处理:回落本地数据,均无数据时图表区域显示灰色"暂无数据"
|
|
62
|
+
const [remoteData, setRemoteData] = useState<RemoteChartData | null>(null);
|
|
63
|
+
const [loading, setLoading] = useState(false);
|
|
64
|
+
|
|
65
|
+
// 实际渲染数据:远端优先,本地 CSV 作为初始/后备
|
|
66
|
+
const effectiveLabels = remoteData ? remoteData.labels : labels;
|
|
67
|
+
const effectiveDatasets = remoteData ? remoteData.datasets : datasets;
|
|
68
|
+
/** 无任何可渲染数据(本地与远端均为空) */
|
|
69
|
+
const isEmpty = !loading && effectiveLabels.length === 0 && effectiveDatasets.length === 0;
|
|
70
|
+
|
|
71
|
+
// 远端数据源:fetch 拉取 CSV 文本或 JSON,成功后覆盖本地数据
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!url) return;
|
|
74
|
+
let cancelled = false;
|
|
75
|
+
setLoading(true);
|
|
76
|
+
(async () => {
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(url);
|
|
79
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
80
|
+
const text = await res.text();
|
|
81
|
+
if (cancelled) return;
|
|
82
|
+
let parsed: RemoteChartData | null = null;
|
|
83
|
+
const trimmed = text.trim();
|
|
84
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
85
|
+
try {
|
|
86
|
+
parsed = normalizeRemoteChartData(JSON.parse(trimmed));
|
|
87
|
+
} catch {
|
|
88
|
+
parsed = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!parsed) parsed = parseChartCsv(text);
|
|
92
|
+
if (cancelled) return;
|
|
93
|
+
if (!parsed) throw new Error('响应无法解析为图表数据(CSV 或 JSON)');
|
|
94
|
+
setRemoteData(parsed);
|
|
95
|
+
} catch {
|
|
96
|
+
// 加载失败:静默回落本地数据
|
|
97
|
+
} finally {
|
|
98
|
+
if (!cancelled) setLoading(false);
|
|
99
|
+
}
|
|
100
|
+
})();
|
|
101
|
+
return () => { cancelled = true; };
|
|
102
|
+
}, [url]);
|
|
103
|
+
|
|
104
|
+
// Clamp height to safe bounds: 200px min, 800px max, 400px default
|
|
105
|
+
const safeHeight = Math.max(200, Math.min(800, height || 400));
|
|
106
|
+
|
|
107
|
+
const getChartType = (widgetType: string) => {
|
|
108
|
+
switch (widgetType) {
|
|
109
|
+
case 'chart-line': return 'line';
|
|
110
|
+
case 'chart-bar': return 'bar';
|
|
111
|
+
case 'chart-pie': return 'pie';
|
|
112
|
+
case 'chart-scatter': return 'scatter';
|
|
113
|
+
default: return 'line';
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const getDefaultColors = () => [
|
|
118
|
+
'rgb(75, 192, 192)',
|
|
119
|
+
'rgb(255, 99, 132)',
|
|
120
|
+
'rgb(54, 162, 235)',
|
|
121
|
+
'rgb(255, 206, 86)',
|
|
122
|
+
'rgb(153, 102, 255)',
|
|
123
|
+
'rgb(255, 159, 64)'
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
const prepareDatasets = (datasets: Dataset[], chartType: string, chartLabels: string[]) => {
|
|
127
|
+
const colors = getDefaultColors();
|
|
128
|
+
|
|
129
|
+
return datasets.map((dataset, index) => {
|
|
130
|
+
const color = colors[index % colors.length];
|
|
131
|
+
let processedData: any = dataset.data;
|
|
132
|
+
|
|
133
|
+
// For scatter plots, convert data to {x, y} format
|
|
134
|
+
if (chartType === 'scatter') {
|
|
135
|
+
processedData = dataset.data.map((value, dataIndex) => ({
|
|
136
|
+
x: dataIndex,
|
|
137
|
+
y: value
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (chartType === 'pie') {
|
|
142
|
+
return {
|
|
143
|
+
...dataset,
|
|
144
|
+
data: processedData,
|
|
145
|
+
backgroundColor: colors.slice(0, dataset.data.length),
|
|
146
|
+
borderColor: colors.slice(0, dataset.data.length),
|
|
147
|
+
borderWidth: 1
|
|
148
|
+
};
|
|
149
|
+
} else {
|
|
150
|
+
return {
|
|
151
|
+
...dataset,
|
|
152
|
+
data: processedData,
|
|
153
|
+
borderColor: color,
|
|
154
|
+
backgroundColor: chartType === 'line' ? color + '20' : color,
|
|
155
|
+
borderWidth: 2,
|
|
156
|
+
fill: chartType === 'line' ? false : true
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!canvasRef.current) return;
|
|
164
|
+
|
|
165
|
+
// Destroy existing chart
|
|
166
|
+
if (chartRef.current) {
|
|
167
|
+
chartRef.current.destroy();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const chartType = getChartType(type);
|
|
171
|
+
const preparedDatasets = prepareDatasets(effectiveDatasets, chartType, effectiveLabels);
|
|
172
|
+
|
|
173
|
+
const defaultOptions = {
|
|
174
|
+
responsive: true,
|
|
175
|
+
maintainAspectRatio: false,
|
|
176
|
+
plugins: {
|
|
177
|
+
title: {
|
|
178
|
+
display: !!title,
|
|
179
|
+
text: title || ''
|
|
180
|
+
},
|
|
181
|
+
legend: {
|
|
182
|
+
display: legend !== undefined ? legend : (effectiveDatasets.length > 1 || chartType === 'pie')
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
onClick: (_event: any, elements: any[]) => {
|
|
186
|
+
if (elements.length > 0) {
|
|
187
|
+
const element = elements[0];
|
|
188
|
+
const dataIndex = element.index;
|
|
189
|
+
const datasetIndex = element.datasetIndex;
|
|
190
|
+
|
|
191
|
+
onchange({
|
|
192
|
+
label: effectiveLabels[dataIndex],
|
|
193
|
+
value: effectiveDatasets[datasetIndex]?.data[dataIndex],
|
|
194
|
+
datasetLabel: effectiveDatasets[datasetIndex]?.label,
|
|
195
|
+
dataIndex,
|
|
196
|
+
datasetIndex
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const mergedOptions = { ...defaultOptions, ...options };
|
|
203
|
+
|
|
204
|
+
const chartData = chartType === 'scatter'
|
|
205
|
+
? { datasets: preparedDatasets }
|
|
206
|
+
: { labels: effectiveLabels, datasets: preparedDatasets };
|
|
207
|
+
|
|
208
|
+
chartRef.current = new ChartJS(canvasRef.current, {
|
|
209
|
+
type: chartType as any,
|
|
210
|
+
data: chartData,
|
|
211
|
+
options: mergedOptions
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Cleanup function
|
|
215
|
+
return () => {
|
|
216
|
+
if (chartRef.current) {
|
|
217
|
+
chartRef.current.destroy();
|
|
218
|
+
chartRef.current = null;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}, [type, title, height, url, legend, effectiveLabels, effectiveDatasets, options, onchange]);
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div className="widget-chart" style={{ height: `${safeHeight}px`, position: 'relative', width: '100%' }}>
|
|
225
|
+
{isEmpty && (
|
|
226
|
+
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#9ca3af', fontSize: 13, background: '#ffffff' }}>
|
|
227
|
+
暂无数据
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
{loading && (
|
|
231
|
+
<div style={{ position: 'absolute', top: 8, right: 8, zIndex: 1, padding: '2px 8px', borderRadius: 4, background: 'rgba(0, 0, 0, 0.05)', fontSize: 12 }}>
|
|
232
|
+
数据加载中...
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
<canvas ref={canvasRef} style={{ maxWidth: '100%', height: '100%' }}></canvas>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
238
|
}
|