@c2-markdown-ui/svelte 0.0.0-stage → 0.4.3

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.
@@ -0,0 +1,278 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from 'svelte';
3
+ import {
4
+ Chart,
5
+ CategoryScale,
6
+ LinearScale,
7
+ PointElement,
8
+ LineElement,
9
+ BarElement,
10
+ Title,
11
+ Tooltip,
12
+ Legend,
13
+ ArcElement,
14
+ LineController,
15
+ BarController,
16
+ PieController,
17
+ ScatterController
18
+ } from 'chart.js';
19
+ import { parseChartCsv, normalizeRemoteChartData, type RemoteChartData } from './chartRemoteData';
20
+
21
+ // Register Chart.js components
22
+ Chart.register(
23
+ CategoryScale,
24
+ LinearScale,
25
+ PointElement,
26
+ LineElement,
27
+ BarElement,
28
+ Title,
29
+ Tooltip,
30
+ Legend,
31
+ ArcElement,
32
+ LineController,
33
+ BarController,
34
+ PieController,
35
+ ScatterController
36
+ );
37
+
38
+ interface Dataset {
39
+ label: string;
40
+ data: number[];
41
+ }
42
+
43
+ interface Props {
44
+ type: "chart-line" | "chart-bar" | "chart-pie" | "chart-scatter";
45
+ title?: string;
46
+ height?: number;
47
+ /** 远端数据源 URL:设置后运行时 fetch 拉取(CSV 文本或 JSON),本地 CSV 作为初始/后备数据 */
48
+ url?: string;
49
+ /** 是否展示图例:未设置(undefined)时自动判断(多数据集或饼图时展示) */
50
+ legend?: boolean;
51
+ labels: string[];
52
+ datasets: Dataset[];
53
+ options?: Record<string, any>;
54
+ onchange: (data: any) => void;
55
+ }
56
+
57
+ let { type, title, height, url = '', legend, labels, datasets, options = {}, onchange }: Props = $props();
58
+
59
+ // 远端数据:拉取成功后覆盖本地 CSV 数据
60
+ // 加载失败静默处理:回落本地数据,均无数据时图表区域显示灰色"暂无数据"
61
+ let remoteData = $state<RemoteChartData | null>(null);
62
+ let loading = $state(false);
63
+
64
+ // 实际渲染数据:远端优先,本地 CSV 作为初始/后备
65
+ const effectiveLabels = $derived(remoteData ? remoteData.labels : labels);
66
+ const effectiveDatasets = $derived(remoteData ? remoteData.datasets : datasets);
67
+ /** 无任何可渲染数据(本地与远端均为空) */
68
+ const isEmpty = $derived(
69
+ !loading && effectiveLabels.length === 0 && effectiveDatasets.length === 0
70
+ );
71
+
72
+ // Clamp height to safe bounds: 200px min, 800px max, 400px default
73
+ const safeHeight = Math.max(200, Math.min(800, height || 400));
74
+
75
+ let canvas = $state<HTMLCanvasElement>();
76
+ let chart: Chart | null = null;
77
+
78
+ const getChartType = (widgetType: string) => {
79
+ switch (widgetType) {
80
+ case 'chart-line': return 'line';
81
+ case 'chart-bar': return 'bar';
82
+ case 'chart-pie': return 'pie';
83
+ case 'chart-scatter': return 'scatter';
84
+ default: return 'line';
85
+ }
86
+ };
87
+
88
+ const getDefaultColors = () => [
89
+ '#1677ff',
90
+ '#52c41a',
91
+ '#faad14',
92
+ '#ff4d4f',
93
+ '#722ed1',
94
+ '#13c2c2'
95
+ ];
96
+
97
+ const prepareDatasets = (datasets: Dataset[], chartType: string, chartLabels: string[]) => {
98
+ const colors = getDefaultColors();
99
+
100
+ return datasets.map((dataset, index) => {
101
+ const color = colors[index % colors.length];
102
+ let processedData: any = dataset.data;
103
+
104
+ // For scatter plots, convert data to {x, y} format
105
+ if (chartType === 'scatter') {
106
+ processedData = dataset.data.map((value, dataIndex) => ({
107
+ x: dataIndex,
108
+ y: value
109
+ }));
110
+ }
111
+
112
+ if (chartType === 'pie') {
113
+ return {
114
+ ...dataset,
115
+ data: processedData,
116
+ backgroundColor: colors.slice(0, dataset.data.length),
117
+ borderColor: colors.slice(0, dataset.data.length),
118
+ borderWidth: 1
119
+ };
120
+ } else {
121
+ return {
122
+ ...dataset,
123
+ data: processedData,
124
+ borderColor: color,
125
+ backgroundColor: chartType === 'line' ? color + '20' : color,
126
+ borderWidth: 2,
127
+ fill: chartType === 'line' ? false : true
128
+ };
129
+ }
130
+ });
131
+ };
132
+
133
+ // 远端数据源:fetch 拉取 CSV 文本或 JSON,成功后覆盖本地数据并重建图表
134
+ $effect(() => {
135
+ if (!url) return;
136
+ loading = true;
137
+ let cancelled = false;
138
+ (async () => {
139
+ try {
140
+ const res = await fetch(url);
141
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
142
+ const text = await res.text();
143
+ if (cancelled) return;
144
+ let parsed: RemoteChartData | null = null;
145
+ const trimmed = text.trim();
146
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
147
+ try {
148
+ parsed = normalizeRemoteChartData(JSON.parse(trimmed));
149
+ } catch {
150
+ parsed = null;
151
+ }
152
+ }
153
+ if (!parsed) parsed = parseChartCsv(text);
154
+ if (cancelled) return;
155
+ if (!parsed) throw new Error('响应无法解析为图表数据(CSV 或 JSON)');
156
+ remoteData = parsed;
157
+ } catch {
158
+ // 加载失败:静默回落本地数据(均无数据时图表区域显示灰色"暂无数据")
159
+ } finally {
160
+ if (!cancelled) loading = false;
161
+ }
162
+ })();
163
+ return () => { cancelled = true; };
164
+ });
165
+
166
+ // 图表构建/重建:type/标题/数据(本地或远端)变化时销毁重建
167
+ $effect(() => {
168
+ if (!canvas) return;
169
+
170
+ const chartType = getChartType(type);
171
+ // Svelte 5 的 $state/$derived 会生成深度代理,而 Chart.js 会向数据数组定义
172
+ // $listeners 等内部属性,触发 state_descriptors_fixed 报错;深拷贝为纯数据后再使用
173
+ const plainLabels: string[] = JSON.parse(JSON.stringify(effectiveLabels ?? []));
174
+ const plainDatasets: Dataset[] = JSON.parse(JSON.stringify(effectiveDatasets ?? []));
175
+ const preparedDatasets = prepareDatasets(plainDatasets, chartType, plainLabels);
176
+
177
+ const defaultOptions = {
178
+ responsive: true,
179
+ maintainAspectRatio: false,
180
+ plugins: {
181
+ title: {
182
+ display: !!title,
183
+ text: title || ''
184
+ },
185
+ legend: {
186
+ display: legend !== undefined ? legend : (plainDatasets.length > 1 || chartType === 'pie')
187
+ }
188
+ },
189
+ onClick: (_event: any, elements: any[]) => {
190
+ if (elements.length > 0) {
191
+ const element = elements[0];
192
+ const dataIndex = element.index;
193
+ const datasetIndex = element.datasetIndex;
194
+
195
+ onchange({
196
+ label: plainLabels[dataIndex],
197
+ value: plainDatasets[datasetIndex]?.data[dataIndex],
198
+ datasetLabel: plainDatasets[datasetIndex]?.label,
199
+ dataIndex,
200
+ datasetIndex
201
+ });
202
+ }
203
+ }
204
+ };
205
+
206
+ const mergedOptions = { ...defaultOptions, ...options };
207
+
208
+ const chartData = chartType === 'scatter'
209
+ ? { datasets: preparedDatasets }
210
+ : { labels: plainLabels, datasets: preparedDatasets };
211
+
212
+ if (chart) {
213
+ chart.destroy();
214
+ chart = null;
215
+ }
216
+ chart = new Chart(canvas, {
217
+ type: chartType as any,
218
+ data: chartData,
219
+ options: mergedOptions
220
+ });
221
+ });
222
+
223
+ onDestroy(() => {
224
+ if (chart) {
225
+ chart.destroy();
226
+ chart = null;
227
+ }
228
+ });
229
+ </script>
230
+
231
+ <div class="widget-chart" style="height: {safeHeight}px;">
232
+ {#if isEmpty}
233
+ <div class="chart-empty">暂无数据</div>
234
+ {/if}
235
+ {#if loading}
236
+ <div class="chart-loading">数据加载中...</div>
237
+ {/if}
238
+ <canvas bind:this={canvas}></canvas>
239
+ </div>
240
+
241
+ <style>
242
+ .widget-chart {
243
+ position: relative;
244
+ width: 100%;
245
+ }
246
+
247
+ canvas {
248
+ max-width: 100%;
249
+ height: 100%;
250
+ }
251
+
252
+ .chart-empty {
253
+ position: absolute;
254
+ top: 0;
255
+ left: 0;
256
+ right: 0;
257
+ bottom: 0;
258
+ z-index: 1;
259
+ display: flex;
260
+ align-items: center;
261
+ justify-content: center;
262
+ color: rgba(0, 0, 0, 0.45);
263
+ font-size: 13px;
264
+ background: #ffffff;
265
+ }
266
+
267
+ .chart-loading {
268
+ position: absolute;
269
+ top: 8px;
270
+ right: 8px;
271
+ z-index: 1;
272
+ padding: 2px 8px;
273
+ border-radius: 4px;
274
+ background: rgba(0, 0, 0, 0.05);
275
+ color: inherit;
276
+ font-size: 12px;
277
+ }
278
+ </style>