@ldforge/renderers 0.1.4 → 0.1.6

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.
package/src/table.ts ADDED
@@ -0,0 +1,323 @@
1
+ import { parseColumnWidths, validColumnWidths } from './table-widths.js';
2
+ import {
3
+ translate,
4
+ resolveLocale,
5
+ createSvgIcon,
6
+ graphToBindings,
7
+ } from '@ldforge/core';
8
+ import type { ResultRendererPlugin, Binding } from '@ldforge/core';
9
+ import { createResourceInspector } from './resource-inspector.js';
10
+ import type { ResourceLoader } from './resource-inspector.js';
11
+ export interface TableRendererOptions {
12
+ /** Optional endpoint-backed resource lookup; local graph triples work without it. */
13
+ readonly loadResource?: ResourceLoader;
14
+ readonly virtualizeThreshold?: number;
15
+ readonly viewportHeight?: number;
16
+ /** Percentages in result-variable order; must be positive and total 100. */
17
+ readonly columnWidths?: readonly number[];
18
+ }
19
+ export function createTableRenderer(
20
+ options: TableRendererOptions = {},
21
+ ): ResultRendererPlugin {
22
+ if (options.columnWidths && !validColumnWidths(options.columnWidths))
23
+ throw new Error(
24
+ 'columnWidths must contain positive percentages totaling 100.',
25
+ );
26
+ return {
27
+ id: 'table',
28
+ wizard: {
29
+ description: {
30
+ en: 'Accepts SELECT bindings or RDF graph results.',
31
+ nl: 'Voor SELECT-bindings of RDF-graafresultaten.',
32
+ },
33
+ options: [
34
+ {
35
+ key: 'columns',
36
+ aliases: ['colums'],
37
+ label: {
38
+ en: 'Column widths (e.g. 40%,40%,20%)',
39
+ nl: 'Kolombreedtes (bijv. 40%,40%,20%)',
40
+ },
41
+ type: 'text',
42
+ defaultValue: '',
43
+ },
44
+ ],
45
+ bindings: [],
46
+ },
47
+ documentationUrl: 'https://ldforge.nl/plugins.html#table',
48
+ icon: createSvgIcon(['M3 3h18v18H3zM3 9h18M9 9v12']),
49
+ label: { en: 'Table', nl: 'Tabel' },
50
+ supports: (result) => result.kind === 'bindings' || result.kind === 'graph',
51
+ render(container, result, context) {
52
+ const graph = result.kind === 'graph' ? result : undefined;
53
+ if (result.kind === 'graph') result = graphToBindings(result);
54
+ if (result.kind !== 'bindings') return;
55
+ const doc = container.ownerDocument;
56
+ const table = doc.createElement('table');
57
+ table.className = 'ldforge-table';
58
+ table.style.tableLayout = 'fixed';
59
+ table.style.width = '100%';
60
+ const configured =
61
+ context?.directives?.columns ?? context?.directives?.colums;
62
+ const requested =
63
+ configured === undefined
64
+ ? options.columnWidths
65
+ : parseColumnWidths(configured);
66
+ const invalid =
67
+ (configured !== undefined && !requested) ||
68
+ (requested !== undefined &&
69
+ requested.length !== result.variables.length);
70
+ const widths =
71
+ !invalid && requested
72
+ ? requested
73
+ : result.variables.map(() => 100 / result.variables.length);
74
+ const columns = doc.createElement('colgroup');
75
+ for (const width of widths) {
76
+ const column = doc.createElement('col');
77
+ column.style.width = `${width}%`;
78
+ columns.append(column);
79
+ }
80
+ table.append(columns);
81
+ const caption = table.createCaption();
82
+ const locale = context?.locale ?? resolveLocale();
83
+ caption.textContent = translate(
84
+ locale,
85
+ result.bindings.length === 1 ? 'resultOne' : 'resultMany',
86
+ { count: new Intl.NumberFormat(locale).format(result.bindings.length) },
87
+ );
88
+ const data = result;
89
+ const inspector = createResourceInspector(container, {
90
+ locale,
91
+ ...(graph ? { graph } : {}),
92
+ ...(options.loadResource ? { loadResource: options.loadResource } : {}),
93
+ });
94
+ const controls = doc.createElement('div');
95
+ controls.className = 'ldforge-table-controls';
96
+ controls.style.cssText =
97
+ 'display:flex;flex-wrap:wrap;gap:8px;align-items:end;margin-bottom:12px';
98
+ const search = doc.createElement('input');
99
+ search.type = 'search';
100
+ search.style.cssText =
101
+ 'flex:1 1 180px;min-width:0;max-width:100%;box-sizing:border-box';
102
+ search.placeholder =
103
+ locale === 'nl' ? 'Zoeken in alle kolommen' : 'Search all columns';
104
+ search.setAttribute('aria-label', search.placeholder);
105
+ controls.append(search);
106
+ const viewport = doc.createElement('div');
107
+ viewport.className = 'ldforge-table-viewport';
108
+ const virtual =
109
+ data.bindings.length > (options.virtualizeThreshold ?? 500);
110
+ const height = Math.max(120, options.viewportHeight ?? 480);
111
+ if (virtual) {
112
+ viewport.style.maxHeight = `${height}px`;
113
+ viewport.style.overflow = 'auto';
114
+ viewport.style.overflowAnchor = 'none';
115
+ viewport.tabIndex = 0;
116
+ viewport.setAttribute(
117
+ 'aria-label',
118
+ locale === 'nl' ? 'Resultaten' : 'Results',
119
+ );
120
+ }
121
+ viewport.append(table);
122
+ const header = table.createTHead().insertRow();
123
+ const filters: HTMLInputElement[] = [];
124
+
125
+ let sortColumn = -1;
126
+ let direction = 1;
127
+ let rows = [...data.bindings];
128
+ const collator = new Intl.Collator(locale, { numeric: true });
129
+ const headers: HTMLTableCellElement[] = [];
130
+ for (const [index, variable] of data.variables.entries()) {
131
+ const cell = doc.createElement('th');
132
+ cell.scope = 'col';
133
+ if (virtual) cell.style.top = '0';
134
+ cell.style.overflowWrap = 'anywhere';
135
+ const button = doc.createElement('button');
136
+ button.type = 'button';
137
+ button.textContent = variable;
138
+ button.onclick = () => {
139
+ direction = sortColumn === index ? -direction : 1;
140
+ sortColumn = index;
141
+ refresh();
142
+ };
143
+ cell.append(button);
144
+ headers.push(cell);
145
+ header.append(cell);
146
+ const filterCell = doc.createElement('label');
147
+ filterCell.textContent = variable;
148
+ filterCell.style.cssText =
149
+ 'display:flex;flex-direction:column;flex:1 1 120px;min-width:0';
150
+ controls.append(filterCell);
151
+ const filter = doc.createElement('input');
152
+ filter.type = 'search';
153
+ filter.style.width = '100%';
154
+ filter.style.minWidth = '0';
155
+ filter.style.boxSizing = 'border-box';
156
+ filter.setAttribute(
157
+ 'aria-label',
158
+ `${locale === 'nl' ? 'Filter' : 'Filter'} ${variable}`,
159
+ );
160
+ filter.oninput = () => refresh();
161
+ filters.push(filter);
162
+ filterCell.append(filter);
163
+ }
164
+ const body = table.createTBody();
165
+ const rowHeight = 36;
166
+ function draw() {
167
+ const scrollTop = viewport.scrollTop;
168
+ const nextBody = doc.createElement('tbody');
169
+ const start = virtual
170
+ ? Math.max(
171
+ 0,
172
+ Math.floor(
173
+ (scrollTop - (table.tHead?.offsetHeight ?? 0)) / rowHeight,
174
+ ) - 5,
175
+ )
176
+ : 0;
177
+ const first = Math.min(start, rows.length);
178
+ const end = virtual
179
+ ? Math.min(rows.length, first + Math.ceil(height / rowHeight) + 12)
180
+ : rows.length;
181
+ function spacer(size: number) {
182
+ if (!size) return;
183
+ const row = nextBody.insertRow();
184
+ row.setAttribute('aria-hidden', 'true');
185
+ const cell = row.insertCell();
186
+ cell.colSpan = Math.max(1, data.variables.length);
187
+ cell.style.cssText = `height:${size}px;padding:0;border:0;line-height:0`;
188
+ }
189
+ spacer(first * rowHeight);
190
+ for (let index = first; index < end; index++) {
191
+ const binding = rows[index];
192
+ if (!binding) continue;
193
+ const row = nextBody.insertRow();
194
+ row.setAttribute('aria-rowindex', String(index + 2));
195
+ if (virtual) row.style.height = `${rowHeight}px`;
196
+ for (const variable of data.variables) {
197
+ const cell = row.insertCell();
198
+ cell.style.overflowWrap = 'anywhere';
199
+ cell.style.whiteSpace = 'pre-wrap';
200
+ const term = binding[variable];
201
+ const value = doc.createElement('span');
202
+ value.textContent = term?.value ?? '';
203
+ if (term) value.title = term.value;
204
+ if (virtual) {
205
+ cell.style.cssText +=
206
+ ';height:36px;box-sizing:border-box;padding:5px 12px;line-height:25px';
207
+ cell.style.whiteSpace = 'nowrap';
208
+ cell.style.overflow = 'hidden';
209
+ value.style.cssText =
210
+ 'display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-height:28px';
211
+ }
212
+ if (term?.type === 'uri') {
213
+ const button = doc.createElement('button');
214
+ button.type = 'button';
215
+ button.className = 'ldforge-resource-info';
216
+ button.style.cssText =
217
+ 'display:inline-flex;align-items:center;justify-content:center;flex:0 0 24px;width:24px;height:24px;padding:3px;margin:0;border:0;border-radius:50%;background:transparent;color:inherit;box-shadow:none;cursor:pointer';
218
+ const label = `${locale === 'nl' ? 'Informatie over' : 'Information about'} ${term.value}`;
219
+ button.setAttribute('aria-label', label);
220
+ button.title = label;
221
+ const infoIcon = createSvgIcon([
222
+ 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z',
223
+ 'M12 11v6M12 7h.01',
224
+ ])(doc);
225
+ button.append(infoIcon);
226
+ button.onclick = () => {
227
+ void inspector.open(term);
228
+ };
229
+ const resource = doc.createElement('span');
230
+ resource.style.cssText =
231
+ 'display:flex;align-items:baseline;gap:4px;min-width:0';
232
+ value.style.minWidth = '0';
233
+ value.style.flex = '0 1 auto';
234
+ button.style.alignSelf = 'center';
235
+ resource.append(value, button);
236
+ cell.append(resource);
237
+ }
238
+ if (term?.type !== 'uri') cell.append(value);
239
+ if (term)
240
+ cell.title = term['xml:lang']
241
+ ? translate(locale, 'termLanguage', {
242
+ language: term['xml:lang'],
243
+ })
244
+ : (term.datatype ?? term.type);
245
+ }
246
+ }
247
+ spacer((rows.length - end) * rowHeight);
248
+ body.replaceChildren(...nextBody.childNodes);
249
+ table.setAttribute('aria-rowcount', String(rows.length + 1));
250
+ if (virtual) viewport.scrollTop = scrollTop;
251
+ }
252
+ function refresh() {
253
+ const query = search.value.toLocaleLowerCase(locale);
254
+ rows = data.bindings.filter(
255
+ (binding) =>
256
+ data.variables.every((variable, i) =>
257
+ (binding[variable]?.value ?? '')
258
+ .toLocaleLowerCase(locale)
259
+ .includes((filters[i]?.value ?? '').toLocaleLowerCase(locale)),
260
+ ) &&
261
+ (!query ||
262
+ data.variables.some((variable) =>
263
+ (binding[variable]?.value ?? '')
264
+ .toLocaleLowerCase(locale)
265
+ .includes(query),
266
+ )),
267
+ );
268
+ const variable = data.variables[sortColumn];
269
+ if (variable !== undefined) {
270
+ rows.sort(
271
+ (a: Binding, b: Binding) =>
272
+ direction *
273
+ collator.compare(
274
+ a[variable]?.value ?? '',
275
+ b[variable]?.value ?? '',
276
+ ),
277
+ );
278
+ }
279
+ headers.forEach((cell, i) =>
280
+ cell.setAttribute(
281
+ 'aria-sort',
282
+ i === sortColumn
283
+ ? direction === 1
284
+ ? 'ascending'
285
+ : 'descending'
286
+ : 'none',
287
+ ),
288
+ );
289
+ caption.textContent =
290
+ search.value || filters.some((f) => f.value)
291
+ ? `${rows.length} / ${data.bindings.length} ${locale === 'nl' ? 'resultaten' : 'results'}`
292
+ : translate(
293
+ locale,
294
+ rows.length === 1 ? 'resultOne' : 'resultMany',
295
+ { count: new Intl.NumberFormat(locale).format(rows.length) },
296
+ );
297
+ viewport.scrollTop = 0;
298
+ draw();
299
+ }
300
+ search.oninput = () => refresh();
301
+ viewport.onscroll = () => draw();
302
+ refresh();
303
+ if (invalid) {
304
+ const warning = doc.createElement('p');
305
+ warning.setAttribute('role', 'alert');
306
+ warning.textContent =
307
+ locale === 'nl'
308
+ ? 'Ongeldige kolombreedtes: gebruik één positief percentage per kolom, samen 100%. Gelijke breedtes worden gebruikt.'
309
+ : 'Invalid column widths: use one positive percentage per column, totaling 100%. Equal widths are used.';
310
+ container.replaceChildren(
311
+ warning,
312
+ controls,
313
+ viewport,
314
+ inspector.element,
315
+ );
316
+ } else container.replaceChildren(controls, viewport, inspector.element);
317
+ return () => {
318
+ viewport.onscroll = null;
319
+ inspector.destroy();
320
+ };
321
+ },
322
+ };
323
+ }