@disy/cadenza.js 0.0.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.
package/cadenza.js ADDED
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Creates a new Cadenza instance of the Cadenza JS client.
3
+ *
4
+ * @param {string} baseUrl - The base URL of the Cadenza server
5
+ * @param {object} [options] - Options
6
+ * @param {HTMLIFrameElement | string} [options.iframe] - An iframe for embedding Cadenza or the iframe's ID
7
+ * @param {boolean} [options.debug] - Whether to enable debug logging
8
+ * @throws For invalid base URL
9
+ */
10
+ export function cadenza(baseUrl, options) {
11
+ return new CadenzaClient(baseUrl, options);
12
+ }
13
+ /* @ts-ignore */
14
+ const previousGlobalCadenza = globalThis.cadenza;
15
+ globalThis.cadenza = Object.assign(
16
+ /** @param {Parameters<cadenza>} args */
17
+ (...args) => cadenza(...args), {
18
+ noConflict() {
19
+ globalThis.cadenza = previousGlobalCadenza;
20
+ return cadenza;
21
+ },
22
+ });
23
+ /** @typedef {string} EmbeddingTargetId - The ID of an embedding target */
24
+ /**
25
+ * @typedef WorkbookKey - A tuple qualifying a workbook
26
+ * @property {string} repositoryName - The name of the workbook's repository
27
+ * @property {string} workbookId - The ID of the workbook
28
+ */
29
+ /**
30
+ * @typedef WorksheetKey - A tuple qualifying a worksheet
31
+ * @property {string} repositoryName - The name of the workbook's repository
32
+ * @property {string} workbookId - The ID of the workbook
33
+ * @property {string} worksheetId - The ID of the worksheet
34
+ */
35
+ /**
36
+ * @typedef WorkbookViewKey - A tuple qualifying a workbook view
37
+ * @property {string} repositoryName - The name of the workbook's repository
38
+ * @property {string} workbookId - The ID of the workbook
39
+ * @property {string} viewId - The ID of the view
40
+ */
41
+ /** @typedef {EmbeddingTargetId | WorkbookKey} WorkbookSource - A workbook source */
42
+ /** @typedef {EmbeddingTargetId | WorksheetKey} WorksheetSource - A worksheet source */
43
+ /** @typedef {EmbeddingTargetId | WorkbookViewKey} WorkbookViewSource - A workbook view source */
44
+ /**
45
+ * @typedef Geometry - A [GeoJSON](https://geojson.org/) geometry object
46
+ * @property {GeometryType} type - The type of the geometry
47
+ */
48
+ /**
49
+ * @typedef {'Point'|'MultiPoint'|'LineString'|'MultiLineString'|'Polygon'|'MultiPolygon'} GeometryType - A GeoJSON geometry type
50
+ *
51
+ * _Note:_ The GeoJSON geometry type "GeometryCollection" is currently not supported.
52
+ */
53
+ /** @typedef {[number,number,number,number]} Extent - An array of numbers representing an extent: [minx, miny, maxx, maxy] */
54
+ /**
55
+ * _Notes:_
56
+ * * Most public methods can be aborted using an [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal).
57
+ * When aborted, the result Promise is rejected with an {@link AbortError}.
58
+ * * If there's another error, the result Promise is rejected with a {@link CadenzaError}.
59
+ * * For methods that support the `hideMainHeaderAndFooter` and `hideWorkbookToolBar` parameters - the parameters cannot override the configuration of an embedding target.
60
+ * * For methods that support the `locationFinder` and `mapExtent` parameters - when both are given, the `mapExtent` takes precedence.
61
+ */
62
+ // Must be exported to be included in the docs.
63
+ export class CadenzaClient {
64
+ /** @readonly */
65
+ #baseUrl;
66
+ /** @readonly */
67
+ #origin;
68
+ /** @readonly */
69
+ #iframe;
70
+ /** @type {HTMLIFrameElement | undefined} */
71
+ #iframeElement;
72
+ /** @readonly */
73
+ #debug;
74
+ /** @type {[ string, (event: CadenzaEvent<never>) => void ][]} */
75
+ #subscriptions = [];
76
+ /**
77
+ * @hidden
78
+ * @param {string} baseUrl
79
+ * @param {object} [options]
80
+ * @param {HTMLIFrameElement | string} [options.iframe]
81
+ * @param {boolean} [options.debug]
82
+ */
83
+ constructor(baseUrl, { debug = false, iframe } = {}) {
84
+ assert(validUrl(baseUrl), `Invalid baseUrl: ${baseUrl}`);
85
+ // Remove trailing /
86
+ if (baseUrl.at(-1) === '/') {
87
+ baseUrl = baseUrl.substring(0, baseUrl.length - 1);
88
+ }
89
+ this.#log('Create Cadenza client', baseUrl, iframe);
90
+ this.#baseUrl = baseUrl;
91
+ this.#origin = new URL(baseUrl).origin;
92
+ this.#iframe = iframe;
93
+ this.#debug = debug;
94
+ }
95
+ /** The base URL of the Cadenza server this client is requesting */
96
+ get baseUrl() {
97
+ return this.#baseUrl;
98
+ }
99
+ /** The iframe this client is using for embedding Cadenza. */
100
+ get iframe() {
101
+ const iframe = this.#iframe;
102
+ if (!this.#iframeElement && iframe) {
103
+ this.#iframeElement =
104
+ typeof iframe === 'string'
105
+ ? /** @type {HTMLIFrameElement} */ (document.getElementById(iframe) ?? undefined)
106
+ : iframe;
107
+ }
108
+ return this.#iframeElement;
109
+ }
110
+ get #requiredIframe() {
111
+ const iframe = this.iframe;
112
+ assert(iframe instanceof HTMLIFrameElement, 'Required iframe is not present.');
113
+ return /** @type {HTMLIFrameElement} */ (iframe);
114
+ }
115
+ /**
116
+ * Show a workbook, worksheet or workbook view in an iframe.
117
+ *
118
+ * @param {WorkbookSource | WorksheetSource | WorkbookViewSource} source - The source to show
119
+ * @param {object} [options]
120
+ * @param {boolean} [options.hideMainHeaderAndFooter] - Whether to hide the main Cadenza header and footer.
121
+ * @param {boolean} [options.hideWorkbookToolBar] - Whether to hide the workbook toolbar.
122
+ * @param {AbortSignal} [options.signal] - A signal to abort the iframe loading
123
+ * @return {Promise<void>} A Promise for when the iframe is loaded
124
+ * @throws For an invalid source
125
+ */
126
+ show(source, { hideMainHeaderAndFooter, hideWorkbookToolBar, signal } = {}) {
127
+ this.#log('CadenzaClient#show', source);
128
+ const params = new URLSearchParams({
129
+ ...(hideMainHeaderAndFooter && { hideMainHeaderAndFooter: 'true' }),
130
+ ...(hideWorkbookToolBar && { hideWorkbookToolBar: 'true' }),
131
+ });
132
+ return this.#show(resolvePath(source), { params, signal });
133
+ }
134
+ /**
135
+ * Show a workbook map view in an iframe.
136
+ *
137
+ * @param {WorkbookViewSource} mapView - The workbook map view to show
138
+ * @param {object} [options] - Options
139
+ * @param {Geometry} [options.geometry] - A geometry to show on the map
140
+ * @param {boolean} [options.hideMainHeaderAndFooter] - Whether to hide the main Cadenza header and footer.
141
+ * @param {boolean} [options.hideWorkbookToolBar] - Whether to hide the workbook toolbar.
142
+ * @param {string} [options.locationFinder] - A search query for the location finder
143
+ * @param {Extent} [options.mapExtent] - A map extent to set
144
+ * @param {boolean} [options.useMapSrs] - Whether the geometry and the extent are in the map's SRS (otherwise EPSG:4326 is assumed)
145
+ * @param {AbortSignal} [options.signal] - A signal to abort the iframe loading
146
+ * @return {Promise<void>} A Promise for when the iframe is loaded
147
+ * @throws For an invalid workbook view source or geometry type
148
+ */
149
+ showMap(mapView, { geometry, hideMainHeaderAndFooter, hideWorkbookToolBar, locationFinder, mapExtent, useMapSrs, signal, } = {}) {
150
+ this.#log('CadenzaClient#showMap', mapView, geometry);
151
+ if (geometry) {
152
+ assertValidGeometryType(geometry.type);
153
+ }
154
+ const params = new URLSearchParams({
155
+ ...(hideMainHeaderAndFooter && { hideMainHeaderAndFooter: 'true' }),
156
+ ...(hideWorkbookToolBar && { hideWorkbookToolBar: 'true' }),
157
+ ...(locationFinder && { locationFinder }),
158
+ ...(mapExtent && { mapExtent: mapExtent.join() }),
159
+ ...(useMapSrs && { useMapSrs: 'true' }),
160
+ });
161
+ return this.#show(resolvePath(mapView), { params, signal }).then(() => this.#postEvent('setGeometry', { geometry }));
162
+ }
163
+ /**
164
+ * Create a geometry.
165
+ *
166
+ * _Note:_ Under the hood, creating a geometry is similar to editing a geometry.
167
+ * That's why the events use the `editGeometry` prefix.
168
+ *
169
+ * @param {WorkbookViewSource} backgroundMapView - The workbook map view in the background
170
+ * @param {GeometryType} geometryType - The geometry type
171
+ * @param {object} [options] - Options
172
+ * @param {Extent} [options.mapExtent] - A map extent to set
173
+ * @param {string} [options.locationFinder] - A search query for the location finder
174
+ * @param {boolean} [options.useMapSrs] - Whether the created geometry should use the map's SRS (otherwise EPSG:4326 will be used)
175
+ * @param {number} [options.minScale] - The minimum scale where the user should work on. A warning is shown when the map is zoomed out above the threshold.
176
+ * @param {AbortSignal} [options.signal] - A signal to abort the iframe loading
177
+ * @return {Promise<void>} A Promise for when the iframe is loaded
178
+ * @throws For an invalid workbook view source or geometry type
179
+ * @fires `editGeometry:update` - When the user changed the geometry. The event includes the edited geometry.
180
+ * @fires `editGeometry:ok` - When the user completed the geometry editing. The event includes the edited geometry.
181
+ * @fires `editGeometry:cancel` - When the user cancelled the geometry editing in Cadenza.
182
+ */
183
+ createGeometry(backgroundMapView, geometryType, { mapExtent, locationFinder, useMapSrs, minScale, signal } = {}) {
184
+ this.#log('CadenzaClient#createGeometry', backgroundMapView, geometryType);
185
+ assertValidGeometryType(geometryType);
186
+ const params = new URLSearchParams({
187
+ action: 'editGeometry',
188
+ geometryType,
189
+ ...(useMapSrs && { useMapSrs: 'true' }),
190
+ ...(locationFinder && { locationFinder }),
191
+ ...(mapExtent && { mapExtent: mapExtent.join() }),
192
+ ...(minScale && { minScale: String(minScale) }),
193
+ });
194
+ return this.#show(resolvePath(backgroundMapView), { params, signal });
195
+ }
196
+ /**
197
+ * Edit a geometry.
198
+ *
199
+ * @param {WorkbookViewSource} backgroundMapView - The workbook map view in the background
200
+ * @param {Geometry} geometry - The geometry
201
+ * @param {object} [options] - Options
202
+ * @param {string} [options.locationFinder] - A search query for the location finder
203
+ * @param {Extent} [options.mapExtent] - A map extent to set
204
+ * @param {boolean} [options.useMapSrs] - Whether the geometry is in the map's SRS (otherwise EPSG:4326 is assumed)
205
+ * @param {number} [options.minScale] - The minimum scale where the user should work on. A warning is shown when the map is zoomed out above the threshold.
206
+ * @param {AbortSignal} [options.signal] - A signal to abort the iframe loading
207
+ * @return {Promise<void>} A Promise for when the iframe is loaded
208
+ * @throws For an invalid workbook view source
209
+ * @fires `editGeometry:update` - When the user changed the geometry. The event includes the edited geometry.
210
+ * @fires `editGeometry:ok` - When the user completed the geometry editing. The event includes the edited geometry.
211
+ * @fires `editGeometry:cancel` - When the user cancelled the geometry editing in Cadenza.
212
+ */
213
+ editGeometry(backgroundMapView, geometry, { mapExtent, locationFinder, useMapSrs, minScale, signal } = {}) {
214
+ this.#log('CadenzaClient#editGeometry', backgroundMapView, geometry);
215
+ assertValidGeometryType(geometry.type);
216
+ const params = new URLSearchParams({
217
+ action: 'editGeometry',
218
+ ...(locationFinder && { locationFinder }),
219
+ ...(mapExtent && { mapExtent: mapExtent.join() }),
220
+ ...(useMapSrs && { useMapSrs: 'true' }),
221
+ ...(minScale && { minScale: String(minScale) }),
222
+ });
223
+ return this.#show(resolvePath(backgroundMapView), { params, signal }).then(() => this.#postEvent('setGeometry', { geometry }));
224
+ }
225
+ /**
226
+ * @param {string} path
227
+ * @param {object} options
228
+ * @param {URLSearchParams} [options.params]
229
+ * @param {AbortSignal} [options.signal]
230
+ */
231
+ #show(path, { params, signal }) {
232
+ const url = new URL(this.baseUrl + path);
233
+ if (params) {
234
+ for (const [param, value] of params) {
235
+ url.searchParams.append(param, value);
236
+ }
237
+ }
238
+ this.#log('Load iframe', url.toString());
239
+ this.#requiredIframe.src = url.toString();
240
+ return this.#getIframePromise(signal);
241
+ }
242
+ /**
243
+ * @param {AbortSignal} [signal]
244
+ */
245
+ #getIframePromise(signal) {
246
+ const iframe = this.#requiredIframe;
247
+ /** @type {() => void} */
248
+ let onerror;
249
+ /** @type {() => void} */
250
+ let onabort;
251
+ /** @type {(() => void)[]} */
252
+ let unsubscribes;
253
+ /** @type {Promise<void>} */
254
+ let promise = new Promise((resolve, reject) => {
255
+ onerror = () => reject(new CadenzaError('loading-error', 'Loading failed'));
256
+ iframe.addEventListener('error', onerror);
257
+ if (signal) {
258
+ onabort = () => {
259
+ iframe.contentWindow?.stop();
260
+ reject(new AbortError());
261
+ };
262
+ signal.addEventListener('abort', onabort);
263
+ }
264
+ unsubscribes = [
265
+ this.on('ready', () => resolve()),
266
+ this.on('error', (/** @type {CadenzaErrorEvent} */ event) => {
267
+ const { type, message } = event.detail;
268
+ reject(new CadenzaError(type, message ?? 'Loading failed'));
269
+ }),
270
+ ];
271
+ });
272
+ promise.then(() => this.#log('Iframe loaded'), (error) => this.#log('Iframe loading failed', error));
273
+ promise.finally(() => {
274
+ iframe.removeEventListener('error', onerror);
275
+ signal?.removeEventListener('abort', onabort);
276
+ unsubscribes.forEach((unsubscribe) => unsubscribe());
277
+ });
278
+ return promise;
279
+ }
280
+ /**
281
+ * Subscribe to a `postMessage()` event.
282
+ *
283
+ * @template [T=unknown]
284
+ * @param {string} type - The event type
285
+ * @param {(event: CadenzaEvent<T>) => void} subscriber - The subscriber function
286
+ * @return {() => void} An unsubscribe function
287
+ */
288
+ on(type, subscriber) {
289
+ const subscriptions = this.#subscriptions;
290
+ if (subscriptions.length === 0) {
291
+ window.addEventListener('message', this.#onMessage);
292
+ }
293
+ subscriptions.push([type, subscriber]);
294
+ return () => {
295
+ subscriptions.forEach(([subscriptionType, subscriptionSubscriber], i) => {
296
+ if (subscriptionType === type &&
297
+ subscriptionSubscriber === subscriber) {
298
+ subscriptions.splice(i, 1);
299
+ }
300
+ });
301
+ if (subscriptions.length === 0) {
302
+ window.removeEventListener('message', this.#onMessage);
303
+ }
304
+ };
305
+ }
306
+ /** @param {MessageEvent<CadenzaEvent<never>>} event */
307
+ // Use arrow function so that it's bound to this.
308
+ #onMessage = (event) => {
309
+ this.#log('Received message', event);
310
+ if (event.origin !== this.#origin ||
311
+ event.source !== this.#requiredIframe.contentWindow) {
312
+ return;
313
+ }
314
+ const cadenzaEvent = event.data;
315
+ this.#subscriptions.forEach(([type, subscriber]) => {
316
+ if (type === cadenzaEvent.type) {
317
+ subscriber(cadenzaEvent);
318
+ }
319
+ });
320
+ };
321
+ /**
322
+ * @param {string} type
323
+ * @param {unknown} detail
324
+ */
325
+ #postEvent(type, detail) {
326
+ const event = { type, detail };
327
+ this.#log('postMessage', event);
328
+ const contentWindow = /** @type {WindowProxy} */ (this.#requiredIframe.contentWindow);
329
+ contentWindow.postMessage(event, { targetOrigin: this.#origin });
330
+ }
331
+ /** @param {unknown[]} args */
332
+ #log(...args) {
333
+ if (this.#debug) {
334
+ console.log(...args);
335
+ }
336
+ }
337
+ }
338
+ /** @param {WorkbookSource | WorksheetSource | WorkbookViewSource} source */
339
+ function resolvePath(source) {
340
+ if (typeof source === 'string') {
341
+ assert(validEmbeddingTargetId(source), `Invalid embedding target ID: ${source}`);
342
+ return `/w/${source}`;
343
+ }
344
+ else {
345
+ const { repositoryName, workbookId } = source;
346
+ assert(validRepositoryName(repositoryName), `Invalid repository name: ${repositoryName}`);
347
+ assert(validWorkbookId(workbookId), `Invalid workbook ID: ${workbookId}`);
348
+ const path = `/public/repositories/${repositoryName}/workbooks/${workbookId}`;
349
+ if ('worksheetId' in source) {
350
+ const worksheetId = source.worksheetId;
351
+ assert(validWorkbookId(worksheetId), `Invalid worksheet ID: ${worksheetId}`);
352
+ return `${path}/worksheets/${worksheetId}`;
353
+ }
354
+ if ('viewId' in source) {
355
+ const viewId = source.viewId;
356
+ assert(validWorkbookId(viewId), `Invalid view ID: ${viewId}`);
357
+ return `${path}/views/${viewId}`;
358
+ }
359
+ return path;
360
+ }
361
+ }
362
+ /**
363
+ * @param {boolean} assertion
364
+ * @param {string} message
365
+ */
366
+ function assert(assertion, message) {
367
+ if (!assertion) {
368
+ throw new Error(message);
369
+ }
370
+ }
371
+ /** @param {string} value */
372
+ function validUrl(value) {
373
+ try {
374
+ new URL(value);
375
+ return true;
376
+ }
377
+ catch {
378
+ return false;
379
+ }
380
+ }
381
+ /** @param {string} value */
382
+ function validEmbeddingTargetId(value) {
383
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
384
+ }
385
+ /** @param {string} value */
386
+ function validRepositoryName(value) {
387
+ return /^[\w -]{1,255}$/.test(value);
388
+ }
389
+ /** @param {string} value */
390
+ function validWorkbookId(value) {
391
+ try {
392
+ // Workbook IDs are url-safe base64 strings.
393
+ // https://stackoverflow.com/a/44528376
394
+ atob(value.replace(/_/g, '/').replace(/-/g, '+'));
395
+ return value !== '';
396
+ }
397
+ catch {
398
+ return false;
399
+ }
400
+ }
401
+ /** @param {string} value */
402
+ function assertValidGeometryType(value) {
403
+ assert(validGeometryType(value), 'Invalid geometry type');
404
+ }
405
+ /** @param {string} value */
406
+ function validGeometryType(value) {
407
+ return [
408
+ 'Point',
409
+ 'MultiPoint',
410
+ 'LineString',
411
+ 'MultiLineString',
412
+ 'Polygon',
413
+ 'MultiPolygon',
414
+ ].includes(value);
415
+ }
416
+ /**
417
+ * @template [T=unknown]
418
+ * @typedef CadenzaEvent - A Cadenza `postMessage()` event
419
+ * @property {string} type - The event type
420
+ * @property {T} detail - Optional event details (depending on the event type)
421
+ */
422
+ /** @typedef {CadenzaEvent<{type: string, message?: string}>} CadenzaErrorEvent - An error event that is mapped to a {@link CadenzaError} */
423
+ export class AbortError extends DOMException {
424
+ constructor() {
425
+ super('Aborted', 'AbortError');
426
+ }
427
+ }
428
+ /**
429
+ * An `Error` implementation for errors in the communication with Cadenza.
430
+ *
431
+ * _Note:_ For invalid parameters, the Cadenza client will throw "normal" `Error`s.
432
+ */
433
+ export class CadenzaError extends Error {
434
+ #type;
435
+ /**
436
+ * @param {string} type - The technical identifier of the error
437
+ * @param {string} message - A description of the error
438
+ */
439
+ constructor(type, message) {
440
+ super(message);
441
+ this.#type = type;
442
+ }
443
+ get type() {
444
+ return this.#type;
445
+ }
446
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@disy/cadenza.js",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "./cadenza.js",
6
+ "types": "./cadenza.d.ts",
7
+ "files": [
8
+ "apidoc/**/*",
9
+ "cadenza.js",
10
+ "cadenza.d.ts",
11
+ "CHANGELOG.md"
12
+ ],
13
+ "scripts": {
14
+ "build": "run-p build:*",
15
+ "build:docs": "typedoc",
16
+ "build:ts": "tsc",
17
+ "reformat": "prettier ./src --write",
18
+ "start": "run-p start:*",
19
+ "start:docs": "npm run build:docs -- --watch",
20
+ "start:test": "npm test -- --watch",
21
+ "start:reformat": "onchange \"src/**/*\" -- prettier {{changed}} --write",
22
+ "test": "cross-env NODE_OPTIONS=--unhandled-rejections=warn jest",
23
+ "deploy:docs": "gh-pages --dist apidoc",
24
+ "sandbox": "npx http-server -a localhost -c-1 -d false --proxy http://localhost:8000 -o /sandbox.html"
25
+ },
26
+ "devDependencies": {
27
+ "@types/jest": "29.5.3",
28
+ "cross-env": "7.0.3",
29
+ "gh-pages": "6.0.0",
30
+ "jest": "29.6.1",
31
+ "jest-environment-jsdom": "29.6.1",
32
+ "npm-run-all": "4.1.5",
33
+ "onchange": "7.1.0",
34
+ "prettier": "3.0.0",
35
+ "ts-jest": "29.1.1",
36
+ "typedoc": "0.24.8"
37
+ },
38
+ "volta": {
39
+ "node": "16.13.1"
40
+ }
41
+ }