@agent-native/core 0.128.3 → 0.128.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.
Files changed (31) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +7 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/chat/legacy-chart-shorthand.tsx +479 -0
  5. package/corpus/core/src/client/chat/markdown-renderer.tsx +23 -3
  6. package/corpus/core/src/extensions/actions.ts +1 -1
  7. package/corpus/templates/analytics/.agents/skills/data-querying/SKILL.md +12 -0
  8. package/corpus/templates/analytics/actions/generate-chart.ts +2 -2
  9. package/corpus/templates/chat/README.md +2 -0
  10. package/dist/client/chat/legacy-chart-shorthand.d.ts +29 -0
  11. package/dist/client/chat/legacy-chart-shorthand.d.ts.map +1 -0
  12. package/dist/client/chat/legacy-chart-shorthand.js +330 -0
  13. package/dist/client/chat/legacy-chart-shorthand.js.map +1 -0
  14. package/dist/client/chat/markdown-renderer.d.ts.map +1 -1
  15. package/dist/client/chat/markdown-renderer.js +8 -2
  16. package/dist/client/chat/markdown-renderer.js.map +1 -1
  17. package/dist/collab/struct-routes.d.ts +1 -1
  18. package/dist/extensions/actions.js +1 -1
  19. package/dist/extensions/actions.js.map +1 -1
  20. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  21. package/dist/observability/routes.d.ts +3 -3
  22. package/dist/provider-api/actions/custom-provider-registration.d.ts +14 -14
  23. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  24. package/dist/server/realtime-token.d.ts +1 -1
  25. package/dist/server/transcribe-voice.d.ts +1 -1
  26. package/dist/templates/chat/README.md +2 -0
  27. package/package.json +1 -1
  28. package/src/client/chat/legacy-chart-shorthand.tsx +479 -0
  29. package/src/client/chat/markdown-renderer.tsx +23 -3
  30. package/src/extensions/actions.ts +1 -1
  31. package/src/templates/chat/README.md +2 -0
@@ -17,12 +17,12 @@ declare const _default: import("../../action.js").ActionDefinition<{
17
17
  id?: undefined;
18
18
  provider?: undefined;
19
19
  } | {
20
- error?: undefined;
21
20
  configured?: undefined;
22
21
  connectPath?: undefined;
23
22
  url: string;
24
23
  id: string;
25
24
  provider: string;
25
+ error?: undefined;
26
26
  }>;
27
27
  export default _default;
28
28
  //# sourceMappingURL=upload-image.d.ts.map
@@ -41,16 +41,16 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
41
41
  thumbsUpRate: number;
42
42
  avgEvalScore: number;
43
43
  } | {
44
- error?: undefined;
45
44
  summary: import("./types.js").TraceSummary;
46
45
  spans: import("./types.js").TraceSpan[];
47
46
  id?: undefined;
47
+ error?: undefined;
48
48
  ok?: undefined;
49
49
  } | {
50
- error?: undefined;
51
50
  summary?: undefined;
52
51
  spans?: undefined;
53
52
  id: string;
53
+ error?: undefined;
54
54
  ok?: undefined;
55
55
  } | {
56
56
  summary?: undefined;
@@ -59,10 +59,10 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
59
59
  error: any;
60
60
  ok?: undefined;
61
61
  } | {
62
- error?: undefined;
63
62
  summary?: undefined;
64
63
  spans?: undefined;
65
64
  id?: undefined;
66
65
  ok: boolean;
66
+ error?: undefined;
67
67
  }>>;
68
68
  //# sourceMappingURL=routes.d.ts.map
@@ -75,11 +75,10 @@ export declare function createCustomProviderRegistrationAction<TSchema extends Z
75
75
  user: "user";
76
76
  }>>;
