@lotics/xlsx 0.1.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.
Files changed (90) hide show
  1. package/package.json +30 -0
  2. package/src/auto_sum.test.ts +71 -0
  3. package/src/auto_sum.ts +83 -0
  4. package/src/canvas_cf.ts +135 -0
  5. package/src/canvas_chart.ts +687 -0
  6. package/src/canvas_fill.ts +156 -0
  7. package/src/canvas_images.ts +99 -0
  8. package/src/canvas_layout.test.ts +159 -0
  9. package/src/canvas_layout.ts +239 -0
  10. package/src/canvas_renderer.ts +1010 -0
  11. package/src/canvas_shape.ts +242 -0
  12. package/src/canvas_sparkline.ts +163 -0
  13. package/src/canvas_text.ts +454 -0
  14. package/src/cell_editor.ts +558 -0
  15. package/src/clipboard.test.ts +145 -0
  16. package/src/clipboard.ts +341 -0
  17. package/src/command_history.ts +102 -0
  18. package/src/csv_parser.test.ts +130 -0
  19. package/src/csv_parser.ts +172 -0
  20. package/src/data_validation.test.ts +95 -0
  21. package/src/data_validation.ts +114 -0
  22. package/src/editing_layer.test.ts +309 -0
  23. package/src/excel_cf_evaluate.test.ts +293 -0
  24. package/src/excel_cf_evaluate.ts +719 -0
  25. package/src/excel_cf_icons.ts +108 -0
  26. package/src/excel_cf_types.ts +67 -0
  27. package/src/excel_numfmt.test.ts +607 -0
  28. package/src/excel_numfmt.ts +1033 -0
  29. package/src/excel_parser.test.ts +1061 -0
  30. package/src/excel_parser.ts +393 -0
  31. package/src/excel_pattern_fills.ts +64 -0
  32. package/src/excel_table_styles.ts +160 -0
  33. package/src/excel_utils.test.ts +108 -0
  34. package/src/excel_utils.ts +162 -0
  35. package/src/fast-formula-parser.d.ts +53 -0
  36. package/src/fill_logic.ts +257 -0
  37. package/src/fill_patterns.test.ts +90 -0
  38. package/src/fill_patterns.ts +71 -0
  39. package/src/flash_fill.test.ts +75 -0
  40. package/src/flash_fill.ts +221 -0
  41. package/src/formula_deps.ts +189 -0
  42. package/src/formula_engine.test.ts +348 -0
  43. package/src/formula_engine.ts +401 -0
  44. package/src/formula_highlight.test.ts +81 -0
  45. package/src/formula_highlight.ts +139 -0
  46. package/src/formula_suggest.ts +100 -0
  47. package/src/hidden_sheets.test.ts +49 -0
  48. package/src/index.ts +149 -0
  49. package/src/keyboard_nav.test.ts +394 -0
  50. package/src/keyboard_nav.ts +891 -0
  51. package/src/move_logic.ts +63 -0
  52. package/src/numfmt_type.test.ts +158 -0
  53. package/src/numfmt_type.ts +231 -0
  54. package/src/ooxml_cell_format.ts +70 -0
  55. package/src/ooxml_chart.test.ts +85 -0
  56. package/src/ooxml_chart.ts +347 -0
  57. package/src/ooxml_chart_types.ts +207 -0
  58. package/src/ooxml_color.ts +339 -0
  59. package/src/ooxml_drawing.test.ts +87 -0
  60. package/src/ooxml_drawing.ts +287 -0
  61. package/src/ooxml_pivot.test.ts +195 -0
  62. package/src/ooxml_pivot.ts +468 -0
  63. package/src/ooxml_pivot_types.ts +165 -0
  64. package/src/ooxml_shape.ts +355 -0
  65. package/src/ooxml_sheet.ts +1271 -0
  66. package/src/ooxml_styles.ts +556 -0
  67. package/src/ooxml_table.test.ts +70 -0
  68. package/src/ooxml_table.ts +131 -0
  69. package/src/ooxml_workbook.test.ts +40 -0
  70. package/src/ooxml_workbook.ts +259 -0
  71. package/src/ooxml_zip.ts +33 -0
  72. package/src/pivot_model.ts +237 -0
  73. package/src/pivot_recompute.test.ts +210 -0
  74. package/src/pivot_recompute.ts +413 -0
  75. package/src/selection_model.ts +364 -0
  76. package/src/spreadsheet_commands.test.ts +772 -0
  77. package/src/spreadsheet_commands.ts +1805 -0
  78. package/src/structured_refs.test.ts +128 -0
  79. package/src/structured_refs.ts +160 -0
  80. package/src/style_helpers.test.ts +33 -0
  81. package/src/style_helpers.ts +30 -0
  82. package/src/template_builder.test.ts +139 -0
  83. package/src/template_builder.ts +36 -0
  84. package/src/types.ts +239 -0
  85. package/src/workbook_model.test.ts +536 -0
  86. package/src/workbook_model.ts +911 -0
  87. package/src/xlsx_round_trip.test.ts +279 -0
  88. package/src/xlsx_writer.test.ts +488 -0
  89. package/src/xlsx_writer.ts +1549 -0
  90. package/src/xml_entities.ts +23 -0
