angular-my-report 0.1.0 → 0.1.1

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 (88) hide show
  1. package/README.md +124 -40
  2. package/fesm2022/angular-my-report.mjs +7931 -0
  3. package/fesm2022/angular-my-report.mjs.map +1 -0
  4. package/index.d.ts +5 -0
  5. package/lib/engine/barcode.d.ts +17 -0
  6. package/lib/engine/chart.d.ts +26 -0
  7. package/lib/engine/chartLayout.d.ts +45 -0
  8. package/lib/engine/crosstab.d.ts +22 -0
  9. package/lib/engine/dataSources.d.ts +35 -0
  10. package/lib/engine/expressions.d.ts +46 -0
  11. package/lib/engine/measure.d.ts +7 -0
  12. package/lib/engine/parameters.d.ts +15 -0
  13. package/lib/engine/qr.d.ts +8 -0
  14. package/lib/engine/renderer.d.ts +43 -0
  15. package/lib/engine/table.d.ts +22 -0
  16. package/lib/model/defaults.d.ts +31 -0
  17. package/lib/model/factories.d.ts +31 -0
  18. package/lib/model/types.d.ts +244 -0
  19. package/lib/ui/css.d.ts +2 -0
  20. package/lib/ui/designer/banded-table-dialog.component.d.ts +27 -0
  21. package/lib/ui/designer/canvas.component.d.ts +41 -0
  22. package/lib/ui/designer/data-source-dialog.component.d.ts +37 -0
  23. package/lib/ui/designer/element-box.component.d.ts +32 -0
  24. package/lib/ui/designer/graphics.components.d.ts +36 -0
  25. package/lib/ui/designer/left-panel.component.d.ts +37 -0
  26. package/lib/ui/designer/safe-image.pipe.d.ts +13 -0
  27. package/lib/ui/designer/table-view.component.d.ts +19 -0
  28. package/lib/ui/designer.component.d.ts +59 -0
  29. package/lib/ui/designer.store.d.ts +82 -0
  30. package/lib/ui/inputs.d.ts +8 -0
  31. package/lib/ui/properties/band-props.component.d.ts +15 -0
  32. package/lib/ui/properties/element-props.component.d.ts +46 -0
  33. package/lib/ui/properties/expr-input.component.d.ts +43 -0
  34. package/lib/ui/properties/graphics-props.component.d.ts +52 -0
  35. package/lib/ui/properties/image-picker.component.d.ts +15 -0
  36. package/lib/ui/properties/properties-panel.component.d.ts +13 -0
  37. package/lib/ui/properties/report-extras.component.d.ts +40 -0
  38. package/lib/ui/properties/report-props.component.d.ts +34 -0
  39. package/lib/ui/properties/table-props.component.d.ts +31 -0
  40. package/lib/ui/viewer/page-view.component.d.ts +17 -0
  41. package/lib/ui/viewer/render.d.ts +43 -0
  42. package/lib/ui/viewer/viewer.component.d.ts +99 -0
  43. package/lib/ui/viewer.component.d.ts +40 -0
  44. package/package.json +16 -5
  45. package/{src/public-api.ts → public-api.d.ts} +0 -5
  46. package/ng-package.json +0 -7
  47. package/src/lib/engine/barcode.ts +0 -118
  48. package/src/lib/engine/chart.ts +0 -85
  49. package/src/lib/engine/chartLayout.ts +0 -265
  50. package/src/lib/engine/crosstab.ts +0 -96
  51. package/src/lib/engine/dataSources.ts +0 -204
  52. package/src/lib/engine/expressions.ts +0 -734
  53. package/src/lib/engine/measure.ts +0 -62
  54. package/src/lib/engine/parameters.ts +0 -96
  55. package/src/lib/engine/qr.ts +0 -26
  56. package/src/lib/engine/renderer.ts +0 -324
  57. package/src/lib/engine/table.ts +0 -58
  58. package/src/lib/model/defaults.ts +0 -249
  59. package/src/lib/model/factories.ts +0 -324
  60. package/src/lib/model/types.ts +0 -281
  61. package/src/lib/ui/css.ts +0 -12
  62. package/src/lib/ui/designer/banded-table-dialog.component.ts +0 -127
  63. package/src/lib/ui/designer/canvas.component.ts +0 -281
  64. package/src/lib/ui/designer/data-source-dialog.component.ts +0 -201
  65. package/src/lib/ui/designer/element-box.component.ts +0 -138
  66. package/src/lib/ui/designer/graphics.components.ts +0 -140
  67. package/src/lib/ui/designer/left-panel.component.ts +0 -189
  68. package/src/lib/ui/designer/safe-image.pipe.ts +0 -16
  69. package/src/lib/ui/designer/table-view.component.ts +0 -120
  70. package/src/lib/ui/designer.component.ts +0 -296
  71. package/src/lib/ui/designer.store.ts +0 -230
  72. package/src/lib/ui/inputs.ts +0 -34
  73. package/src/lib/ui/myreport.css +0 -267
  74. package/src/lib/ui/properties/band-props.component.ts +0 -86
  75. package/src/lib/ui/properties/element-props.component.ts +0 -326
  76. package/src/lib/ui/properties/expr-input.component.ts +0 -191
  77. package/src/lib/ui/properties/graphics-props.component.ts +0 -260
  78. package/src/lib/ui/properties/image-picker.component.ts +0 -62
  79. package/src/lib/ui/properties/properties-panel.component.ts +0 -37
  80. package/src/lib/ui/properties/report-extras.component.ts +0 -198
  81. package/src/lib/ui/properties/report-props.component.ts +0 -211
  82. package/src/lib/ui/properties/table-props.component.ts +0 -197
  83. package/src/lib/ui/viewer/page-view.component.ts +0 -73
  84. package/src/lib/ui/viewer/render.ts +0 -161
  85. package/src/lib/ui/viewer/viewer.component.ts +0 -555
  86. package/src/lib/ui/viewer.component.ts +0 -107
  87. package/tsconfig.lib.json +0 -12
  88. package/tsconfig.lib.prod.json +0 -5
