@plainviz/core 0.1.0 → 0.2.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.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/ir.d.ts +10 -1
- package/dist/ir.d.ts.map +1 -1
- package/dist/parse.d.ts +1 -1
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +140 -17
- package/dist/parse.test.js +57 -2
- package/package.json +1 -1
- package/src/index.ts +3 -2
- package/src/ir.ts +12 -2
- package/src/parse.test.ts +61 -2
- package/src/parse.ts +154 -19
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { parse } from './parse';
|
|
2
|
-
export type { PlainVizIR, ParseResult, ParseError, ChartType, } from './ir';
|
|
1
|
+
export { parse } from './parse.js';
|
|
2
|
+
export type { PlainVizIR, ParseResult, ParseError, ChartType, DataSeries, } from './ir.js';
|
|
3
3
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,YAAY,EACV,UAAU,EACV,WAAW,EACX,UAAU,EACV,SAAS,EACT,UAAU,GACX,MAAM,SAAS,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { parse } from './parse';
|
|
1
|
+
export { parse } from './parse.js';
|
package/dist/ir.d.ts
CHANGED
|
@@ -3,22 +3,31 @@
|
|
|
3
3
|
* This is the canonical output of the parser.
|
|
4
4
|
* All renderers consume this format.
|
|
5
5
|
*/
|
|
6
|
-
export type ChartType = 'bar' | 'line' | 'pie' | 'area';
|
|
6
|
+
export type ChartType = 'bar' | 'line' | 'pie' | 'area' | 'donut';
|
|
7
|
+
export interface DataSeries {
|
|
8
|
+
name: string;
|
|
9
|
+
values: number[];
|
|
10
|
+
color?: string;
|
|
11
|
+
}
|
|
7
12
|
export interface PlainVizIR {
|
|
8
13
|
type: ChartType;
|
|
9
14
|
title?: string;
|
|
10
15
|
subtitle?: string;
|
|
11
16
|
labels: string[];
|
|
12
17
|
values: number[];
|
|
18
|
+
series?: DataSeries[];
|
|
13
19
|
meta?: {
|
|
14
20
|
xAxis?: string;
|
|
15
21
|
yAxis?: string;
|
|
16
22
|
theme?: string;
|
|
23
|
+
colors?: string[];
|
|
17
24
|
};
|
|
18
25
|
}
|
|
19
26
|
export interface ParseError {
|
|
20
27
|
line: number;
|
|
21
28
|
message: string;
|
|
29
|
+
hint?: string;
|
|
30
|
+
source?: string;
|
|
22
31
|
}
|
|
23
32
|
export type ParseResult = {
|
|
24
33
|
ok: true;
|
package/dist/ir.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ir.d.ts","sourceRoot":"","sources":["../src/ir.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,MAAM,SAAS,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"ir.d.ts","sourceRoot":"","sources":["../src/ir.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,MAAM,SAAS,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,OAAO,CAAC;AAElE,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,MAAM,CAAC,EAAE,UAAU,EAAE,CAAC;IACtB,IAAI,CAAC,EAAE;QACL,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;KACnB,CAAC;CACH;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,WAAW,GACnB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,EAAE,EAAE,UAAU,CAAA;CAAE,GAC5B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,UAAU,EAAE,CAAA;CAAE,CAAC"}
|
package/dist/parse.d.ts
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
* PlainViz Parser
|
|
3
3
|
* Parses PlainViz syntax into IR (Intermediate Representation)
|
|
4
4
|
*/
|
|
5
|
-
import type { ParseResult } from './ir';
|
|
5
|
+
import type { ParseResult } from './ir.js';
|
|
6
6
|
export declare function parse(input: string): ParseResult;
|
|
7
7
|
//# sourceMappingURL=parse.d.ts.map
|
package/dist/parse.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../src/parse.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAc,WAAW,EAAyB,MAAM,
|
|
1
|
+
{"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../src/parse.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAc,WAAW,EAAyB,MAAM,SAAS,CAAC;AAiD9E,wBAAgB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,CA8NhD"}
|
package/dist/parse.js
CHANGED
|
@@ -4,8 +4,34 @@
|
|
|
4
4
|
*/
|
|
5
5
|
const HEADER_KEYS = new Set([
|
|
6
6
|
'type', 'title', 'subtitle', 'theme',
|
|
7
|
-
'x-axis', 'y-axis', 'x', 'y'
|
|
7
|
+
'x-axis', 'y-axis', 'x', 'y',
|
|
8
|
+
'legend', 'colors'
|
|
8
9
|
]);
|
|
10
|
+
// Simple split by comma (both English and Chinese) - for headers like Legend, Colors
|
|
11
|
+
function splitList(value) {
|
|
12
|
+
return value.split(/[,,]/).map(s => s.trim()).filter(s => s);
|
|
13
|
+
}
|
|
14
|
+
// Smart split for data values
|
|
15
|
+
// Preserves number formatting like $1,200 or 1,000,000
|
|
16
|
+
function splitValues(value) {
|
|
17
|
+
// Chinese comma always splits
|
|
18
|
+
// English comma only splits if followed by whitespace or at end
|
|
19
|
+
const parts = value.split(/,|,(?=\s|$)/).map(s => s.trim()).filter(s => s);
|
|
20
|
+
// If we got only one part, return as-is
|
|
21
|
+
if (parts.length <= 1) {
|
|
22
|
+
return parts;
|
|
23
|
+
}
|
|
24
|
+
// Verify all parts look like numbers (with optional formatting)
|
|
25
|
+
// If any part is clearly not a number, don't split
|
|
26
|
+
const allNumbers = parts.every(p => {
|
|
27
|
+
const cleaned = p.replace(/[$%,\s]/g, '');
|
|
28
|
+
return !isNaN(parseFloat(cleaned));
|
|
29
|
+
});
|
|
30
|
+
if (!allNumbers) {
|
|
31
|
+
return [value]; // Return original if not all parts are numbers
|
|
32
|
+
}
|
|
33
|
+
return parts;
|
|
34
|
+
}
|
|
9
35
|
function isHeaderKey(key) {
|
|
10
36
|
return HEADER_KEYS.has(key.toLowerCase());
|
|
11
37
|
}
|
|
@@ -23,9 +49,13 @@ export function parse(input) {
|
|
|
23
49
|
let xAxis;
|
|
24
50
|
let yAxis;
|
|
25
51
|
let theme;
|
|
52
|
+
let legend = [];
|
|
53
|
+
let colors = [];
|
|
26
54
|
const labels = [];
|
|
27
55
|
const values = [];
|
|
56
|
+
const multiValues = []; // 多系列数据
|
|
28
57
|
let inDataSection = false;
|
|
58
|
+
let isMultiSeries = false;
|
|
29
59
|
for (let i = 0; i < lines.length; i++) {
|
|
30
60
|
const lineNum = i + 1;
|
|
31
61
|
const line = lines[i];
|
|
@@ -37,13 +67,43 @@ export function parse(input) {
|
|
|
37
67
|
// Find colon separator
|
|
38
68
|
const colonIndex = trimmed.indexOf(':');
|
|
39
69
|
if (colonIndex === -1) {
|
|
40
|
-
|
|
70
|
+
// Check for common mistakes
|
|
71
|
+
if (trimmed.includes('=')) {
|
|
72
|
+
errors.push({
|
|
73
|
+
line: lineNum,
|
|
74
|
+
message: `Use ':' instead of '='`,
|
|
75
|
+
hint: `Try: ${trimmed.replace('=', ':')}`,
|
|
76
|
+
source: trimmed,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
errors.push({
|
|
81
|
+
line: lineNum,
|
|
82
|
+
message: `Missing ':' separator`,
|
|
83
|
+
hint: `Each line should be "Label: Value", e.g., "Sales: 100"`,
|
|
84
|
+
source: trimmed,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
41
87
|
continue;
|
|
42
88
|
}
|
|
43
89
|
const key = trimmed.slice(0, colonIndex).trim();
|
|
44
90
|
const value = trimmed.slice(colonIndex + 1).trim();
|
|
45
91
|
if (!key) {
|
|
46
|
-
errors.push({
|
|
92
|
+
errors.push({
|
|
93
|
+
line: lineNum,
|
|
94
|
+
message: `Empty label before ':'`,
|
|
95
|
+
hint: `Add a label name, e.g., "Product A: 50"`,
|
|
96
|
+
source: trimmed,
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!value) {
|
|
101
|
+
errors.push({
|
|
102
|
+
line: lineNum,
|
|
103
|
+
message: `Missing value after ':'`,
|
|
104
|
+
hint: `Add a number value, e.g., "${key}: 100"`,
|
|
105
|
+
source: trimmed,
|
|
106
|
+
});
|
|
47
107
|
continue;
|
|
48
108
|
}
|
|
49
109
|
// Parse header fields
|
|
@@ -51,15 +111,19 @@ export function parse(input) {
|
|
|
51
111
|
const keyLower = key.toLowerCase();
|
|
52
112
|
switch (keyLower) {
|
|
53
113
|
case 'type':
|
|
54
|
-
const validTypes = ['bar', 'line', 'pie', 'area'];
|
|
114
|
+
const validTypes = ['bar', 'line', 'pie', 'area', 'donut'];
|
|
55
115
|
const typeLower = value.toLowerCase();
|
|
56
116
|
if (validTypes.includes(typeLower)) {
|
|
57
117
|
type = typeLower;
|
|
58
118
|
}
|
|
59
119
|
else {
|
|
120
|
+
// Find closest match for suggestion
|
|
121
|
+
const suggestion = validTypes.find(t => t.startsWith(typeLower.charAt(0)) || typeLower.includes(t.charAt(0))) || 'bar';
|
|
60
122
|
errors.push({
|
|
61
123
|
line: lineNum,
|
|
62
|
-
message: `
|
|
124
|
+
message: `Unknown chart type "${value}"`,
|
|
125
|
+
hint: `Valid types: bar, line, pie, area. Did you mean "${suggestion}"?`,
|
|
126
|
+
source: trimmed,
|
|
63
127
|
});
|
|
64
128
|
}
|
|
65
129
|
break;
|
|
@@ -80,26 +144,67 @@ export function parse(input) {
|
|
|
80
144
|
case 'theme':
|
|
81
145
|
theme = value;
|
|
82
146
|
break;
|
|
147
|
+
case 'legend':
|
|
148
|
+
legend = splitList(value);
|
|
149
|
+
break;
|
|
150
|
+
case 'colors':
|
|
151
|
+
colors = splitList(value);
|
|
152
|
+
break;
|
|
83
153
|
}
|
|
84
154
|
}
|
|
85
155
|
else {
|
|
86
156
|
// Data section
|
|
87
157
|
inDataSection = true;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
158
|
+
// Check for multi-series (comma-separated values)
|
|
159
|
+
const rawValues = splitValues(value);
|
|
160
|
+
if (rawValues.length > 1) {
|
|
161
|
+
// Multi-series data
|
|
162
|
+
isMultiSeries = true;
|
|
163
|
+
const nums = [];
|
|
164
|
+
let hasError = false;
|
|
165
|
+
for (const rv of rawValues) {
|
|
166
|
+
const num = cleanNumber(rv);
|
|
167
|
+
if (isNaN(num)) {
|
|
168
|
+
errors.push({
|
|
169
|
+
line: lineNum,
|
|
170
|
+
message: `"${rv}" is not a valid number`,
|
|
171
|
+
hint: `Use numbers like: ${key}: 100, 80, 60`,
|
|
172
|
+
source: trimmed,
|
|
173
|
+
});
|
|
174
|
+
hasError = true;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
nums.push(num);
|
|
178
|
+
}
|
|
179
|
+
if (!hasError) {
|
|
180
|
+
labels.push(key);
|
|
181
|
+
multiValues.push(nums);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
// Single value
|
|
186
|
+
const numValue = cleanNumber(value);
|
|
187
|
+
if (isNaN(numValue)) {
|
|
188
|
+
errors.push({
|
|
189
|
+
line: lineNum,
|
|
190
|
+
message: `"${value}" is not a valid number`,
|
|
191
|
+
hint: `Use a number like: ${key}: 100 (supports $, %, commas)`,
|
|
192
|
+
source: trimmed,
|
|
193
|
+
});
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
labels.push(key);
|
|
197
|
+
values.push(numValue);
|
|
95
198
|
}
|
|
96
|
-
labels.push(key);
|
|
97
|
-
values.push(numValue);
|
|
98
199
|
}
|
|
99
200
|
}
|
|
100
201
|
// Validation
|
|
101
202
|
if (labels.length === 0 && errors.length === 0) {
|
|
102
|
-
errors.push({
|
|
203
|
+
errors.push({
|
|
204
|
+
line: 0,
|
|
205
|
+
message: 'No data points found',
|
|
206
|
+
hint: `Add data like:\nApples: 50\nOranges: 30\nBananas: 45`,
|
|
207
|
+
});
|
|
103
208
|
}
|
|
104
209
|
if (errors.length > 0) {
|
|
105
210
|
return { ok: false, errors };
|
|
@@ -107,13 +212,29 @@ export function parse(input) {
|
|
|
107
212
|
const ir = {
|
|
108
213
|
type,
|
|
109
214
|
labels,
|
|
110
|
-
values,
|
|
215
|
+
values: isMultiSeries ? [] : values,
|
|
111
216
|
};
|
|
217
|
+
// Build series for multi-series data
|
|
218
|
+
if (isMultiSeries && multiValues.length > 0) {
|
|
219
|
+
const seriesCount = multiValues[0].length;
|
|
220
|
+
ir.series = [];
|
|
221
|
+
for (let s = 0; s < seriesCount; s++) {
|
|
222
|
+
const seriesName = legend[s] || `Series ${s + 1}`;
|
|
223
|
+
const seriesValues = multiValues.map(row => row[s] ?? 0);
|
|
224
|
+
ir.series.push({
|
|
225
|
+
name: seriesName,
|
|
226
|
+
values: seriesValues,
|
|
227
|
+
color: colors[s],
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
// Also populate values with first series for backward compatibility
|
|
231
|
+
ir.values = ir.series[0]?.values || [];
|
|
232
|
+
}
|
|
112
233
|
if (title)
|
|
113
234
|
ir.title = title;
|
|
114
235
|
if (subtitle)
|
|
115
236
|
ir.subtitle = subtitle;
|
|
116
|
-
if (xAxis || yAxis || theme) {
|
|
237
|
+
if (xAxis || yAxis || theme || colors.length > 0) {
|
|
117
238
|
ir.meta = {};
|
|
118
239
|
if (xAxis)
|
|
119
240
|
ir.meta.xAxis = xAxis;
|
|
@@ -121,6 +242,8 @@ export function parse(input) {
|
|
|
121
242
|
ir.meta.yAxis = yAxis;
|
|
122
243
|
if (theme)
|
|
123
244
|
ir.meta.theme = theme;
|
|
245
|
+
if (colors.length > 0)
|
|
246
|
+
ir.meta.colors = colors;
|
|
124
247
|
}
|
|
125
248
|
return { ok: true, ir };
|
|
126
249
|
}
|
package/dist/parse.test.js
CHANGED
|
@@ -105,7 +105,8 @@ A: abc
|
|
|
105
105
|
`);
|
|
106
106
|
expect(result.ok).toBe(false);
|
|
107
107
|
if (!result.ok) {
|
|
108
|
-
expect(result.errors[0].message).toContain("
|
|
108
|
+
expect(result.errors[0].message).toContain("not a valid number");
|
|
109
|
+
expect(result.errors[0].hint).toBeDefined();
|
|
109
110
|
}
|
|
110
111
|
});
|
|
111
112
|
it('reports error for invalid chart type', () => {
|
|
@@ -115,7 +116,8 @@ A: 10
|
|
|
115
116
|
`);
|
|
116
117
|
expect(result.ok).toBe(false);
|
|
117
118
|
if (!result.ok) {
|
|
118
|
-
expect(result.errors[0].message).toContain("
|
|
119
|
+
expect(result.errors[0].message).toContain("Unknown chart type");
|
|
120
|
+
expect(result.errors[0].hint).toContain("Valid types");
|
|
119
121
|
}
|
|
120
122
|
});
|
|
121
123
|
it('reports error when no data points', () => {
|
|
@@ -151,4 +153,57 @@ A: 10
|
|
|
151
153
|
}
|
|
152
154
|
});
|
|
153
155
|
});
|
|
156
|
+
describe('multi-series parsing', () => {
|
|
157
|
+
it('parses comma-separated values as multi-series', () => {
|
|
158
|
+
const result = parse(`
|
|
159
|
+
Type: Bar
|
|
160
|
+
Legend: 阿里, 腾讯
|
|
161
|
+
营收: 100, 80
|
|
162
|
+
利润: 30, 25
|
|
163
|
+
`);
|
|
164
|
+
expect(result.ok).toBe(true);
|
|
165
|
+
if (result.ok) {
|
|
166
|
+
expect(result.ir.labels).toEqual(['营收', '利润']);
|
|
167
|
+
expect(result.ir.series).toBeDefined();
|
|
168
|
+
expect(result.ir.series?.length).toBe(2);
|
|
169
|
+
expect(result.ir.series?.[0].name).toBe('阿里');
|
|
170
|
+
expect(result.ir.series?.[0].values).toEqual([100, 30]);
|
|
171
|
+
expect(result.ir.series?.[1].name).toBe('腾讯');
|
|
172
|
+
expect(result.ir.series?.[1].values).toEqual([80, 25]);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
it('uses default series names when Legend not provided', () => {
|
|
176
|
+
const result = parse(`
|
|
177
|
+
Type: Bar
|
|
178
|
+
A: 10, 20
|
|
179
|
+
B: 30, 40
|
|
180
|
+
`);
|
|
181
|
+
expect(result.ok).toBe(true);
|
|
182
|
+
if (result.ok) {
|
|
183
|
+
expect(result.ir.series?.[0].name).toBe('Series 1');
|
|
184
|
+
expect(result.ir.series?.[1].name).toBe('Series 2');
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
it('supports Chinese commas', () => {
|
|
188
|
+
const result = parse(`
|
|
189
|
+
Type: Bar
|
|
190
|
+
A: 100,200
|
|
191
|
+
`);
|
|
192
|
+
expect(result.ok).toBe(true);
|
|
193
|
+
if (result.ok) {
|
|
194
|
+
expect(result.ir.series?.length).toBe(2);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
it('preserves number formatting with commas', () => {
|
|
198
|
+
const result = parse(`
|
|
199
|
+
Type: Bar
|
|
200
|
+
Sales: $1,200
|
|
201
|
+
`);
|
|
202
|
+
expect(result.ok).toBe(true);
|
|
203
|
+
if (result.ok) {
|
|
204
|
+
expect(result.ir.values).toEqual([1200]);
|
|
205
|
+
expect(result.ir.series).toBeUndefined();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|
|
154
209
|
});
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
package/src/ir.ts
CHANGED
|
@@ -4,24 +4,34 @@
|
|
|
4
4
|
* All renderers consume this format.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
export type ChartType = 'bar' | 'line' | 'pie' | 'area';
|
|
7
|
+
export type ChartType = 'bar' | 'line' | 'pie' | 'area' | 'donut';
|
|
8
|
+
|
|
9
|
+
export interface DataSeries {
|
|
10
|
+
name: string;
|
|
11
|
+
values: number[];
|
|
12
|
+
color?: string;
|
|
13
|
+
}
|
|
8
14
|
|
|
9
15
|
export interface PlainVizIR {
|
|
10
16
|
type: ChartType;
|
|
11
17
|
title?: string;
|
|
12
18
|
subtitle?: string;
|
|
13
19
|
labels: string[];
|
|
14
|
-
values: number[];
|
|
20
|
+
values: number[]; // 单系列数据(向后兼容)
|
|
21
|
+
series?: DataSeries[]; // 多系列数据
|
|
15
22
|
meta?: {
|
|
16
23
|
xAxis?: string;
|
|
17
24
|
yAxis?: string;
|
|
18
25
|
theme?: string;
|
|
26
|
+
colors?: string[]; // 自定义颜色
|
|
19
27
|
};
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
export interface ParseError {
|
|
23
31
|
line: number;
|
|
24
32
|
message: string;
|
|
33
|
+
hint?: string;
|
|
34
|
+
source?: string;
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
export type ParseResult =
|
package/src/parse.test.ts
CHANGED
|
@@ -114,7 +114,8 @@ A: abc
|
|
|
114
114
|
`);
|
|
115
115
|
expect(result.ok).toBe(false);
|
|
116
116
|
if (!result.ok) {
|
|
117
|
-
expect(result.errors[0].message).toContain("
|
|
117
|
+
expect(result.errors[0].message).toContain("not a valid number");
|
|
118
|
+
expect(result.errors[0].hint).toBeDefined();
|
|
118
119
|
}
|
|
119
120
|
});
|
|
120
121
|
|
|
@@ -125,7 +126,8 @@ A: 10
|
|
|
125
126
|
`);
|
|
126
127
|
expect(result.ok).toBe(false);
|
|
127
128
|
if (!result.ok) {
|
|
128
|
-
expect(result.errors[0].message).toContain("
|
|
129
|
+
expect(result.errors[0].message).toContain("Unknown chart type");
|
|
130
|
+
expect(result.errors[0].hint).toContain("Valid types");
|
|
129
131
|
}
|
|
130
132
|
});
|
|
131
133
|
|
|
@@ -164,4 +166,61 @@ A: 10
|
|
|
164
166
|
}
|
|
165
167
|
});
|
|
166
168
|
});
|
|
169
|
+
|
|
170
|
+
describe('multi-series parsing', () => {
|
|
171
|
+
it('parses comma-separated values as multi-series', () => {
|
|
172
|
+
const result = parse(`
|
|
173
|
+
Type: Bar
|
|
174
|
+
Legend: 阿里, 腾讯
|
|
175
|
+
营收: 100, 80
|
|
176
|
+
利润: 30, 25
|
|
177
|
+
`);
|
|
178
|
+
expect(result.ok).toBe(true);
|
|
179
|
+
if (result.ok) {
|
|
180
|
+
expect(result.ir.labels).toEqual(['营收', '利润']);
|
|
181
|
+
expect(result.ir.series).toBeDefined();
|
|
182
|
+
expect(result.ir.series?.length).toBe(2);
|
|
183
|
+
expect(result.ir.series?.[0].name).toBe('阿里');
|
|
184
|
+
expect(result.ir.series?.[0].values).toEqual([100, 30]);
|
|
185
|
+
expect(result.ir.series?.[1].name).toBe('腾讯');
|
|
186
|
+
expect(result.ir.series?.[1].values).toEqual([80, 25]);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('uses default series names when Legend not provided', () => {
|
|
191
|
+
const result = parse(`
|
|
192
|
+
Type: Bar
|
|
193
|
+
A: 10, 20
|
|
194
|
+
B: 30, 40
|
|
195
|
+
`);
|
|
196
|
+
expect(result.ok).toBe(true);
|
|
197
|
+
if (result.ok) {
|
|
198
|
+
expect(result.ir.series?.[0].name).toBe('Series 1');
|
|
199
|
+
expect(result.ir.series?.[1].name).toBe('Series 2');
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('supports Chinese commas', () => {
|
|
204
|
+
const result = parse(`
|
|
205
|
+
Type: Bar
|
|
206
|
+
A: 100,200
|
|
207
|
+
`);
|
|
208
|
+
expect(result.ok).toBe(true);
|
|
209
|
+
if (result.ok) {
|
|
210
|
+
expect(result.ir.series?.length).toBe(2);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('preserves number formatting with commas', () => {
|
|
215
|
+
const result = parse(`
|
|
216
|
+
Type: Bar
|
|
217
|
+
Sales: $1,200
|
|
218
|
+
`);
|
|
219
|
+
expect(result.ok).toBe(true);
|
|
220
|
+
if (result.ok) {
|
|
221
|
+
expect(result.ir.values).toEqual([1200]);
|
|
222
|
+
expect(result.ir.series).toBeUndefined();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|
|
167
226
|
});
|
package/src/parse.ts
CHANGED
|
@@ -3,13 +3,45 @@
|
|
|
3
3
|
* Parses PlainViz syntax into IR (Intermediate Representation)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { PlainVizIR, ParseResult, ParseError, ChartType } from './ir';
|
|
6
|
+
import type { PlainVizIR, ParseResult, ParseError, ChartType } from './ir.js';
|
|
7
7
|
|
|
8
8
|
const HEADER_KEYS = new Set([
|
|
9
9
|
'type', 'title', 'subtitle', 'theme',
|
|
10
|
-
'x-axis', 'y-axis', 'x', 'y'
|
|
10
|
+
'x-axis', 'y-axis', 'x', 'y',
|
|
11
|
+
'legend', 'colors'
|
|
11
12
|
]);
|
|
12
13
|
|
|
14
|
+
// Simple split by comma (both English and Chinese) - for headers like Legend, Colors
|
|
15
|
+
function splitList(value: string): string[] {
|
|
16
|
+
return value.split(/[,,]/).map(s => s.trim()).filter(s => s);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Smart split for data values
|
|
20
|
+
// Preserves number formatting like $1,200 or 1,000,000
|
|
21
|
+
function splitValues(value: string): string[] {
|
|
22
|
+
// Chinese comma always splits
|
|
23
|
+
// English comma only splits if followed by whitespace or at end
|
|
24
|
+
const parts = value.split(/,|,(?=\s|$)/).map(s => s.trim()).filter(s => s);
|
|
25
|
+
|
|
26
|
+
// If we got only one part, return as-is
|
|
27
|
+
if (parts.length <= 1) {
|
|
28
|
+
return parts;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Verify all parts look like numbers (with optional formatting)
|
|
32
|
+
// If any part is clearly not a number, don't split
|
|
33
|
+
const allNumbers = parts.every(p => {
|
|
34
|
+
const cleaned = p.replace(/[$%,\s]/g, '');
|
|
35
|
+
return !isNaN(parseFloat(cleaned));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (!allNumbers) {
|
|
39
|
+
return [value]; // Return original if not all parts are numbers
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return parts;
|
|
43
|
+
}
|
|
44
|
+
|
|
13
45
|
function isHeaderKey(key: string): boolean {
|
|
14
46
|
return HEADER_KEYS.has(key.toLowerCase());
|
|
15
47
|
}
|
|
@@ -30,11 +62,15 @@ export function parse(input: string): ParseResult {
|
|
|
30
62
|
let xAxis: string | undefined;
|
|
31
63
|
let yAxis: string | undefined;
|
|
32
64
|
let theme: string | undefined;
|
|
65
|
+
let legend: string[] = [];
|
|
66
|
+
let colors: string[] = [];
|
|
33
67
|
|
|
34
68
|
const labels: string[] = [];
|
|
35
69
|
const values: number[] = [];
|
|
70
|
+
const multiValues: number[][] = []; // 多系列数据
|
|
36
71
|
|
|
37
72
|
let inDataSection = false;
|
|
73
|
+
let isMultiSeries = false;
|
|
38
74
|
|
|
39
75
|
for (let i = 0; i < lines.length; i++) {
|
|
40
76
|
const lineNum = i + 1;
|
|
@@ -49,7 +85,22 @@ export function parse(input: string): ParseResult {
|
|
|
49
85
|
// Find colon separator
|
|
50
86
|
const colonIndex = trimmed.indexOf(':');
|
|
51
87
|
if (colonIndex === -1) {
|
|
52
|
-
|
|
88
|
+
// Check for common mistakes
|
|
89
|
+
if (trimmed.includes('=')) {
|
|
90
|
+
errors.push({
|
|
91
|
+
line: lineNum,
|
|
92
|
+
message: `Use ':' instead of '='`,
|
|
93
|
+
hint: `Try: ${trimmed.replace('=', ':')}`,
|
|
94
|
+
source: trimmed,
|
|
95
|
+
});
|
|
96
|
+
} else {
|
|
97
|
+
errors.push({
|
|
98
|
+
line: lineNum,
|
|
99
|
+
message: `Missing ':' separator`,
|
|
100
|
+
hint: `Each line should be "Label: Value", e.g., "Sales: 100"`,
|
|
101
|
+
source: trimmed,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
53
104
|
continue;
|
|
54
105
|
}
|
|
55
106
|
|
|
@@ -57,7 +108,22 @@ export function parse(input: string): ParseResult {
|
|
|
57
108
|
const value = trimmed.slice(colonIndex + 1).trim();
|
|
58
109
|
|
|
59
110
|
if (!key) {
|
|
60
|
-
errors.push({
|
|
111
|
+
errors.push({
|
|
112
|
+
line: lineNum,
|
|
113
|
+
message: `Empty label before ':'`,
|
|
114
|
+
hint: `Add a label name, e.g., "Product A: 50"`,
|
|
115
|
+
source: trimmed,
|
|
116
|
+
});
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!value) {
|
|
121
|
+
errors.push({
|
|
122
|
+
line: lineNum,
|
|
123
|
+
message: `Missing value after ':'`,
|
|
124
|
+
hint: `Add a number value, e.g., "${key}: 100"`,
|
|
125
|
+
source: trimmed,
|
|
126
|
+
});
|
|
61
127
|
continue;
|
|
62
128
|
}
|
|
63
129
|
|
|
@@ -67,14 +133,20 @@ export function parse(input: string): ParseResult {
|
|
|
67
133
|
|
|
68
134
|
switch (keyLower) {
|
|
69
135
|
case 'type':
|
|
70
|
-
const validTypes = ['bar', 'line', 'pie', 'area'];
|
|
136
|
+
const validTypes = ['bar', 'line', 'pie', 'area', 'donut'];
|
|
71
137
|
const typeLower = value.toLowerCase();
|
|
72
138
|
if (validTypes.includes(typeLower)) {
|
|
73
139
|
type = typeLower as ChartType;
|
|
74
140
|
} else {
|
|
141
|
+
// Find closest match for suggestion
|
|
142
|
+
const suggestion = validTypes.find(t =>
|
|
143
|
+
t.startsWith(typeLower.charAt(0)) || typeLower.includes(t.charAt(0))
|
|
144
|
+
) || 'bar';
|
|
75
145
|
errors.push({
|
|
76
146
|
line: lineNum,
|
|
77
|
-
message: `
|
|
147
|
+
message: `Unknown chart type "${value}"`,
|
|
148
|
+
hint: `Valid types: bar, line, pie, area. Did you mean "${suggestion}"?`,
|
|
149
|
+
source: trimmed,
|
|
78
150
|
});
|
|
79
151
|
}
|
|
80
152
|
break;
|
|
@@ -95,28 +167,71 @@ export function parse(input: string): ParseResult {
|
|
|
95
167
|
case 'theme':
|
|
96
168
|
theme = value;
|
|
97
169
|
break;
|
|
170
|
+
case 'legend':
|
|
171
|
+
legend = splitList(value);
|
|
172
|
+
break;
|
|
173
|
+
case 'colors':
|
|
174
|
+
colors = splitList(value);
|
|
175
|
+
break;
|
|
98
176
|
}
|
|
99
177
|
} else {
|
|
100
178
|
// Data section
|
|
101
179
|
inDataSection = true;
|
|
102
180
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
181
|
+
// Check for multi-series (comma-separated values)
|
|
182
|
+
const rawValues = splitValues(value);
|
|
183
|
+
|
|
184
|
+
if (rawValues.length > 1) {
|
|
185
|
+
// Multi-series data
|
|
186
|
+
isMultiSeries = true;
|
|
187
|
+
const nums: number[] = [];
|
|
188
|
+
let hasError = false;
|
|
111
189
|
|
|
112
|
-
|
|
113
|
-
|
|
190
|
+
for (const rv of rawValues) {
|
|
191
|
+
const num = cleanNumber(rv);
|
|
192
|
+
if (isNaN(num)) {
|
|
193
|
+
errors.push({
|
|
194
|
+
line: lineNum,
|
|
195
|
+
message: `"${rv}" is not a valid number`,
|
|
196
|
+
hint: `Use numbers like: ${key}: 100, 80, 60`,
|
|
197
|
+
source: trimmed,
|
|
198
|
+
});
|
|
199
|
+
hasError = true;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
nums.push(num);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!hasError) {
|
|
206
|
+
labels.push(key);
|
|
207
|
+
multiValues.push(nums);
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
// Single value
|
|
211
|
+
const numValue = cleanNumber(value);
|
|
212
|
+
if (isNaN(numValue)) {
|
|
213
|
+
errors.push({
|
|
214
|
+
line: lineNum,
|
|
215
|
+
message: `"${value}" is not a valid number`,
|
|
216
|
+
hint: `Use a number like: ${key}: 100 (supports $, %, commas)`,
|
|
217
|
+
source: trimmed,
|
|
218
|
+
});
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
labels.push(key);
|
|
223
|
+
values.push(numValue);
|
|
224
|
+
}
|
|
114
225
|
}
|
|
115
226
|
}
|
|
116
227
|
|
|
117
228
|
// Validation
|
|
118
229
|
if (labels.length === 0 && errors.length === 0) {
|
|
119
|
-
errors.push({
|
|
230
|
+
errors.push({
|
|
231
|
+
line: 0,
|
|
232
|
+
message: 'No data points found',
|
|
233
|
+
hint: `Add data like:\nApples: 50\nOranges: 30\nBananas: 45`,
|
|
234
|
+
});
|
|
120
235
|
}
|
|
121
236
|
|
|
122
237
|
if (errors.length > 0) {
|
|
@@ -126,16 +241,36 @@ export function parse(input: string): ParseResult {
|
|
|
126
241
|
const ir: PlainVizIR = {
|
|
127
242
|
type,
|
|
128
243
|
labels,
|
|
129
|
-
values,
|
|
244
|
+
values: isMultiSeries ? [] : values,
|
|
130
245
|
};
|
|
131
246
|
|
|
247
|
+
// Build series for multi-series data
|
|
248
|
+
if (isMultiSeries && multiValues.length > 0) {
|
|
249
|
+
const seriesCount = multiValues[0].length;
|
|
250
|
+
ir.series = [];
|
|
251
|
+
|
|
252
|
+
for (let s = 0; s < seriesCount; s++) {
|
|
253
|
+
const seriesName = legend[s] || `Series ${s + 1}`;
|
|
254
|
+
const seriesValues = multiValues.map(row => row[s] ?? 0);
|
|
255
|
+
ir.series.push({
|
|
256
|
+
name: seriesName,
|
|
257
|
+
values: seriesValues,
|
|
258
|
+
color: colors[s],
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Also populate values with first series for backward compatibility
|
|
263
|
+
ir.values = ir.series[0]?.values || [];
|
|
264
|
+
}
|
|
265
|
+
|
|
132
266
|
if (title) ir.title = title;
|
|
133
267
|
if (subtitle) ir.subtitle = subtitle;
|
|
134
|
-
if (xAxis || yAxis || theme) {
|
|
268
|
+
if (xAxis || yAxis || theme || colors.length > 0) {
|
|
135
269
|
ir.meta = {};
|
|
136
270
|
if (xAxis) ir.meta.xAxis = xAxis;
|
|
137
271
|
if (yAxis) ir.meta.yAxis = yAxis;
|
|
138
272
|
if (theme) ir.meta.theme = theme;
|
|
273
|
+
if (colors.length > 0) ir.meta.colors = colors;
|
|
139
274
|
}
|
|
140
275
|
|
|
141
276
|
return { ok: true, ir };
|