@geops/rvf-mobility-web-component 0.1.10 → 0.1.12

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 (98) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/docutils.js +198 -0
  3. package/index.html +48 -217
  4. package/index.js +680 -87
  5. package/input.css +11 -1
  6. package/jest-setup.js +3 -2
  7. package/package.json +4 -3
  8. package/scripts/build.mjs +3 -2
  9. package/scripts/dev.mjs +2 -1
  10. package/search.html +38 -69
  11. package/src/BaseLayer/BaseLayer.tsx +20 -12
  12. package/src/FloatingMenu/FloatingMenu.tsx +42 -0
  13. package/src/FloatingMenu/index.tsx +1 -0
  14. package/src/GeolocationButton/GeolocationButton.tsx +6 -5
  15. package/src/Map/Map.tsx +1 -0
  16. package/src/MobilityMap/MobilityMap.tsx +10 -9
  17. package/src/MobilityMap/index.css +0 -13
  18. package/src/RealtimeLayer/RealtimeLayer.tsx +2 -3
  19. package/src/RvfButton/RvfButton.tsx +28 -21
  20. package/src/RvfCheckbox/RvfCheckbox.tsx +24 -0
  21. package/src/RvfCheckbox/index.tsx +1 -0
  22. package/src/RvfExportMenu/RvfExportMenu.tsx +103 -0
  23. package/src/RvfExportMenu/index.tsx +1 -0
  24. package/src/RvfExportMenuButton/RvfExportMenuButton.tsx +27 -0
  25. package/src/RvfExportMenuButton/index.tsx +1 -0
  26. package/src/RvfFeatureDetails/RvfFeatureDetails.tsx +29 -0
  27. package/src/RvfFeatureDetails/index.tsx +1 -0
  28. package/src/RvfFloatingMenu/RvfFloatingMenu.tsx +44 -0
  29. package/src/RvfFloatingMenu/index.tsx +1 -0
  30. package/src/RvfIconButton/RvfIconButton.tsx +35 -0
  31. package/src/RvfIconButton/index.tsx +1 -0
  32. package/src/RvfLayerTree/RvfLayerTree.tsx +41 -0
  33. package/src/RvfLayerTree/TreeItem/TreeItem.tsx +120 -0
  34. package/src/RvfLayerTree/TreeItem/index.tsx +1 -0
  35. package/src/RvfLayerTree/index.tsx +1 -0
  36. package/src/RvfLayerTree/layersTreeContext.ts +4 -0
  37. package/src/RvfLayerTree/layersTreeReducer.ts +152 -0
  38. package/src/RvfLineNetworkPlanLayer/RvfLineNetworkPlanLayer.tsx +42 -0
  39. package/src/RvfLineNetworkPlanLayer/index.tsx +1 -0
  40. package/src/RvfMobilityMap/RvfMobilityMap.tsx +122 -83
  41. package/src/RvfMobilityMap/index.css +0 -13
  42. package/src/RvfModal/RvfModal.tsx +52 -0
  43. package/src/RvfModal/index.tsx +1 -0
  44. package/src/RvfPoisLayer/RvfPoisLayer.tsx +39 -0
  45. package/src/RvfPoisLayer/index.tsx +1 -0
  46. package/src/RvfRadioButton/RvfRadioButton.tsx +16 -0
  47. package/src/RvfRadioButton/index.tsx +1 -0
  48. package/src/RvfSelect/RvfSelect.tsx +22 -0
  49. package/src/RvfSelect/index.tsx +1 -0
  50. package/src/RvfSellingPointsLayer/RvfSellingPointsLayer.tsx +41 -0
  51. package/src/RvfSellingPointsLayer/index.tsx +1 -0
  52. package/src/RvfSharedMobilityLayerGroup/RvfSharedMobilityLayerGroup.tsx +100 -0
  53. package/src/RvfSharedMobilityLayerGroup/index.tsx +1 -0
  54. package/src/RvfSingleClickListener/RvfSingleClickListener.tsx +146 -0
  55. package/src/RvfSingleClickListener/index.tsx +1 -0
  56. package/src/RvfTarifZonenLayer/RvfTarifZonenLayer.tsx +41 -0
  57. package/src/RvfTarifZonenLayer/index.tsx +1 -0
  58. package/src/RvfTopics/RvfTopics.tsx +47 -0
  59. package/src/RvfTopics/index.tsx +1 -0
  60. package/src/RvfZoomButtons/RvfZoomButtons.tsx +36 -29
  61. package/src/Search/Search.tsx +11 -9
  62. package/src/SingleClickListener/index.tsx +1 -1
  63. package/src/StationsLayer/StationsLayer.tsx +0 -1
  64. package/src/StopsSearch/StopsSearch.tsx +38 -6
  65. package/src/icons/ArrowDown/ArrowDown.tsx +22 -0
  66. package/src/icons/ArrowDown/down-open.svg +7 -0
  67. package/src/icons/ArrowDown/index.tsx +1 -0
  68. package/src/icons/ArrowUp/ArrowUp.tsx +22 -0
  69. package/src/icons/ArrowUp/index.tsx +1 -0
  70. package/src/icons/ArrowUp/up-open.svg +7 -0
  71. package/src/icons/Bicycle/verkehrstraeger-rad-2px-white.svg +19 -0
  72. package/src/icons/Cancel/Cancel.tsx +21 -0
  73. package/src/icons/Cancel/cancel.svg +7 -0
  74. package/src/icons/Cancel/index.tsx +1 -0
  75. package/src/icons/Car/verkehrstraeger-auto-2px-white.svg +14 -0
  76. package/src/icons/CargoBicycle/verkehrstraeger-lastenrad-2px-white.svg +27 -0
  77. package/src/icons/DownOpen/DownOpen.tsx +24 -0
  78. package/src/icons/DownOpen/down-open.svg +7 -0
  79. package/src/icons/DownOpen/index.tsx +1 -0
  80. package/src/icons/Download/Download.tsx +20 -0
  81. package/src/icons/Download/download.svg +15 -0
  82. package/src/icons/Download/index.tsx +1 -0
  83. package/src/icons/Elevator/Elevator.tsx +1 -1
  84. package/src/icons/Menu/Menu.tsx +32 -0
  85. package/src/icons/Menu/index.tsx +1 -0
  86. package/src/icons/Menu/menu.svg +9 -0
  87. package/src/icons/Ok/ok-grey.svg +7 -0
  88. package/src/icons/Ok/ok.svg +4 -0
  89. package/src/icons/Scooter/scooter.svg +10 -0
  90. package/src/utils/constants.ts +9 -0
  91. package/src/utils/createMobiDataBwWfsLayer.ts +120 -0
  92. package/src/utils/createSharedMobilityLayer.ts +165 -0
  93. package/src/utils/exportPdf.ts +657 -0
  94. package/src/utils/hooks/useRvfContext.tsx +37 -0
  95. package/src/utils/hooks/useUpdatePermalink.tsx +2 -9
  96. package/tailwind.config.mjs +41 -19
  97. package/src/RvfSharedMobilityLayer/RvfSharedMobilityLayer.tsx +0 -147
  98. package/src/RvfSharedMobilityLayer/index.tsx +0 -1
