@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.
- package/CHANGELOG.md +52 -0
- package/docutils.js +198 -0
- package/index.html +48 -217
- package/index.js +680 -87
- package/input.css +11 -1
- package/jest-setup.js +3 -2
- package/package.json +4 -3
- package/scripts/build.mjs +3 -2
- package/scripts/dev.mjs +2 -1
- package/search.html +38 -69
- package/src/BaseLayer/BaseLayer.tsx +20 -12
- package/src/FloatingMenu/FloatingMenu.tsx +42 -0
- package/src/FloatingMenu/index.tsx +1 -0
- package/src/GeolocationButton/GeolocationButton.tsx +6 -5
- package/src/Map/Map.tsx +1 -0
- package/src/MobilityMap/MobilityMap.tsx +10 -9
- package/src/MobilityMap/index.css +0 -13
- package/src/RealtimeLayer/RealtimeLayer.tsx +2 -3
- package/src/RvfButton/RvfButton.tsx +28 -21
- package/src/RvfCheckbox/RvfCheckbox.tsx +24 -0
- package/src/RvfCheckbox/index.tsx +1 -0
- package/src/RvfExportMenu/RvfExportMenu.tsx +103 -0
- package/src/RvfExportMenu/index.tsx +1 -0
- package/src/RvfExportMenuButton/RvfExportMenuButton.tsx +27 -0
- package/src/RvfExportMenuButton/index.tsx +1 -0
- package/src/RvfFeatureDetails/RvfFeatureDetails.tsx +29 -0
- package/src/RvfFeatureDetails/index.tsx +1 -0
- package/src/RvfFloatingMenu/RvfFloatingMenu.tsx +44 -0
- package/src/RvfFloatingMenu/index.tsx +1 -0
- package/src/RvfIconButton/RvfIconButton.tsx +35 -0
- package/src/RvfIconButton/index.tsx +1 -0
- package/src/RvfLayerTree/RvfLayerTree.tsx +41 -0
- package/src/RvfLayerTree/TreeItem/TreeItem.tsx +120 -0
- package/src/RvfLayerTree/TreeItem/index.tsx +1 -0
- package/src/RvfLayerTree/index.tsx +1 -0
- package/src/RvfLayerTree/layersTreeContext.ts +4 -0
- package/src/RvfLayerTree/layersTreeReducer.ts +152 -0
- package/src/RvfLineNetworkPlanLayer/RvfLineNetworkPlanLayer.tsx +42 -0
- package/src/RvfLineNetworkPlanLayer/index.tsx +1 -0
- package/src/RvfMobilityMap/RvfMobilityMap.tsx +122 -83
- package/src/RvfMobilityMap/index.css +0 -13
- package/src/RvfModal/RvfModal.tsx +52 -0
- package/src/RvfModal/index.tsx +1 -0
- package/src/RvfPoisLayer/RvfPoisLayer.tsx +39 -0
- package/src/RvfPoisLayer/index.tsx +1 -0
- package/src/RvfRadioButton/RvfRadioButton.tsx +16 -0
- package/src/RvfRadioButton/index.tsx +1 -0
- package/src/RvfSelect/RvfSelect.tsx +22 -0
- package/src/RvfSelect/index.tsx +1 -0
- package/src/RvfSellingPointsLayer/RvfSellingPointsLayer.tsx +41 -0
- package/src/RvfSellingPointsLayer/index.tsx +1 -0
- package/src/RvfSharedMobilityLayerGroup/RvfSharedMobilityLayerGroup.tsx +100 -0
- package/src/RvfSharedMobilityLayerGroup/index.tsx +1 -0
- package/src/RvfSingleClickListener/RvfSingleClickListener.tsx +146 -0
- package/src/RvfSingleClickListener/index.tsx +1 -0
- package/src/RvfTarifZonenLayer/RvfTarifZonenLayer.tsx +41 -0
- package/src/RvfTarifZonenLayer/index.tsx +1 -0
- package/src/RvfTopics/RvfTopics.tsx +47 -0
- package/src/RvfTopics/index.tsx +1 -0
- package/src/RvfZoomButtons/RvfZoomButtons.tsx +36 -29
- package/src/Search/Search.tsx +11 -9
- package/src/SingleClickListener/index.tsx +1 -1
- package/src/StationsLayer/StationsLayer.tsx +0 -1
- package/src/StopsSearch/StopsSearch.tsx +38 -6
- package/src/icons/ArrowDown/ArrowDown.tsx +22 -0
- package/src/icons/ArrowDown/down-open.svg +7 -0
- package/src/icons/ArrowDown/index.tsx +1 -0
- package/src/icons/ArrowUp/ArrowUp.tsx +22 -0
- package/src/icons/ArrowUp/index.tsx +1 -0
- package/src/icons/ArrowUp/up-open.svg +7 -0
- package/src/icons/Bicycle/verkehrstraeger-rad-2px-white.svg +19 -0
- package/src/icons/Cancel/Cancel.tsx +21 -0
- package/src/icons/Cancel/cancel.svg +7 -0
- package/src/icons/Cancel/index.tsx +1 -0
- package/src/icons/Car/verkehrstraeger-auto-2px-white.svg +14 -0
- package/src/icons/CargoBicycle/verkehrstraeger-lastenrad-2px-white.svg +27 -0
- package/src/icons/DownOpen/DownOpen.tsx +24 -0
- package/src/icons/DownOpen/down-open.svg +7 -0
- package/src/icons/DownOpen/index.tsx +1 -0
- package/src/icons/Download/Download.tsx +20 -0
- package/src/icons/Download/download.svg +15 -0
- package/src/icons/Download/index.tsx +1 -0
- package/src/icons/Elevator/Elevator.tsx +1 -1
- package/src/icons/Menu/Menu.tsx +32 -0
- package/src/icons/Menu/index.tsx +1 -0
- package/src/icons/Menu/menu.svg +9 -0
- package/src/icons/Ok/ok-grey.svg +7 -0
- package/src/icons/Ok/ok.svg +4 -0
- package/src/icons/Scooter/scooter.svg +10 -0
- package/src/utils/constants.ts +9 -0
- package/src/utils/createMobiDataBwWfsLayer.ts +120 -0
- package/src/utils/createSharedMobilityLayer.ts +165 -0
- package/src/utils/exportPdf.ts +657 -0
- package/src/utils/hooks/useRvfContext.tsx +37 -0
- package/src/utils/hooks/useUpdatePermalink.tsx +2 -9
- package/tailwind.config.mjs +41 -19
- package/src/RvfSharedMobilityLayer/RvfSharedMobilityLayer.tsx +0 -147
- 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;
|