77
77
  }, z.core.$strip>>, {
78
- deleted?: undefined;
78
+ message?: undefined;
79
79
  id?: undefined;
80
80
  provider?: undefined;
81
- found?: undefined;
82
- message?: undefined;
81
+ deleted?: undefined;
83
82
  providers: {
84
83
  id: string;
85
84
  label: string;
@@ -90,44 +89,45 @@ export declare function createCustomProviderRegistrationAction<TSchema extends Z
90
89
  updatedAt: number;
91
90
  }[];
92
91
  count: number;
92
+ found?: undefined;
93
93
  registered?: undefined;
94
94
  label?: undefined;
95
95
  } | {
96
- deleted?: undefined;
97
- count?: undefined;
98
- id?: undefined;
99
96
  message?: undefined;
97
+ id?: undefined;
98
+ deleted?: undefined;
100
99
  providers?: undefined;
100
+ count?: undefined;
101
101
  found: boolean;
102
102
  provider: import("../custom-registry.js").CustomProviderConfig;
103
103
  registered?: undefined;
104
104
  label?: undefined;
105
105
  } | {
106
- deleted?: undefined;
107
- count?: undefined;
108
- provider?: undefined;
109
106
  message?: undefined;
107
+ provider?: undefined;
108
+ deleted?: undefined;
110
109
  providers?: undefined;
110
+ count?: undefined;
111
111
  found: boolean;
112
112
  id: string;
113
113
  registered?: undefined;
114
114
  label?: undefined;
115
115
  } | {
116
- count?: undefined;
117
- provider?: undefined;
118
- found?: undefined;
119
116
  message?: undefined;
117
+ provider?: undefined;
120
118
  providers?: undefined;
119
+ count?: undefined;
120
+ found?: undefined;
121
121
  deleted: boolean;
122
122
  id: string;
123
123
  registered?: undefined;
124
124
  label?: undefined;
125
125
  } | {
126
+ provider?: undefined;
126
127
  deleted?: undefined;
128
+ providers?: undefined;
127
129
  count?: undefined;
128
- provider?: undefined;
129
130
  found?: undefined;
130
- providers?: undefined;
131
131
  registered: boolean;
132
132
  id: string;
133
133
  label: string;
@@ -312,8 +312,6 @@ export declare function createProviderApiActions(runtime: Pick<ProviderApiRuntim
312
312
  scope?: "org" | "user" | undefined;
313
313
  }, {
314
314
  id?: undefined;
315
- deleted?: undefined;
316
- found?: undefined;
317
315
  providers: {
318
316
  id: string;
319
317
  label: string;
@@ -325,45 +323,47 @@ export declare function createProviderApiActions(runtime: Pick<ProviderApiRuntim
325
323
  }[];
326
324
  count: number;
327
325
  provider?: undefined;
326
+ found?: undefined;
327
+ deleted?: undefined;
328
328
  registered?: undefined;
329
329
  label?: undefined;
330
330
  message?: undefined;
331
331
  } | {
332
- id?: undefined;
333
- deleted?: undefined;
334
332
  count?: undefined;
333
+ id?: undefined;
335
334
  providers?: undefined;
336
335
  found: boolean;
337
336
  provider: import("../custom-registry.js").CustomProviderConfig;
337
+ deleted?: undefined;
338
338
  registered?: undefined;
339
339
  label?: undefined;
340
340
  message?: undefined;
341
341
  } | {
342
- deleted?: undefined;
343
342
  count?: undefined;
344
343
  providers?: undefined;
345
344
  provider?: undefined;
346
345
  found: boolean;
347
346
  id: string;
347
+ deleted?: undefined;
348
348
  registered?: undefined;
349
349
  label?: undefined;
350
350
  message?: undefined;
351
351
  } | {
352
- found?: undefined;
353
352
  count?: undefined;
354
353
  providers?: undefined;
355
354
  provider?: undefined;
355
+ found?: undefined;
356
356
  deleted: boolean;
357
357
  id: string;
358
358
  registered?: undefined;
359
359
  label?: undefined;
360
360
  message?: undefined;
361
361
  } | {
362
- deleted?: undefined;
363
- found?: undefined;
364
362
  count?: undefined;
365
363
  providers?: undefined;
366
364
  provider?: undefined;
365
+ found?: undefined;
366
+ deleted?: undefined;
367
367
  registered: boolean;
368
368
  id: string;
369
369
  label: string;
@@ -26,9 +26,9 @@ export declare function createRealtimeTokenHandler(): import("h3").EventHandlerW
26
26
  expiresAt?: undefined;
27
27
  ttlSeconds?: undefined;
28
28
  } | {
29
- error?: undefined;
30
29
  token: string;
31
30
  expiresAt: string;
32
31
  ttlSeconds: number;
32
+ error?: undefined;
33
33
  }>>;
34
34
  //# sourceMappingURL=realtime-token.d.ts.map
@@ -20,7 +20,7 @@ export declare function createTranscribeVoiceHandler(): import("h3").EventHandle
20
20
  error: string;
21
21
  text?: undefined;
22
22
  } | {
23
- error?: undefined;
24
23
  text: string;
24
+ error?: undefined;
25
25
  }>>;
26
26
  //# sourceMappingURL=transcribe-voice.d.ts.map
@@ -30,3 +30,5 @@ pnpm dev
30
30
  ```
31
31
 
32
32
  Full docs: [agent-native.com/docs/template-chat](https://agent-native.com/docs/template-chat).
33
+
34
+ <!-- vendor-branch mirror end-to-end test — safe to remove -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.128.3",
3
+ "version": "0.128.4",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -0,0 +1,479 @@
1
+ // Some tool schemas expose chart parameters named `type`/`title`/`labels`/
2
+ // `data` (e.g. a chart-generation action). Models occasionally regress to
3
+ // typing those parameter names as a bare chat line instead of calling the
4
+ // tool or emitting a real ```embed fence, e.g.:
5
+ // /chart type=bar title="..." labels=["Mon","Tue"] data=[5,8]
6
+ // That has no real markdown syntax and would otherwise render as inert text.
7
+ // This module detects that generic shape (any "/word ... labels=[...]
8
+ // data=[...]" line) and renders a best-effort inline chart so the user still
9
+ // sees something useful, without hardcoding any single template's tool name.
10
+
11
+ import React from "react";
12
+
13
+ export const LEGACY_CHART_SHORTHAND_LANG = "chart-shorthand";
14
+
15
+ const LEGACY_CHART_PALETTE = [
16
+ "#6366f1",
17
+ "#22c55e",
18
+ "#f59e0b",
19
+ "#ef4444",
20
+ "#0ea5e9",
21
+ "#a855f7",
22
+ ];
23
+
24
+ const SAFE_HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/;
25
+ const BACKSLASH = String.fromCharCode(92);
26
+ const MAX_LINE_LENGTH = 20_000;
27
+ const MAX_LABELS = 60;
28
+ const MAX_SERIES = 6;
29
+
30
+ export interface LegacyChartShorthand {
31
+ type: "bar" | "line" | "area";
32
+ title: string;
33
+ labels: string[];
34
+ series: { label: string; data: number[]; color?: string }[];
35
+ }
36
+
37
+ // Extracts a balanced JSON array/object starting exactly at `startIndex`
38
+ // (which must point at "["), tracking string/escape state so brackets or
39
+ // braces inside JSON string values don't confuse the boundary.
40
+ function extractBalancedArrayAt(
41
+ text: string,
42
+ startIndex: number,
43
+ ): string | null {
44
+ if (text[startIndex] !== "[") return null;
45
+ let depth = 0;
46
+ let inString = false;
47
+ for (let idx = startIndex; idx < text.length; idx++) {
48
+ const ch = text[idx];
49
+ if (inString) {
50
+ if (ch === "\\")
51
+ idx++; // skip escaped character, including \"
52
+ else if (ch === '"') inString = false;
53
+ continue;
54
+ }
55
+ if (ch === '"') inString = true;
56
+ else if (ch === "[" || ch === "{") depth++;
57
+ else if (ch === "]" || ch === "}") {
58
+ depth--;
59
+ if (depth === 0) return text.slice(startIndex, idx + 1);
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+
65
+ // Marks which character indices fall inside a JSON/quoted-string literal so
66
+ // key-token scanning can ignore false matches such as a key= that's part of
67
+ // a title string, or embedded in a label's own text, rather than a real
68
+ // assignment in the line.
69
+ function computeStringMask(text: string): boolean[] {
70
+ const mask = new Array<boolean>(text.length).fill(false);
71
+ let inString = false;
72
+ for (let i = 0; i < text.length; i++) {
73
+ mask[i] = inString;
74
+ const ch = text[i];
75
+ if (inString) {
76
+ if (ch === BACKSLASH) {
77
+ i++;
78
+ if (i < text.length) mask[i] = true;
79
+ continue;
80
+ }
81
+ if (ch === '"') inString = false;
82
+ } else if (ch === '"') {
83
+ inString = true;
84
+ }
85
+ }
86
+ return mask;
87
+ }
88
+
89
+ // Finds the array value immediately following a `key=` token, scanning every
90
+ // occurrence of `key=` outside of quoted strings (not just the first) so a
91
+ // `key=` that appears inside an unrelated quoted string (e.g.
92
+ // title="data=quality", or a label literally containing "data=[1,2]") is
93
+ // skipped in favor of the real one. Only whitespace is allowed between
94
+ // `key=` and the `[`.
95
+ function extractArrayForKey(
96
+ text: string,
97
+ key: "labels" | "data",
98
+ ): string | null {
99
+ const mask = computeStringMask(text);
100
+ const re = new RegExp(`\\b${key}=`, "g");
101
+ let match: RegExpExecArray | null;
102
+ while ((match = re.exec(text))) {
103
+ if (mask[match.index]) continue; // this key= is inside a string literal
104
+ let idx = match.index + match[0].length;
105
+ while (idx < text.length && /\s/.test(text[idx])) idx++;
106
+ const arr = extractBalancedArrayAt(text, idx);
107
+ if (arr) return arr;
108
+ }
109
+ return null;
110
+ }
111
+
112
+ // Extracts the value of a `title="..."`/`title='...'` token, honoring
113
+ // backslash-escaped quotes and backslashes inside the string (e.g.
114
+ // title="Sales \"Metrics\"") instead of stopping at the first escaped quote.
115
+ function extractLegacyChartTitle(text: string): string {
116
+ const match = /\btitle=/.exec(text);
117
+ if (!match) return "";
118
+ let idx = match.index + match[0].length;
119
+ const quoteChar = text[idx];
120
+ if (quoteChar !== '"' && quoteChar !== "'") return "";
121
+ idx++;
122
+ let result = "";
123
+ while (idx < text.length) {
124
+ const ch = text[idx];
125
+ if (ch === BACKSLASH && idx + 1 < text.length) {
126
+ const next = text[idx + 1];
127
+ if (next === quoteChar || next === BACKSLASH) {
128
+ result += next;
129
+ idx += 2;
130
+ continue;
131
+ }
132
+ }
133
+ if (ch === quoteChar) return result;
134
+ result += ch;
135
+ idx++;
136
+ }
137
+ return "";
138
+ }
139
+
140
+ /**
141
+ * Cheap-ish pre-check so callers can skip the full parse on ordinary text.
142
+ * Requires `labels=`/`data=` to actually resolve to bracketed arrays (not
143
+ * just appear as substrings) so unrelated slash commands or API paths that
144
+ * happen to mention both words aren't diverted out of normal markdown
145
+ * rendering.
146
+ */
147
+ export function looksLikeLegacyChartShorthand(line: string): boolean {
148
+ const trimmed = line.trim();
149
+ if (trimmed.length === 0 || trimmed.length > MAX_LINE_LENGTH) return false;
150
+ if (!/^\/[a-zA-Z][\w-]*\b/.test(trimmed)) return false;
151
+ if (!/\blabels=/.test(trimmed) || !/\bdata=/.test(trimmed)) return false;
152
+ return (
153
+ extractArrayForKey(trimmed, "labels") !== null &&
154
+ extractArrayForKey(trimmed, "data") !== null
155
+ );
156
+ }
157
+
158
+ export function parseLegacyChartShorthand(
159
+ rawLine: string,
160
+ ): LegacyChartShorthand | null {
161
+ const trimmed = rawLine.trim();
162
+ if (!looksLikeLegacyChartShorthand(trimmed)) return null;
163
+
164
+ const typeMatch = trimmed.match(/\btype=(bar|line|area)\b/i);
165
+ const chartType =
166
+ (typeMatch?.[1].toLowerCase() as LegacyChartShorthand["type"]) || "bar";
167
+
168
+ const chartTitle = extractLegacyChartTitle(trimmed);
169
+
170
+ const labelsRaw = extractArrayForKey(trimmed, "labels");
171
+ const dataRaw = extractArrayForKey(trimmed, "data");
172
+ if (!labelsRaw || !dataRaw) return null;
173
+
174
+ let parsedLabels: unknown;
175
+ let parsedData: unknown;
176
+ try {
177
+ parsedLabels = JSON.parse(labelsRaw);
178
+ parsedData = JSON.parse(dataRaw);
179
+ } catch {
180
+ return null;
181
+ }
182
+ if (
183
+ !Array.isArray(parsedLabels) ||
184
+ parsedLabels.length === 0 ||
185
+ parsedLabels.length > MAX_LABELS ||
186
+ // Reject nested arrays/objects as labels — String(nestedArray) recurses
187
+ // through Array.prototype.join and can throw on deeply nested input.
188
+ !parsedLabels.every((l) =>
189
+ ["string", "number", "boolean"].includes(typeof l),
190
+ )
191
+ ) {
192
+ return null;
193
+ }
194
+ const safeLabels = parsedLabels.map((l) => String(l));
195
+
196
+ const isValidSeriesData = (values: unknown): values is number[] =>
197
+ Array.isArray(values) &&
198
+ values.length === safeLabels.length &&
199
+ values.every((v) => typeof v === "number" && Number.isFinite(v) && v >= 0);
200
+
201
+ const colorMatch = trimmed.match(/(?:^|\s)color=(#[0-9a-fA-F]{3,8})\b/);
202
+ const topLevelColor =
203
+ colorMatch && SAFE_HEX_COLOR.test(colorMatch[1])
204
+ ? colorMatch[1]
205
+ : undefined;
206
+
207
+ let chartSeries: LegacyChartShorthand["series"];
208
+ if (isValidSeriesData(parsedData)) {
209
+ chartSeries = [
210
+ { label: chartTitle || "Value", data: parsedData, color: topLevelColor },
211
+ ];
212
+ } else if (
213
+ Array.isArray(parsedData) &&
214
+ parsedData.length > 0 &&
215
+ // Reject rather than silently truncate an over-limit series count:
216
+ // presenting a partial chart as complete is worse than plain text.
217
+ parsedData.length <= MAX_SERIES &&
218
+ parsedData.every(
219
+ (d) =>
220
+ d &&
221
+ typeof d === "object" &&
222
+ isValidSeriesData((d as { data?: unknown }).data),
223
+ )
224
+ ) {
225
+ chartSeries = (
226
+ parsedData as { label?: unknown; data: number[]; color?: unknown }[]
227
+ ).map((d, idx) => ({
228
+ label: typeof d.label === "string" ? d.label : `Series ${idx + 1}`,
229
+ data: d.data,
230
+ color:
231
+ typeof d.color === "string" && SAFE_HEX_COLOR.test(d.color)
232
+ ? d.color
233
+ : undefined,
234
+ }));
235
+ } else {
236
+ return null;
237
+ }
238
+ if (chartSeries.length === 0) return null;
239
+ return {
240
+ type: chartType,
241
+ title: chartTitle,
242
+ labels: safeLabels,
243
+ series: chartSeries,
244
+ };
245
+ }
246
+
247
+ // Wraps any line matching the legacy shorthand shape in a fenced code block
248
+ // tagged `chart-shorthand`, so it routes through markdownComponents.pre()
249
+ // like any other language fence instead of rendering as inert prose.
250
+ // Fence/indented-code tracking mirrors ../../shared/markdown-block-split.ts
251
+ // (marker char + length, indented-code detection) so real code blocks —
252
+ // including ~~~ fences, longer-than-3 fences, and 4-space/tab indented code —
253
+ // are never mistaken for chat prose and rewritten.
254
+ export function wrapLegacyChartShorthandLines(markdown: string): string {
255
+ if (!markdown.includes("labels=") || !markdown.includes("data=")) {
256
+ return markdown;
257
+ }
258
+ let fenceMarker = ""; // non-empty while inside a ``` or ~~~ fence
259
+ const lines = markdown.split("\n");
260
+ const out: string[] = [];
261
+ for (const line of lines) {
262
+ // CommonMark: a line indented 4+ spaces (or a tab) is an indented code
263
+ // block, not a fence marker. Check this first so a literal four-space
264
+ // " ```" example in a message doesn't toggle fence state.
265
+ if (/^(?: {4}|\t)/.test(line)) {
266
+ out.push(line);
267
+ continue;
268
+ }
269
+ const trimmed = line.trimStart();
270
+ if (fenceMarker) {
271
+ out.push(line);
272
+ const closeMatch = /^(`{3,}|~{3,})\s*$/.exec(trimmed);
273
+ if (
274
+ closeMatch &&
275
+ closeMatch[1].charAt(0) === fenceMarker.charAt(0) &&
276
+ closeMatch[1].length >= fenceMarker.length
277
+ ) {
278
+ fenceMarker = "";
279
+ }
280
+ continue;
281
+ }
282
+ const openMatch = /^(`{3,}|~{3,})/.exec(trimmed);
283
+ if (openMatch) {
284
+ fenceMarker = openMatch[1].charAt(0).repeat(openMatch[1].length);
285
+ out.push(line);
286
+ continue;
287
+ }
288
+ if (looksLikeLegacyChartShorthand(line)) {
289
+ // Preserve the line's own indentation on the emitted fence so
290
+ // shorthand inside a list item or blockquote continuation stays part
291
+ // of that container instead of being dedented to a top-level block.
292
+ const leadingWs = line.match(/^\s*/)?.[0] ?? "";
293
+ out.push(
294
+ leadingWs + "```" + LEGACY_CHART_SHORTHAND_LANG,
295
+ leadingWs + line.trim(),
296
+ leadingWs + "```",
297
+ );
298
+ continue;
299
+ }
300
+ out.push(line);
301
+ }
302
+ return out.join("\n");
303
+ }
304
+
305
+ export function LegacyChartShorthandFallback({ text }: { text: string }) {
306
+ return <span style={{ whiteSpace: "pre-wrap" }}>{text}</span>;
307
+ }
308
+
309
+ export function LegacyChartShorthandChart({
310
+ parsed,
311
+ }: {
312
+ parsed: LegacyChartShorthand;
313
+ }) {
314
+ const { title, labels, series, type } = parsed;
315
+ const width = 640;
316
+ const height = 280;
317
+ const padding = { top: 16, right: 16, bottom: 44, left: 48 };
318
+ const innerW = width - padding.left - padding.right;
319
+ const innerH = height - padding.top - padding.bottom;
320
+ const maxVal = Math.max(
321
+ 1,
322
+ series.reduce(
323
+ (acc, s) => s.data.reduce((seriesAcc, v) => Math.max(seriesAcc, v), acc),
324
+ 0,
325
+ ),
326
+ );
327
+ const groupW = innerW / labels.length;
328
+ const gridLineCount = 4;
329
+ const gridLines = Array.from({ length: gridLineCount + 1 }, (_, g) => {
330
+ const y = padding.top + (innerH * g) / gridLineCount;
331
+ const val = Math.round(maxVal - (maxVal * g) / gridLineCount);
332
+ return { y, val };
333
+ });
334
+
335
+ const colorFor = (s: LegacyChartShorthand["series"][number], si: number) =>
336
+ s.color || LEGACY_CHART_PALETTE[si % LEGACY_CHART_PALETTE.length];
337
+
338
+ return (
339
+ <div className="my-4 rounded-lg border border-border bg-muted/20 p-3">
340
+ {title && (
341
+ <div className="mb-1 text-sm font-medium text-foreground">{title}</div>
342
+ )}
343
+ <svg
344
+ viewBox={`0 0 ${width} ${height}`}
345
+ className="w-full text-muted-foreground"
346
+ role="img"
347
+ aria-label={title || "Chart"}
348
+ >
349
+ {gridLines.map(({ y, val }, i) => (
350
+ <React.Fragment key={i}>
351
+ <line
352
+ x1={padding.left}
353
+ y1={y}
354
+ x2={width - padding.right}
355
+ y2={y}
356
+ stroke="currentColor"
357
+ strokeOpacity={0.1}
358
+ />
359
+ <text
360
+ x={padding.left - 8}
361
+ y={y + 4}
362
+ textAnchor="end"
363
+ fontSize={10}
364
+ fill="currentColor"
365
+ fillOpacity={0.6}
366
+ >
367
+ {val}
368
+ </text>
369
+ </React.Fragment>
370
+ ))}
371
+
372
+ {type === "bar" &&
373
+ labels.map((_, li) => {
374
+ const groupPad = groupW * 0.15;
375
+ const barsW = groupW - groupPad * 2;
376
+ const barW = barsW / series.length;
377
+ return series.map((s, si) => {
378
+ const value = s.data[li] ?? 0;
379
+ const barH = (Math.max(0, value) / maxVal) * innerH;
380
+ const x = padding.left + li * groupW + groupPad + si * barW;
381
+ const y = padding.top + (innerH - barH);
382
+ return (
383
+ <rect
384
+ key={`${li}-${si}`}
385
+ x={x}
386
+ y={y}
387
+ width={Math.max(1, barW - 2)}
388
+ height={Math.max(0, barH)}
389
+ fill={colorFor(s, si)}
390
+ rx={2}
391
+ />
392
+ );
393
+ });
394
+ })}
395
+
396
+ {type !== "bar" &&
397
+ series.map((s, si) => {
398
+ const coords = labels.map((_, li) => {
399
+ const value = s.data[li] ?? 0;
400
+ const x = padding.left + groupW * li + groupW / 2;
401
+ const y =
402
+ padding.top + innerH - (Math.max(0, value) / maxVal) * innerH;
403
+ return { x, y };
404
+ });
405
+ if (coords.length === 1) {
406
+ // A single point has no line segment to draw — render a marker
407
+ // instead of an invisible zero-length polyline.
408
+ return (
409
+ <circle
410
+ key={si}
411
+ cx={coords[0].x}
412
+ cy={coords[0].y}
413
+ r={4}
414
+ fill={colorFor(s, si)}
415
+ />
416
+ );
417
+ }
418
+ const points = coords
419
+ .map(({ x, y }) => `${x.toFixed(1)},${y.toFixed(1)}`)
420
+ .join(" ");
421
+ const baseY = padding.top + innerH;
422
+ const firstX = padding.left + groupW / 2;
423
+ const lastX =
424
+ padding.left + groupW * (labels.length - 1) + groupW / 2;
425
+ return (
426
+ <React.Fragment key={si}>
427
+ {type === "area" && (
428
+ <polygon
429
+ points={`${firstX.toFixed(1)},${baseY.toFixed(1)} ${points} ${lastX.toFixed(1)},${baseY.toFixed(1)}`}
430
+ fill={colorFor(s, si)}
431
+ fillOpacity={0.15}
432
+ />
433
+ )}
434
+ <polyline
435
+ points={points}
436
+ fill="none"
437
+ stroke={colorFor(s, si)}
438
+ strokeWidth={2}
439
+ />
440
+ </React.Fragment>
441
+ );
442
+ })}
443
+
444
+ {labels.map((label, li) => {
445
+ const x = padding.left + groupW * li + groupW / 2;
446
+ const y = height - padding.bottom + 16;
447
+ const truncated =
448
+ label.length > 12 ? `${label.slice(0, 11)}...` : label;
449
+ return (
450
+ <text
451
+ key={li}
452
+ x={x}
453
+ y={y}
454
+ textAnchor="middle"
455
+ fontSize={10}
456
+ fill="currentColor"
457
+ fillOpacity={0.7}
458
+ >
459
+ {truncated}
460
+ </text>
461
+ );
462
+ })}
463
+ </svg>
464
+ {series.length > 1 && (
465
+ <div className="mt-2 flex flex-wrap gap-3 text-xs text-muted-foreground">
466
+ {series.map((s, si) => (
467
+ <span key={si} className="inline-flex items-center gap-1">
468
+ <span
469
+ className="inline-block h-2 w-2 rounded-full"
470
+ style={{ background: colorFor(s, si) }}
471
+ />
472
+ {s.label}
473
+ </span>
474
+ ))}
475
+ </div>
476
+ )}
477
+ </div>
478
+ );
479
+ }