adds maplibre print

This commit is contained in:
Christian Beutel
2024-11-29 21:41:35 +01:00
parent 390a7b52ee
commit 60c1400dfd
10 changed files with 862 additions and 332 deletions

55
web/package-lock.json generated
View File

@@ -12,6 +12,8 @@
"@sveltejs/adapter-node": "^4.0.1", "@sveltejs/adapter-node": "^4.0.1",
"@threlte/core": "^7.1.0", "@threlte/core": "^7.1.0",
"@threlte/extras": "^8.7.5", "@threlte/extras": "^8.7.5",
"@turf/destination": "^7.1.0",
"@turf/distance": "^7.1.0",
"@types/chart.js": "^2.9.41", "@types/chart.js": "^2.9.41",
"@types/leaflet.awesome-markers": "^2.0.28", "@types/leaflet.awesome-markers": "^2.0.28",
"@types/three": "^0.161.2", "@types/three": "^0.161.2",
@@ -1159,6 +1161,59 @@
} }
} }
}, },
"node_modules/@turf/destination": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@turf/destination/-/destination-7.1.0.tgz",
"integrity": "sha512-97XuvB0iaAiMg86hrnZ529WwP44TQAA9mmI5PMlchACiA4LFrEtWjjDzvO6234coieoqhrw6dZYcJvd5O2PwrQ==",
"dependencies": {
"@turf/helpers": "^7.1.0",
"@turf/invariant": "^7.1.0",
"@types/geojson": "^7946.0.10",
"tslib": "^2.6.2"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/distance": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@turf/distance/-/distance-7.1.0.tgz",
"integrity": "sha512-hhNHhxCHB3ddzAGCNY4BtE29OZh+DAJPvUapQz+wOjISnlwvMcwLKvslgHWSYF536QDVe/93FEU2q67+CsZTPA==",
"dependencies": {
"@turf/helpers": "^7.1.0",
"@turf/invariant": "^7.1.0",
"@types/geojson": "^7946.0.10",
"tslib": "^2.6.2"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/helpers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.1.0.tgz",
"integrity": "sha512-dTeILEUVeNbaEeoZUOhxH5auv7WWlOShbx7QSd4s0T4Z0/iz90z9yaVCtZOLbU89umKotwKaJQltBNO9CzVgaQ==",
"dependencies": {
"@types/geojson": "^7946.0.10",
"tslib": "^2.6.2"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/invariant": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.1.0.tgz",
"integrity": "sha512-OCLNqkItBYIP1nE9lJGuIUatWGtQ4rhBKAyTfFu0z8npVzGEYzvguEeof8/6LkKmTTEHW53tCjoEhSSzdRh08Q==",
"dependencies": {
"@turf/helpers": "^7.1.0",
"@types/geojson": "^7946.0.10",
"tslib": "^2.6.2"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@types/chart.js": { "node_modules/@types/chart.js": {
"version": "2.9.41", "version": "2.9.41",
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.41.tgz", "resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.41.tgz",

View File

@@ -36,6 +36,8 @@
"@sveltejs/adapter-node": "^4.0.1", "@sveltejs/adapter-node": "^4.0.1",
"@threlte/core": "^7.1.0", "@threlte/core": "^7.1.0",
"@threlte/extras": "^8.7.5", "@threlte/extras": "^8.7.5",
"@turf/destination": "^7.1.0",
"@turf/distance": "^7.1.0",
"@types/chart.js": "^2.9.41", "@types/chart.js": "^2.9.41",
"@types/leaflet.awesome-markers": "^2.0.28", "@types/leaflet.awesome-markers": "^2.0.28",
"@types/three": "^0.161.2", "@types/three": "^0.161.2",

View File

@@ -15,6 +15,7 @@
import * as M from "maplibre-gl"; import * as M from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import { createEventDispatcher, onDestroy, onMount } from "svelte"; import { createEventDispatcher, onDestroy, onMount } from "svelte";
import MaplibreGraticule from "$lib/vendor/maplibre-graticule/maplibre-graticule";
export let trails: Trail[] = []; export let trails: Trail[] = [];
export let markers: M.Marker[] = []; export let markers: M.Marker[] = [];
@@ -22,11 +23,17 @@
export let drawing: boolean = false; export let drawing: boolean = false;
export let showElevation: boolean = true; export let showElevation: boolean = true;
export let showInfoPopup: boolean = false; export let showInfoPopup: boolean = false;
export let showGrid: boolean = false;
export let elevationProfileContainer: string | HTMLDivElement | undefined =
undefined;
export let mapOptions: Partial<M.MapOptions> | undefined = undefined;
export let activeTrail: number = 0; export let activeTrail: number = 0;
export let minZoom: number = 0; export let minZoom: number = 0;
let mapContainer: HTMLDivElement; let mapContainer: HTMLDivElement;
let epc: ElevationProfileControl | null = null; let epc: ElevationProfileControl | null = null;
let graticule: MaplibreGraticule | null = null;
let layers: Record< let layers: Record<
string, string,
@@ -78,6 +85,30 @@
); );
} }
$: if (showGrid) {
if (!graticule) {
graticule = new MaplibreGraticule({
minZoom: 0,
maxZoom: 20,
showLabels: true,
labelType: "hdms",
labelSize: 10,
labelColor: "#858585",
longitudePosition: "top",
latitudePosition: "right",
paint: {
"line-opacity": 0.8,
"line-color": "rgba(0,0,0,0.2)",
},
});
}
map?.addControl(graticule);
} else {
if (graticule) {
map?.removeControl(graticule);
}
}
function getData(trails: Trail[]) { function getData(trails: Trail[]) {
if (!trails.length) { if (!trails.length) {
return []; return [];
@@ -155,7 +186,7 @@
? (data[activeTrail].bbox as M.LngLatBoundsLike) ? (data[activeTrail].bbox as M.LngLatBoundsLike)
: getBounds(); : getBounds();
map!.fitBounds(bounds, { map!.fitBounds(bounds, {
animate: true, animate: trails.length > 1,
padding: { padding: {
top: 16, top: 16,
left: 16, left: 16,
@@ -410,16 +441,22 @@
(s) => s.text === localStorage.getItem("layer"), (s) => s.text === localStorage.getItem("layer"),
); );
map = new M.Map({ const finalMapOptions: M.MapOptions = {
...{
container: mapContainer, container: mapContainer,
style: style:
mapStyles[preferredMapStyleIndex].value ?? mapStyles[0].value, mapStyles[preferredMapStyleIndex].value ??
mapStyles[0].value,
center: [initialState.lng, initialState.lat], center: [initialState.lng, initialState.lat],
zoom: initialState.zoom, zoom: initialState.zoom,
}); },
...mapOptions,
};
map = new M.Map(finalMapOptions);
const elevationMarker = new FontawesomeMarker( const elevationMarker = new FontawesomeMarker(
{ {
id: "elevation-marker",
icon: "fa-regular fa-circle", icon: "fa-regular fa-circle",
fontSize: "xs", fontSize: "xs",
width: 4, width: 4,
@@ -449,7 +486,6 @@
}), }),
"top-left", "top-left",
); );
map.addControl(switcherControl);
if (showElevation) { if (showElevation) {
epc = new ElevationProfileControl({ epc = new ElevationProfileControl({
@@ -462,6 +498,7 @@
displayDistanceGrid: true, displayDistanceGrid: true,
tooltipDisplayDPlus: false, tooltipDisplayDPlus: false,
zoom: false, zoom: false,
container: elevationProfileContainer,
onEnter: () => { onEnter: () => {
elevationMarker.setOpacity("1"); elevationMarker.setOpacity("1");
}, },
@@ -474,6 +511,7 @@
}); });
map.addControl(epc); map.addControl(epc);
} }
map.addControl(switcherControl);
map.on("styledata", () => { map.on("styledata", () => {
trails.forEach((t, i) => { trails.forEach((t, i) => {

View File

@@ -1,5 +1,7 @@
import { haversineDistance } from "$lib/models/gpx/utils";
import type { Waypoint } from "$lib/models/waypoint"; import type { Waypoint } from "$lib/models/waypoint";
import type { LeafletEvent, Map, Marker } from "leaflet"; import type { LeafletEvent, Map, Marker } from "leaflet";
import * as M from "maplibre-gl";
export const startIcon = () => L.divIcon({ export const startIcon = () => L.divIcon({
html: '<i class="p-2 text-white bg-gray-500 rounded-full fa fa-bullseye -translate-x-1/2 -translate-y-1/3"></i>', html: '<i class="p-2 text-white bg-gray-500 rounded-full fa fa-bullseye -translate-x-1/2 -translate-y-1/3"></i>',
@@ -64,16 +66,16 @@ export function createAnchorMarker(L: any, lat: number, lon: number, index: numb
return marker return marker
} }
export function calculatePixelPerMeter(map: Map, meters: number) { export function calculatePixelPerMeter(map: M.Map, meters: number) {
const y = map.getSize().y; const y = map.getCanvas().getBoundingClientRect().y;
const x = map.getSize().x; const x = map.getCanvas().getBoundingClientRect().x;
const maxMeters = map.containerPointToLatLng([0, y]).distanceTo(map.containerPointToLatLng([x, y])); const maxMeters = map.unproject([0, y]).distanceTo(map.unproject([x, y]));
const pixelPerMeter = x / maxMeters; const pixelPerMeter = x / maxMeters;
return pixelPerMeter * meters return pixelPerMeter * meters
} }
export function calculateScaleFactor(map: Map) { export function calculateScaleFactor(map: M.Map) {
function _pxTOmm() { function _pxTOmm() {
let heightRef = document.createElement('div'); let heightRef = document.createElement('div');
heightRef.style.height = '1mm'; heightRef.style.height = '1mm';
@@ -89,16 +91,17 @@ export function calculateScaleFactor(map: Map) {
return px / pxPermm; return px / pxPermm;
} }
} }
var centerOfMap = map.getSize().y / 2; var centerOfMap = map.getCanvas().getBoundingClientRect().y / 2;
var realWorlMetersPer100Pixels = map.distance( const p1 = map.unproject([0, centerOfMap]);
map.containerPointToLatLng([0, centerOfMap]), const p2 = map.unproject([100, centerOfMap]);
map.containerPointToLatLng([100, centerOfMap]) var realWorldMetersPer100Pixels = haversineDistance(
p1.lat, p1.lng, p2.lat, p2.lng
); );
const screenMetersPer100Pixels = _pxTOmm()(100) / 1000; const screenMetersPer100Pixels = _pxTOmm()(100) / 1000;
const scaleFactor = realWorlMetersPer100Pixels / screenMetersPer100Pixels const scaleFactor = realWorldMetersPer100Pixels / screenMetersPer100Pixels
return scaleFactor return scaleFactor
} }

View File

@@ -7,9 +7,10 @@ import { get } from "svelte/store";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
export class FontawesomeMarker extends M.Marker { export class FontawesomeMarker extends M.Marker {
constructor(options: { icon: string, fontSize?: string, width?: number, backgroundColor?: string, fontColor?: string }, markerOptions?: M.MarkerOptions) { constructor(options: { icon: string, fontSize?: string, width?: number, backgroundColor?: string, fontColor?: string, id?: string }, markerOptions?: M.MarkerOptions) {
const element = document.createElement('div') const element = document.createElement('div')
element.className = `cursor-pointer flex items-center justify-center w-${options.width ?? 7} aspect-square bg-${options.backgroundColor ?? "gray-500"} rounded-full text-${options.fontSize ?? "normal"}` element.className = `cursor-pointer flex items-center justify-center w-${options.width ?? 7} aspect-square bg-${options.backgroundColor ?? "gray-500"} rounded-full text-${options.fontSize ?? "normal"}`
element.id = options.id ?? "";
super({ element: element, ...markerOptions }); super({ element: element, ...markerOptions });
const { const {

View File

@@ -144,15 +144,15 @@ export class ElevationProfileControl implements IControl {
} }
} }
mapContainer.appendChild(this.profileContainer);
}
const waypointContainer = document.createElement("div") const waypointContainer = document.createElement("div")
waypointContainer.className = "absolute w-full" waypointContainer.className = "absolute w-full"
waypointContainer.id = "waypoint-container" waypointContainer.id = "waypoint-container"
this.profileContainer.append(waypointContainer); this.profileContainer.append(waypointContainer);
mapContainer.appendChild(this.profileContainer);
}
if (this.settings.containerClass) { if (this.settings.containerClass) {
this.profileContainer.classList.add(this.settings.containerClass); this.profileContainer.classList.add(this.settings.containerClass);
} }

View File

@@ -410,6 +410,7 @@ export class ElevationProfile {
} }
this.canvas = document.createElement("canvas"); this.canvas = document.createElement("canvas");
this.canvas.id = "elevation-profile-chart"
appContainer.appendChild(this.canvas); appContainer.appendChild(this.canvas);
Chart.register(...registerables); Chart.register(...registerables);
@@ -515,7 +516,7 @@ export class ElevationProfile {
tickColor: "#0002" tickColor: "#0002"
}, },
ticks: { ticks: {
stepSize: 0.1, stepSize: 1,
align: "inner", align: "inner",
display: this.settings.displayDistanceLabels, display: this.settings.displayDistanceLabels,
color: this.settings.labelColor, color: this.settings.labelColor,

View File

@@ -0,0 +1,213 @@
import distance from '@turf/distance';
import destination from '@turf/destination';
type graticuleJson = {
meridians: GeoJSON.Feature<GeoJSON.LineString>[];
parallels: GeoJSON.Feature<GeoJSON.LineString>[];
};
import { type Units } from '@turf/helpers';
/**
*
* @param {number} n
* @param {number} decimals
* @returns {number}
*/
export function toFixed(n: number, decimals: number): number {
const factor = Math.pow(10, decimals);
return Math.round(n * factor) / factor;
}
/**
*
* @param {number} a
* @param {number} b
* @returns {number}
*/
export function modulo(a: number, b: number): number {
const r = a % b;
return r * b < 0 ? r + b : r;
}
/**
*
* @param {number} number
* @param {number} width
* @param {number | undefined} precision
* @returns {string}
*/
export function padNumber(number: number, width: number, precision: number | undefined = undefined): string {
const numberString =
precision !== undefined ? number.toFixed(precision) : '' + number;
let decimal = numberString.indexOf('.');
decimal = decimal === -1 ? numberString.length : decimal;
return decimal > width
? numberString
: new Array(1 + width - decimal).join('0') + numberString;
}
/**
*
* @param {string} hemispheres
* @param {number} degrees
* @param {number} fractionDigits
* @returns {string}
*/
export function degreesToStringHDMS(hemispheres: string, degrees: number, fractionDigits: number): string {
const normalizedDegrees = modulo(degrees + 180, 360) - 180;
const x = Math.abs(3600 * normalizedDegrees);
const decimals = fractionDigits || 0;
let deg = Math.floor(x / 3600);
let min = Math.floor((x - deg * 3600) / 60);
let sec = toFixed(x - deg * 3600 - min * 60, decimals);
if (sec >= 60) {
sec = 0;
min += 1;
}
if (min >= 60) {
min = 0;
deg += 1;
}
let hdms = deg + '\u00b0';
if (min !== 0 || sec !== 0) {
hdms += ' ' + padNumber(min, 2) + '\u2032';
}
if (sec !== 0) {
hdms += ' ' + padNumber(sec, 2, decimals) + '\u2033';
}
if (normalizedDegrees !== 0) {
hdms += ' ' + hemispheres;
}
return hdms;
}
/**
* @param {GeoJSON.BBox} bbox
* @param {number} graticuleWidth
* @param {number} graticuleHeight
* @param {Units} units
* @param {string} labelType
* @param {string} longitudePosition
* @param {string} latitudePosition
* @returns {graticuleJson}
*/
export function getGraticule(bbox: GeoJSON.BBox, graticuleWidth: number, graticuleHeight: number, units: Units, labelType: string, longitudePosition: string, latitudePosition: string): graticuleJson {
const earthCircumference = Math.ceil(distance([0, 0], [180, 0], { units }) * 2);
const maxColumns = Math.floor(earthCircumference / graticuleWidth);
const fullDistance: (from: GeoJSON.Position, to: GeoJSON.Position, options: { units: Units; }) => number = (from, to, options): number => {
const dist = distance(from, to, options);
if (Math.abs(to[0] - from[0]) >= 180) {
return earthCircumference - dist;
}
return dist;
};
const meridians: GeoJSON.Feature<GeoJSON.LineString>[] = [];
const parallels = [];
const west = bbox[0];
const south = bbox[1];
const east = bbox[2];
const north = bbox[3];
// calculate graticule start point
const deltaX = (west < 0 ? -1 : 1) * fullDistance([0, 0], [west, 0], { units });
const deltaY = (south < 0 ? -1 : 1) * fullDistance([0, 0], [0, south], { units });
const startDeltaX = Math.ceil(deltaX / graticuleWidth) * graticuleWidth;
const startDeltaY = Math.ceil(deltaY / graticuleHeight) * graticuleHeight;
const startPoint: GeoJSON.Position = [
destination([0, 0], startDeltaX, 90, { units }).geometry.coordinates[0],
destination([0, 0], startDeltaY, 0, { units }).geometry.coordinates[1]
];
// calculate graticule columns and rows count
const width = fullDistance([west, 0], [east, 0], { units });
const height = fullDistance([0, south], [0, north], { units });
const columns = Math.min(Math.ceil(width / graticuleWidth), maxColumns);
const rows = Math.ceil(height / graticuleHeight);
let currentPoint: GeoJSON.Position;
// meridians
currentPoint = startPoint;
for (let i = 0; i < columns; i++) {
let coordinates: GeoJSON.Position[];
if (longitudePosition === 'bottom') {
coordinates = [
[currentPoint[0], south],
[currentPoint[0], north],
];
} else {
coordinates = [
[currentPoint[0], north],
[currentPoint[0], south],
];
}
let hemisphere;
if (currentPoint[0] > 0) {
hemisphere = 'E'
} else {
hemisphere = 'W'
}
const hdms = degreesToStringHDMS(hemisphere, currentPoint[0], 2)
let feature: GeoJSON.Feature<GeoJSON.LineString>;
if (labelType === 'hdms') {
feature = { type: 'Feature', geometry: { type: 'LineString', coordinates }, properties: { "coord": hdms } };
} else {
feature = { type: 'Feature', geometry: { type: 'LineString', coordinates }, properties: { "coord": currentPoint[0].toFixed(3) + "°" } };
}
meridians.push(feature);
currentPoint = [
destination([currentPoint[0], 0], graticuleWidth, 90, { units }).geometry.coordinates[0],
currentPoint[1]
];
}
// parallels
currentPoint = startPoint;
for (let i = 0; i < rows; i++) {
let coordinates: GeoJSON.Position[];
if (latitudePosition === 'right') {
coordinates = [
[east, currentPoint[1]],
[west, currentPoint[1]],
];
} else {
coordinates = [
[west, currentPoint[1]],
[east, currentPoint[1]],
];
}
let hemisphere = 'S';
if (currentPoint[1] > 0) {
hemisphere = 'N'
} else {
hemisphere = 'S'
}
const hdms = degreesToStringHDMS(hemisphere, currentPoint[1], 2)
let feature: GeoJSON.Feature<GeoJSON.LineString>;
if (labelType === 'hdms') {
feature = { type: 'Feature', geometry: { type: 'LineString', coordinates }, properties: { "coord": hdms } };
} else {
feature = { type: 'Feature', geometry: { type: 'LineString', coordinates }, properties: { "coord": currentPoint[1].toFixed(3) + "°" } };
}
parallels.push(feature);
currentPoint = [
currentPoint[0],
destination([0, currentPoint[1]], graticuleHeight, 0, { units }).geometry.coordinates[1]
];
}
return {
"meridians": meridians,
"parallels": parallels
};
}

View File

@@ -0,0 +1,266 @@
import { getGraticule } from "./generator";
import * as M from "maplibre-gl";
export interface GraticuleConfig {
minZoom?: number;
maxZoom?: number;
showLabels?: boolean;
labelType?: "hdms" | "decimal";
labelSize?: number;
labelColor?: string;
longitudePosition?: "top" | "bottom";
latitudePosition?: "left" | "right";
longitudeOffset?: number[];
latitudeOffset?: number[];
paint?: any;
}
type graticuleJson = {
meridians: GeoJSON.Feature<GeoJSON.LineString>[];
parallels: GeoJSON.Feature<GeoJSON.LineString>[];
};
export function randomString() {
return Math.floor(Math.random() * 10e12).toString(36);
}
/**
*
* @param {Map} map
* @returns
*/
function calculateResolution(map: M.Map) {
const zoom = map.getZoom();
const container = map.getContainer();
const containerWidth = container.offsetWidth;
const containerHeight = container.offsetHeight;
const tileSize = 256;
const metersPerPixel = (Math.PI * 2 * 6378137) / (tileSize * 2 ** zoom);
const resolutionX = metersPerPixel * (containerWidth / tileSize);
const resolutionY = metersPerPixel * (containerHeight / tileSize);
return {
x: resolutionX,
y: resolutionY,
};
}
export default class MaplibreGraticule {
xid: string;
yid: string;
config: GraticuleConfig;
updateBound: () => void;
labelSize: any;
map: any;
constructor(config: GraticuleConfig) {
this.xid = `graticule-meridains-${randomString()}`;
this.yid = `graticule-parallels-${randomString()}`;
this.config = config;
this.updateBound = this.update.bind(this);
this.labelSize = this.config.labelSize;
}
/**
* @param {Map} map
* @returns {HTMLElement}
*/
onAdd(map: M.Map): HTMLElement {
this.map = map;
this.map.on("load", this.updateBound);
this.map.on("move", this.updateBound);
if (this.map.loaded()) {
this.update();
}
return document.createElement("div");
}
/**
* @returns {void}
*/
onRemove(): void {
if (!this.map) {
return;
}
// Remove symbol layers
const xSymbolLayer = this.map.getLayer(`symbols${this.xid}`);
if (xSymbolLayer) {
this.map.removeLayer(`symbols${this.xid}`);
}
const ySymbolLayer = this.map.getLayer(`symbols${this.yid}`);
if (ySymbolLayer) {
this.map.removeLayer(`symbols${this.yid}`);
}
const xsource = this.map.getSource(this.xid);
if (xsource) {
this.map.removeLayer(this.xid);
this.map.removeSource(this.xid);
}
const ysource = this.map.getSource(this.yid);
if (ysource) {
this.map.removeLayer(this.yid);
this.map.removeSource(this.yid);
}
this.map.off("load", this.updateBound);
this.map.off("move", this.updateBound);
this.map = undefined;
}
/**
* @returns {void}
*/
update(): void {
if (!this.map) {
return;
}
const longitudePosition = this.config.longitudePosition ?? "bottom";
const latitudePosition = this.config.latitudePosition ?? "right";
const labelType = this.config.labelType ?? "hdms";
const resolution = calculateResolution(this.map);
const xWidth = (resolution.x / 100) * 2;
const yWidth = (resolution.y / 100) * 2;
/** @type {graticuleJson} */
let graticule: graticuleJson = {
meridians: [],
parallels: [],
};
if (this.active) {
graticule = getGraticule(
this.bbox,
xWidth,
yWidth,
"kilometers",
labelType,
longitudePosition,
latitudePosition
);
}
const xsource: M.GeoJSONSource = this.map.getSource(this.xid);
if (!xsource) {
this.map.addSource(this.xid, {
type: "geojson",
data: { type: "FeatureCollection", features: graticule.meridians },
});
this.map.addLayer({
id: this.xid,
source: this.xid,
type: "line",
paint: this.config.paint ?? {},
});
if (this.config.showLabels) {
this.map.addLayer({
id: `symbols${this.xid}`,
type: "symbol",
source: this.xid,
layout: {
"symbol-placement": "point",
"text-field": "{coord}",
"text-size": this.config.labelSize ?? 12,
"text-anchor": longitudePosition === "top" ? "top" : "bottom",
"text-offset": this.config.longitudeOffset ?? [0, 0],
},
paint: {
"text-color": this.config.labelColor ?? "#000000",
"text-halo-blur": 1,
"text-halo-color": "rgba(255,255,255,1)",
"text-halo-width": 3,
},
});
}
} else {
xsource.setData({
type: "FeatureCollection",
features: graticule.meridians,
});
}
const ysource: M.GeoJSONSource = this.map.getSource(this.yid);
if (!ysource) {
this.map.addSource(this.yid, {
type: "geojson",
data: { type: "FeatureCollection", features: graticule.parallels },
});
this.map.addLayer({
id: this.yid,
source: this.yid,
type: "line",
paint: this.config.paint ?? {},
});
if (this.config.showLabels) {
this.map.addLayer({
id: `symbols${this.yid}`,
type: "symbol",
source: this.yid,
layout: {
"symbol-placement": "point",
"text-field": "{coord}",
"text-size": this.config.labelSize ?? 12,
"text-anchor": latitudePosition === "left" ? "left" : "right",
"text-offset": this.config.latitudeOffset ?? [0, 0],
},
paint: {
"text-color": this.config.labelColor ?? "#000000",
"text-halo-blur": 1,
"text-halo-color": "rgba(255,255,255,1)",
"text-halo-width": 3,
},
});
}
} else {
ysource.setData({
type: "FeatureCollection",
features: graticule.parallels,
});
}
}
/**
* @returns {boolean}
*/
get active(): boolean {
if (!this.map) {
return false;
}
const minZoom = this.config.minZoom ?? 0;
const maxZoom = this.config.maxZoom ?? 22;
const zoom = this.map.getZoom();
return minZoom <= zoom && zoom < maxZoom;
}
/**
* @returns {GeoJSON.BBox}
*/
get bbox(): GeoJSON.BBox {
if (!this.map) {
throw new Error("Invalid state");
}
const bounds = this.map.getBounds();
if (bounds.getEast() - bounds.getWest() >= 360) {
bounds.setNorthEast([bounds.getWest() + 360, bounds.getNorth()]);
}
const bbox = /** @type {GeoJSON.BBox} */ bounds.toArray().flat();
return bbox;
}
}

View File

@@ -6,7 +6,7 @@
import Button from "$lib/components/base/button.svelte"; import Button from "$lib/components/base/button.svelte";
import Select from "$lib/components/base/select.svelte"; import Select from "$lib/components/base/select.svelte";
import LogoText from "$lib/components/logo/logo_text.svelte"; import LogoText from "$lib/components/logo/logo_text.svelte";
import MapWithElevation from "$lib/components/trail/map_with_elevation.svelte"; import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte";
import type { Settings } from "$lib/models/settings"; import type { Settings } from "$lib/models/settings";
import { show_toast } from "$lib/stores/toast_store"; import { show_toast } from "$lib/stores/toast_store";
import { trail } from "$lib/stores/trail_store"; import { trail } from "$lib/stores/trail_store";
@@ -21,19 +21,16 @@
} from "$lib/util/leaflet_util"; } from "$lib/util/leaflet_util";
import { createRect, createText } from "$lib/util/svg_util"; import { createRect, createText } from "$lib/util/svg_util";
import "$lib/vendor/leaflet-elevation/src/index.css"; import "$lib/vendor/leaflet-elevation/src/index.css";
import type AutoGraticule from "$lib/vendor/leaflet-graticule/leaflet-auto-graticule"; import QrCodeWithLogo from "$lib/vendor/qr-code-with-logos/index";
import leafletImage from "$lib/vendor/leaflet-image/leaflet-image.js";
import { Canvg } from "canvg";
import { jsPDF } from "jspdf"; import { jsPDF } from "jspdf";
import type { Map } from "leaflet";
import "leaflet.awesome-markers/dist/leaflet.awesome-markers.css"; import "leaflet.awesome-markers/dist/leaflet.awesome-markers.css";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import QrCodeWithLogo from "$lib/vendor/qr-code-with-logos/index"; import * as M from "maplibre-gl";
import { onMount, tick } from "svelte"; import { onMount, tick } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
let map: Map; let map: M.Map;
let graticule: AutoGraticule; let showGrid: boolean = true;
const settings: Settings = $page.data.settings; const settings: Settings = $page.data.settings;
@@ -94,7 +91,7 @@
}); });
}); });
function print() { async function print() {
printLoading = true; printLoading = true;
const doc = new jsPDF(selectedOrientation, "mm", [ const doc = new jsPDF(selectedOrientation, "mm", [
paperDimensions[selectedPaperSize].pdf[ paperDimensions[selectedPaperSize].pdf[
@@ -117,20 +114,43 @@
?.getBoundingClientRect().width; ?.getBoundingClientRect().width;
try { try {
leafletImage(
map,
async function (err: string, canvas: HTMLCanvasElement) {
// Map
var img = document.createElement("img"); var img = document.createElement("img");
var dimensions = map.getSize(); var dimensions = map.getCanvas().getBoundingClientRect();
img.width = dimensions.x; img.width = dimensions.width;
img.height = dimensions.y; img.height = dimensions.height;
const ratio = img.height / img.width; let ratio = img.height / img.width;
img.src = canvas.toDataURL(); img.src = map.getCanvas().toDataURL();
doc.addImage(img.src, "png", 8, 8, 0, (width - 16) * ratio); doc.addImage(img.src, "png", 8, 8, 0, (width - 16) * ratio);
currentHeight += (width - 16) * ratio + 11; currentHeight += (width - 16) * ratio + 11;
const elevationProfileHeight = currentHeight - 4; const elevationProfileHeight = currentHeight - 11;
// Waypoints
const canvasContainer = map.getCanvasContainer();
const marker =
canvasContainer.getElementsByClassName("maplibregl-marker");
for (const m of marker) {
if (m.id === "elevation-marker") {
continue;
}
const markerStyle = window.getComputedStyle(m);
const matrix = new DOMMatrixReadOnly(markerStyle.transform);
const percentX = matrix.m41 / dimensions.width;
const percentY = matrix.m42 / dimensions.height;
const markerIcon = m.querySelector("i") as HTMLElement;
const iconContent = window
.getComputedStyle(markerIcon, ":before")
.getPropertyValue("content");
const iconText = String.fromCharCode(parseInt(iconContent.replace(/['"\\]/g, "").trim(), 16));
doc.setFont("fa-solid-900", "normal");
doc.text(iconText, width * percentX, height * percentY);
}
// printLoading = false;
// return;
// QR Code // QR Code
doc.addImage( doc.addImage(
document.getElementById("qrcode") as HTMLImageElement, document.getElementById("qrcode") as HTMLImageElement,
@@ -182,9 +202,7 @@
currentHeight, currentHeight,
); );
currentWidth += currentWidth +=
doc.getTextWidth( doc.getTextWidth(formatDistance($trail.elevation_gain)) + 4;
formatDistance($trail.elevation_gain),
) + 4;
// Elevation loss // Elevation loss
doc.setFont("fa-solid-900", "normal"); doc.setFont("fa-solid-900", "normal");
@@ -204,10 +222,8 @@
for (let i = 0; i < rulerSegments.length; i++) { for (let i = 0; i < rulerSegments.length; i++) {
const segment = rulerSegments[i]; const segment = rulerSegments[i];
const label = rulerLabels[i].textContent ?? ""; const label = rulerLabels[i].textContent ?? "";
const segmentWidth = const segmentWidth = segment.getBoundingClientRect().width;
segment.getBoundingClientRect().width; const segmentWidthPercent = segmentWidth / (pageWidth ?? 0);
const segmentWidthPercent =
segmentWidth / (pageWidth ?? 0);
const segmentPDFWidth = width * segmentWidthPercent; const segmentPDFWidth = width * segmentWidthPercent;
let textWidth = doc.getTextWidth(label); let textWidth = doc.getTextWidth(label);
@@ -217,13 +233,7 @@
doc.setFillColor(93, 93, 93); doc.setFillColor(93, 93, 93);
} }
doc.rect( doc.rect(currentWidth, currentHeight, segmentPDFWidth, 2, "FD");
currentWidth,
currentHeight,
segmentPDFWidth,
2,
"FD",
);
doc.text( doc.text(
label, label,
currentWidth - textWidth / 2, currentWidth - textWidth / 2,
@@ -231,8 +241,7 @@
); );
currentWidth += segmentPDFWidth; currentWidth += segmentPDFWidth;
if (i == rulerSegments.length - 1) { if (i == rulerSegments.length - 1) {
const endLabel = const endLabel = rulerLabels[i + 1].textContent ?? "";
rulerLabels[i + 1].textContent ?? "";
textWidth = doc.getTextWidth(endLabel); textWidth = doc.getTextWidth(endLabel);
doc.text( doc.text(
endLabel, endLabel,
@@ -240,11 +249,7 @@
currentHeight + 6, currentHeight + 6,
); );
textWidth = doc.getTextWidth("KM"); textWidth = doc.getTextWidth("KM");
doc.text( doc.text("KM", currentWidth - textWidth, currentHeight - 2);
"KM",
currentWidth - textWidth,
currentHeight - 2,
);
} }
} }
@@ -258,55 +263,22 @@
); );
// Elevation profile // Elevation profile
const gridLines = document.querySelectorAll( const elevationChartCanvas = document.getElementById(
"#elevation .grid line", "elevation-profile-chart",
); ) as HTMLCanvasElement;
gridLines.forEach((l) => var dimensions = elevationChartCanvas.getBoundingClientRect();
l.setAttribute("stroke", "#d1d5db"), img.width = dimensions.width;
); img.height = dimensions.height;
const gridBorders = document.querySelectorAll( ratio = img.height / img.width;
"#elevation .grid path", img.src = elevationChartCanvas.toDataURL();
);
gridBorders.forEach((l) =>
l.setAttribute("stroke", "#fff"),
);
const tooltip = document.querySelector(
"#elevation .tooltip",
) as HTMLElement;
tooltip.style.display = "none";
const units = document.querySelectorAll(
"#elevation .axis text",
);
units.forEach((l) => l.setAttribute("fill", "#000"));
const elevationCanvas = document.createElement("canvas");
const ctx = elevationCanvas.getContext("2d")!;
const v = await Canvg.from(
ctx,
document.querySelector("#elevation svg")!.outerHTML,
);
v.start();
const profileRatio =
elevationCanvas.height / elevationCanvas.width;
doc.addImage( doc.addImage(
elevationCanvas.toDataURL("image/png"), elevationChartCanvas.toDataURL("image/png"),
width - 79.3 - 8, width - 79.3 - 8,
elevationProfileHeight, elevationProfileHeight,
79.3, 79.3,
79.3 * profileRatio, 79.3 * ratio,
); );
const elevationPlot = (
document.querySelector(
".canvas-plot",
) as HTMLCanvasElement
).toDataURL("image/png");
doc.addImage(
elevationPlot,
width - 77.3,
elevationProfileHeight + 8,
0,
0,
);
tooltip.style.display = "block";
// Separator // Separator
currentHeight += 5; currentHeight += 5;
@@ -354,9 +326,8 @@
doc.save($trail.name + ".pdf"); doc.save($trail.name + ".pdf");
printLoading = false; printLoading = false;
},
);
} catch (e) { } catch (e) {
console.error(e);
show_toast({ show_toast({
icon: "close", icon: "close",
type: "error", type: "error",
@@ -371,11 +342,10 @@
return; return;
} }
await tick(); await tick();
map.invalidateSize();
updateScale(map); updateScale(map);
} }
async function updateScale(map: Map) { async function updateScale(map: M.Map) {
const svg = document.getElementById("ruler") as HTMLElement; const svg = document.getElementById("ruler") as HTMLElement;
if (!svg) { if (!svg) {
return; return;
@@ -454,9 +424,9 @@
function toggleGrid() { function toggleGrid() {
if (selectedGrid == "off") { if (selectedGrid == "off") {
graticule?.remove(); showGrid = false;
} else { } else {
graticule.addTo(map); showGrid = true;
} }
} }
</script> </script>
@@ -521,28 +491,18 @@
]}" ]}"
> >
<div class="basis-full"> <div class="basis-full">
<MapWithElevation <MapWithElevationMaplibre
trail={$trail} trails={[$trail]}
options={{ on:zoom={(e) => updateScale(e.detail)}
theme: "gray-theme",
slope: false,
speed: false,
legend: false,
time: false,
hotline: false,
graticule: true,
height: 150,
}}
on:zoomend={(e) => updateScale(e.detail)}
bind:map bind:map
bind:graticule {showGrid}
> elevationProfileContainer="elevation-profile"
<img mapOptions={{ preserveDrawingBuffer: true }}
class="w-[100px] h-[100px]" ></MapWithElevationMaplibre>
id="qrcode" </div>
alt="QR Code" <div class="flex items-center min-w-0">
/> <img class="w-[100px] h-[100px]" id="qrcode" alt="QR Code" />
<div class="basis-full mx-4"> <div>
<div <div
class="flex mt-1 gap-x-4 gap-y-2 text-sm text-gray-500 whitespace-nowrap" class="flex mt-1 gap-x-4 gap-y-2 text-sm text-gray-500 whitespace-nowrap"
> >
@@ -551,20 +511,17 @@
></i>{formatDistance($trail.distance)}</span ></i>{formatDistance($trail.distance)}</span
> >
<span <span
><i class="fa fa-clock mr-2" ><i class="fa fa-clock mr-2"></i>{formatTimeHHMM(
></i>{formatTimeHHMM($trail.duration)}</span $trail.duration,
)}</span
> >
<span <span
><i class="fa fa-arrow-trend-up mr-2" ><i class="fa fa-arrow-trend-up mr-2"
></i>{formatElevation( ></i>{formatElevation($trail.elevation_gain)}</span
$trail.elevation_gain,
)}</span
> >
<span <span
><i class="fa fa-arrow-trend-down mr-2" ><i class="fa fa-arrow-trend-down mr-2"
></i>{formatElevation( ></i>{formatElevation($trail.elevation_loss)}</span
$trail.elevation_loss,
)}</span
> >
</div> </div>
<svg <svg
@@ -579,7 +536,7 @@
>Scale: &nbsp 1 : {Math.round(scale)}</span >Scale: &nbsp 1 : {Math.round(scale)}</span
> >
</div> </div>
</MapWithElevation> <div class="min-w-0 basis-full" id="elevation-profile"></div>
</div> </div>
<div class="pt-4 -mt-4 border-t border-t-input-border z-10"> <div class="pt-4 -mt-4 border-t border-t-input-border z-10">
<div class="float-right"><LogoText height={48}></LogoText></div> <div class="float-right"><LogoText height={48}></LogoText></div>
@@ -593,9 +550,3 @@
</div> </div>
</div> </div>
</main> </main>
<style>
:global(#map-container) {
height: 100%;
}
</style>