@@ -0,0 +1,657 @@
1
+ import jsPDF, { jsPDFOptions } from "jspdf";
2
+ import { CopyrightControl } from "mobility-toolbox-js/ol";
3
+ import { Map } from "ol";
4
+ import { Coordinate } from "ol/coordinate";
5
+ import { Extent, getBottomRight, getTopLeft } from "ol/extent";
6
+ import BaseLayer from "ol/layer/Base";
7
+ import { MapOptions } from "ol/Map";
8
+ import { Size } from "ol/size";
9
+ import View, { ViewOptions } from "ol/View";
10
+
11
+ import { RVF_EXTENT_3857 } from "./constants";
12
+
13
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
14
+ const getMargin = (destCanvas) => {
15
+ const newMargin = destCanvas.width / 100; // 1% of the canvas width
16
+ return newMargin;
17
+ };
18
+
19
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
20
+ const getDefaultDownloadImageName = (format) => {
21
+ const fileExt = format === "image/jpeg" ? "jpg" : "png";
22
+ return `${window.document.title.replace(/ /g, "_").toLowerCase()}.${fileExt}`;
23
+ };
24
+
25
+ let multilineCopyright = false;
26
+ const copyrightY = 0;
27
+
28
+ // Ensure the font size fita with the image width.
29
+ const decreaseFontSize = (destContext, maxWidth, copyright, scale = 1) => {
30
+ const minFontSize = 8;
31
+ let sizeMatch;
32
+ let fontSize;
33
+ do {
34
+ sizeMatch = destContext.font.match(/[0-9]+(?:\.[0-9]+)?(px)/i);
35
+ fontSize = parseInt(sizeMatch[0].replace(sizeMatch[1], ""), 10);
36
+
37
+ destContext.font = destContext.font.replace(fontSize, fontSize - 1);
38
+
39
+ multilineCopyright = false;
40
+
41
+ if (fontSize - 1 === minFontSize) {
42
+ multilineCopyright = true;
43
+ }
44
+ } while (
45
+ fontSize - 1 > minFontSize &&
46
+ destContext.measureText(copyright).width * scale > maxWidth
47
+ );
48
+
49
+ return destContext.font;
50
+ };
51
+
52
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
53
+ const drawTextBackground = (
54
+ destContext,
55
+ x,
56
+ y,
57
+ width,
58
+ height,
59
+ styleOptions = {},
60
+ ) => {
61
+ destContext.save();
62
+ // Dflt is a white background
63
+ destContext.fillStyle = "rgba(255,255,255,.8)";
64
+
65
+ // To simplify usability the user could pass a boolean to use only default values.
66
+ if (typeof styleOptions === "object") {
67
+ Object.entries(styleOptions).forEach(([key, value]) => {
68
+ destContext[key] = value;
69
+ });
70
+ }
71
+
72
+ /// draw background rect assuming height of font
73
+ destContext.fillRect(x, y, width, height);
74
+ destContext.restore();
75
+ };
76
+
77
+ export interface DrawCopyrightOptions {
78
+ background?: boolean;
79
+ fillStyle?: string;
80
+ font?: string;
81
+ margin?: number;
82
+ maxWidth?: number;
83
+ padding?: number;
84
+ placement?: Placement;
85
+ scale?: number;
86
+ }
87
+
88
+ export type Placement =
89
+ | "bottom-left"
90
+ | "bottom-right"
91
+ | "top-left"
92
+ | "top-right";
93
+
94
+ // Remove all transparent pixels around the drawing
95
+ const trimCanvas = (function () {
96
+ function rowBlank(imageData, width, y) {
97
+ for (let x = 0; x < width; ++x) {
98
+ if (imageData.data[y * width * 4 + x * 4 + 3] !== 0) {
99
+ return false;
100
+ }
101
+ }
102
+ return true;
103
+ }
104
+
105
+ function columnBlank(imageData, width, x, top, bottom) {
106
+ for (let y = top; y < bottom; ++y) {
107
+ if (imageData.data[y * width * 4 + x * 4 + 3] !== 0) {
108
+ return false;
109
+ }
110
+ }
111
+ return true;
112
+ }
113
+
114
+ return function (canvas) {
115
+ const ctx = canvas.getContext("2d");
116
+ const width = canvas.width;
117
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
118
+ let bottom = imageData.height,
119
+ left = 0,
120
+ right = imageData.width,
121
+ top = 0;
122
+
123
+ while (top < bottom && rowBlank(imageData, width, top)) {
124
+ ++top;
125
+ }
126
+ while (bottom - 1 > top && rowBlank(imageData, width, bottom - 1)) {
127
+ --bottom;
128
+ }
129
+ while (left < right && columnBlank(imageData, width, left, top, bottom)) {
130
+ ++left;
131
+ }
132
+ while (
133
+ right - 1 > left &&
134
+ columnBlank(imageData, width, right - 1, top, bottom)
135
+ ) {
136
+ --right;
137
+ }
138
+
139
+ const trimmed = ctx.getImageData(left, top, right - left, bottom - top);
140
+ const copy = canvas.ownerDocument.createElement("canvas");
141
+ const copyCtx = copy.getContext("2d");
142
+ copy.width = trimmed.width;
143
+ copy.height = trimmed.height;
144
+ copyCtx.putImageData(trimmed, 0, 0);
145
+
146
+ return copy;
147
+ };
148
+ })();
149
+
150
+ const drawText = (
151
+ text: string,
152
+ width: number,
153
+ height: number,
154
+ contextProps: {
155
+ fillStyle?: string;
156
+ font?: string;
157
+ textAlign?: CanvasTextAlign;
158
+ textBaseline?: CanvasTextBaseline;
159
+ },
160
+ ) => {
161
+ const canvas = document.createElement("canvas");
162
+ canvas.width = width;
163
+ canvas.height = height;
164
+
165
+ const {
166
+ fillStyle = "black",
167
+ font = "12px Arial",
168
+ textAlign = "left",
169
+ textBaseline = "top",
170
+ } = contextProps;
171
+
172
+ const ctx = canvas.getContext("2d");
173
+
174
+ ctx.fillStyle = fillStyle;
175
+ ctx.font = font;
176
+ ctx.font = decreaseFontSize(ctx, canvas.width, text);
177
+ ctx.textAlign = textAlign;
178
+ ctx.textBaseline = textBaseline;
179
+
180
+ // We search if the display on 2 line is necessary
181
+ let firstLine = text;
182
+ let firstLineMetrics = ctx.measureText(firstLine);
183
+
184
+ // If the text is bigger than the max width we split it into 2 lines
185
+ if (multilineCopyright) {
186
+ const wordNumber = text.split(" ").length;
187
+ for (let i = 0; i < wordNumber; i += 1) {
188
+ // Stop removing word when fits within one line.
189
+ if (firstLineMetrics.width * scale < canvas.width) {
190
+ break;
191
+ }
192
+ firstLine = firstLine.substring(0, firstLine.lastIndexOf(" "));
193
+ firstLineMetrics = ctx.measureText(firstLine);
194
+ }
195
+ }
196
+
197
+ // Define second line if necessary
198
+ const secondLine = text.replace(firstLine, "").trim();
199
+
200
+ // At this point we know the number of lines to display.
201
+ let lines = [firstLine, secondLine].filter((l) => {
202
+ return !!l;
203
+ });
204
+
205
+ if (textBaseline === "bottom") {
206
+ lines = lines.reverse();
207
+ }
208
+
209
+ // Draw the elements on the canvas
210
+ const lineX = textAlign === "right" ? canvas.width : 0;
211
+ let lineY = textBaseline === "bottom" ? canvas.height : 0;
212
+
213
+ lines.forEach((line) => {
214
+ const { fontBoundingBoxAscent, fontBoundingBoxDescent } =
215
+ ctx.measureText(line);
216
+ const height = fontBoundingBoxAscent + fontBoundingBoxDescent;
217
+ ctx.fillText(line, lineX, lineY);
218
+
219
+ let delta = height;
220
+ if (textBaseline === "bottom") {
221
+ delta = -height;
222
+ }
223
+
224
+ lineY = lineY + delta;
225
+ });
226
+
227
+ const canvasTrim = trimCanvas(canvas);
228
+ return canvasTrim;
229
+ };
230
+
231
+ const drawCopyright = (
232
+ text: (() => string) | string,
233
+ destCanvas: HTMLCanvasElement,
234
+ options: DrawCopyrightOptions = {},
235
+ ) => {
236
+ const { fillStyle, font, margin = 10, placement = "bottom-left" } = options;
237
+ const [placementY, placementX] = placement.split("-");
238
+ const txt = typeof text === "function" ? text() : text;
239
+ const canvas = drawText(txt, destCanvas.width, destCanvas.height, {
240
+ fillStyle,
241
+ font,
242
+ textAlign: placementX as CanvasTextAlign,
243
+ textBaseline: placementY as CanvasTextBaseline,
244
+ });
245
+
246
+ let x = margin;
247
+ let y = margin;
248
+
249
+ if (placementX === "right") {
250
+ x = destCanvas.width - canvas.width - margin;
251
+ }
252
+ if (placementY === "bottom") {
253
+ y = destCanvas.height - canvas.height - margin;
254
+ }
255
+
256
+ destCanvas.getContext("2d").drawImage(canvas, x, y);
257
+ };
258
+
259
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
260
+ const drawElement = (
261
+ data: {
262
+ height?: number;
263
+ rotation?: (() => number) | number;
264
+ src: string;
265
+ width?: number;
266
+ },
267
+ destCanvas: HTMLCanvasElement,
268
+ scale: number,
269
+ margin: number,
270
+ padding: number,
271
+ previousItemSize = [0, 0],
272
+ side = "right",
273
+ ) => {
274
+ const destContext = destCanvas.getContext("2d");
275
+ const { height, rotation, src, width } = data;
276
+
277
+ return new Promise<number[] | undefined>((resolve) => {
278
+ const img = new Image();
279
+ img.crossOrigin = "Anonymous";
280
+ img.src = src;
281
+ img.onload = () => {
282
+ destContext.save();
283
+ const elementWidth = (width || 80) * scale;
284
+ const elementHeight = (height || 80) * scale;
285
+ const left =
286
+ side === "left"
287
+ ? margin + elementWidth / 2
288
+ : destCanvas.width - margin - elementWidth / 2;
289
+ const top =
290
+ (side === "left" && copyrightY
291
+ ? copyrightY - padding
292
+ : destCanvas.height) -
293
+ margin -
294
+ elementHeight / 2 -
295
+ previousItemSize[1];
296
+
297
+ destContext.translate(left, top);
298
+
299
+ if (rotation) {
300
+ const angle = typeof rotation === "function" ? rotation() : rotation;
301
+ destContext.rotate(angle * (Math.PI / 180));
302
+ }
303
+
304
+ destContext.drawImage(
305
+ img,
306
+ -elementWidth / 2,
307
+ -elementHeight / 2,
308
+ elementWidth,
309
+ elementHeight,
310
+ );
311
+ destContext.restore();
312
+
313
+ // Return the pixels width of the arrow and the margin right,
314
+ // that must not be occupied by the copyright.
315
+ resolve([elementWidth + 2 * padding, elementHeight + 2 * padding]);
316
+ };
317
+
318
+ img.onerror = () => {
319
+ resolve(undefined);
320
+ };
321
+ });
322
+ };
323
+
324
+ const calculatePixelsToExport = (map: Map, extent: Extent, coordinates) => {
325
+ let firstCoordinate;
326
+ let oppositeCoordinate;
327
+
328
+ if (extent) {
329
+ firstCoordinate = getTopLeft(extent);
330
+ oppositeCoordinate = getBottomRight(extent);
331
+ } else if (coordinates) {
332
+ // In case of coordinates coming from DragBox interaction:
333
+ // firstCoordinate is the first coordinate drawn by the user.
334
+ // oppositeCoordinate is the coordinate of the point dragged by the user.
335
+ [firstCoordinate, , oppositeCoordinate] = coordinates;
336
+ }
337
+
338
+ if (firstCoordinate && oppositeCoordinate) {
339
+ const firstPixel = map.getPixelFromCoordinate(firstCoordinate);
340
+ const oppositePixel = map.getPixelFromCoordinate(oppositeCoordinate);
341
+ const pixelTopLeft = [
342
+ firstPixel[0] <= oppositePixel[0] ? firstPixel[0] : oppositePixel[0],
343
+ firstPixel[1] <= oppositePixel[1] ? firstPixel[1] : oppositePixel[1],
344
+ ];
345
+ const pixelBottomRight = [
346
+ firstPixel[0] > oppositePixel[0] ? firstPixel[0] : oppositePixel[0],
347
+ firstPixel[1] > oppositePixel[1] ? firstPixel[1] : oppositePixel[1],
348
+ ];
349
+
350
+ return {
351
+ h: pixelBottomRight[1] - pixelTopLeft[1],
352
+ w: pixelBottomRight[0] - pixelTopLeft[0],
353
+ x: pixelTopLeft[0],
354
+ y: pixelTopLeft[1],
355
+ };
356
+ }
357
+ return null;
358
+ };
359
+
360
+ // Create an canvas from a map.
361
+ const createCanvasImage = async (
362
+ map: Map,
363
+ extent?: Extent,
364
+ coordinates?: Coordinate[],
365
+ ): Promise<HTMLCanvasElement> => {
366
+ // Find all layer canvases and add it to dest canvas.
367
+ const canvases = map.getTargetElement().getElementsByTagName("canvas");
368
+
369
+ // Create the canvas to export with the good size.
370
+ let destCanvas: HTMLCanvasElement;
371
+ let destContext: CanvasRenderingContext2D;
372
+
373
+ // canvases is an HTMLCollection, we don't try to transform to array because some compilers like cra doesn't translate it right.
374
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of
375
+ for (let i = 0; i < canvases.length; i += 1) {
376
+ const canvas = canvases[i];
377
+ if (!canvas.width || !canvas.height) {
378
+ continue;
379
+ }
380
+ const clip = calculatePixelsToExport(map, extent, coordinates) || {
381
+ h: canvas.height,
382
+ w: canvas.width,
383
+ x: 0,
384
+ y: 0,
385
+ };
386
+
387
+ if (!destCanvas) {
388
+ destCanvas = document.createElement("canvas");
389
+ destCanvas.width = clip.w;
390
+ destCanvas.height = clip.h;
391
+ destContext = destCanvas.getContext("2d");
392
+ }
393
+
394
+ // Draw canvas to the canvas to export.
395
+ destContext.drawImage(
396
+ canvas,
397
+ clip.x,
398
+ clip.y,
399
+ clip.w,
400
+ clip.h,
401
+ 0,
402
+ 0,
403
+ destCanvas.width,
404
+ destCanvas.height,
405
+ );
406
+ }
407
+
408
+ return destCanvas;
409
+ };
410
+
411
+ const downloadCanvasImage = async (
412
+ canvas: HTMLCanvasElement,
413
+ format = "image/png",
414
+ getDownloadImageName = (format: string) => {
415
+ return format.replace("/", ".");
416
+ },
417
+ ): Promise<boolean> => {
418
+ try {
419
+ // @ts-expect-error - msToBlob is not standard
420
+ if (window.navigator.msSaveBlob) {
421
+ // ie 11 and higher
422
+ let image;
423
+ try {
424
+ // @ts-expect-error - msToBlob is not standard
425
+ image = canvas.msToBlob();
426
+ } catch (error) {
427
+ console.error(error);
428
+ }
429
+ const blob = new Blob([image], {
430
+ type: format,
431
+ });
432
+ // @ts-expect-error - msSaveBlob is not standard
433
+ window.navigator.msSaveBlob(blob, getDownloadImageName(format));
434
+ } else {
435
+ canvas.toBlob((blob) => {
436
+ const link = document.createElement("a");
437
+ link.download = getDownloadImageName(format);
438
+ link.href = URL.createObjectURL(blob);
439
+ // append child to document for firefox to be able to download.
440
+ document.body.appendChild(link);
441
+ link.click();
442
+ }, format);
443
+ }
444
+
445
+ return true;
446
+ } catch (error) {
447
+ console.error(error);
448
+ return false;
449
+ }
450
+ };
451
+
452
+ // Create a map with the good pixel ratio and the good extent
453
+ const createMapToExport = async (
454
+ map: Map,
455
+ layers: BaseLayer[],
456
+ extent?: Extent,
457
+ size?: Size,
458
+ mapOptions: MapOptions = {},
459
+ viewOptions: ViewOptions = {},
460
+ ): Promise<Map> => {
461
+ const mapSize = size || map.getSize();
462
+ const extentToFit = extent;
463
+
464
+ // We create a temporary map.
465
+ const div = document.createElement("div");
466
+ div.style.width = `${mapSize[0]}px`;
467
+ div.style.height = `${mapSize[1]}px`;
468
+ div.style.margin = `0 0 0 -50000px`; // we move the map to the left to be ensure it is hidden during export
469
+ document.body.style.overflow = "hidden";
470
+ div.style.position = "absolute";
471
+ div.style.top = "400px";
472
+ div.style.zIndex = "10000";
473
+ document.body.append(div);
474
+
475
+ const mapHd = new Map({
476
+ pixelRatio: window.devicePixelRatio,
477
+ target: div,
478
+ view: new View({
479
+ center: map.getView().getCenter(),
480
+ projection: map.getView().getProjection(),
481
+ zoom: map.getView().getZoom(),
482
+ ...viewOptions,
483
+ // extent: extentToFit,
484
+ }),
485
+ ...mapOptions,
486
+ });
487
+
488
+ if (extentToFit) {
489
+ mapHd.getView().fit(extentToFit);
490
+ }
491
+
492
+ mapHd.setLayers(layers);
493
+
494
+ // The following is a bit hackish I think it's because the RealtimeLayer is never considered as readyif there is not trajectories
495
+ const promise = new Promise<Map>((resolve) => {
496
+ // If nothing happens in 10 sec we try to produce a pdf
497
+ const timeout = window.setTimeout(() => {
498
+ resolve(mapHd);
499
+ }, 10000);
500
+
501
+ // rendercomplete is triggered when all the layers renderer have the property "ready" to true.
502
+ mapHd.once("rendercomplete", async () => {
503
+ clearTimeout(timeout);
504
+ resolve(mapHd);
505
+ });
506
+ });
507
+
508
+ return await promise;
509
+ };
510
+
511
+ // Sizes in pixel from a jspdf format
512
+ export const sizesByFormat72Dpi: Record<string, number[]> = {
513
+ // https://www.din-formate.de/reihe-a-din-groessen-mm-pixel-dpi.html
514
+ A0: [3370, 2384],
515
+ A1: [2384, 1684],
516
+ A3: [1191, 842],
517
+ A4: [842, 595],
518
+ };
519
+
520
+ export interface CopyrightExportOptions {
521
+ margin?: number;
522
+ padding?: number;
523
+ placement?: "bottom-left" | "bottom-right" | "top-left" | "top-right";
524
+ text?: string;
525
+ }
526
+
527
+ export interface MapExportOptions {
528
+ copyrightOptions?: CopyrightExportOptions;
529
+ pixelRatio?: number;
530
+ text?: string;
531
+ useCopyright?: boolean;
532
+ useMaxExtent?: boolean;
533
+ usePlaceholder?: boolean;
534
+ }
535
+
536
+ async function exportPdf(
537
+ map: Map,
538
+ formatOptions: jsPDFOptions = {},
539
+ mapExportOptions: MapExportOptions = {},
540
+ ): Promise<boolean> {
541
+ const {
542
+ pixelRatio = 3,
543
+ useCopyright = true,
544
+ useMaxExtent = false,
545
+ usePlaceholder = true,
546
+ } = mapExportOptions;
547
+ const format = formatOptions?.format || "A4";
548
+ const sizePt = sizesByFormat72Dpi[format as string] || (format as number[]);
549
+ const size = sizePt.map((n) => {
550
+ return (n * 96) / 72;
551
+ });
552
+ const extent = useMaxExtent
553
+ ? RVF_EXTENT_3857
554
+ : map.getView().calculateExtent();
555
+
556
+ // Save current pixel ratio
557
+ const actualPixelRatio = window.devicePixelRatio;
558
+
559
+ // Set pixel ratio for maplibre
560
+ Object.defineProperty(window, "devicePixelRatio", {
561
+ get() {
562
+ return pixelRatio;
563
+ },
564
+ });
565
+
566
+ // Create a shadow map during export process to avoid flickering
567
+ let placeholderCanvas: HTMLCanvasElement | undefined;
568
+ if (usePlaceholder) {
569
+ placeholderCanvas = await createCanvasImage(map);
570
+ placeholderCanvas.style.zIndex = "0";
571
+ placeholderCanvas.style.position = "absolute";
572
+ map
573
+ .getViewport()
574
+ .querySelector(".ol-layers")
575
+ .insertAdjacentElement("afterend", placeholderCanvas);
576
+ }
577
+
578
+ // Save current map state
579
+ const layers = [...map.getLayers().getArray()];
580
+
581
+ const copyrightText =
582
+ mapExportOptions?.copyrightOptions?.text ||
583
+ map
584
+ .getControls()
585
+ .getArray()
586
+ .find((control) => {
587
+ return control instanceof CopyrightControl;
588
+ // @ts-expect-error - element is private
589
+ })?.element?.textContent;
590
+
591
+ map.getLayers().clear();
592
+
593
+ // Creates a new map with proper size and extent
594
+ const mapToExport = await createMapToExport(map, layers, extent, size, {
595
+ pixelRatio: pixelRatio,
596
+ });
597
+
598
+ Object.defineProperty(window, "devicePixelRatio", {
599
+ get() {
600
+ return actualPixelRatio;
601
+ },
602
+ });
603
+
604
+ // Creates map canvas image
605
+ const canvas = await createCanvasImage(mapToExport);
606
+
607
+ // Clean up mapHd
608
+ mapToExport.getLayers().clear();
609
+ mapToExport.getTargetElement().remove();
610
+ mapToExport.setTarget(null);
611
+ mapToExport.dispose();
612
+
613
+ document.body.style.overflow = "auto";
614
+
615
+ // Reset map to previous state
616
+ map.setLayers(layers);
617
+
618
+ if (placeholderCanvas) {
619
+ map.once("rendercomplete", () => {
620
+ placeholderCanvas.remove();
621
+ });
622
+ }
623
+
624
+ // Add custom content
625
+ if (useCopyright) {
626
+ drawCopyright(copyrightText, canvas, {
627
+ ...(mapExportOptions?.copyrightOptions || {}),
628
+ font: (pixelRatio * 12).toString() + "px Arial",
629
+ });
630
+ }
631
+
632
+ // Transform to PDF
633
+ if (/^A[0-9]{1,2}/.test(format as string) || format?.length === 2) {
634
+ try {
635
+ const doc = new jsPDF({
636
+ orientation: "landscape",
637
+ unit: "pt",
638
+ ...formatOptions,
639
+ });
640
+
641
+ // Add canvas to PDF
642
+ doc.addImage(canvas, "JPEG", 0, 0, sizePt[0], sizePt[1]);
643
+
644
+ // Download the pdf
645
+ doc.save(`rvf-${new Date().toISOString().slice(0, 10)}.pdf`);
646
+ } catch (error) {
647
+ console.error(error);
648
+ return false;
649
+ }
650
+ return true;
651
+ } else {
652
+ // Download the canvas as image
653
+ return await downloadCanvasImage(canvas);
654
+ }
655
+ }
656
+
657
+ export default exportPdf;
@@ -0,0 +1,37 @@
1
+ import { Feature } from "ol";
2
+ import { createContext } from "preact";
3
+ import { useContext } from "preact/hooks";
4
+
5
+ export interface RvfContextType {
6
+ isExportMenuOpen: boolean;
7
+ selectedFeature: Feature;
8
+ selectedFeatures: Feature[];
9
+ setIsExportMenuOpen: (isOpen: boolean) => void;
10
+ setSelectedFeature: (feature: Feature) => void;
11
+ setSelectedFeatures: (features: Feature[]) => void;
12
+ }
13
+
14
+ export const RvfContext = createContext<RvfContextType>({
15
+ isExportMenuOpen: false,
16
+ selectedFeature: null,
17
+ selectedFeatures: [],
18
+ setIsExportMenuOpen: () => {
19
+ console.warn("setIsExportMenuOpen is not implemented");
20
+ },
21
+ setSelectedFeature: () => {
22
+ console.warn("setSelectedFeature is not implemented");
23
+ },
24
+ setSelectedFeatures: () => {
25
+ console.warn("setSelectedFeatures is not implemented");
26
+ },
27
+ });
28
+
29
+ const useRvfContext = (): RvfContextType => {
30
+ const context = useContext<RvfContextType>(RvfContext);
31
+ if (!context) {
32
+ throw new Error("useRvfContext must be used within a ContextProvider");
33
+ }
34
+ return context;
35
+ };
36
+
37
+ export default useRvfContext;