@indiscale/linkahead-webui-ext-map 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.eslintrc.json +45 -0
  2. package/.gitlab-ci.yml +44 -0
  3. package/CHANGELOG.md +78 -0
  4. package/README.md +97 -0
  5. package/RELEASE_GUIDELINES.md +45 -0
  6. package/__mocks__/fileMock.js +3 -0
  7. package/__mocks__/styleMock.js +1 -0
  8. package/babel.config.js +22 -0
  9. package/cypress/e2e/standalone-map.cy.js +55 -0
  10. package/cypress/support/commands.js +25 -0
  11. package/cypress/support/e2e.js +17 -0
  12. package/cypress.config.js +10 -0
  13. package/dist/2b3e1faf89f94a483539.png +0 -0
  14. package/dist/416d91365b44e4b4f477.png +0 -0
  15. package/dist/8f2c4d11474275fbc161.png +0 -0
  16. package/dist/index.html +1 -0
  17. package/dist/linkahead-webui-ext-map.js +3 -0
  18. package/dist/linkahead-webui-ext-map.js.LICENSE.txt +45 -0
  19. package/dist/linkahead-webui-ext-map.js.map +1 -0
  20. package/iframe/index.html +6 -0
  21. package/indiscale-linkahead-webui-ext-map-0.4.1.tgz +0 -0
  22. package/jest.config.js +23 -0
  23. package/jest.setup.js +2 -0
  24. package/package.json +105 -0
  25. package/public/favicon.ico +0 -0
  26. package/public/index.html +11 -0
  27. package/public/logo192.png +0 -0
  28. package/public/logo512.png +0 -0
  29. package/public/manifest.json +25 -0
  30. package/public/map_tile_caosdb_logo.png +0 -0
  31. package/public/mock.js +41 -0
  32. package/public/robots.txt +3 -0
  33. package/select_query.json +3 -0
  34. package/src/AllMapEntities.tsx +294 -0
  35. package/src/CurrentPageEntities.js +318 -0
  36. package/src/Map.helpers.css +8 -0
  37. package/src/Map.helpers.js +536 -0
  38. package/src/Map.js +288 -0
  39. package/src/Map.test.js +252 -0
  40. package/src/MapConfig.js +75 -0
  41. package/src/__snapshots__/Map.test.js.snap +1725 -0
  42. package/src/components/Coordinates.js +24 -0
  43. package/src/components/ErrorComponent.tsx +2 -0
  44. package/src/components/Graticule.js +27 -0
  45. package/src/components/Loader.module.css +17 -0
  46. package/src/components/Loader.tsx +36 -0
  47. package/src/components/PathDropDown.js +108 -0
  48. package/src/components/SearchControl.js +502 -0
  49. package/src/components/ToggleMapButton.js +194 -0
  50. package/src/components/ViewChangeControl.js +104 -0
  51. package/src/constants/index.js +1 -0
  52. package/src/context/ConfigProvider.test.js +232 -0
  53. package/src/context/ConfigProvider.tsx +189 -0
  54. package/src/context/LoadingProvider.test.js +124 -0
  55. package/src/context/LoadingProvider.tsx +117 -0
  56. package/src/context/PathIdProvider.js +102 -0
  57. package/src/contrib/latlnggraticule/LICENSE +20 -0
  58. package/src/contrib/latlnggraticule/README.md +68 -0
  59. package/src/contrib/latlnggraticule/leaflet.latlng-graticule.js +528 -0
  60. package/src/contrib/simplegraticule/L.Graticule.js +138 -0
  61. package/src/default_config.json +57 -0
  62. package/src/global.d.ts +8 -0
  63. package/src/index.js +6 -0
  64. package/src/index.scss +133 -0
  65. package/src/logging.js +7 -0
  66. package/src/renderHtmlTemplate.test.js +60 -0
  67. package/src/select-search.min.svg +1 -0
  68. package/src/select-search.svg +46 -0
  69. package/src/setupTests.js +5 -0
  70. package/src/utils/GenerateQueryString.js +200 -0
  71. package/src/utils/GenerateQueryString.test.js +304 -0
  72. package/src/utils/index.ts +3 -0
  73. package/standalone.config.js +5 -0
  74. package/static/map_tile_caosdb_logo.png +0 -0
  75. package/tsconfig.json +25 -0
  76. package/webpack.config.js +193 -0
