angular-my-report 0.0.0-stage → 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 (46) hide show
  1. package/README.md +99 -2
  2. package/ng-package.json +7 -0
  3. package/package.json +23 -3
  4. package/src/lib/engine/barcode.ts +118 -0
  5. package/src/lib/engine/chart.ts +85 -0
  6. package/src/lib/engine/chartLayout.ts +265 -0
  7. package/src/lib/engine/crosstab.ts +96 -0
  8. package/src/lib/engine/dataSources.ts +204 -0
  9. package/src/lib/engine/expressions.ts +734 -0
  10. package/src/lib/engine/measure.ts +62 -0
  11. package/src/lib/engine/parameters.ts +96 -0
  12. package/src/lib/engine/qr.ts +26 -0
  13. package/src/lib/engine/renderer.ts +324 -0
  14. package/src/lib/engine/table.ts +58 -0
  15. package/src/lib/model/defaults.ts +249 -0
  16. package/src/lib/model/factories.ts +324 -0
  17. package/src/lib/model/types.ts +281 -0
  18. package/src/lib/ui/css.ts +12 -0
  19. package/src/lib/ui/designer/banded-table-dialog.component.ts +127 -0
  20. package/src/lib/ui/designer/canvas.component.ts +281 -0
  21. package/src/lib/ui/designer/data-source-dialog.component.ts +201 -0
  22. package/src/lib/ui/designer/element-box.component.ts +138 -0
  23. package/src/lib/ui/designer/graphics.components.ts +140 -0
  24. package/src/lib/ui/designer/left-panel.component.ts +189 -0
  25. package/src/lib/ui/designer/safe-image.pipe.ts +16 -0
  26. package/src/lib/ui/designer/table-view.component.ts +120 -0
  27. package/src/lib/ui/designer.component.ts +296 -0
  28. package/src/lib/ui/designer.store.ts +230 -0
  29. package/src/lib/ui/inputs.ts +34 -0
  30. package/src/lib/ui/myreport.css +267 -0
  31. package/src/lib/ui/properties/band-props.component.ts +86 -0
  32. package/src/lib/ui/properties/element-props.component.ts +326 -0
  33. package/src/lib/ui/properties/expr-input.component.ts +191 -0
  34. package/src/lib/ui/properties/graphics-props.component.ts +260 -0
  35. package/src/lib/ui/properties/image-picker.component.ts +62 -0
  36. package/src/lib/ui/properties/properties-panel.component.ts +37 -0
  37. package/src/lib/ui/properties/report-extras.component.ts +198 -0
  38. package/src/lib/ui/properties/report-props.component.ts +211 -0
  39. package/src/lib/ui/properties/table-props.component.ts +197 -0
  40. package/src/lib/ui/viewer/page-view.component.ts +73 -0
  41. package/src/lib/ui/viewer/render.ts +161 -0
  42. package/src/lib/ui/viewer/viewer.component.ts +555 -0
  43. package/src/lib/ui/viewer.component.ts +107 -0
  44. package/src/public-api.ts +12 -0
  45. package/tsconfig.lib.json +12 -0
  46. package/tsconfig.lib.prod.json +5 -0
