@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 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
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAChC,YAAY,EACV,UAAU,EACV,WAAW,EACX,UAAU,EACV,SAAS,GACV,MAAM,MAAM,CAAC"}
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;AAExD,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,IAAI,CAAC,EAAE;QACL,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC;CACH;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,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"}
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
@@ -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,MAAM,CAAC;AAiB3E,wBAAgB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,CAuHhD"}
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
- errors.push({ line: lineNum, message: `Missing ':' separator` });
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({ line: lineNum, message: `Empty key before ':'` });
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: `Invalid chart type '${value}'. Valid types: ${validTypes.join(', ')}`
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
- const numValue = cleanNumber(value);
89
- if (isNaN(numValue)) {
90
- errors.push({
91
- line: lineNum,
92
- message: `Invalid number '${value}' for label '${key}'`
93
- });
94
- continue;
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({ line: 0, message: 'No data points found' });
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
  }
@@ -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("Invalid number");
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("Invalid chart type");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainviz/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "PlainViz core parser and IR",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -1,7 +1,8 @@
1
- export { parse } from './parse';
1
+ export { parse } from './parse.js';
2
2
  export type {
3
3
  PlainVizIR,
4
4
  ParseResult,
5
5
  ParseError,
6
6
  ChartType,
7
- } from './ir';
7
+ DataSeries,
8
+ } from './ir.js';
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("Invalid number");
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("Invalid chart type");
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
- errors.push({ line: lineNum, message: `Missing ':' separator` });
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({ line: lineNum, message: `Empty key before ':'` });
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: `Invalid chart type '${value}'. Valid types: ${validTypes.join(', ')}`
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
- const numValue = cleanNumber(value);
104
- if (isNaN(numValue)) {
105
- errors.push({
106
- line: lineNum,
107
- message: `Invalid number '${value}' for label '${key}'`
108
- });
109
- continue;
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
- labels.push(key);
113
- values.push(numValue);
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({ line: 0, message: 'No data points found' });
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 };