@cube-nl/dmp-sdk 1.0.0-rc.4 → 1.1.0-rc.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 (89) hide show
  1. package/README.md +298 -6
  2. package/dist/app.d.ts +36 -2
  3. package/dist/app.js +192 -10
  4. package/dist/browser.js +6 -1
  5. package/dist/client.d.ts +2 -0
  6. package/dist/cube-sdk.js +179 -2
  7. package/dist/cube-ui.manifest.json +971 -0
  8. package/dist/index.d.ts +6 -0
  9. package/dist/index.js +4 -0
  10. package/dist/preview.d.ts +9 -0
  11. package/dist/preview.js +39 -1
  12. package/dist/size.json +6 -0
  13. package/dist/ui/aggregate-block.d.ts +73 -0
  14. package/dist/ui/aggregate-block.js +138 -0
  15. package/dist/ui/attributes.d.ts +5 -0
  16. package/dist/ui/attributes.js +72 -0
  17. package/dist/ui/block.d.ts +63 -0
  18. package/dist/ui/block.js +229 -0
  19. package/dist/ui/blocks/app-shell.d.ts +4 -0
  20. package/dist/ui/blocks/app-shell.js +167 -0
  21. package/dist/ui/blocks/board.d.ts +8 -0
  22. package/dist/ui/blocks/board.js +292 -0
  23. package/dist/ui/blocks/calc.d.ts +9 -0
  24. package/dist/ui/blocks/calc.js +240 -0
  25. package/dist/ui/blocks/chart.d.ts +7 -0
  26. package/dist/ui/blocks/chart.js +166 -0
  27. package/dist/ui/blocks/detail.d.ts +8 -0
  28. package/dist/ui/blocks/detail.js +270 -0
  29. package/dist/ui/blocks/field.d.ts +8 -0
  30. package/dist/ui/blocks/field.js +16 -0
  31. package/dist/ui/blocks/form.d.ts +8 -0
  32. package/dist/ui/blocks/form.js +153 -0
  33. package/dist/ui/blocks/gap-list.d.ts +4 -0
  34. package/dist/ui/blocks/gap-list.js +36 -0
  35. package/dist/ui/blocks/kpi.d.ts +4 -0
  36. package/dist/ui/blocks/kpi.js +74 -0
  37. package/dist/ui/blocks/period-picker.d.ts +4 -0
  38. package/dist/ui/blocks/period-picker.js +155 -0
  39. package/dist/ui/blocks/state.d.ts +4 -0
  40. package/dist/ui/blocks/state.js +135 -0
  41. package/dist/ui/blocks/table.d.ts +8 -0
  42. package/dist/ui/blocks/table.js +607 -0
  43. package/dist/ui/blocks/top.d.ts +4 -0
  44. package/dist/ui/blocks/top.js +67 -0
  45. package/dist/ui/chart-svg.d.ts +28 -0
  46. package/dist/ui/chart-svg.js +111 -0
  47. package/dist/ui/csv.d.ts +9 -0
  48. package/dist/ui/csv.js +55 -0
  49. package/dist/ui/define.d.ts +24 -0
  50. package/dist/ui/define.js +107 -0
  51. package/dist/ui/dom.d.ts +12 -0
  52. package/dist/ui/dom.js +57 -0
  53. package/dist/ui/fields.d.ts +39 -0
  54. package/dist/ui/fields.js +73 -0
  55. package/dist/ui/format.d.ts +44 -0
  56. package/dist/ui/format.js +214 -0
  57. package/dist/ui/inputs.d.ts +35 -0
  58. package/dist/ui/inputs.js +161 -0
  59. package/dist/ui/manifest.d.ts +40 -0
  60. package/dist/ui/manifest.js +878 -0
  61. package/dist/ui/period.d.ts +18 -0
  62. package/dist/ui/period.js +52 -0
  63. package/dist/ui/query.d.ts +26 -0
  64. package/dist/ui/query.js +98 -0
  65. package/dist/ui/schema.d.ts +23 -0
  66. package/dist/ui/schema.js +21 -0
  67. package/dist/ui/styles.d.ts +8 -0
  68. package/dist/ui/styles.js +51 -0
  69. package/dist/ui/texts.d.ts +134 -0
  70. package/dist/ui/texts.js +122 -0
  71. package/dist/ui/theme.d.ts +58 -0
  72. package/dist/ui/theme.js +311 -0
  73. package/dist/ui/write.d.ts +20 -0
  74. package/dist/ui/write.js +54 -0
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/dist/wireframe.d.ts +37 -0
  78. package/dist/wireframe.js +315 -0
  79. package/fonts/OFL-albert-sans.txt +93 -0
  80. package/fonts/OFL-ibm-plex-sans.txt +93 -0
  81. package/fonts/OFL-inter.txt +93 -0
  82. package/fonts/OFL-nunito-sans.txt +93 -0
  83. package/fonts/OFL-source-sans-3.txt +93 -0
  84. package/fonts/albert-sans.woff2 +0 -0
  85. package/fonts/ibm-plex-sans.woff2 +0 -0
  86. package/fonts/inter.woff2 +0 -0
  87. package/fonts/nunito-sans.woff2 +0 -0
  88. package/fonts/source-sans-3.woff2 +0 -0
  89. package/package.json +8 -3