package/README.md CHANGED
@@ -1,3 +1,100 @@
1
- # Temporary Holding Version
1
+ # @mohamed351/angular
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Banded report designer and viewer for Angular, as two standalone components.
4
+
5
+ ```bash
6
+ npm install @mohamed351/angular
7
+ ```
8
+
9
+ Requires Angular 19.2 or later. The styles are bundled with the components and scoped under `.myreport`, so there is no stylesheet to import and nothing leaks into your app.
10
+
11
+ ## Designer
12
+
13
+ ```ts
14
+ import { MyReportDesignerComponent, type ReportDefinition } from '@mohamed351/angular';
15
+
16
+ @Component({
17
+ imports: [MyReportDesignerComponent],
18
+ template: `<myreport-designer path="/reports/sales.report.json" [download]="false" (save)="store($event)" />`,
19
+ styles: `myreport-designer { height: 100vh; }`,
20
+ })
21
+ export class DesignerPage {
22
+ store(report: ReportDefinition) { /* POST it to your API */ }
23
+ }
24
+ ```
25
+
26
+ | Input | Type | Description |
27
+ |---|---|---|
28
+ | `path` | `string` | URL of a `.report.json` to open. |
29
+ | `report` | `ReportDefinition \| string` | A report to open (object or JSON). Used when `path` is empty. |
30
+ | `datasource` | `string \| array \| object` | Data to design against, replacing the report's data source. See [Data](#data). |
31
+ | `type` | `'json' \| 'odata'` | How `datasource` is read. Default `json`. |
32
+ | `params` | `object \| string` | Parameter values. See [Parameters](#parameters). |
33
+ | `persistKey` | `string` | `localStorage` key to autosave to. When `path` and `report` are empty, the saved report is restored from it. |
34
+ | `viewerUrl` | `string` | Shows a "Viewer ↗" toolbar link to this URL. |
35
+ | `download` | `boolean` | Whether **Save** downloads a `.report.json`. Default `true`. `(save)` fires either way. |
36
+ | `brand` | `string` | Toolbar title. Default `My Report`. |
37
+
38
+ | Output | Payload |
39
+ |---|---|
40
+ | `save` | `ReportDefinition`, when the user clicks Save |
41
+ | `reportChange` | `ReportDefinition`, after every edit |
42
+ | `loaded` | `ReportDefinition`, after `path` loaded |
43
+ | `loadError` | `string` |
44
+
45
+ ## Viewer
46
+
47
+ ```html
48
+ <!-- the report's own data source -->
49
+ <myreport-viewer path="/reports/sales.report.json" />
50
+
51
+ <!-- JSON from your API -->
52
+ <myreport-viewer path="/reports/sales.report.json" datasource="/api/orders" type="json" />
53
+
54
+ <!-- OData: service root or full entity-set URL -->
55
+ <myreport-viewer path="/reports/orders.report.json" datasource="https://host/odata/Orders" type="odata" />
56
+
57
+ <!-- data you already have -->
58
+ <myreport-viewer path="/reports/sales.report.json" [datasource]="orders" />
59
+ ```
60
+
61
+ | Input | Type | Description |
62
+ |---|---|---|
63
+ | `path` | `string` | URL of the `.report.json` to show. |
64
+ | `report` | `ReportDefinition \| string` | A report to show (object or JSON). Used when `path` is empty. |
65
+ | `datasource` | `string \| array \| object` | The report's data. See [Data](#data). |
66
+ | `type` | `'json' \| 'odata'` | How `datasource` is read. Default `json`. |
67
+ | `params` | `object \| string` | Parameter values. See [Parameters](#parameters). |
68
+ | `designerUrl` | `string` | Shows a "Designer" toolbar link to this URL. |
69
+ | `allowOpen` | `boolean` | Shows the toolbar Open button for local `.report.json` files. Default `true`. |
70
+
71
+ | Output | Payload |
72
+ |---|---|
73
+ | `loaded` | `ReportDefinition` |
74
+ | `dataLoaded` | the data rows |
75
+ | `loadError` | `string`, for a report or data load failure |
76
+
77
+ The viewer has paging, zoom, find, bookmarks, thumbnails, parameters, print, and export to PDF (via the print dialog), HTML, CSV and `.report.json`.
78
+
79
+ ## Data
80
+
81
+ `datasource` replaces the data source saved in the report. Leave it empty to use the report's own.
82
+
83
+ - **URL, `type="json"`**: fetched as JSON. The report's array path (for example `data.items`) still applies. If that path is missing from the response, the array is detected automatically.
84
+ - **URL, `type="odata"`**: an OData v2/v4 service root or entity-set URL. The report's entity set, `$select`, `$filter`, `$orderby`, `$expand` and `$top` still apply, and `nextLink` paging is followed.
85
+ - **JSON string, array or object**: used as the data directly.
86
+
87
+ Request headers (for example `Authorization`) come from the report's data source settings.
88
+
89
+ ## Parameters
90
+
91
+ Pass values as an object (`{ Country: 'Egypt' }`), a JSON string, or a query string (`Country=Egypt&Year=2024`). They fill the report's parameters, are used as `@Country` in formulas and `{@Country}` in data source URLs, and can be changed in the viewer's parameter bar.
92
+
93
+ ## Also exported
94
+
95
+ The report model types (`ReportDefinition`, `Band`, `ReportElement` and others), `newReport()`, `receiptReport(width)`, `loadData()` and `detectDataSource()`.
96
+
97
+ ## Other frameworks
98
+
99
+ - React: [`@mohamed351/react`](https://www.npmjs.com/package/@mohamed351/react)
100
+ - Any framework or plain HTML (Web Components): [`@mohamed351/elements`](https://www.npmjs.com/package/@mohamed351/elements)
@@ -0,0 +1,7 @@
1
+ {
2
+ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
3
+ "dest": "../../dist/angular",
4
+ "lib": { "entryFile": "src/public-api.ts" },
5
+ "allowedNonPeerDependencies": ["qrcode-generator"],
6
+ "assets": ["README.md"]
7
+ }
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "angular-my-report",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "MyReport banded report designer and viewer for Angular: <myreport-designer> and <myreport-viewer>.",
5
+ "keywords": [
6
+ "angular",
7
+ "report",
8
+ "report-designer",
9
+ "report-viewer",
10
+ "odata",
11
+ "json",
12
+ "pdf",
13
+ "print"
14
+ ],
15
+ "license": "MIT",
16
+ "peerDependencies": {
17
+ "@angular/common": ">=19.2.0 <21.0.0",
18
+ "@angular/core": ">=19.2.0 <21.0.0",
19
+ "@angular/forms": ">=19.2.0 <21.0.0"
20
+ },
21
+ "dependencies": {
22
+ "qrcode-generator": "^1.4.4",
23
+ "tslib": "^2.3.0"
24
+ },
25
+ "sideEffects": false
6
26
  }
@@ -0,0 +1,118 @@
1
+ import type { BarcodeSymbology } from '../model/types';
2
+
3
+ export interface BarcodeResult {
4
+ /** One char per module: '1' = bar, '0' = space. Empty when `error` is set. */
5
+ modules: string;
6
+ /** Human-readable text printed under the bars. */
7
+ text: string;
8
+ error?: string;
9
+ }
10
+
11
+ // Code 128 symbol widths (bar, space, bar, space, bar, space) for values 0–105, then STOP (106).
12
+ const CODE128 = [
13
+ '212222', '222122', '222221', '121223', '121322', '131222', '122213', '122312', '132212', '221213',
14
+ '221312', '231212', '112232', '122132', '122231', '113222', '123122', '123221', '223211', '221132',
15
+ '221231', '213212', '223112', '312131', '311222', '321122', '321221', '312212', '322112', '322211',
16
+ '212123', '212321', '232121', '111323', '131123', '131321', '112313', '132113', '132311', '211313',
17
+ '231113', '231311', '112133', '112331', '132131', '113123', '113321', '133121', '313121', '211331',
18
+ '231131', '213113', '213311', '213131', '311123', '311321', '331121', '312113', '312311', '332111',
19
+ '314111', '221411', '431111', '111224', '111422', '121124', '121421', '141122', '141221', '112214',
20
+ '112412', '122114', '122411', '142112', '142211', '241211', '221114', '413111', '241112', '134111',
21
+ '111242', '121142', '121241', '114212', '124112', '124211', '411212', '421112', '421211', '212141',
22
+ '214121', '412121', '111143', '111341', '131141', '114113', '114311', '411113', '411311', '113141',
23
+ '114131', '311141', '411131', '211412', '211214', '211232', '2331112',
24
+ ];
25
+ const START_B = 104;
26
+ const START_C = 105;
27
+ const CODE_B = 100;
28
+ const CODE_C = 99;
29
+ const STOP = 106;
30
+
31
+ const widthsToModules = (w: string) => [...w].map((n, i) => (i % 2 === 0 ? '1' : '0').repeat(Number(n))).join('');
32
+
33
+ /**
34
+ * Code 128: code set B for text, switching to code set C for runs of 4+ digits (denser).
35
+ * Supports printable ASCII (32–126).
36
+ */
37
+ export function encodeCode128(value: string): BarcodeResult {
38
+ if (!value) return { modules: '', text: '', error: 'Empty value' };
39
+ if (![...value].every((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) <= 126)) {
40
+ return { modules: '', text: value, error: 'Code 128 supports printable ASCII only' };
41
+ }
42
+ const digitRun = (i: number) => {
43
+ let n = 0;
44
+ while (i + n < value.length && value[i + n] >= '0' && value[i + n] <= '9') n++;
45
+ return n;
46
+ };
47
+
48
+ const codes: number[] = [];
49
+ let set: 'B' | 'C';
50
+ let i = 0;
51
+ const first = digitRun(0);
52
+ if (first >= 4) {
53
+ set = 'C';
54
+ codes.push(START_C);
55
+ } else {
56
+ set = 'B';
57
+ codes.push(START_B);
58
+ }
59
+
60
+ while (i < value.length) {
61
+ const run = digitRun(i);
62
+ if (set === 'C') {
63
+ if (run >= 2) {
64
+ codes.push(Number(value.slice(i, i + 2)));
65
+ i += 2;
66
+ continue;
67
+ }
68
+ codes.push(CODE_B);
69
+ set = 'B';
70
+ }
71
+ // Switch to C for a run of >= 4 digits; an odd run keeps its first digit in B.
72
+ if (run >= 4) {
73
+ if (run % 2 === 1) {
74
+ codes.push(value.charCodeAt(i) - 32);
75
+ i++;
76
+ }
77
+ codes.push(CODE_C);
78
+ set = 'C';
79
+ continue;
80
+ }
81
+ codes.push(value.charCodeAt(i) - 32);
82
+ i++;
83
+ }
84
+
85
+ const checksum = codes.reduce((sum, c, idx) => sum + c * (idx === 0 ? 1 : idx), 0) % 103;
86
+ codes.push(checksum, STOP);
87
+ return { modules: codes.map((c) => widthsToModules(CODE128[c])).join(''), text: value };
88
+ }
89
+
90
+ const EAN_L = ['0001101', '0011001', '0010011', '0111101', '0100011', '0110001', '0101111', '0111011', '0110111', '0001011'];
91
+ const EAN_R = EAN_L.map((p) => [...p].map((b) => (b === '1' ? '0' : '1')).join(''));
92
+ const EAN_G = EAN_R.map((p) => [...p].reverse().join(''));
93
+ const EAN_PARITY = ['LLLLLL', 'LLGLGG', 'LLGGLG', 'LLGGGL', 'LGLLGG', 'LGGLLG', 'LGGGLL', 'LGLGLG', 'LGLGGL', 'LGGLGL'];
94
+
95
+ export function ean13CheckDigit(first12: string): number {
96
+ const sum = [...first12].reduce((s, d, i) => s + Number(d) * (i % 2 === 0 ? 1 : 3), 0);
97
+ return (10 - (sum % 10)) % 10;
98
+ }
99
+
100
+ /** EAN-13 from 12 digits (check digit added) or 13 digits (check digit verified). */
101
+ export function encodeEan13(value: string): BarcodeResult {
102
+ const v = value.replace(/\s+/g, '');
103
+ if (!/^\d{12,13}$/.test(v)) return { modules: '', text: value, error: 'EAN-13 needs 12 or 13 digits' };
104
+ const check = ean13CheckDigit(v.slice(0, 12));
105
+ if (v.length === 13 && Number(v[12]) !== check) return { modules: '', text: value, error: `Invalid check digit (expected ${check})` };
106
+ const digits = v.slice(0, 12) + check;
107
+ const parity = EAN_PARITY[Number(digits[0])];
108
+ let m = '101';
109
+ for (let k = 1; k <= 6; k++) m += (parity[k - 1] === 'L' ? EAN_L : EAN_G)[Number(digits[k])];
110
+ m += '01010';
111
+ for (let k = 7; k <= 12; k++) m += EAN_R[Number(digits[k])];
112
+ m += '101';
113
+ return { modules: m, text: digits };
114
+ }
115
+
116
+ export function encodeBarcode(symbology: BarcodeSymbology, value: string): BarcodeResult {
117
+ return symbology === 'ean13' ? encodeEan13(value) : encodeCode128(value);
118
+ }
@@ -0,0 +1,85 @@
1
+ import type { ChartSpec } from '../model/types';
2
+ import { aggregate, getPath, type Row } from './expressions';
3
+
4
+ /** Validated categorical palette (light surface), assigned in fixed order — never cycled. */
5
+ export const SERIES_COLORS = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#6250d6', '#e34948'];
6
+ export const OTHER_COLOR = '#9a9a96';
7
+ export const MAX_SERIES = SERIES_COLORS.length;
8
+
9
+ export interface ChartData {
10
+ categories: string[];
11
+ series: { name: string; values: number[]; color: string }[];
12
+ }
13
+
14
+ const toNum = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : Number(v) || 0);
15
+
16
+ /** Groups rows by category and aggregates each value field into a series. */
17
+ export function chartData(spec: ChartSpec, rows: Row[]): ChartData {
18
+ const pie = spec.type === 'pie' || spec.type === 'doughnut';
19
+ const fields = (spec.valueFields.filter(Boolean).length ? spec.valueFields.filter(Boolean) : ['']).slice(0, pie ? 1 : MAX_SERIES);
20
+
21
+ const groups = new Map<string, Row[]>();
22
+ for (const r of rows) {
23
+ const key = spec.categoryField ? String(getPath(r, spec.categoryField) ?? '(blank)') : 'All';
24
+ let g = groups.get(key);
25
+ if (!g) groups.set(key, (g = []));
26
+ g.push(r);
27
+ }
28
+ let cats = [...groups.keys()].map((name) => ({
29
+ name,
30
+ rows: groups.get(name)!,
31
+ values: fields.map((f) => toNum(aggregate(spec.aggregate, f, groups.get(name)!))),
32
+ }));
33
+
34
+ if (spec.sort === 'category') cats.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
35
+ else if (spec.sort === 'valueDesc') cats.sort((a, b) => b.values[0] - a.values[0]);
36
+ else if (spec.sort === 'valueAsc') cats.sort((a, b) => a.values[0] - b.values[0]);
37
+
38
+ // Pie slices beyond the palette (or maxCategories) fold into "Other"; other charts are just truncated.
39
+ const max = Math.max(1, pie ? Math.min(spec.maxCategories || MAX_SERIES, MAX_SERIES) : spec.maxCategories || 50);
40
+ if (cats.length > max) {
41
+ if (pie) {
42
+ const keep = cats.slice(0, max - 1);
43
+ const rest = cats.slice(max - 1).flatMap((c) => c.rows);
44
+ keep.push({ name: 'Other', rows: rest, values: fields.map((f) => toNum(aggregate(spec.aggregate, f, rest))) });
45
+ cats = keep;
46
+ } else {
47
+ cats = cats.slice(0, max);
48
+ }
49
+ }
50
+
51
+ if (pie) {
52
+ return {
53
+ categories: cats.map((c) => c.name),
54
+ series: [{ name: fields[0] || 'Count', values: cats.map((c) => c.values[0]), color: SERIES_COLORS[0] }],
55
+ };
56
+ }
57
+ return {
58
+ categories: cats.map((c) => c.name),
59
+ series: fields.map((f, i) => ({ name: f || 'Count', values: cats.map((c) => c.values[i]), color: SERIES_COLORS[i] })),
60
+ };
61
+ }
62
+
63
+ /** Pie slice colors: one fixed-order hue per category, "Other" in neutral gray. */
64
+ export const sliceColor = (name: string, i: number) => (name === 'Other' ? OTHER_COLOR : SERIES_COLORS[i % MAX_SERIES]);
65
+
66
+ /** "Nice" axis: rounded max and tick step for values in [min, max]. */
67
+ export function niceScale(min: number, max: number, ticks = 5): { min: number; max: number; step: number } {
68
+ const lo = Math.min(0, min);
69
+ const hi = Math.max(0, max);
70
+ if (hi === lo) return { min: lo, max: lo + 1, step: 0.2 };
71
+ const raw = (hi - lo) / ticks;
72
+ const mag = Math.pow(10, Math.floor(Math.log10(raw)));
73
+ const norm = raw / mag;
74
+ const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag;
75
+ return { min: Math.floor(lo / step) * step, max: Math.ceil(hi / step) * step, step };
76
+ }
77
+
78
+ /** Short tick label: 1.2K, 3.4M. */
79
+ export function compactNumber(n: number): string {
80
+ const a = Math.abs(n);
81
+ if (a >= 1e9) return +(n / 1e9).toFixed(1) + 'B';
82
+ if (a >= 1e6) return +(n / 1e6).toFixed(1) + 'M';
83
+ if (a >= 1e3) return +(n / 1e3).toFixed(1) + 'K';
84
+ return String(+n.toFixed(2));
85
+ }
@@ -0,0 +1,265 @@
1
+ import type { ChartSpec } from '../model/types';
2
+ import { chartData, compactNumber, niceScale, sliceColor } from './chart';
3
+ import { formatValue, type Row } from './expressions';
4
+
5
+ /** SVG drawing primitives (coordinates in element pixels). */
6
+ export type Prim =
7
+ | { t: 'path'; d: string; fill: string; stroke?: string; sw?: number; opacity?: number; title?: string }
8
+ | { t: 'line'; x1: number; y1: number; x2: number; y2: number; stroke: string; sw: number }
9
+ | { t: 'poly'; points: string; stroke: string; sw: number }
10
+ | { t: 'circle'; cx: number; cy: number; r: number; fill: string; stroke: string; sw: number; title?: string }
11
+ | { t: 'text'; x: number; y: number; text: string; anchor: 'start' | 'middle' | 'end'; size: number; weight: number; fill: string };
12
+
13
+ const GRID = '#e4e4e0';
14
+ const AXIS = '#9a9a96';
15
+ const MUTED = '#5f5e5a';
16
+ const SURFACE_GAP = '#ffffff';
17
+
18
+ /** Rect with a rounded data end (the end away from the baseline). */
19
+ function bar(x: number, y: number, w: number, h: number, end: 'top' | 'bottom' | 'right' | 'left'): string {
20
+ const r = Math.max(0, Math.min(3, w / 2, h / 2));
21
+ const R = (n: number) => +n.toFixed(2);
22
+ switch (end) {
23
+ case 'top':
24
+ return `M${R(x)},${R(y + h)}V${R(y + r)}Q${R(x)},${R(y)} ${R(x + r)},${R(y)}H${R(x + w - r)}Q${R(x + w)},${R(y)} ${R(x + w)},${R(y + r)}V${R(y + h)}Z`;
25
+ case 'bottom':
26
+ return `M${R(x)},${R(y)}V${R(y + h - r)}Q${R(x)},${R(y + h)} ${R(x + r)},${R(y + h)}H${R(x + w - r)}Q${R(x + w)},${R(y + h)} ${R(x + w)},${R(y + h - r)}V${R(y)}Z`;
27
+ case 'right':
28
+ return `M${R(x)},${R(y)}H${R(x + w - r)}Q${R(x + w)},${R(y)} ${R(x + w)},${R(y + r)}V${R(y + h - r)}Q${R(x + w)},${R(y + h)} ${R(x + w - r)},${R(y + h)}H${R(x)}Z`;
29
+ case 'left':
30
+ return `M${R(x + w)},${R(y)}H${R(x + r)}Q${R(x)},${R(y)} ${R(x)},${R(y + r)}V${R(y + h - r)}Q${R(x)},${R(y + h)} ${R(x + r)},${R(y + h)}H${R(x + w)}Z`;
31
+ }
32
+ }
33
+
34
+ /** Lays out a chart into SVG primitives for a w×h box. */
35
+ export function layoutChart(spec: ChartSpec, rows: Row[], w: number, h: number, fontSize: number, textColor: string): Prim[] {
36
+ const P: Prim[] = [];
37
+ const fs = Math.max(6, fontSize);
38
+ const cw = fs * 0.58; // average glyph width estimate
39
+ const text = (x: number, y: number, s: string, anchor: 'start' | 'middle' | 'end', size = fs, weight = 400, fill = MUTED) =>
40
+ s && P.push({ t: 'text', x, y, text: s, anchor, size, weight, fill });
41
+ const fit = (s: string, maxW: number) => {
42
+ if (s.length * cw <= maxW) return s;
43
+ const n = Math.floor(maxW / cw) - 1;
44
+ return n < 1 ? '' : s.slice(0, n) + '…';
45
+ };
46
+ const fmt = (v: number) => formatValue(v, spec.valueFormat);
47
+
48
+ let top = 4;
49
+ let bottom = h - 4;
50
+ const left = 4;
51
+ const right = w - 4;
52
+ if (spec.title) {
53
+ text(w / 2, top + fs + 1, fit(spec.title, w - 8), 'middle', fs + 2, 700, textColor);
54
+ top += fs + 9;
55
+ }
56
+
57
+ const data = chartData(spec, rows);
58
+ const pie = spec.type === 'pie' || spec.type === 'doughnut';
59
+ if (!data.categories.length || bottom - top < 20) {
60
+ text(w / 2, (top + bottom) / 2, 'No data', 'middle');
61
+ return P;
62
+ }
63
+
64
+ // Legend (bottom): always for ≥2 series; pie slices are categories, so pies always get one.
65
+ const legend = pie
66
+ ? data.categories.map((c, i) => ({ name: c, color: sliceColor(c, i) }))
67
+ : data.series.length > 1
68
+ ? data.series.map((s) => ({ name: s.name, color: s.color }))
69
+ : [];
70
+ if (spec.showLegend && legend.length) {
71
+ const lineH = fs + 6;
72
+ const items = legend.map((l) => ({ ...l, label: fit(l.name, (right - left) / 2), width: 0 }));
73
+ items.forEach((it) => (it.width = 14 + it.label.length * cw + 12));
74
+ const lines: (typeof items)[] = [[]];
75
+ let x = 0;
76
+ for (const it of items) {
77
+ if (x + it.width > right - left && lines[lines.length - 1].length) {
78
+ lines.push([]);
79
+ x = 0;
80
+ }
81
+ lines[lines.length - 1].push(it);
82
+ x += it.width;
83
+ }
84
+ const shown = lines.slice(0, 3);
85
+ bottom -= shown.length * lineH + 2;
86
+ shown.forEach((line, li) => {
87
+ const total = line.reduce((s, it) => s + it.width, 0) - 12;
88
+ let lx = (w - total) / 2;
89
+ const ly = bottom + 4 + li * lineH;
90
+ for (const it of line) {
91
+ P.push({ t: 'path', d: bar(lx, ly + (lineH - 10) / 2 - 1, 10, 10, 'top'), fill: it.color });
92
+ text(lx + 14, ly + lineH / 2 + fs * 0.35 - 1, it.label, 'start', fs, 400, textColor);
93
+ lx += it.width;
94
+ }
95
+ });
96
+ }
97
+
98
+ if (pie) {
99
+ layoutPie(P, data, spec, left, top, right, bottom, fs, fmt);
100
+ return P;
101
+ }
102
+
103
+ const values = data.series.flatMap((s) => s.values);
104
+ const scale = niceScale(Math.min(...values), Math.max(...values));
105
+ const ticks: number[] = [];
106
+ for (let t = scale.min; t <= scale.max + scale.step / 2; t += scale.step) ticks.push(+t.toFixed(10));
107
+ const n = data.categories.length;
108
+ const sCount = data.series.length;
109
+ const title = (cat: string, s: { name: string }, v: number) => `${cat}${sCount > 1 || spec.valueFields.length ? ' · ' + s.name : ''}: ${fmt(v)}`;
110
+
111
+ if (spec.type === 'hbar') {
112
+ const labelW = Math.min((right - left) * 0.35, Math.max(...data.categories.map((c) => c.length)) * cw + 8);
113
+ const valueW = spec.showValues && sCount === 1 ? Math.max(...values.map((v) => fmt(v).length)) * cw + 6 : 6;
114
+ const x0 = left + labelW;
115
+ const x1 = right - valueW;
116
+ const y0 = top + 2;
117
+ const y1 = bottom - fs - 6;
118
+ if (x1 - x0 < 20 || y1 - y0 < 10) return P;
119
+ const xOf = (v: number) => x0 + ((v - scale.min) / (scale.max - scale.min)) * (x1 - x0);
120
+ for (const t of ticks) {
121
+ if (spec.showGrid) P.push({ t: 'line', x1: xOf(t), y1: y0, x2: xOf(t), y2: y1, stroke: GRID, sw: 1 });
122
+ text(xOf(t), y1 + fs + 3, compactNumber(t), 'middle');
123
+ }
124
+ P.push({ t: 'line', x1: xOf(0), y1: y0, x2: xOf(0), y2: y1, stroke: AXIS, sw: 1 });
125
+ const band = (y1 - y0) / n;
126
+ const every = Math.max(1, Math.ceil((fs + 2) / band));
127
+ const groupH = band * 0.72;
128
+ const barH = Math.max(1, (groupH - (sCount - 1) * 2) / sCount);
129
+ data.categories.forEach((cat, i) => {
130
+ if (i % every === 0) text(x0 - 5, y0 + i * band + band / 2 + fs * 0.35, fit(cat, labelW - 8), 'end', fs, 400, textColor);
131
+ data.series.forEach((s, j) => {
132
+ const v = s.values[i];
133
+ const y = y0 + i * band + (band - groupH) / 2 + j * (barH + 2);
134
+ const a = xOf(0);
135
+ const b = xOf(v);
136
+ const x = Math.min(a, b);
137
+ const bw = Math.abs(b - a);
138
+ if (bw > 0) P.push({ t: 'path', d: bar(x, y, bw, barH, v >= 0 ? 'right' : 'left'), fill: s.color, title: title(cat, s, v) });
139
+ if (spec.showValues && sCount === 1 && barH >= fs * 0.8) text(Math.max(a, b) + 4, y + barH / 2 + fs * 0.35, fmt(v), 'start', fs, 400, textColor);
140
+ });
141
+ });
142
+ return P;
143
+ }
144
+
145
+ // Vertical charts: bar, line, area.
146
+ const yLabelW = Math.max(...ticks.map((t) => compactNumber(t).length)) * cw + 6;
147
+ const x0 = left + yLabelW;
148
+ const x1 = right;
149
+ const y0 = top + (spec.showValues ? fs + 2 : 4);
150
+ const y1 = bottom - fs - 6;
151
+ if (x1 - x0 < 20 || y1 - y0 < 10) return P;
152
+ const yOf = (v: number) => y1 - ((v - scale.min) / (scale.max - scale.min)) * (y1 - y0);
153
+ for (const t of ticks) {
154
+ if (spec.showGrid) P.push({ t: 'line', x1: x0, y1: yOf(t), x2: x1, y2: yOf(t), stroke: GRID, sw: 1 });
155
+ text(x0 - 5, yOf(t) + fs * 0.35, compactNumber(t), 'end');
156
+ }
157
+ const band = (x1 - x0) / n;
158
+ const maxLabel = Math.max(...data.categories.map((c) => Math.min(c.length, 14))) * cw + 6;
159
+ // Label every bar when ~4 characters fit (truncated); otherwise thin the labels out.
160
+ const every = band >= cw * 4 + 4 ? 1 : Math.max(1, Math.ceil(maxLabel / band));
161
+ data.categories.forEach((cat, i) => {
162
+ if (i % every !== 0) return;
163
+ const cx = x0 + i * band + band / 2;
164
+ // Centred labels must not run past the plot edges.
165
+ const room = Math.min(band * every - 4, 2 * (cx - left), 2 * (right - cx));
166
+ text(cx, y1 + fs + 3, fit(cat, room), 'middle', fs, 400, textColor);
167
+ });
168
+
169
+ if (spec.type === 'bar') {
170
+ const groupW = band * 0.72;
171
+ const barW = Math.max(1, (groupW - (sCount - 1) * 2) / sCount);
172
+ data.categories.forEach((cat, i) => {
173
+ data.series.forEach((s, j) => {
174
+ const v = s.values[i];
175
+ const x = x0 + i * band + (band - groupW) / 2 + j * (barW + 2);
176
+ const a = yOf(0);
177
+ const b = yOf(v);
178
+ const y = Math.min(a, b);
179
+ const bh = Math.abs(a - b);
180
+ if (bh > 0) P.push({ t: 'path', d: bar(x, y, barW, bh, v >= 0 ? 'top' : 'bottom'), fill: s.color, title: title(cat, s, v) });
181
+ const label = fmt(v);
182
+ if (spec.showValues && sCount === 1 && label.length * cw <= band + 4) text(x + barW / 2, Math.min(a, b) - 4, label, 'middle', fs, 400, textColor);
183
+ });
184
+ });
185
+ } else {
186
+ const xAt = (i: number) => x0 + i * band + band / 2;
187
+ data.series.forEach((s) => {
188
+ const pts = s.values.map((v, i) => [xAt(i), yOf(v)] as const);
189
+ if (spec.type === 'area') {
190
+ const d = `M${pts[0][0]},${yOf(0)}` + pts.map(([x, y]) => `L${x},${y}`).join('') + `L${pts[pts.length - 1][0]},${yOf(0)}Z`;
191
+ P.push({ t: 'path', d, fill: s.color, opacity: 0.16 });
192
+ }
193
+ P.push({ t: 'poly', points: pts.map(([x, y]) => `${+x.toFixed(2)},${+y.toFixed(2)}`).join(' '), stroke: s.color, sw: 2 });
194
+ const markers = n <= 40;
195
+ pts.forEach(([x, y], i) => {
196
+ // Small markers carry the hover tooltip; a 2px surface ring separates them from the line.
197
+ P.push({ t: 'circle', cx: x, cy: y, r: markers ? 3.5 : 0.01, fill: s.color, stroke: SURFACE_GAP, sw: markers ? 1.5 : 0, title: title(data.categories[i], s, s.values[i]) });
198
+ });
199
+ if (spec.showValues && sCount === 1 && n <= 15) pts.forEach(([x, y], i) => text(x, y - 7, fmt(s.values[i]), 'middle', fs, 400, textColor));
200
+ });
201
+ }
202
+ P.push({ t: 'line', x1: x0, y1: yOf(0), x2: x1, y2: yOf(0), stroke: AXIS, sw: 1 });
203
+ return P;
204
+ }
205
+
206
+ function layoutPie(
207
+ P: Prim[],
208
+ data: ReturnType<typeof chartData>,
209
+ spec: ChartSpec,
210
+ left: number,
211
+ top: number,
212
+ right: number,
213
+ bottom: number,
214
+ fs: number,
215
+ fmt: (v: number) => string,
216
+ ) {
217
+ const vals = data.series[0].values.map((v) => Math.max(0, v));
218
+ const total = vals.reduce((a, b) => a + b, 0);
219
+ const cx = (left + right) / 2;
220
+ const cy = (top + bottom) / 2;
221
+ const r = Math.max(4, Math.min(right - left, bottom - top) / 2 - 2);
222
+ const ir = spec.type === 'doughnut' ? r * 0.58 : 0;
223
+ if (total <= 0) {
224
+ P.push({ t: 'text', x: cx, y: cy, text: 'No data', anchor: 'middle', size: fs, weight: 400, fill: MUTED });
225
+ return;
226
+ }
227
+ const pt = (a: number, rad: number) => `${+(cx + rad * Math.cos(a)).toFixed(2)},${+(cy + rad * Math.sin(a)).toFixed(2)}`;
228
+ let a0 = -Math.PI / 2;
229
+ vals.forEach((v, i) => {
230
+ if (v <= 0) return;
231
+ const cat = data.categories[i];
232
+ const sweep = (v / total) * Math.PI * 2;
233
+ const a1 = a0 + sweep;
234
+ const pct = (v / total) * 100;
235
+ const title = `${cat}: ${fmt(v)} (${pct.toFixed(1)}%)`;
236
+ let d: string;
237
+ if (sweep >= Math.PI * 2 - 1e-6) {
238
+ // Full circle: two half arcs.
239
+ d = `M${pt(0, r)}A${r},${r} 0 1 1 ${pt(Math.PI, r)}A${r},${r} 0 1 1 ${pt(0, r)}Z`;
240
+ if (ir) d += `M${pt(0, ir)}A${ir},${ir} 0 1 0 ${pt(Math.PI, ir)}A${ir},${ir} 0 1 0 ${pt(0, ir)}Z`;
241
+ } else {
242
+ const large = sweep > Math.PI ? 1 : 0;
243
+ d = ir
244
+ ? `M${pt(a0, r)}A${r},${r} 0 ${large} 1 ${pt(a1, r)}L${pt(a1, ir)}A${ir},${ir} 0 ${large} 0 ${pt(a0, ir)}Z`
245
+ : `M${cx},${cy}L${pt(a0, r)}A${r},${r} 0 ${large} 1 ${pt(a1, r)}Z`;
246
+ }
247
+ // 2px surface-colored stroke separates neighbouring slices.
248
+ P.push({ t: 'path', d, fill: sliceColor(cat, i), stroke: SURFACE_GAP, sw: 2, title });
249
+ if (spec.showValues && sweep > 0.35) {
250
+ const mid = (a0 + a1) / 2;
251
+ const lr = ir ? (r + ir) / 2 : r * 0.62;
252
+ P.push({
253
+ t: 'text',
254
+ x: cx + lr * Math.cos(mid),
255
+ y: cy + lr * Math.sin(mid) + fs * 0.35,
256
+ text: `${Math.round(pct)}%`,
257
+ anchor: 'middle',
258
+ size: fs,
259
+ weight: 700,
260
+ fill: '#ffffff',
261
+ });
262
+ }
263
+ a0 = a1;
264
+ });
265
+ }