@@ -0,0 +1,536 @@
1
+ import { useState } from "react";
2
+ import PropTypes from "prop-types";
3
+ import L, { DivIcon } from "leaflet";
4
+ import { Marker, Popup } from "react-leaflet";
5
+ import { logger } from "./logging";
6
+ import {
7
+ Property,
8
+ TransactionService,
9
+ } from "@indiscale/linkahead-webui-entity-service";
10
+ import { useConfig } from "./context/ConfigProvider";
11
+ import { usePathId } from "./context/PathIdProvider";
12
+ import { get } from "lodash";
13
+ import { DEFAULT_IFRAMESETTINGS_PATHID } from "./constants";
14
+ import "./Map.helpers.css";
15
+
16
+ /**
17
+ * Return an array of components displaying a badge with the parents of an entity.
18
+ *
19
+ * @param {string[]} parents - the parent's names.
20
+ * @returns array of components
21
+ */
22
+ const make_parent_labels = function (parents) {
23
+ logger.trace("make_parent_labels", parents);
24
+ if (!parents || !Array.isArray(parents) || parents.length === 0) {
25
+ return null;
26
+ }
27
+ return (
28
+ <>
29
+ {parents.map((par, index) => (
30
+ <span className="badge caosdb-f-map-parent-badge" key={index}>
31
+ {par}
32
+ </span>
33
+ ))}
34
+ </>
35
+ );
36
+ };
37
+
38
+ /**
39
+ * Generates a Property Operator Value (POV) expression by chaining the
40
+ * provided arguments with "WITH".
41
+ *
42
+ * @param {string[]} path - array with the names of RecordTypes
43
+ * @returns {string} string with the the filter
44
+ */
45
+ const get_with_POV = function (path) {
46
+ var pov = "";
47
+ for (let p of path) {
48
+ pov = pov + ` WITH "${p}" `;
49
+ }
50
+ return pov;
51
+ };
52
+
53
+ /**
54
+ * Generates a Property Operator Value (POV) by joining ids with OR.
55
+ *
56
+ * @param {number[]} ids - array of ids for the filter
57
+ * @returns {string} string with the the filter
58
+ */
59
+ const get_id_POV = function (ids) {
60
+ ids = ids.map((x) => "id=" + x);
61
+ return "WITH " + ids.join(" or ");
62
+ };
63
+
64
+ const get_selector = function (path) {
65
+ const sliced = path.slice(1, path.length);
66
+ var selector = sliced.join(".");
67
+ if (selector != "") {
68
+ selector = selector + ".";
69
+ }
70
+ return selector;
71
+ };
72
+
73
+ /**
74
+ * Generates a SELECT query string that applies the provided path of
75
+ * properties as POV and as selector
76
+ *
77
+ * If ids is provided, the condition is not created from the path, but
78
+ * from ids.
79
+ *
80
+ * @param {DataModelConfig} datamodel - datamodel of the entities to be returned.
81
+ * @param {string[]} path - array with the names of RecordTypes
82
+ * @param {number[]} ids - array of ids for the filter
83
+ * @returns {string} query string
84
+ */
85
+ const get_select_with_path = function (datamodel, path, ids) {
86
+ if (typeof datamodel === "undefined") {
87
+ throw new Error("Supply the datamodel.");
88
+ }
89
+ if (typeof path === "undefined" || path.length == 0) {
90
+ throw new Error("Supply at least a RecordType.");
91
+ }
92
+ const recordtype = path[0];
93
+ const selector = get_selector(path);
94
+ var pov = undefined;
95
+ if (typeof ids === "undefined") {
96
+ const sliced = path.slice(1, path.length);
97
+ pov =
98
+ get_with_POV(sliced) +
99
+ ` WITH ( "${datamodel.lat}" AND "${datamodel.lng}" )`;
100
+ } else {
101
+ pov = get_id_POV(ids);
102
+ }
103
+ return `SELECT id,name,parent,${selector}${datamodel.lat},${selector}${datamodel.lng} FROM ENTITY "${recordtype}" ${pov} `;
104
+ };
105
+
106
+ const LINE_BREAK_HTML = '<div class="linebreak"></div>';
107
+
108
+ export function renderHtmlTemplate({ format }, result, { missing = "" } = {}) {
109
+ return (
110
+ String(format)
111
+ // 1) replace <br></br> (and whitespace between)
112
+ .replace(/<\s*br\s*>\s*<\s*\/\s*br\s*>/gi, LINE_BREAK_HTML)
113
+ // 2) replace <br>, <br/>, <br />
114
+ .replace(/<\s*br\s*\/?\s*>/gi, LINE_BREAK_HTML)
115
+ // 3) replace {placeholders}
116
+ .replace(/\{([^}]+)\}/g, (_, rawKey) => {
117
+ const key = rawKey.trim();
118
+ const value = result?.[key];
119
+ return value == null ? missing : String(value);
120
+ })
121
+ );
122
+ }
123
+ /**
124
+ * Component which shows a customentity containing info and links to the entity on the
125
+ * map.
126
+ */
127
+ function EntityPopup({ lat, lng, entity, path, customContent }) {
128
+ const { pathId } = usePathId();
129
+
130
+ const dms_lat = L.NumberFormatter.toDMS(lat).replace("&deg;", "°");
131
+ const dms_lng = L.NumberFormatter.toDMS(lng).replace("&deg;", "°");
132
+
133
+ let extra_loc_hint = "";
134
+ if (path && path.length > 1) {
135
+ extra_loc_hint = (
136
+ <div>{`Location of related ${path[path.length - 1]}`}</div>
137
+ );
138
+ }
139
+
140
+ const parent_labels = make_parent_labels(entity.parents);
141
+ const name_label = make_entity_name_label(entity.id, entity.name);
142
+ return (
143
+ <Popup key={`${entity.id}-${pathId}`}>
144
+ {parent_labels}
145
+ {name_label}
146
+ <div className="small text-muted">
147
+ {extra_loc_hint}
148
+ {`Lat: ${dms_lat} Lng: ${dms_lng}`}
149
+ </div>
150
+ {customContent ? (
151
+ <div
152
+ style={{ display: "flex", flexDirection: "column" }}
153
+ className="customEntityPreview small text-muted"
154
+ dangerouslySetInnerHTML={{ __html: customContent }}
155
+ />
156
+ ) : null}
157
+ </Popup>
158
+ );
159
+ }
160
+
161
+ EntityPopup.propTypes = {
162
+ path: PropTypes.arrayOf(PropTypes.string),
163
+ customContent: PropTypes.string,
164
+ entity: PropTypes.shape({
165
+ id: PropTypes.string,
166
+ name: PropTypes.string,
167
+ parents: PropTypes.arrayOf(PropTypes.string),
168
+ customEntityPreview: PropTypes.object,
169
+ }),
170
+ lat: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
171
+ lng: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
172
+ };
173
+
174
+ const getCustomEntityContent = async ({
175
+ config,
176
+ saveContent,
177
+ entityId,
178
+ pathId,
179
+ }) => {
180
+ const service = get_transaction_service();
181
+ // For custom Entity Preview
182
+ // Check that in the JSON Config there is an entry that correspondes to
183
+ // the current path and get the entryname
184
+ const configKey = Object.keys(config?.entityPreview ?? {}).find(
185
+ (entityPreviewItem) => entityPreviewItem === pathId
186
+ );
187
+
188
+ // With that entryname get the settings from within
189
+ const entityPreviewSettings = get(config?.entityPreview, configKey) || {};
190
+ const queryString =
191
+ entityPreviewSettings?.query &&
192
+ entityPreviewSettings.query.replace("{id}", String(entityId));
193
+
194
+ // if no customcontent for current path or lacking querystring
195
+ // eject !
196
+ if (!configKey || !queryString) {
197
+ saveContent(null);
198
+ return;
199
+ }
200
+
201
+ try {
202
+ const queryResultRes = await service.executeQuery(queryString);
203
+ const queryResult = await get_select_results(
204
+ queryResultRes
205
+ .getResponsesList()[0]
206
+ .getRetrieveResponse()
207
+ .getSelectResult()
208
+ );
209
+
210
+ const entityPreview = renderHtmlTemplate(
211
+ entityPreviewSettings,
212
+ queryResult[0]
213
+ );
214
+
215
+ saveContent(entityPreview);
216
+ } catch (err) {
217
+ /* eslint-disable-line no-empty */
218
+ throw Error(
219
+ `There was an issue executing the custom query: ${queryString}`,
220
+ err
221
+ );
222
+ }
223
+ };
224
+
225
+ function get_transaction_service() {
226
+ const api = process?.env?.GRPC_API_URI || undefined;
227
+ return new TransactionService(api);
228
+ }
229
+
230
+ // TODO this should be moved to the legacy-adapter
231
+ function getBasePath() {
232
+ if (window.connection?.getBasePath) {
233
+ return window.connection.getBasePath();
234
+ }
235
+ return "/";
236
+ }
237
+
238
+ /**
239
+ * Create a div component which shows the name of the entity and contains a
240
+ * link which points to the entity.
241
+ *
242
+ * This is shown as a part of the pop-up when the user click on an
243
+ * entity marker in the map.
244
+ */
245
+ const make_entity_name_label = function (id, name) {
246
+ const entity_on_page = !!document.getElementById(id);
247
+ const href = entity_on_page ? `#${id}` : getBasePath() + `Entity/${id}`;
248
+ const target = entity_on_page ? "_self" : "_blank";
249
+ const title = entity_on_page
250
+ ? "Jump to this entity."
251
+ : "Browse to this entity.";
252
+ return (
253
+ <div className="caosdb-f-map-entity-name-label">
254
+ {name}
255
+ <a
256
+ href={href}
257
+ title={title}
258
+ target={target}
259
+ className="caosdb-f-map-popup-entity-link"
260
+ >
261
+ <i className="bi bi-box-arrow-up-right" />
262
+ </a>
263
+ </div>
264
+ );
265
+ };
266
+
267
+ /**
268
+ * Return the html string for one option in the layer-chooser menu.
269
+ */
270
+ function make_layer_chooser_html(icon, name, description) {
271
+ return `<span title="${description}">${icon} ${name}</span>`;
272
+ }
273
+
274
+ /**
275
+ * Single marker component
276
+ */
277
+ function EntityMarker({ icon_options, zIndexOffset, lat, lng, path, entity }) {
278
+ // Get current Path from provider
279
+ const { pathId } = usePathId();
280
+ // and config
281
+ const { config } = useConfig();
282
+ const [content, saveContent] = useState();
283
+ const configKey = Object.keys(config?.entityPreview ?? {})[0];
284
+
285
+ if (!icon_options.className) {
286
+ icon_options.className = "";
287
+ }
288
+ const isStandalone = process.env.STANDALONE_MODE === true;
289
+ const standaloneEntityPreviewPath =
290
+ config?.iframeSettings?.entityPreviewPath || DEFAULT_IFRAMESETTINGS_PATHID;
291
+
292
+ const icon = new DivIcon(icon_options);
293
+ return (
294
+ <Marker
295
+ icon={icon}
296
+ zIndexOffset={zIndexOffset}
297
+ position={[lat, lng]}
298
+ eventHandlers={{
299
+ // Fetch custom content (if existing) for popup
300
+ // upon click event on marker
301
+ click: () => {
302
+ // When in standalone mode no path dropdown available so we need a pathId whic
303
+ // is provided through the configs
304
+ const globalPathId = isStandalone
305
+ ? standaloneEntityPreviewPath
306
+ : pathId;
307
+ if (configKey === globalPathId) {
308
+ getCustomEntityContent({
309
+ config,
310
+ saveContent,
311
+ entityId: entity.id,
312
+ pathId: globalPathId,
313
+ });
314
+ } else {
315
+ saveContent(null);
316
+ }
317
+ },
318
+ }}
319
+ >
320
+ <EntityPopup
321
+ lat={lat}
322
+ lng={lng}
323
+ path={path}
324
+ entity={entity}
325
+ pathId={pathId}
326
+ customContent={content}
327
+ />
328
+ </Marker>
329
+ );
330
+ }
331
+
332
+ EntityMarker.propTypes = {
333
+ path: PropTypes.arrayOf(PropTypes.string),
334
+ entity: PropTypes.shape({
335
+ id: PropTypes.string,
336
+ name: PropTypes.string,
337
+ parents: PropTypes.arrayOf(PropTypes.string),
338
+ }),
339
+ lat: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
340
+ lng: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
341
+ icon_options: PropTypes.object,
342
+ zIndexOffset: PropTypes.number,
343
+ };
344
+
345
+ function isUndefinedOrEmpty(obj) {
346
+ return typeof obj === "undefined" || (obj.length && obj.length == 0);
347
+ }
348
+
349
+ /**
350
+ * Return a component for markers on the map for an array of entities.
351
+ */
352
+ function EntityMarkers({ path, entities, zIndexOffset, icon_options }) {
353
+ var ret = [];
354
+ for (const entity of entities) {
355
+ var lat_vals = entity.lat;
356
+ var lng_vals = entity.lng;
357
+
358
+ if (isUndefinedOrEmpty(lng_vals) || isUndefinedOrEmpty(lat_vals)) {
359
+ logger.debug(
360
+ "undefined latitude or longitude",
361
+ entity,
362
+ lat_vals,
363
+ lng_vals
364
+ );
365
+ continue;
366
+ }
367
+
368
+ // we need lat_vals and lng_lavs to be arrays so we make them
369
+ // be one
370
+ var is_list_lat = true;
371
+ if (!Array.isArray(lat_vals)) {
372
+ lat_vals = [lat_vals];
373
+ is_list_lat = false;
374
+ }
375
+ var is_list_lng = true;
376
+ if (!Array.isArray(lng_vals)) {
377
+ lng_vals = [lng_vals];
378
+ is_list_lng = false;
379
+ }
380
+
381
+ // both array's length must match
382
+ if (
383
+ is_list_lng !== is_list_lat ||
384
+ (is_list_lat && is_list_lng && lat_vals.length !== lng_vals.length)
385
+ ) {
386
+ logger.error(
387
+ "Cannot show this entity on the map. " +
388
+ "Its lat/long properties have different lenghts: ",
389
+ entity
390
+ );
391
+ continue;
392
+ }
393
+
394
+ // zip both arrays
395
+ // [lat1, lat2, ... latN]
396
+ // [lng1, lng2, ... lngN]
397
+ // into one
398
+ // [[lat1,lng1],[lat2,lng2],... [latN,lngN]]
399
+ const const_lng_vals_array = lng_vals;
400
+ var latlngs = lat_vals.map(function (e, i) {
401
+ return [e, const_lng_vals_array[i]];
402
+ });
403
+
404
+ logger.debug(
405
+ `create point marker(s) at ${latlngs} for`,
406
+ entity,
407
+ zIndexOffset,
408
+ icon_options
409
+ );
410
+ for (let latlng of latlngs) {
411
+ ret.push(
412
+ <EntityMarker
413
+ key={`${entity.id}-${latlng[0]}-${latlng[1]}-${ret.length}`}
414
+ icon_options={icon_options}
415
+ zIndexOffset={zIndexOffset}
416
+ lat={latlng[0]}
417
+ lng={latlng[1]}
418
+ path={path}
419
+ entity={entity}
420
+ />
421
+ );
422
+ }
423
+
424
+ /* Code for showing a PATH on the map.
425
+ * Maybe we re-use it later
426
+ *
427
+ logger.debug(`create path line at ${latlngs} for`,
428
+ map_entity);
429
+
430
+ var opts = {color:'red', smoothFactor: 10.0, weight: 1.5, opacity: 0.5};
431
+ var opts_2 = {color:'green', smoothFactor: 10.0, weight: 3, opacity: 0.5};
432
+ var path = L.polyline(latlngs, opts);
433
+ if (make_popup) {
434
+ path.bindPopup(make_popup(map_entity, datamodel, lat, lng));
435
+ }
436
+ path.on("mouseover",()=>path.setStyle(opts_2));
437
+ path.on("mouseout",()=>path.setStyle(opts));
438
+ ret.push(path);
439
+ *
440
+ *
441
+ */
442
+ }
443
+ return <>{ret}</>;
444
+ }
445
+
446
+ EntityMarkers.propTypes = {
447
+ path: PropTypes.arrayOf(PropTypes.string),
448
+ zIndexOffset: PropTypes.number,
449
+ icon_options: PropTypes.object,
450
+ entities: PropTypes.arrayOf(
451
+ PropTypes.shape({
452
+ id: PropTypes.string,
453
+ name: PropTypes.string,
454
+ parents: PropTypes.arrayOf(PropTypes.string),
455
+ lat: PropTypes.oneOfType([
456
+ PropTypes.string,
457
+ PropTypes.number,
458
+ PropTypes.arrayOf(
459
+ PropTypes.oneOfType([PropTypes.string, PropTypes.number])
460
+ ),
461
+ ]),
462
+ lng: PropTypes.oneOfType([
463
+ PropTypes.string,
464
+ PropTypes.number,
465
+ PropTypes.arrayOf(
466
+ PropTypes.oneOfType([PropTypes.string, PropTypes.number])
467
+ ),
468
+ ]),
469
+ })
470
+ ),
471
+ };
472
+
473
+ /**
474
+ * Return the value of a single cell in a select result as a POJO.
475
+ *
476
+ * TODO move to linkahead-webui-entity-service
477
+ */
478
+ function parseCell(cell) {
479
+ // evil, refactor in linkahead-webui-entity-service
480
+ const property = new Property({ getValue: () => cell });
481
+ return property.getValue();
482
+ }
483
+
484
+ /**
485
+ * Return the select results as a list of POJOs.
486
+ *
487
+ * TODO move to linkahead-webui-entity-service
488
+ */
489
+ function get_select_results(select_result) {
490
+ const header = select_result.getHeader();
491
+ if (!header) {
492
+ return [];
493
+ }
494
+
495
+ const data_rows = select_result.getDataRowsList();
496
+ if (!data_rows) {
497
+ return [];
498
+ }
499
+
500
+ const columns = header.getColumnsList().map((col) => col.getName());
501
+ const data = data_rows.map((row) => {
502
+ const obj = {};
503
+
504
+ const cells = row.getCellsList();
505
+ for (let i = 0; i < cells.length; i++) {
506
+ console.assert(
507
+ columns.length === cells.length,
508
+ "broken select result. columns don't match data_rows"
509
+ );
510
+
511
+ const key = columns[i];
512
+ const value = parseCell(cells[i]);
513
+
514
+ obj[key] = value;
515
+ }
516
+
517
+ return obj;
518
+ });
519
+
520
+ logger.trace("get_select_results", columns, data);
521
+ return data;
522
+ }
523
+
524
+ export {
525
+ get_select_results,
526
+ get_transaction_service,
527
+ EntityMarkers,
528
+ EntityMarker,
529
+ get_selector,
530
+ make_entity_name_label,
531
+ make_parent_labels,
532
+ get_select_with_path,
533
+ get_id_POV,
534
+ get_with_POV,
535
+ make_layer_chooser_html,
536
+ };