@@ -0,0 +1,687 @@
1
+ // =============================================================================
2
+ // Canvas Chart Renderer
3
+ // =============================================================================
4
+ //
5
+ // Draws parsed charts on the canvas at their anchor positions.
6
+ // Supports: bar, column, line, pie, doughnut, area, scatter, radar.
7
+ // Unsupported types render as labeled placeholders.
8
+ // =============================================================================
9
+
10
+ import type { ParsedChart, ChartSeries } from "./ooxml_chart_types";
11
+ import type { LayoutEngine } from "./canvas_layout";
12
+ import { emuToPixels } from "./canvas_images";
13
+
14
+ // =============================================================================
15
+ // Default colors for series
16
+ // =============================================================================
17
+
18
+ const SERIES_COLORS = [
19
+ "#4472C4", "#ED7D31", "#A5A5A5", "#FFC000", "#5B9BD5",
20
+ "#70AD47", "#264478", "#9B57A0", "#636363", "#EB6F3A",
21
+ ];
22
+
23
+ // =============================================================================
24
+ // Main entry
25
+ // =============================================================================
26
+
27
+ export function drawChart(
28
+ ctx: CanvasRenderingContext2D,
29
+ chart: ParsedChart,
30
+ layout: LayoutEngine,
31
+ scrollX: number,
32
+ scrollY: number,
33
+ ): void {
34
+ const { anchor } = chart;
35
+ const x = (layout.colX[anchor.from.col] ?? 0) + emuToPixels(anchor.from.colOffset ?? 0) - scrollX;
36
+ const y = (layout.rowY[anchor.from.row] ?? 0) + emuToPixels(anchor.from.rowOffset ?? 0) - scrollY;
37
+ const x2 = (layout.colX[anchor.to.col] ?? 0) + emuToPixels(anchor.to.colOffset ?? 0) - scrollX;
38
+ const y2 = (layout.rowY[anchor.to.row] ?? 0) + emuToPixels(anchor.to.rowOffset ?? 0) - scrollY;
39
+ const w = x2 - x;
40
+ const h = y2 - y;
41
+
42
+ if (w <= 0 || h <= 0) return;
43
+
44
+ // Background
45
+ ctx.save();
46
+ ctx.fillStyle = "#FFFFFF";
47
+ ctx.strokeStyle = "#D1D5DB";
48
+ ctx.lineWidth = 1;
49
+ ctx.fillRect(x, y, w, h);
50
+ ctx.strokeRect(x, y, w, h);
51
+
52
+ // Title
53
+ const titleH = chart.title ? 24 : 4;
54
+ if (chart.title) {
55
+ ctx.fillStyle = "#1F2937";
56
+ ctx.font = "bold 13px -apple-system, sans-serif";
57
+ ctx.textAlign = "center";
58
+ ctx.textBaseline = "middle";
59
+ ctx.fillText(chart.title, x + w / 2, y + titleH / 2 + 2, w - 20);
60
+ }
61
+
62
+ // Legend
63
+ const legendH = chart.series.length > 0 ? 20 : 0;
64
+
65
+ // Plot area — adjust for axis labels
66
+ const leftMargin = 60;
67
+ const bottomMargin = 28;
68
+ const plotX = x + leftMargin;
69
+ const plotY = y + titleH + 4;
70
+ const plotW = w - leftMargin - 20;
71
+ const plotH = h - titleH - legendH - bottomMargin;
72
+
73
+ if (plotW <= 0 || plotH <= 0) {
74
+ ctx.restore();
75
+ return;
76
+ }
77
+
78
+ // Render chart type
79
+ switch (chart.chartType) {
80
+ case "col":
81
+ case "bar":
82
+ drawBarChart(ctx, chart, plotX, plotY, plotW, plotH, chart.chartType === "bar");
83
+ break;
84
+ case "line":
85
+ drawLineChart(ctx, chart, plotX, plotY, plotW, plotH);
86
+ break;
87
+ case "pie":
88
+ case "doughnut":
89
+ drawPieChart(ctx, chart, plotX, plotY, plotW, plotH, chart.chartType === "doughnut");
90
+ break;
91
+ case "area":
92
+ drawAreaChart(ctx, chart, plotX, plotY, plotW, plotH);
93
+ break;
94
+ case "scatter":
95
+ case "bubble":
96
+ drawScatterChart(ctx, chart, plotX, plotY, plotW, plotH);
97
+ break;
98
+ case "radar":
99
+ drawRadarChart(ctx, chart, plotX, plotY, plotW, plotH);
100
+ break;
101
+ case "stock":
102
+ drawStockChart(ctx, chart, plotX, plotY, plotW, plotH);
103
+ break;
104
+ case "combo":
105
+ drawComboChart(ctx, chart, plotX, plotY, plotW, plotH);
106
+ break;
107
+ default:
108
+ drawPlaceholder(ctx, chart, plotX, plotY, plotW, plotH);
109
+ }
110
+
111
+ // Legend
112
+ if (legendH > 0 && chart.series.length > 0) {
113
+ drawLegend(ctx, chart.series, x + 10, y + h - legendH, w - 20);
114
+ }
115
+
116
+ ctx.restore();
117
+ }
118
+
119
+ // =============================================================================
120
+ // Bar/Column chart
121
+ // =============================================================================
122
+
123
+ function drawBarChart(
124
+ ctx: CanvasRenderingContext2D,
125
+ chart: ParsedChart,
126
+ x: number, y: number, w: number, h: number,
127
+ horizontal: boolean,
128
+ ): void {
129
+ const { series } = chart;
130
+ if (series.length === 0) return;
131
+
132
+ const maxLen = arrayMax(series.map((s) => s.values.length));
133
+ if (maxLen === 0) return;
134
+
135
+ const allValues = series.flatMap((s) => s.values);
136
+ const maxVal = arrayMax([...allValues, 0]);
137
+ const minVal = arrayMin([...allValues, 0]);
138
+ const range = maxVal - minVal || 1;
139
+
140
+ drawAxisLines(ctx, x, y, w, h);
141
+
142
+ const groupCount = maxLen;
143
+ const seriesCount = series.length;
144
+
145
+ if (horizontal) {
146
+ const groupH = h / groupCount;
147
+ const barH = (groupH * 0.7) / seriesCount;
148
+
149
+ for (let si = 0; si < seriesCount; si++) {
150
+ ctx.fillStyle = series[si].color ?? SERIES_COLORS[si % SERIES_COLORS.length];
151
+ for (let i = 0; i < series[si].values.length; i++) {
152
+ const val = series[si].values[i];
153
+ const barW = ((val - minVal) / range) * w;
154
+ const barY = y + i * groupH + (groupH * 0.15) + si * barH;
155
+ ctx.fillRect(x, barY, barW, barH);
156
+ }
157
+ }
158
+ } else {
159
+ const groupW = w / groupCount;
160
+ const barW = (groupW * 0.7) / seriesCount;
161
+
162
+ for (let si = 0; si < seriesCount; si++) {
163
+ ctx.fillStyle = series[si].color ?? SERIES_COLORS[si % SERIES_COLORS.length];
164
+ for (let i = 0; i < series[si].values.length; i++) {
165
+ const val = series[si].values[i];
166
+ const barH = ((val - minVal) / range) * h;
167
+ const barX = x + i * groupW + (groupW * 0.15) + si * barW;
168
+ ctx.fillRect(barX, y + h - barH, barW, barH);
169
+ }
170
+ }
171
+ }
172
+ }
173
+
174
+ // =============================================================================
175
+ // Line chart
176
+ // =============================================================================
177
+
178
+ function drawLineChart(
179
+ ctx: CanvasRenderingContext2D,
180
+ chart: ParsedChart,
181
+ x: number, y: number, w: number, h: number,
182
+ ): void {
183
+ drawAxisLines(ctx, x, y, w, h);
184
+
185
+ const allValues = chart.series.flatMap((s) => s.values);
186
+ const maxVal = arrayMax([...allValues, 0]);
187
+ const minVal = arrayMin([...allValues, 0]);
188
+ const range = maxVal - minVal || 1;
189
+
190
+ for (let si = 0; si < chart.series.length; si++) {
191
+ const s = chart.series[si];
192
+ const color = s.color ?? SERIES_COLORS[si % SERIES_COLORS.length];
193
+ ctx.strokeStyle = color;
194
+ ctx.lineWidth = 2;
195
+ ctx.beginPath();
196
+
197
+ for (let i = 0; i < s.values.length; i++) {
198
+ const px = x + (i / Math.max(s.values.length - 1, 1)) * w;
199
+ const py = y + h - ((s.values[i] - minVal) / range) * h;
200
+ if (i === 0) ctx.moveTo(px, py);
201
+ else ctx.lineTo(px, py);
202
+ }
203
+ ctx.stroke();
204
+
205
+ // Draw markers
206
+ ctx.fillStyle = color;
207
+ for (let i = 0; i < s.values.length; i++) {
208
+ const px = x + (i / Math.max(s.values.length - 1, 1)) * w;
209
+ const py = y + h - ((s.values[i] - minVal) / range) * h;
210
+ ctx.beginPath();
211
+ ctx.arc(px, py, 3, 0, Math.PI * 2);
212
+ ctx.fill();
213
+ }
214
+ }
215
+ }
216
+
217
+ // =============================================================================
218
+ // Pie / Doughnut chart
219
+ // =============================================================================
220
+
221
+ function drawPieChart(
222
+ ctx: CanvasRenderingContext2D,
223
+ chart: ParsedChart,
224
+ x: number, y: number, w: number, h: number,
225
+ isDoughnut: boolean,
226
+ ): void {
227
+ const values = chart.series[0]?.values ?? [];
228
+ if (values.length === 0) return;
229
+
230
+ const total = values.reduce((a, b) => a + Math.abs(b), 0);
231
+ if (total === 0) return;
232
+
233
+ const cx = x + w / 2;
234
+ const cy = y + h / 2;
235
+ const r = Math.min(w, h) / 2 - 10;
236
+ const innerR = isDoughnut ? r * 0.5 : 0;
237
+
238
+ let startAngle = -Math.PI / 2;
239
+ for (let i = 0; i < values.length; i++) {
240
+ const sliceAngle = (Math.abs(values[i]) / total) * Math.PI * 2;
241
+ ctx.fillStyle = SERIES_COLORS[i % SERIES_COLORS.length];
242
+ ctx.beginPath();
243
+ ctx.arc(cx, cy, r, startAngle, startAngle + sliceAngle);
244
+ if (innerR > 0) {
245
+ ctx.arc(cx, cy, innerR, startAngle + sliceAngle, startAngle, true);
246
+ } else {
247
+ ctx.lineTo(cx, cy);
248
+ }
249
+ ctx.fill();
250
+
251
+ // Slice border
252
+ ctx.strokeStyle = "#FFFFFF";
253
+ ctx.lineWidth = 1;
254
+ ctx.beginPath();
255
+ ctx.arc(cx, cy, r, startAngle, startAngle + sliceAngle);
256
+ if (innerR > 0) {
257
+ ctx.arc(cx, cy, innerR, startAngle + sliceAngle, startAngle, true);
258
+ } else {
259
+ ctx.lineTo(cx, cy);
260
+ }
261
+ ctx.stroke();
262
+
263
+ startAngle += sliceAngle;
264
+ }
265
+ }
266
+
267
+ // =============================================================================
268
+ // Area chart
269
+ // =============================================================================
270
+
271
+ function drawAreaChart(
272
+ ctx: CanvasRenderingContext2D,
273
+ chart: ParsedChart,
274
+ x: number, y: number, w: number, h: number,
275
+ ): void {
276
+ drawAxisLines(ctx, x, y, w, h);
277
+
278
+ const allValues = chart.series.flatMap((s) => s.values);
279
+ const maxVal = arrayMax([...allValues, 0]);
280
+ const minVal = arrayMin([...allValues, 0]);
281
+ const range = maxVal - minVal || 1;
282
+
283
+ for (let si = chart.series.length - 1; si >= 0; si--) {
284
+ const s = chart.series[si];
285
+ const color = s.color ?? SERIES_COLORS[si % SERIES_COLORS.length];
286
+
287
+ ctx.fillStyle = adjustAlpha(color, 0.3);
288
+ ctx.strokeStyle = color;
289
+ ctx.lineWidth = 1.5;
290
+
291
+ ctx.beginPath();
292
+ ctx.moveTo(x, y + h);
293
+ for (let i = 0; i < s.values.length; i++) {
294
+ const px = x + (i / Math.max(s.values.length - 1, 1)) * w;
295
+ const py = y + h - ((s.values[i] - minVal) / range) * h;
296
+ ctx.lineTo(px, py);
297
+ }
298
+ ctx.lineTo(x + w, y + h);
299
+ ctx.closePath();
300
+ ctx.fill();
301
+ ctx.stroke();
302
+ }
303
+ }
304
+
305
+ // =============================================================================
306
+ // Scatter chart
307
+ // =============================================================================
308
+
309
+ function drawScatterChart(
310
+ ctx: CanvasRenderingContext2D,
311
+ chart: ParsedChart,
312
+ x: number, y: number, w: number, h: number,
313
+ ): void {
314
+ drawAxisLabels(ctx, chart, x, y, w, h);
315
+
316
+ const allValues = chart.series.flatMap((s) => s.values);
317
+ const maxVal = arrayMax([...allValues, 1]);
318
+ const isBubble = chart.chartType === "bubble";
319
+
320
+ // Find max bubble size for normalization
321
+ let maxBubble = 1;
322
+ if (isBubble) {
323
+ for (const s of chart.series) {
324
+ if (s.bubbleSizes) {
325
+ maxBubble = Math.max(maxBubble, arrayMax(s.bubbleSizes));
326
+ }
327
+ }
328
+ }
329
+
330
+ for (let si = 0; si < chart.series.length; si++) {
331
+ const s = chart.series[si];
332
+ const color = s.color ?? SERIES_COLORS[si % SERIES_COLORS.length];
333
+ ctx.fillStyle = color;
334
+
335
+ for (let i = 0; i < s.values.length; i++) {
336
+ const px = x + (i / Math.max(s.values.length - 1, 1)) * w;
337
+ const py = y + h - (s.values[i] / maxVal) * h;
338
+
339
+ let radius = 4;
340
+ if (isBubble && s.bubbleSizes && i < s.bubbleSizes.length) {
341
+ radius = 4 + (s.bubbleSizes[i] / maxBubble) * 16;
342
+ }
343
+
344
+ ctx.globalAlpha = isBubble ? 0.6 : 1;
345
+ ctx.beginPath();
346
+ ctx.arc(px, py, radius, 0, Math.PI * 2);
347
+ ctx.fill();
348
+ ctx.globalAlpha = 1;
349
+ }
350
+ }
351
+ }
352
+
353
+ // =============================================================================
354
+ // Radar chart
355
+ // =============================================================================
356
+
357
+ function drawRadarChart(
358
+ ctx: CanvasRenderingContext2D,
359
+ chart: ParsedChart,
360
+ x: number, y: number, w: number, h: number,
361
+ ): void {
362
+ const maxLen = arrayMax([...chart.series.map((s) => s.values.length), 3]);
363
+ const allValues = chart.series.flatMap((s) => s.values);
364
+ const maxVal = arrayMax([...allValues, 1]);
365
+ const cx = x + w / 2;
366
+ const cy = y + h / 2;
367
+ const r = Math.min(w, h) / 2 - 10;
368
+
369
+ // Draw grid
370
+ ctx.strokeStyle = "#E5E7EB";
371
+ ctx.lineWidth = 0.5;
372
+ for (let ring = 1; ring <= 4; ring++) {
373
+ const ringR = (ring / 4) * r;
374
+ ctx.beginPath();
375
+ for (let i = 0; i <= maxLen; i++) {
376
+ const angle = (i / maxLen) * Math.PI * 2 - Math.PI / 2;
377
+ const px = cx + ringR * Math.cos(angle);
378
+ const py = cy + ringR * Math.sin(angle);
379
+ if (i === 0) ctx.moveTo(px, py);
380
+ else ctx.lineTo(px, py);
381
+ }
382
+ ctx.stroke();
383
+ }
384
+
385
+ // Draw series
386
+ for (let si = 0; si < chart.series.length; si++) {
387
+ const s = chart.series[si];
388
+ const color = s.color ?? SERIES_COLORS[si % SERIES_COLORS.length];
389
+ ctx.fillStyle = adjustAlpha(color, 0.15);
390
+ ctx.strokeStyle = color;
391
+ ctx.lineWidth = 2;
392
+
393
+ ctx.beginPath();
394
+ for (let i = 0; i < s.values.length; i++) {
395
+ const angle = (i / maxLen) * Math.PI * 2 - Math.PI / 2;
396
+ const val = (s.values[i] / maxVal) * r;
397
+ const px = cx + val * Math.cos(angle);
398
+ const py = cy + val * Math.sin(angle);
399
+ if (i === 0) ctx.moveTo(px, py);
400
+ else ctx.lineTo(px, py);
401
+ }
402
+ ctx.closePath();
403
+ ctx.fill();
404
+ ctx.stroke();
405
+ }
406
+ }
407
+
408
+ // =============================================================================
409
+ // Stock chart (Open-High-Low-Close)
410
+ // =============================================================================
411
+
412
+ function drawStockChart(
413
+ ctx: CanvasRenderingContext2D,
414
+ chart: ParsedChart,
415
+ x: number, y: number, w: number, h: number,
416
+ ): void {
417
+ drawAxisLabels(ctx, chart, x, y, w, h);
418
+
419
+ // Need at least 4 series: Open, High, Low, Close
420
+ const { series } = chart;
421
+ if (series.length < 4) {
422
+ drawPlaceholder(ctx, chart, x, y, w, h);
423
+ return;
424
+ }
425
+
426
+ const openVals = series[0].values;
427
+ const highVals = series[1].values;
428
+ const lowVals = series[2].values;
429
+ const closeVals = series[3].values;
430
+ const count = Math.min(openVals.length, highVals.length, lowVals.length, closeVals.length);
431
+ if (count === 0) return;
432
+
433
+ const allVals = [...highVals, ...lowVals];
434
+ const maxVal = arrayMax(allVals);
435
+ const minVal = arrayMin(allVals);
436
+ const range = maxVal - minVal || 1;
437
+
438
+ const barW = Math.max(1, (w / count) * 0.6);
439
+
440
+ for (let i = 0; i < count; i++) {
441
+ const cx = x + (i + 0.5) / count * w;
442
+ const highY = y + h - ((highVals[i] - minVal) / range) * h;
443
+ const lowY = y + h - ((lowVals[i] - minVal) / range) * h;
444
+ const openY = y + h - ((openVals[i] - minVal) / range) * h;
445
+ const closeY = y + h - ((closeVals[i] - minVal) / range) * h;
446
+
447
+ const isUp = closeVals[i] >= openVals[i];
448
+ ctx.strokeStyle = isUp ? "#22C55E" : "#EF4444";
449
+ ctx.lineWidth = 1;
450
+
451
+ // Vertical line: High to Low
452
+ ctx.beginPath();
453
+ ctx.moveTo(cx, highY);
454
+ ctx.lineTo(cx, lowY);
455
+ ctx.stroke();
456
+
457
+ // Open tick (left)
458
+ ctx.beginPath();
459
+ ctx.moveTo(cx - barW / 2, openY);
460
+ ctx.lineTo(cx, openY);
461
+ ctx.stroke();
462
+
463
+ // Close tick (right)
464
+ ctx.beginPath();
465
+ ctx.moveTo(cx, closeY);
466
+ ctx.lineTo(cx + barW / 2, closeY);
467
+ ctx.stroke();
468
+ }
469
+ }
470
+
471
+ // =============================================================================
472
+ // Combo chart
473
+ // =============================================================================
474
+
475
+ function drawComboChart(
476
+ ctx: CanvasRenderingContext2D,
477
+ chart: ParsedChart,
478
+ x: number, y: number, w: number, h: number,
479
+ ): void {
480
+ drawAxisLabels(ctx, chart, x, y, w, h);
481
+
482
+ const allValues = chart.series.flatMap((s) => s.values);
483
+ const maxVal = arrayMax([...allValues, 0]);
484
+ const minVal = arrayMin([...allValues, 0]);
485
+ const range = maxVal - minVal || 1;
486
+ const maxLen = arrayMax(chart.series.map((s) => s.values.length));
487
+
488
+ // Separate bar and line series
489
+ const barSeries = chart.series.filter((s) => s.seriesChartType === "col" || s.seriesChartType === "bar");
490
+ const lineSeries = chart.series.filter((s) => s.seriesChartType === "line");
491
+
492
+ // Draw bars
493
+ if (barSeries.length > 0) {
494
+ const groupW = w / maxLen;
495
+ const barW = (groupW * 0.7) / barSeries.length;
496
+ for (let si = 0; si < barSeries.length; si++) {
497
+ ctx.fillStyle = barSeries[si].color ?? SERIES_COLORS[si % SERIES_COLORS.length];
498
+ for (let i = 0; i < barSeries[si].values.length; i++) {
499
+ const val = barSeries[si].values[i];
500
+ const barH = ((val - minVal) / range) * h;
501
+ const barX = x + i * groupW + (groupW * 0.15) + si * barW;
502
+ ctx.fillRect(barX, y + h - barH, barW, barH);
503
+ }
504
+ }
505
+ }
506
+
507
+ // Draw lines
508
+ for (let si = 0; si < lineSeries.length; si++) {
509
+ const s = lineSeries[si];
510
+ const color = s.color ?? SERIES_COLORS[(barSeries.length + si) % SERIES_COLORS.length];
511
+ ctx.strokeStyle = color;
512
+ ctx.lineWidth = 2;
513
+ ctx.beginPath();
514
+ for (let i = 0; i < s.values.length; i++) {
515
+ const px = x + (i + 0.5) / maxLen * w;
516
+ const py = y + h - ((s.values[i] - minVal) / range) * h;
517
+ if (i === 0) ctx.moveTo(px, py);
518
+ else ctx.lineTo(px, py);
519
+ }
520
+ ctx.stroke();
521
+
522
+ ctx.fillStyle = color;
523
+ for (let i = 0; i < s.values.length; i++) {
524
+ const px = x + (i + 0.5) / maxLen * w;
525
+ const py = y + h - ((s.values[i] - minVal) / range) * h;
526
+ ctx.beginPath();
527
+ ctx.arc(px, py, 3, 0, Math.PI * 2);
528
+ ctx.fill();
529
+ }
530
+ }
531
+ }
532
+
533
+ // =============================================================================
534
+ // Placeholder (unsupported chart types)
535
+ // =============================================================================
536
+
537
+ function drawPlaceholder(
538
+ ctx: CanvasRenderingContext2D,
539
+ chart: ParsedChart,
540
+ x: number, y: number, w: number, h: number,
541
+ ): void {
542
+ ctx.fillStyle = "#F3F4F6";
543
+ ctx.fillRect(x, y, w, h);
544
+ ctx.fillStyle = "#9CA3AF";
545
+ ctx.font = "12px -apple-system, sans-serif";
546
+ ctx.textAlign = "center";
547
+ ctx.textBaseline = "middle";
548
+ ctx.fillText(`[${chart.chartType} chart]`, x + w / 2, y + h / 2);
549
+ }
550
+
551
+ // =============================================================================
552
+ // Legend
553
+ // =============================================================================
554
+
555
+ function drawLegend(
556
+ ctx: CanvasRenderingContext2D,
557
+ series: ChartSeries[],
558
+ x: number,
559
+ y: number,
560
+ maxW: number,
561
+ ): void {
562
+ ctx.font = "11px -apple-system, sans-serif";
563
+ ctx.textBaseline = "middle";
564
+
565
+ let cx = x;
566
+ for (let i = 0; i < series.length; i++) {
567
+ const color = series[i].color ?? SERIES_COLORS[i % SERIES_COLORS.length];
568
+ const name = series[i].name ?? `Series ${i + 1}`;
569
+
570
+ // Color box
571
+ ctx.fillStyle = color;
572
+ ctx.fillRect(cx, y + 4, 10, 10);
573
+ cx += 14;
574
+
575
+ // Name
576
+ ctx.fillStyle = "#374151";
577
+ ctx.textAlign = "left";
578
+ ctx.fillText(name, cx, y + 10, maxW - (cx - x) - 10);
579
+ cx += ctx.measureText(name).width + 16;
580
+
581
+ if (cx > x + maxW) break;
582
+ }
583
+ }
584
+
585
+ // =============================================================================
586
+ // Helpers
587
+ // =============================================================================
588
+
589
+ function drawAxisLines(
590
+ ctx: CanvasRenderingContext2D,
591
+ x: number, y: number, w: number, h: number,
592
+ ): void {
593
+ ctx.strokeStyle = "#D1D5DB";
594
+ ctx.lineWidth = 1;
595
+ ctx.beginPath();
596
+ ctx.moveTo(x, y);
597
+ ctx.lineTo(x, y + h);
598
+ ctx.lineTo(x + w, y + h);
599
+ ctx.stroke();
600
+ }
601
+
602
+ function drawAxisLabels(
603
+ ctx: CanvasRenderingContext2D,
604
+ chart: ParsedChart,
605
+ x: number, y: number, w: number, h: number,
606
+ ): void {
607
+ drawAxisLines(ctx, x, y, w, h);
608
+
609
+ ctx.font = "9px -apple-system, sans-serif";
610
+ ctx.fillStyle = "#6B7280";
611
+
612
+ // Value axis: 5 evenly-spaced numeric labels along Y-axis
613
+ const allValues = chart.series.flatMap((s) => s.values);
614
+ if (allValues.length > 0) {
615
+ const maxVal = arrayMax([...allValues, 0]);
616
+ const minVal = arrayMin([...allValues, 0]);
617
+ const range = maxVal - minVal || 1;
618
+
619
+ ctx.textAlign = "right";
620
+ ctx.textBaseline = "middle";
621
+ for (let i = 0; i <= 4; i++) {
622
+ const ratio = i / 4;
623
+ const value = minVal + ratio * range;
624
+ const py = y + h - ratio * h;
625
+ const label = Math.abs(value) >= 1000 ? `${(value / 1000).toFixed(0)}k` : value.toFixed(0);
626
+ ctx.fillText(label, x - 4, py);
627
+ }
628
+ }
629
+
630
+ // Category axis: category names along X-axis
631
+ if (chart.categories && chart.categories.length > 0) {
632
+ ctx.textAlign = "center";
633
+ ctx.textBaseline = "top";
634
+ const count = chart.categories.length;
635
+ for (let i = 0; i < count; i++) {
636
+ const px = x + (i + 0.5) / count * w;
637
+ ctx.fillText(chart.categories[i], px, y + h + 3, w / count - 2);
638
+ }
639
+ }
640
+
641
+ // Axis titles
642
+ if (chart.axes) {
643
+ const valueAxis = chart.axes.find((a) => a.type === "value");
644
+ const catAxis = chart.axes.find((a) => a.type === "category" || a.type === "date");
645
+
646
+ if (catAxis?.title) {
647
+ ctx.font = "10px -apple-system, sans-serif";
648
+ ctx.textAlign = "center";
649
+ ctx.textBaseline = "top";
650
+ ctx.fillText(catAxis.title, x + w / 2, y + h + 14, w);
651
+ }
652
+
653
+ if (valueAxis?.title) {
654
+ ctx.save();
655
+ ctx.font = "10px -apple-system, sans-serif";
656
+ ctx.textAlign = "center";
657
+ ctx.textBaseline = "bottom";
658
+ ctx.translate(x - 40, y + h / 2);
659
+ ctx.rotate(-Math.PI / 2);
660
+ ctx.fillText(valueAxis.title, 0, 0);
661
+ ctx.restore();
662
+ }
663
+ }
664
+ }
665
+
666
+ function adjustAlpha(color: string, alpha: number): string {
667
+ const match = color.match(/^#([0-9a-fA-F]{6})/);
668
+ if (match) {
669
+ const r = parseInt(match[1].slice(0, 2), 16);
670
+ const g = parseInt(match[1].slice(2, 4), 16);
671
+ const b = parseInt(match[1].slice(4, 6), 16);
672
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
673
+ }
674
+ return color;
675
+ }
676
+
677
+ function arrayMin(arr: number[]): number {
678
+ let min = arr[0] ?? 0;
679
+ for (let i = 1; i < arr.length; i++) if (arr[i] < min) min = arr[i];
680
+ return min;
681
+ }
682
+
683
+ function arrayMax(arr: number[]): number {
684
+ let max = arr[0] ?? 0;
685
+ for (let i = 1; i < arr.length; i++) if (arr[i] > max) max = arr[i];
686
+ return max;
687
+ }