@@ -0,0 +1,28 @@
1
+ export interface Series {
2
+ label: string;
3
+ points: Array<{
4
+ key: string;
5
+ label: string;
6
+ value: number | null;
7
+ tick?: string;
8
+ }>;
9
+ /** Categories get a label each, cut to its band; points in time are thinned to the labels that fit. */
10
+ categories?: boolean;
11
+ }
12
+ /** Formats a value; `compact` for the axis (`4K`), the full form for the point's title. */
13
+ export type ChartFormat = (n: number, compact?: boolean) => string;
14
+ /**
15
+ * Round axis values covering min..max, about `count` steps of 1, 2 or 5 × 10ⁿ. The range is widened to take in
16
+ * 0 (bars grow from the zero line), so 0 is always a tick; with nothing to span (0..0) it is `[0, 1]`.
17
+ */
18
+ export declare function niceTicks(min: number, max: number, count?: number): number[];
19
+ /** A line through the points (a gap where a value is null), with a dot and a title per point. */
20
+ export declare function renderLine(doc: Document, series: Series, size: {
21
+ width: number;
22
+ height: number;
23
+ }, format: ChartFormat): SVGElement;
24
+ /** One bar per point from the zero line, with a title each. */
25
+ export declare function renderBars(doc: Document, series: Series, size: {
26
+ width: number;
27
+ height: number;
28
+ }, format: ChartFormat): SVGElement;
@@ -0,0 +1,111 @@
1
+ import { svg } from './dom.js';
2
+ import { NL } from './texts.js';
3
+ const LEFT = 56;
4
+ const RIGHT = 8;
5
+ const TOP = 8;
6
+ const BOTTOM = 24;
7
+ /** An estimate of one character of the 12px axis text, and the room between two labels. */
8
+ const CHAR = 7;
9
+ const GAP = 8;
10
+ /**
11
+ * Round axis values covering min..max, about `count` steps of 1, 2 or 5 × 10ⁿ. The range is widened to take in
12
+ * 0 (bars grow from the zero line), so 0 is always a tick; with nothing to span (0..0) it is `[0, 1]`.
13
+ */
14
+ export function niceTicks(min, max, count = 4) {
15
+ const low = Math.min(min, 0);
16
+ const high = Math.max(max, 0);
17
+ if (high <= low)
18
+ return [0, 1];
19
+ const raw = (high - low) / count;
20
+ const magnitude = 10 ** Math.floor(Math.log10(raw));
21
+ const norm = raw / magnitude;
22
+ const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10) * magnitude;
23
+ const ticks = [];
24
+ for (let i = Math.floor(low / step); i <= Math.ceil(high / step); i++) {
25
+ ticks.push(Number((i * step).toPrecision(12)));
26
+ }
27
+ return ticks;
28
+ }
29
+ /**
30
+ * An x label, cut to `room` with an ellipsis (the full text in its title) and centred under `x`, moved inward only as
31
+ * far as it takes to stay between `left` and `right`.
32
+ */
33
+ function xLabel(doc, text, room, at) {
34
+ const fits = Math.max(1, Math.floor(room / CHAR));
35
+ const shown = text.length <= fits ? text : `${text.slice(0, fits - 1)}…`;
36
+ const half = (shown.length * CHAR) / 2;
37
+ const x = Math.min(Math.max(at.x, at.left + half), at.right - half);
38
+ const label = svg(doc, 'text', { class: 'x', x, y: at.y }, shown);
39
+ if (shown !== text)
40
+ label.append(svg(doc, 'title', {}, text));
41
+ return label;
42
+ }
43
+ /** The axes and grid of a chart, and the scale its marks are drawn with. One unit is one CSS pixel. */
44
+ function frame(doc, series, size, format) {
45
+ const { width, height } = size;
46
+ const values = series.points.map((point) => point.value ?? 0);
47
+ const ticks = niceTicks(Math.min(...values), Math.max(...values));
48
+ const low = ticks[0] ?? 0;
49
+ const span = (ticks.at(-1) ?? 1) - low || 1;
50
+ const plot = height - TOP - BOTTOM;
51
+ const plotWidth = Math.max(width - LEFT - RIGHT, 1);
52
+ const count = series.points.length;
53
+ const band = plotWidth / Math.max(count, 1);
54
+ const y = (value) => TOP + plot * (1 - (value - low) / span);
55
+ const x = (index) => LEFT + band * (index + 0.5);
56
+ const root = svg(doc, 'svg', {
57
+ viewBox: `0 0 ${width} ${height}`,
58
+ width: '100%',
59
+ height,
60
+ role: 'img',
61
+ 'aria-label': `${series.label}, ${NL.points(count)}`,
62
+ });
63
+ for (const tick of ticks) {
64
+ root.append(svg(doc, 'line', { class: 'grid', x1: LEFT, x2: width - RIGHT, y1: y(tick), y2: y(tick) }), svg(doc, 'text', { class: 'tick', x: LEFT - 6, y: y(tick) + 4 }, format(tick, true)));
65
+ }
66
+ const ticksText = series.points.map((point) => point.tick ?? point.label);
67
+ const chars = Math.max(1, ...ticksText.map((text) => text.length));
68
+ const every = series.categories === true
69
+ ? 1
70
+ : Math.ceil(count / Math.max(1, Math.floor(plotWidth / (chars * CHAR + GAP))));
71
+ const shown = series.points.map((_, index) => index).filter((index) => index % every === 0);
72
+ const room = band * every - GAP / 2;
73
+ for (const index of shown) {
74
+ const at = { x: x(index), y: height - 6, left: LEFT, right: width - RIGHT };
75
+ root.append(xLabel(doc, ticksText[index] ?? '', room, at));
76
+ }
77
+ const title = (point) => svg(doc, 'title', {}, `${point.label}: ${point.value === null ? NL.emptyValue : format(point.value)}`);
78
+ return { root, band, x, y, title };
79
+ }
80
+ /** A line through the points (a gap where a value is null), with a dot and a title per point. */
81
+ export function renderLine(doc, series, size, format) {
82
+ const { root, x, y, title } = frame(doc, series, size, format);
83
+ let d = '';
84
+ let pen = 'M';
85
+ series.points.forEach((point, index) => {
86
+ if (point.value === null) {
87
+ pen = 'M';
88
+ return;
89
+ }
90
+ d += `${pen}${x(index).toFixed(1)} ${y(point.value).toFixed(1)}`;
91
+ pen = 'L';
92
+ });
93
+ root.append(svg(doc, 'path', { class: 's1', d }));
94
+ series.points.forEach((point, index) => {
95
+ if (point.value !== null) {
96
+ root.append(svg(doc, 'circle', { class: 's1', cx: x(index), cy: y(point.value), r: 3 }, title(point)));
97
+ }
98
+ });
99
+ return root;
100
+ }
101
+ /** One bar per point from the zero line, with a title each. */
102
+ export function renderBars(doc, series, size, format) {
103
+ const { root, band, x, y, title } = frame(doc, series, size, format);
104
+ const width = band * 0.7;
105
+ series.points.forEach((point, index) => {
106
+ const top = y(Math.max(point.value ?? 0, 0));
107
+ const bottom = y(Math.min(point.value ?? 0, 0));
108
+ root.append(svg(doc, 'rect', { class: 's1', x: x(index) - width / 2, y: top, width, height: bottom - top }, title(point)));
109
+ });
110
+ return root;
111
+ }
@@ -0,0 +1,9 @@
1
+ import type { DmpRecord } from '../types.js';
2
+ import type { Column } from './fields.js';
3
+ /**
4
+ * The rows as CSV for a Dutch spreadsheet: a UTF-8 BOM, `;` between values, CRLF after every line, the column
5
+ * labels as header. Numbers get a decimal comma and no grouping, booleans ja/nee, dates stay as the API sent them.
6
+ */
7
+ export declare function toCsv(columns: readonly Column[], rows: readonly DmpRecord[]): string;
8
+ /** Saves `text` as `name` through a temporary object URL of the document's own window, revoked once handed over. */
9
+ export declare function downloadCsv(doc: Document, name: string, text: string): void;
package/dist/ui/csv.js ADDED
@@ -0,0 +1,55 @@
1
+ import { h } from './dom.js';
2
+ import { NUMERIC_FORMATS, toNumber } from './format.js';
3
+ import { NL } from './texts.js';
4
+ // A spreadsheet runs a cell that starts with one of these as a formula.
5
+ const FORMULA = /^[=+\-@\t\r]/;
6
+ const QUOTE = /[;"\r\n]/;
7
+ /** One value as spreadsheet text: a real number keeps its sign and gets a decimal comma, anything else is guarded. */
8
+ function cell(value, format) {
9
+ if (value === null || value === undefined)
10
+ return '';
11
+ const n = NUMERIC_FORMATS.includes(format) ? toNumber(value) : null;
12
+ let text;
13
+ if (n !== null) {
14
+ // A genuine number cannot be a formula, so a negative one stays a number in the sheet. Plain digits, never
15
+ // an exponent (1e21) a spreadsheet would read differently.
16
+ const plain = String(n).includes('e')
17
+ ? n.toLocaleString('en-US', { useGrouping: false, maximumFractionDigits: 20 })
18
+ : String(n);
19
+ text = plain.replace('.', ',');
20
+ }
21
+ else {
22
+ if (typeof value === 'boolean')
23
+ text = value ? NL.yes.toLowerCase() : NL.no.toLowerCase();
24
+ else if (typeof value === 'object')
25
+ text = JSON.stringify(value);
26
+ else
27
+ text = String(value);
28
+ if (FORMULA.test(text))
29
+ text = `'${text}`;
30
+ }
31
+ return QUOTE.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
32
+ }
33
+ /**
34
+ * The rows as CSV for a Dutch spreadsheet: a UTF-8 BOM, `;` between values, CRLF after every line, the column
35
+ * labels as header. Numbers get a decimal comma and no grouping, booleans ja/nee, dates stay as the API sent them.
36
+ */
37
+ export function toCsv(columns, rows) {
38
+ const lines = [
39
+ columns.map((column) => cell(column.label, 'text')),
40
+ ...rows.map((row) => columns.map((column) => cell(row[column.name], column.format))),
41
+ ];
42
+ return `\uFEFF${lines.map((line) => `${line.join(';')}\r\n`).join('')}`;
43
+ }
44
+ /** Saves `text` as `name` through a temporary object URL of the document's own window, revoked once handed over. */
45
+ export function downloadCsv(doc, name, text) {
46
+ const view = doc.defaultView;
47
+ const blob = new (view?.Blob ?? Blob)([text], { type: 'text/csv;charset=utf-8' });
48
+ const url = (view?.URL ?? URL).createObjectURL(blob);
49
+ const link = h(doc, 'a', { href: url, download: name, hidden: true });
50
+ doc.body.appendChild(link);
51
+ link.click();
52
+ link.remove();
53
+ // Revoking in the same task can cancel the download in some browsers.
54
+ (view?.setTimeout ?? setTimeout)(() => (view?.URL ?? URL).revokeObjectURL(url), 0);
55
+ }
@@ -0,0 +1,24 @@
1
+ import type { CubeAppSession } from '../app.js';
2
+ /** The window the blocks are defined in: the page's own, or a test DOM's. Nothing is read from globals. */
3
+ export interface BlockScope {
4
+ customElements: CustomElementRegistry;
5
+ HTMLElement: typeof HTMLElement;
6
+ document: Document;
7
+ CSSStyleSheet: typeof CSSStyleSheet;
8
+ setTimeout: typeof setTimeout;
9
+ clearTimeout: typeof clearTimeout;
10
+ location?: Location;
11
+ FontFace?: typeof FontFace;
12
+ /** Lets a chart redraw at its new width; without it a chart keeps the width it was drawn at. */
13
+ ResizeObserver?: typeof ResizeObserver;
14
+ }
15
+ export interface DefineOptions {
16
+ /** Where blocks get their session. Default `CubeApp.ready`: the session the app connected, or a default one. */
17
+ session?: () => Promise<CubeAppSession>;
18
+ }
19
+ /**
20
+ * Defines every `cube-*` element in `scope` (default: this window) and applies the theme to its document.
21
+ * Without `customElements` (an old engine, a script run outside a page) it does nothing. A tag already defined
22
+ * is left as it is, so calling it twice is harmless.
23
+ */
24
+ export declare function defineBlocks(scope?: BlockScope, options?: DefineOptions): void;
@@ -0,0 +1,107 @@
1
+ import { blockBase } from './block.js';
2
+ import { appShellBlock } from './blocks/app-shell.js';
3
+ import { boardBlock } from './blocks/board.js';
4
+ import { calcBlock } from './blocks/calc.js';
5
+ import { chartBlock } from './blocks/chart.js';
6
+ import { detailBlock } from './blocks/detail.js';
7
+ import { fieldElement } from './blocks/field.js';
8
+ import { formBlock } from './blocks/form.js';
9
+ import { gapListBlock } from './blocks/gap-list.js';
10
+ import { kpiBlock } from './blocks/kpi.js';
11
+ import { periodPickerBlock } from './blocks/period-picker.js';
12
+ import { stateBlock } from './blocks/state.js';
13
+ import { tableBlock } from './blocks/table.js';
14
+ import { topBlock } from './blocks/top.js';
15
+ import { currentTheme, onTheme, themeCss } from './theme.js';
16
+ /** Every block, by tag. Later steps add theirs here; each tag also has its entry in BLOCK_SPECS. */
17
+ const BLOCKS = [
18
+ ['cube-state', stateBlock],
19
+ ['cube-app-shell', appShellBlock],
20
+ ['cube-period-picker', periodPickerBlock],
21
+ ['cube-kpi', kpiBlock],
22
+ ['cube-chart', chartBlock],
23
+ ['cube-top', topBlock],
24
+ ['cube-table', tableBlock],
25
+ ['cube-detail', detailBlock],
26
+ ['cube-form', formBlock],
27
+ ['cube-calc', calcBlock],
28
+ ['cube-board', boardBlock],
29
+ ['cube-gap-list', gapListBlock],
30
+ ];
31
+ /** The theme sheet per document: defining again replaces it, a theme change rewrites it, it never stacks. */
32
+ const applied = new WeakMap();
33
+ /** `<cube-page>`: a light-DOM container the shell shows or hides. It has no behaviour of its own. */
34
+ function pageElement(scope) {
35
+ return class CubePage extends scope.HTMLElement {
36
+ };
37
+ }
38
+ function loadFonts(scope, theme, seen) {
39
+ // Set-like at runtime; the DOM lib types its add() only under DOM.Iterable, which this build leaves out.
40
+ const fonts = scope.document.fonts;
41
+ if (theme.fontFamily === null || scope.FontFace === undefined || fonts === undefined)
42
+ return;
43
+ for (const file of theme.fontFiles) {
44
+ const key = `${theme.fontFamily}|${file.src}|${file.weight ?? ''}|${file.style ?? ''}`;
45
+ if (seen.has(key))
46
+ continue;
47
+ seen.add(key);
48
+ try {
49
+ const descriptors = { style: file.style ?? 'normal' };
50
+ if (file.weight !== undefined)
51
+ descriptors.weight = file.weight;
52
+ // The src was validated in resolveTheme: same-origin, no quote, parenthesis or whitespace.
53
+ const face = new scope.FontFace(theme.fontFamily, `url("${file.src}")`, descriptors);
54
+ fonts.add(face);
55
+ face.load().catch(() => undefined);
56
+ }
57
+ catch {
58
+ // A font that cannot be added only costs the typeface: the fallback stack applies.
59
+ }
60
+ }
61
+ }
62
+ function applyTheme(scope) {
63
+ const doc = scope.document;
64
+ const previous = applied.get(doc);
65
+ previous?.stop();
66
+ const sheet = new scope.CSSStyleSheet();
67
+ const fonts = previous?.fonts ?? new Set();
68
+ const paint = (theme) => {
69
+ try {
70
+ sheet.replaceSync(themeCss(theme));
71
+ loadFonts(scope, theme, fonts);
72
+ }
73
+ catch {
74
+ // A document whose window has gone (a closed test DOM) keeps its last theme.
75
+ }
76
+ };
77
+ paint(currentTheme());
78
+ doc.adoptedStyleSheets = [...doc.adoptedStyleSheets.filter((s) => s !== previous?.sheet), sheet];
79
+ applied.set(doc, { sheet, stop: onTheme(paint), fonts });
80
+ }
81
+ /**
82
+ * Defines every `cube-*` element in `scope` (default: this window) and applies the theme to its document.
83
+ * Without `customElements` (an old engine, a script run outside a page) it does nothing. A tag already defined
84
+ * is left as it is, so calling it twice is harmless.
85
+ */
86
+ export function defineBlocks(scope, options = {}) {
87
+ const target = scope ?? globalThis;
88
+ if (target.customElements === undefined || target.HTMLElement === undefined || target.document === undefined) {
89
+ return;
90
+ }
91
+ const window = target;
92
+ const registry = window.customElements;
93
+ applyTheme(window);
94
+ const Base = blockBase(window, options);
95
+ for (const [tag, factory] of BLOCKS) {
96
+ if (registry.get(tag) === undefined) {
97
+ registry.define(tag, factory(Base, window));
98
+ }
99
+ }
100
+ if (registry.get('cube-page') === undefined)
101
+ registry.define('cube-page', pageElement(window));
102
+ // Config elements: each tag needs a class of its own, so each gets a fresh one.
103
+ for (const tag of ['cube-field', 'cube-input', 'cube-result', 'cube-column']) {
104
+ if (registry.get(tag) === undefined)
105
+ registry.define(tag, fieldElement(window));
106
+ }
107
+ }
@@ -0,0 +1,12 @@
1
+ type AttrValue = string | boolean | null | undefined | EventListener;
2
+ type Child = Node | string | null | undefined | false;
3
+ /**
4
+ * An element with attributes and children. `true` sets an empty attribute; `false`, null and undefined leave it
5
+ * out; a function under `on<event>` becomes a listener. Strings become text nodes. A `style` attribute and an
6
+ * `on<event>` attribute (anything but a listener) throw: the CSP forbids both, and they would run as code or style.
7
+ */
8
+ export declare function h(doc: Document, tag: string, attrs?: Record<string, AttrValue>, ...children: Child[]): HTMLElement;
9
+ /** An SVG element (namespaced, so it renders); numbers become attribute strings. Refuses `style` and `on*`. */
10
+ export declare function svg(doc: Document, tag: string, attrs?: Record<string, string | number>, ...children: Array<Node | string>): SVGElement;
11
+ export declare function clear(node: Node): void;
12
+ export {};
package/dist/ui/dom.js ADDED
@@ -0,0 +1,57 @@
1
+ // The UI builds every node through these helpers: no HTML string is ever parsed, so row data cannot become markup.
2
+ import { NL } from './texts.js';
3
+ const SVG_NS = 'http://www.w3.org/2000/svg';
4
+ /**
5
+ * An element with attributes and children. `true` sets an empty attribute; `false`, null and undefined leave it
6
+ * out; a function under `on<event>` becomes a listener. Strings become text nodes. A `style` attribute and an
7
+ * `on<event>` attribute (anything but a listener) throw: the CSP forbids both, and they would run as code or style.
8
+ */
9
+ export function h(doc, tag, attrs = {}, ...children) {
10
+ const el = doc.createElement(tag);
11
+ for (const [name, value] of Object.entries(attrs)) {
12
+ refuseUnsafe(name, value === true || typeof value === 'string');
13
+ if (typeof value === 'function') {
14
+ el.addEventListener(name.replace(/^on/, ''), value);
15
+ }
16
+ else if (value === true) {
17
+ el.setAttribute(name, '');
18
+ }
19
+ else if (typeof value === 'string') {
20
+ el.setAttribute(name, value);
21
+ }
22
+ }
23
+ append(doc, el, children);
24
+ return el;
25
+ }
26
+ /** An SVG element (namespaced, so it renders); numbers become attribute strings. Refuses `style` and `on*`. */
27
+ export function svg(doc, tag, attrs = {}, ...children) {
28
+ const el = doc.createElementNS(SVG_NS, tag);
29
+ for (const [name, value] of Object.entries(attrs)) {
30
+ refuseUnsafe(name, true);
31
+ el.setAttribute(name, String(value));
32
+ }
33
+ append(doc, el, children);
34
+ return el;
35
+ }
36
+ /** `style` in any form, and an `on<event>` that would become an attribute rather than a listener. */
37
+ function refuseUnsafe(name, setsAttribute) {
38
+ const lower = name.toLowerCase();
39
+ if (lower === 'style' || (setsAttribute && lower.startsWith('on'))) {
40
+ throw new TypeError(NL.invalid.attribute(name));
41
+ }
42
+ }
43
+ export function clear(node) {
44
+ while (node.firstChild !== null) {
45
+ node.removeChild(node.firstChild);
46
+ }
47
+ }
48
+ function append(doc, el, children) {
49
+ for (const child of children) {
50
+ if (typeof child === 'string') {
51
+ el.appendChild(doc.createTextNode(child));
52
+ }
53
+ else if (child !== null && child !== undefined && child !== false) {
54
+ el.appendChild(child);
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,39 @@
1
+ import type { DmpRecord } from '../types.js';
2
+ import { type Format } from './format.js';
3
+ import type { FieldInfo, ResourceSchema } from './schema.js';
4
+ export interface FieldOption {
5
+ value: string;
6
+ label: string;
7
+ }
8
+ /** What one `<cube-field>` says about a field. Null means "not said": the schema or the values decide. */
9
+ export interface FieldConfig {
10
+ name: string;
11
+ label: string | null;
12
+ format: Format | null;
13
+ multiline: boolean;
14
+ options: FieldOption[] | null;
15
+ hidden: boolean;
16
+ }
17
+ /** One field as a table, detail or form shows it. */
18
+ export interface Column {
19
+ name: string;
20
+ label: string;
21
+ format: Format;
22
+ field: FieldInfo | undefined;
23
+ config: FieldConfig | undefined;
24
+ }
25
+ /** The choices for a field: the cube-field's `options`, else the schema's enum; null when neither gives any. */
26
+ export declare function choicesFor(column: Column): FieldOption[] | null;
27
+ /** A comma-separated list of field names, or null when it names none. */
28
+ export declare function fieldList(raw: string | null): string[] | null;
29
+ /** `laag=Laag,hoog=Hoog`, or `a,b,c` where each value is its own label. */
30
+ export declare function parseOptions(raw: string | null): FieldOption[] | null;
31
+ /** The `<cube-field>` elements directly under `host`, by name; one without a name is ignored. */
32
+ export declare function readFieldConfigs(host: Element): Map<string, FieldConfig>;
33
+ /** What a field is called on screen: its cube-field label, else its name made readable. */
34
+ export declare function fieldLabel(configs: Map<string, FieldConfig>, name: string): string;
35
+ /**
36
+ * The fields to show, in order: the `fields` list, else the schema's summary, else the keys of a sample row.
37
+ * Fields a cube-field hides are left out. Label and format come from the cube-field, else from name and schema.
38
+ */
39
+ export declare function columnsFor(schema: ResourceSchema | null, list: string[] | null, configs: Map<string, FieldConfig>, sample?: DmpRecord): Column[];
@@ -0,0 +1,73 @@
1
+ import { AUTHOR_HIDDEN } from './blocks/field.js';
2
+ import { humanize, inferFormat } from './format.js';
3
+ /** The choices for a field: the cube-field's `options`, else the schema's enum; null when neither gives any. */
4
+ export function choicesFor(column) {
5
+ const values = column.field?.enum ?? null;
6
+ return column.config?.options ?? (values === null ? null : values.map((value) => ({ value, label: value })));
7
+ }
8
+ const FORMATS = ['text', 'number', 'integer', 'currency', 'percent', 'date', 'datetime', 'boolean'];
9
+ /** A comma-separated list of field names, or null when it names none. */
10
+ export function fieldList(raw) {
11
+ const names = (raw ?? '').split(',').map((name) => name.trim()).filter((name) => name !== '');
12
+ return names.length > 0 ? names : null;
13
+ }
14
+ /** `laag=Laag,hoog=Hoog`, or `a,b,c` where each value is its own label. */
15
+ export function parseOptions(raw) {
16
+ if (raw === null)
17
+ return null;
18
+ const options = raw
19
+ .split(',')
20
+ .map((part) => {
21
+ const at = part.indexOf('=');
22
+ const value = (at < 0 ? part : part.slice(0, at)).trim();
23
+ const label = at < 0 ? value : part.slice(at + 1).trim();
24
+ return { value, label: label === '' ? value : label };
25
+ })
26
+ .filter((option) => option.value !== '');
27
+ return options.length > 0 ? options : null;
28
+ }
29
+ /** The `<cube-field>` elements directly under `host`, by name; one without a name is ignored. */
30
+ export function readFieldConfigs(host) {
31
+ const configs = new Map();
32
+ for (const child of Array.from(host.children)) {
33
+ const name = child.getAttribute('name')?.trim() ?? '';
34
+ if (child.localName !== 'cube-field' || name === '')
35
+ continue;
36
+ const format = child.getAttribute('format');
37
+ const authorHidden = child[AUTHOR_HIDDEN];
38
+ configs.set(name, {
39
+ name,
40
+ label: child.getAttribute('label'),
41
+ format: format !== null && FORMATS.includes(format) ? format : null,
42
+ multiline: child.hasAttribute('multiline'),
43
+ options: parseOptions(child.getAttribute('options')),
44
+ hidden: authorHidden ?? child.hasAttribute('hidden'),
45
+ });
46
+ }
47
+ return configs;
48
+ }
49
+ /** What a field is called on screen: its cube-field label, else its name made readable. */
50
+ export function fieldLabel(configs, name) {
51
+ return configs.get(name)?.label ?? humanize(name);
52
+ }
53
+ /**
54
+ * The fields to show, in order: the `fields` list, else the schema's summary, else the keys of a sample row.
55
+ * Fields a cube-field hides are left out. Label and format come from the cube-field, else from name and schema.
56
+ */
57
+ export function columnsFor(schema, list, configs, sample) {
58
+ const summary = schema !== null && schema.summary.length > 0 ? schema.summary : null;
59
+ const names = list ?? summary ?? (sample === undefined ? [] : Object.keys(sample));
60
+ return names
61
+ .filter((name) => configs.get(name)?.hidden !== true)
62
+ .map((name) => {
63
+ const field = schema?.fields.find((candidate) => candidate.name === name);
64
+ const config = configs.get(name);
65
+ return {
66
+ name,
67
+ label: fieldLabel(configs, name),
68
+ format: config?.format ?? inferFormat(field, sample?.[name]),
69
+ field,
70
+ config,
71
+ };
72
+ });
73
+ }
@@ -0,0 +1,44 @@
1
+ import type { FieldInfo } from './schema.js';
2
+ export type Format = 'text' | 'number' | 'integer' | 'currency' | 'percent' | 'date' | 'datetime' | 'boolean';
3
+ /** The formats that hold a number: right-aligned, read and written as numbers. */
4
+ export declare const NUMERIC_FORMATS: readonly Format[];
5
+ export type Truncation = 'day' | 'week' | 'month' | 'quarter' | 'year';
6
+ export interface FormatOptions {
7
+ /** ISO 4217 code; default EUR. */
8
+ currency?: string;
9
+ /** IANA zone for date-times; default the browser's own. */
10
+ timeZone?: string;
11
+ /** `1,2 mln.` instead of `1.234.567`. */
12
+ compact?: boolean;
13
+ }
14
+ type MeasureFn = 'count' | 'sum' | 'avg' | 'min' | 'max';
15
+ /** A number, or a numeric string as the API may send a decimal (`"80.50"`); null for anything else. */
16
+ export declare function toNumber(value: unknown): number | null;
17
+ /** Text for one value in the house format: nl-NL numbers, amounts and dates; `—` for nothing. */
18
+ export declare function formatValue(value: unknown, format: Format, options?: FormatOptions): string;
19
+ /**
20
+ * A label for an aggregate bucket key (`2026-Q2` → `K2 2026`). An unknown key comes back as is. `short` writes a
21
+ * month without its year (`nov`), for an axis.
22
+ */
23
+ export declare function formatBucket(key: string, truncation: Truncation, options?: {
24
+ short?: boolean;
25
+ }): string;
26
+ /** `sum(total)` → `{ fn: 'sum', field: 'total' }`; a bare `count` counts rows. */
27
+ export declare function parseMeasure(measure: string): {
28
+ fn: MeasureFn;
29
+ field: string | null;
30
+ };
31
+ /** `placed_on.month` → `{ field: 'placed_on', truncation: 'month' }`. */
32
+ export declare function parseDimension(by: string): {
33
+ field: string;
34
+ truncation: Truncation | null;
35
+ };
36
+ /** The format a field's values get when the page does not say: from its schema type, else from a sample value. */
37
+ export declare function inferFormat(field: FieldInfo | undefined, sample?: unknown): Format;
38
+ /** A readable label from a field name: `placed_on` → `Placed on`, `customer_id` → `Customer`. */
39
+ export declare function humanize(field: string): string;
40
+ /** `YYYY-MM-DD` as local midnight (Date.parse would make it UTC midnight); anything else via Date.parse. */
41
+ export declare function parseLocalDate(value: string): Date | null;
42
+ /** The local calendar day as `YYYY-MM-DD`. */
43
+ export declare function isoDate(date: Date): string;
44
+ export {};