@@ -1,265 +0,0 @@
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
- }
@@ -1,96 +0,0 @@
1
- import type { ReportElement, TableColumn } from '../model/types';
2
- import { aggregate, formatValue, getPath, type Row } from './expressions';
3
- import type { TableRenderData } from './table';
4
-
5
- export interface Pivot {
6
- rowKeys: string[];
7
- colKeys: string[];
8
- /** cells[r][c] = aggregate, null when no rows fall in the cell. */
9
- cells: (number | null)[][];
10
- rowTotals: (number | null)[];
11
- colTotals: (number | null)[];
12
- grandTotal: number | null;
13
- }
14
-
15
- const num = (v: unknown): number | null => (v == null || Number.isNaN(Number(v)) ? null : Number(v));
16
- const byName = (a: string, b: string) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
17
- // Braces would be read as expressions when the cell text is rendered.
18
- const literal = (s: string) => s.replace(/[{}]/g, '');
19
-
20
- export function pivot(el: ReportElement, rows: Row[]): Pivot {
21
- const x = el.crosstab!;
22
- const key = (r: Row, f: string) => (f ? String(getPath(r, f) ?? '(blank)') : 'All');
23
- const rowKeys = [...new Set(rows.map((r) => key(r, x.rowField)))].sort(byName);
24
- const colKeys = [...new Set(rows.map((r) => key(r, x.columnField)))].sort(byName);
25
- const bucket = new Map<string, Row[]>();
26
- for (const r of rows) {
27
- const k = key(r, x.rowField) + '\u0000' + key(r, x.columnField);
28
- let b = bucket.get(k);
29
- if (!b) bucket.set(k, (b = []));
30
- b.push(r);
31
- }
32
- const agg = (set: Row[]) => (set.length ? num(aggregate(x.aggregate, x.valueField, set)) : null);
33
- const cells = rowKeys.map((rk) => colKeys.map((ck) => agg(bucket.get(rk + '\u0000' + ck) ?? [])));
34
- // Totals aggregate the underlying rows (so avg/min/max stay correct, not sums of averages).
35
- const rowTotals = rowKeys.map((rk) => agg(rows.filter((r) => key(r, x.rowField) === rk)));
36
- const colTotals = colKeys.map((ck) => agg(rows.filter((r) => key(r, x.columnField) === ck)));
37
- return { rowKeys, colKeys, cells, rowTotals, colTotals, grandTotal: agg(rows) };
38
- }
39
-
40
- /**
41
- * Turns a cross table into an ordinary table element + data rows (cells pre-formatted), so the
42
- * renderer can paginate it with the table code: header repeats, rows flow, totals form the footer.
43
- */
44
- export function crosstabAsTable(el: ReportElement, rows: Row[]): { table: ReportElement; data: Row[]; render: TableRenderData } {
45
- const x = el.crosstab!;
46
- const p = pivot(el, rows);
47
- const fmt = (v: number | null) => (v == null ? '' : formatValue(v, x.valueFormat));
48
- const totalLabel = x.aggregate === 'count' ? 'Count' : x.aggregate === 'avg' ? 'Average' : 'Total';
49
-
50
- const col = (id: string, header: string, footer: string, width: number, align: TableColumn['align']): TableColumn => ({
51
- id,
52
- header: literal(header),
53
- text: `{${id}}`,
54
- footer: literal(footer),
55
- width,
56
- align,
57
- format: { kind: 'none', decimals: 0, currency: '' },
58
- });
59
- const columns: TableColumn[] = [
60
- col('c0', x.cornerText || `${x.rowField || ''} / ${x.columnField || ''}`, totalLabel, 1.6, 'left'),
61
- ...p.colKeys.map((k, i) => col(`c${i + 1}`, k, fmt(p.colTotals[i]), 1, 'right')),
62
- ];
63
- if (x.showRowTotals) columns.push(col(`c${p.colKeys.length + 1}`, totalLabel, fmt(p.grandTotal), 1, 'right'));
64
-
65
- const data: Row[] = p.rowKeys.map((rk, r) => {
66
- const o: Row = { c0: literal(rk) };
67
- p.colKeys.forEach((_, c) => (o[`c${c + 1}`] = fmt(p.cells[r][c])));
68
- if (x.showRowTotals) o[`c${p.colKeys.length + 1}`] = fmt(p.rowTotals[r]);
69
- return o;
70
- });
71
-
72
- const table: ReportElement = {
73
- ...el,
74
- kind: 'table',
75
- table: {
76
- columns,
77
- showHeader: true,
78
- repeatHeader: x.repeatHeader,
79
- showFooter: x.showColumnTotals,
80
- headerHeight: x.headerHeight,
81
- rowHeight: x.rowHeight,
82
- headerBackground: x.headerBackground,
83
- headerColor: x.headerColor,
84
- stripeBackground: x.stripeBackground,
85
- gridColor: x.gridColor,
86
- },
87
- };
88
- const render: TableRenderData = {
89
- header: columns.map((c) => c.header),
90
- headerHeight: x.headerHeight,
91
- rows: data.map((d, i) => ({ cells: columns.map((c) => String(d[c.id] ?? '')), height: x.rowHeight, index: i })),
92
- footer: x.showColumnTotals ? columns.map((c) => c.footer) : null,
93
- footerHeight: x.rowHeight,
94
- };
95
- return { table, data, render };
96
- }
@@ -1,204 +0,0 @@
1
- import type { DataSourceDef } from '../model/types';
2
- import { emptyDataSource } from '../model/defaults';
3
- import { getPath, type Row } from './expressions';
4
-
5
- export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'object';
6
- export interface FieldInfo {
7
- name: string;
8
- type: FieldType;
9
- }
10
- export interface LoadResult {
11
- rows: Row[];
12
- fields: FieldInfo[];
13
- requestUrl: string;
14
- /** Non-fatal note, e.g. the array path was not found and auto-detect was used. */
15
- warning?: string;
16
- /** The whole JSON document (first page for OData), readable in formulas as root.x. */
17
- root?: unknown;
18
- }
19
-
20
- export function parseHeaders(text: string): Record<string, string> {
21
- const h: Record<string, string> = {};
22
- for (const line of text.split(/\r?\n/)) {
23
- const i = line.indexOf(':');
24
- if (i > 0) h[line.slice(0, i).trim()] = line.slice(i + 1).trim();
25
- }
26
- return h;
27
- }
28
-
29
- export const hasSource = (ds: DataSourceDef) => !!(ds.url.trim() || (ds.type === 'json' && ds.inline.trim()));
30
-
31
- export function buildODataUrl(ds: DataSourceDef): string {
32
- let base = ds.url.trim().replace(/\/+$/, '');
33
- if (ds.entitySet.trim()) base += '/' + ds.entitySet.trim();
34
- const q: string[] = [];
35
- const add = (k: string, v: string) => v.trim() && q.push(`${k}=${encodeURIComponent(v.trim())}`);
36
- add('$select', ds.select);
37
- add('$filter', ds.filter);
38
- add('$orderby', ds.orderby);
39
- add('$expand', ds.expand);
40
- if (ds.top) q.push(`$top=${ds.top}`);
41
- return q.length ? base + (base.includes('?') ? '&' : '?') + q.join('&') : base;
42
- }
43
-
44
- async function fetchJson(url: string, headers: Record<string, string>, signal?: AbortSignal): Promise<any> {
45
- const res = await fetch(url, { headers: { Accept: 'application/json', ...headers }, signal });
46
- if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} — ${url}`);
47
- const text = await res.text();
48
- try {
49
- return JSON.parse(text);
50
- } catch {
51
- throw new Error(`Response is not valid JSON — ${url}`);
52
- }
53
- }
54
-
55
- function extractArray(data: unknown, path: string, onWarn: (w: string) => void = () => {}): Row[] {
56
- let cur: any = path.trim() ? getPath(data, path.trim()) : data;
57
- // A path that doesn't exist in this response (e.g. left over from another URL) falls back to auto-detect.
58
- if (cur === undefined && path.trim()) {
59
- onWarn(`Array path "${path.trim()}" not found in the response; auto-detected the data instead. Clear the Array path field.`);
60
- cur = data;
61
- path = '';
62
- }
63
- if (Array.isArray(cur)) return cur;
64
- if (cur && typeof cur === 'object') {
65
- for (const key of ['value', 'items', 'data', 'results', 'rows', 'records']) {
66
- if (Array.isArray(cur[key])) return cur[key];
67
- }
68
- const firstArray = Object.values(cur).find(Array.isArray);
69
- if (firstArray) return firstArray as Row[];
70
- return [cur];
71
- }
72
- throw new Error(path ? `No array found at "${path}"` : 'Data is not an array or object');
73
- }
74
-
75
- export async function loadData(ds: DataSourceDef, signal?: AbortSignal): Promise<LoadResult> {
76
- const headers = parseHeaders(ds.headers);
77
- let rows: Row[];
78
- let requestUrl = '';
79
- let warning: string | undefined;
80
- let root: unknown;
81
-
82
- if (ds.type === 'json') {
83
- let data: unknown;
84
- if (ds.inline.trim()) {
85
- try {
86
- data = JSON.parse(ds.inline);
87
- } catch (e) {
88
- throw new Error('Pasted JSON is invalid: ' + (e as Error).message);
89
- }
90
- } else {
91
- requestUrl = ds.url.trim();
92
- data = await fetchJson(requestUrl, headers, signal);
93
- }
94
- root = data;
95
- rows = extractArray(data, ds.dataPath, (w) => (warning = w));
96
- } else {
97
- requestUrl = buildODataUrl(ds);
98
- rows = [];
99
- let next: string | undefined = requestUrl;
100
- const max = ds.maxRows || 5000;
101
- while (next && rows.length < max) {
102
- const json: any = await fetchJson(next, headers, signal);
103
- root ??= json;
104
- const page: Row[] = json.value ?? json.d?.results ?? (Array.isArray(json.d) ? json.d : json.d ? [json.d] : []);
105
- rows.push(...page);
106
- const link: string | undefined = json['@odata.nextLink'] ?? json['odata.nextLink'] ?? json.d?.__next;
107
- next = link && !ds.top ? new URL(link, next).toString() : undefined;
108
- }
109
- rows = rows.slice(0, max);
110
- }
111
- return { rows, fields: inferFields(rows), requestUrl, warning, root };
112
- }
113
-
114
- function typeOf(v: unknown): FieldType | null {
115
- if (v == null) return null;
116
- if (typeof v === 'number') return 'number';
117
- if (typeof v === 'boolean') return 'boolean';
118
- if (typeof v === 'string') {
119
- if (/^\d{4}-\d{2}-\d{2}([T ][\d:.]+)?(Z|[+-]\d{2}:?\d{2})?$/.test(v) || /^\/Date\(-?\d+/.test(v)) return 'date';
120
- return 'string';
121
- }
122
- return 'object';
123
- }
124
-
125
- /** Infers field names (flattening nested objects into dot paths) from a sample of rows. */
126
- export function inferFields(rows: Row[]): FieldInfo[] {
127
- const types = new Map<string, FieldType | null>();
128
- const visit = (obj: Record<string, unknown>, prefix: string, depth: number) => {
129
- for (const [k, v] of Object.entries(obj)) {
130
- if (k.startsWith('@') || k.startsWith('__')) continue;
131
- const name = prefix + k;
132
- if (v && typeof v === 'object' && !Array.isArray(v) && depth < 2) {
133
- visit(v as Record<string, unknown>, name + '.', depth + 1);
134
- continue;
135
- }
136
- if (Array.isArray(v)) continue;
137
- const t = typeOf(v);
138
- if (!types.has(name) || (types.get(name) === null && t)) types.set(name, t);
139
- }
140
- };
141
- for (const r of rows.slice(0, 50)) if (r && typeof r === 'object') visit(r, '', 0);
142
- return [...types].map(([name, type]) => ({ name, type: type ?? 'string' }));
143
- }
144
-
145
- /** Lists entity sets from an OData service ($metadata XML, falling back to the JSON service document). */
146
- export async function loadODataEntitySets(serviceUrl: string, headersText: string): Promise<string[]> {
147
- const base = serviceUrl.trim().replace(/\/+$/, '');
148
- const headers = parseHeaders(headersText);
149
- try {
150
- const res = await fetch(base + '/$metadata', { headers: { Accept: 'application/xml', ...headers } });
151
- if (res.ok) {
152
- const xml = new DOMParser().parseFromString(await res.text(), 'application/xml');
153
- const sets = [...xml.getElementsByTagNameNS('*', 'EntitySet')].map((n) => n.getAttribute('Name') ?? '').filter(Boolean);
154
- if (sets.length) return sets;
155
- }
156
- } catch {
157
- /* fall through */
158
- }
159
- const doc = await fetchJson(base, headers);
160
- const list: any[] = doc.value ?? doc.d?.EntitySets ?? [];
161
- return list.map((x) => (typeof x === 'string' ? x : x.name ?? x.url)).filter(Boolean);
162
- }
163
-
164
- export interface DetectedSource {
165
- dataSource: DataSourceDef;
166
- /** Human-readable summary of what was detected. */
167
- note: string;
168
- }
169
-
170
- /**
171
- * Builds a data source from a pasted URL: fetches it once and decides between plain JSON and OData
172
- * (OData responses carry @odata.context / odata.metadata / a "d" wrapper). An OData service root
173
- * is resolved to its first entity set. Previous settings (array path, query options) are reset.
174
- */
175
- export async function detectDataSource(rawUrl: string, headers = ''): Promise<DetectedSource> {
176
- let url = rawUrl.trim();
177
- if (!url) throw new Error('Enter a URL');
178
- url = url.replace(/\/\$metadata\/?$/i, ''); // a $metadata link means the service root
179
- const base = { ...emptyDataSource(), headers };
180
- const json: any = await fetchJson(url, parseHeaders(headers));
181
-
182
- const isOData = json && typeof json === 'object' && !Array.isArray(json) && ('@odata.context' in json || 'odata.metadata' in json || 'd' in json);
183
- if (!isOData) {
184
- const rows = extractArray(json, '');
185
- return { dataSource: { ...base, type: 'json', url }, note: `JSON · ${rows.length} rows` };
186
- }
187
-
188
- // Service document: lists entity sets instead of data rows.
189
- const ctx: string = json['@odata.context'] ?? json['odata.metadata'] ?? '';
190
- const v4Sets = /\$metadata$/.test(ctx) && Array.isArray(json.value) ? json.value.filter((x: any) => !x.kind || x.kind === 'EntitySet') : null;
191
- const v2Sets: string[] | null = Array.isArray(json.d?.EntitySets) ? json.d.EntitySets : null;
192
- const sets: string[] = v4Sets ? v4Sets.map((x: any) => x.url ?? x.name) : v2Sets ?? [];
193
- if (sets.length) {
194
- return {
195
- dataSource: { ...base, type: 'odata', url: url.replace(/\/+$/, ''), entitySet: sets[0], top: 100 },
196
- note: `OData service · using entity set "${sets[0]}" (${sets.length} available, first 100 rows) — pick another under Entity set`,
197
- };
198
- }
199
- const rows = json.value ?? json.d?.results ?? json.d;
200
- return {
201
- dataSource: { ...base, type: 'odata', url },
202
- note: `OData · ${Array.isArray(rows) ? rows.length : 1} rows${json['@odata.nextLink'] ? ' (more pages will be fetched)' : ''}`,
203
- };
204
- }