adds grid lines to print map

This commit is contained in:
Christian Beutel
2024-04-10 14:40:04 +02:00
parent e44ba65bd9
commit 71d548b9d6
18 changed files with 1254 additions and 69 deletions

789
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -46,6 +46,7 @@
"leaflet.awesome-markers": "^2.0.5",
"meilisearch": "^0.37.0",
"nouislider": "^15.7.1",
"pdfkit": "^0.15.0",
"photoswipe": "^5.4.3",
"pocketbase": "^0.21.0",
"qrcode-with-logos": "^1.0.5",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -25,6 +25,7 @@
await import("leaflet.awesome-markers");
//@ts-ignore
await import("$lib/vendor/leaflet-elevation/src/index.js");
const AutoGraticule = (await import("$lib/vendor/leaflet-graticule/leaflet-auto-graticule")).default;
map = L.map("map", { preferCanvas: true }).setView(
[trail.lat ?? 0, trail.lon ?? 0],
@@ -35,7 +36,7 @@
map!.on("zoomend", function () {
dispatch("zoomend", map);
});
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap contributors",
}).addTo(map);
@@ -92,6 +93,7 @@
iconColor: "white",
}),
},
graticule: false
};
const elevation_options = Object.assign(
@@ -106,6 +108,11 @@
marker.addTo(map!);
markers.push(marker);
}
if(elevation_options.graticule) {
new AutoGraticule().addTo(map!);
}
});
</script>

View File

@@ -83,6 +83,11 @@
"metric": "Metrisch",
"moderate": "Mittel",
"must-be-at-least-n-characters-long": "Muss mindestens {n} Zeichen lang sein",
"n-days-ago": "",
"n-hours-ago": "",
"n-minutes-ago": "",
"n-months-ago": "",
"n-seconds-ago": "",
"n-years-ago": "",
"name": "Name",
"near": "Nahe",
@@ -99,6 +104,7 @@
"pick-a-trail": "Route auswählen",
"polish": "Polnisch",
"portuguese": "Portugiesisch",
"print": "",
"profile": "Profil",
"public": "Öffentlich",
"radius": "Radius",

View File

@@ -83,12 +83,12 @@
"metric": "Metric",
"moderate": "Moderate",
"must-be-at-least-n-characters-long": "Must be at least {n} characters long",
"n-years-ago": "{n} years ago",
"n-months-ago": "{n} months ago",
"n-days-ago": "{n} days ago",
"n-hours-ago": "{n} hours ago",
"n-minutes-ago": "{n} minutes ago",
"n-months-ago": "{n} months ago",
"n-seconds-ago": "{n} seconds ago",
"n-years-ago": "{n} years ago",
"name": "Name",
"near": "Near",
"new-list": "New List",
@@ -104,6 +104,7 @@
"pick-a-trail": "Pick a trail",
"polish": "Polish",
"portuguese": "Portuguese",
"print": "Print",
"profile": "Profile",
"public": "Public",
"radius": "Radius",

View File

@@ -83,6 +83,11 @@
"metric": "Métrique",
"moderate": "Modérer",
"must-be-at-least-n-characters-long": "Doit être composé d'au moins {n} caractères",
"n-days-ago": "",
"n-hours-ago": "",
"n-minutes-ago": "",
"n-months-ago": "",
"n-seconds-ago": "",
"n-years-ago": "",
"name": "Nom",
"near": "Proche",
@@ -99,6 +104,7 @@
"pick-a-trail": "Choisir un itinéraire",
"polish": "polonais",
"portuguese": "Portugais",
"print": "",
"profile": "Profile",
"public": "Publique",
"radius": "Rayon",

View File

@@ -83,6 +83,11 @@
"metric": "Metrikus",
"moderate": "Mérsékelt",
"must-be-at-least-n-characters-long": "Legalább {n} karakter hosszúnak kell lennie",
"n-days-ago": "",
"n-hours-ago": "",
"n-minutes-ago": "",
"n-months-ago": "",
"n-seconds-ago": "",
"n-years-ago": "",
"name": "Név",
"near": "Közelben",
@@ -99,6 +104,7 @@
"pick-a-trail": "Válasszon útvonalat",
"polish": "Lengyel",
"portuguese": "Portugál",
"print": "",
"profile": "Profil",
"public": "Publikus",
"radius": "Átmérő",

View File

@@ -83,6 +83,11 @@
"metric": "Metrisch",
"moderate": "Gemiddeld",
"must-be-at-least-n-characters-long": "Minimaal {n} tekens",
"n-days-ago": "",
"n-hours-ago": "",
"n-minutes-ago": "",
"n-months-ago": "",
"n-seconds-ago": "",
"n-years-ago": "",
"name": "Naam",
"near": "Nabij",
@@ -99,6 +104,7 @@
"pick-a-trail": "Kies een wandelroute",
"polish": "Pools",
"portuguese": "Portugees",
"print": "",
"profile": "Profiel",
"public": "Openbaar",
"radius": "Straal",

View File

@@ -83,6 +83,11 @@
"metric": "Metryczne",
"moderate": "Średnia",
"must-be-at-least-n-characters-long": "Długość musi wynosić przynajmniej {n} znaków",
"n-days-ago": "",
"n-hours-ago": "",
"n-minutes-ago": "",
"n-months-ago": "",
"n-seconds-ago": "",
"n-years-ago": "",
"name": "Nazwa",
"near": "Blisko",
@@ -99,6 +104,7 @@
"pick-a-trail": "Wybierz ścieżkę",
"polish": "Polski",
"portuguese": "Portugalski",
"print": "",
"profile": "Profil",
"public": "Publiczny",
"radius": "Promień",

View File

@@ -83,6 +83,11 @@
"metric": "Métrica",
"moderate": "Moderado",
"must-be-at-least-n-characters-long": "Deve ter pelo menos {n} caracteres",
"n-days-ago": "",
"n-hours-ago": "",
"n-minutes-ago": "",
"n-months-ago": "",
"n-seconds-ago": "",
"n-years-ago": "",
"name": "Nome",
"near": "Próximo",
@@ -99,6 +104,7 @@
"pick-a-trail": "Escolha uma trilha",
"polish": "Polonês",
"portuguese": "Português",
"print": "",
"profile": "Perfil",
"public": "Público",
"radius": "Raio",

View File

@@ -83,6 +83,11 @@
"metric": "公制",
"moderate": "中等",
"must-be-at-least-n-characters-long": "长度至少 {n} 字符",
"n-days-ago": "",
"n-hours-ago": "",
"n-minutes-ago": "",
"n-months-ago": "",
"n-seconds-ago": "",
"n-years-ago": "",
"name": "名称",
"near": "附近",
@@ -99,6 +104,7 @@
"pick-a-trail": "选择一个路线",
"polish": "波兰语",
"portuguese": "葡萄牙语",
"print": "",
"profile": "个人资料",
"public": "公开",
"radius": "半径",

View File

@@ -12,7 +12,10 @@ export function createMarkerFromWaypoint(L: any, waypoint: Waypoint, onDragEnd?:
const marker = L.marker([waypoint.lat, waypoint.lon], {
title: waypoint.name,
icon: fontAwesomeIcon,
draggable: onDragEnd != null
draggable: onDragEnd != null,
meta: {
waypointName: waypoint.name
}
})
.bindPopup(
"<b>" +
@@ -25,7 +28,7 @@ export function createMarkerFromWaypoint(L: any, waypoint: Waypoint, onDragEnd?:
if (onDragEnd) {
marker.on("dragend", onDragEnd);
}
return marker;
}

View File

@@ -9,7 +9,7 @@ export const Colors = {
'steelblue': { area: '#4682B4' },
'red' : { area: '#F00' },
'lime' : { area: '#9CC222', line: '#566B13' },
'gray': { area: '#000000', line: '#242734', alpha: 0.00001, stroke: '#000000' }
'gray': { area: '#000000', line: '#3366CC', alpha: 0.00001, stroke: '#000000' }
};
const SEC = 1000;

View File

@@ -0,0 +1,11 @@
.leaflet-grid-label .gridlabel-vert {
margin-left: 8px;
-webkit-transform: rotate(90deg);
transform: rotate(90deg);
}
.leaflet-grid-label .gridlabel-vert,
.leaflet-grid-label .gridlabel-horiz {
padding-left:2px;
text-shadow: -2px 0 #FFFFFF, 0 2px #FFFFFF, 2px 0 #FFFFFF, 0 -2px #FFFFFF;
}

View File

@@ -0,0 +1,233 @@
import { LatLngBounds, type LatLngExpression, type LayerOptions, type LeafletEventHandlerFnMap, type PolylineOptions, Map, LayerGroup, Util, Polyline, LatLng, marker, divIcon, latLngBounds } from 'leaflet';
import './leaflet-auto-graticule.css';
export interface AutoGraticuleOptions extends LayerOptions {
redraw: keyof LeafletEventHandlerFnMap,
/** Minimum distance between two lines in pixels */
minDistance: number
}
export default class AutoGraticule extends LayerGroup {
options: AutoGraticuleOptions = {
redraw: 'moveend',
minDistance: 100 // Minimum distance between two lines in pixels
};
lineStyle: PolylineOptions = {
stroke: true,
color: '#333',
opacity: 0.6,
weight: 1,
interactive: false
};
_bounds!: LatLngBounds;
constructor(options?: Partial<AutoGraticuleOptions>) {
super();
Util.setOptions(this, options);
}
onAdd(map: Map) {
this._map = map;
this.redraw();
this._map.on('viewreset ' + this.options.redraw, this.redraw, this);
this.eachLayer(map.addLayer, map);
return this;
}
onRemove(map: Map) {
map.off('viewreset ' + this.options.redraw, this.redraw, this);
this.eachLayer(this.removeLayer, this);
return this;
}
redraw() {
this._bounds = this._map.getBounds().pad(0.5);
this.clearLayers();
this.constructLines();
return this;
}
constructLines() {
const bounds = this._map.getBounds();
const zoom = this._map.getZoom();
// Fix drawing of lines outside of bounds
this._bounds = AutoGraticule.bboxIntersect(bounds, [[-85, -180], [85, 180]]);
// Fix drawing of labels outside of bounds
const getBoundsBkp = this._map.getBounds;
try {
this._map.getBounds = function () {
return AutoGraticule.bboxIntersect(getBoundsBkp.apply(this), [[-85, -180], [85, 180]])
};
// Longitude: Draw vartical lines with a fixed distance between each other
const center = this._map.project(bounds.getCenter(), zoom);
const divisor = AutoGraticule.getGridDivisor(this._map.unproject(center.add([this.options.minDistance / 2, 0]), zoom).lng - this._map.unproject(center.subtract([this.options.minDistance / 2, 0]), zoom).lng, false);
const west = Math.max(bounds.getWest(), -180);
const east = Math.min(bounds.getEast(), 180);
for (let lng = AutoGraticule.fixFloatingPoint(Math.ceil(west / divisor) * divisor); lng <= east; lng += divisor) {
this.addLayer(this.buildXLine(lng));
this.addLayer(this.buildLabel('gridlabel-horiz', AutoGraticule.fixFloatingPoint(lng)));
}
// Latitude: Draw horizontal lines with a variable distance between each other (as in Mercator projection, the distance
// between latitudes gets bigger towards the poles). Calculate a divisor that all latitude line coordinates must be
// dividable by, but then only draw those coordinates that keep the minimum distance to their neighbour lines.
// Draw lines north of the equator separately from lines south of the equator (to keep the grid symmetical in
// relation to the equator).
if (bounds.getNorth() > 0) {
let lat = Math.max(0, bounds.getSouth());
let first = true;
while (lat < bounds.getNorth() && lat < 85) {
const point = this._map.project([lat, bounds.getCenter().lng], zoom);
const point2LatLng = this._map.unproject(point.subtract([0, this.options.minDistance]), zoom);
const divisor = AutoGraticule.getGridDivisor(point2LatLng.lat - lat, true);
lat = AutoGraticule.fixFloatingPoint(first ? Math.ceil(lat / divisor) * divisor : Math.ceil(point2LatLng.lat / divisor) * divisor);
first = false;
this.addLayer(this.buildYLine(lat));
this.addLayer(this.buildLabel('gridlabel-vert', lat));
}
}
if (bounds.getSouth() < 0) {
let lat = Math.min(0, bounds.getNorth());
let first = true;
while (lat > bounds.getSouth() && lat > -85) {
const point = this._map.project([lat, bounds.getCenter().lng], zoom);
const point2LatLng = this._map.unproject(point.add([0, this.options.minDistance]), zoom);
const divisor = AutoGraticule.getGridDivisor(AutoGraticule.fixFloatingPoint(lat - point2LatLng.lat), true);
lat = AutoGraticule.fixFloatingPoint(first ? Math.floor(lat / divisor) * divisor : Math.floor(point2LatLng.lat / divisor) * divisor);
first = false;
this.addLayer(this.buildYLine(lat));
this.addLayer(this.buildLabel('gridlabel-vert', lat));
}
}
} finally {
this._map.getBounds = getBoundsBkp;
}
}
buildXLine(x: number): Polyline {
const bottomLL = new LatLng(this._bounds.getSouth(), x);
const topLL = new LatLng(this._bounds.getNorth(), x);
return new Polyline([bottomLL, topLL], this.lineStyle);
}
buildYLine(y: number): L.Polyline {
const leftLL = new LatLng(y, this._bounds.getWest());
const rightLL = new LatLng(y, this._bounds.getEast());
return new Polyline([leftLL, rightLL], this.lineStyle);
}
buildLabel(axis: 'gridlabel-horiz' | 'gridlabel-vert', val: number) {
const bounds = this._map.getBounds().pad(-0.003);
let latLng: LatLng;
if (axis == 'gridlabel-horiz') {
latLng = new LatLng(bounds.getNorth(), val);
} else {
latLng = new LatLng(val, bounds.getWest());
}
return marker(latLng, {
interactive: false,
icon: divIcon({
iconSize: [0, 0],
className: 'leaflet-grid-label',
html: '<div class="' + axis + '">' + val + '&#8239;°</div>'
})
});
}
/**
* Rounds the given number to a fixed number of decimals in order to avoid floating point inaccuracies
* (for example to make 0.1 + 0.2 = 0.3 instead of 0.30000000000000004).
*/
static fixFloatingPoint(number: number): number {
return AutoGraticule.round(number, 12);
}
/**
* Rounds the given number to the given number of decimals.
*/
static round(number: number, digits: number) {
const fac = Math.pow(10, digits);
return Math.round(number * fac) / fac;
}
/**
* Given the distance between two coordinates, floors this distance to 90, 60, 45, 30 or
* 10, 5, 2, 1, 0.5, 0.2, 0.1, 0.05, 0.02, 0.01, ...
* This will define the distance between two grid lines.
* @param variableDistance This should be true when the distance between the grid lines will be variable, that is for the latitude
* lines (because in Meractor projection the distance between two latitutes gets larger towards the poles, but the distance
* between two longitudes is constant throughout the globe).
* For constant distance lines (longitude), a line should be shown for every coordinate dividable by the result of this
* function. For example, if the result is 45, a line should be shown for longitudes -180, -135, -90, -45, 0, 45, 90, 135, 180.
* For variable distance lines (latitude), a line should be shown for coordinates dividable by a result of this function, but
* not for every single coordinate but only those that have a minimum distance towards their neighbour coordinate line. For
* variable distance lines, the maximum number returned by this function is 5, meaning at low zoom levels, all latitude lines
* are dividable by 5 (but not every latitude dividable by 5 will get a line).
*/
static getGridDivisor(number: number, variableDistance: boolean) {
if (number <= 0 || !isFinite(number))
throw new Error("Invalid number " + number);
else {
if (variableDistance && number >= 5)
return 5;
if (number <= 10) {
let fac = 1;
while (number > 1) { fac *= 10; number /= 10; }
while (number <= 0.1) { fac /= 10; number *= 10; }
// Dist is now some number between 0.1 and 1, so we can round it conveniently and then multiply it again by fac to get back to the original dist
if (number == 0.1)
return AutoGraticule.fixFloatingPoint(0.1 * fac);
else if (number <= 0.2)
return AutoGraticule.fixFloatingPoint(0.2 * fac);
else if (number <= 0.5)
return AutoGraticule.fixFloatingPoint(0.5 * fac);
else
return fac;
} else if (number <= 30)
return 30;
else if (number <= 45)
return 45;
else if (number <= 60)
return 60;
else
return 90;
}
}
// Backwards compatibility
static niceRound = AutoGraticule.getGridDivisor;
static bboxIntersect(bbox1: LatLngBounds | LatLngExpression[], bbox2: LatLngBounds | LatLngExpression[]) {
const bounds1 = bbox1 instanceof LatLngBounds ? bbox1 : latLngBounds(bbox1);
const bounds2 = bbox2 instanceof LatLngBounds ? bbox2 : latLngBounds(bbox2);
return latLngBounds([
[Math.max(bounds1.getSouth(), bounds2.getSouth()), Math.max(bounds1.getWest(), bounds2.getWest())],
[Math.min(bounds1.getNorth(), bounds2.getNorth()), Math.min(bounds1.getEast(), bounds2.getEast())]
]);
}
}

View File

@@ -197,12 +197,11 @@ export default function leafletImage(map, callback) {
minPoint = new L.Point(pixelBounds.min.x, pixelBounds.min.y),
pixelPoint = map.project(marker.getLatLng()),
url = "/vendor/leaflet-image/marker.png",
icon = getComputedStyle(marker._icon.children[0], ':before').getPropertyValue('content').substring(1, 2) ?? "\uf111",
im = new Image(),
options = marker.options.icon.options,
size = options.iconSize,
pos = pixelPoint.subtract(minPoint),
anchor = L.point(options.iconAnchor || size && size.divideBy(2, true));
anchor = L.point(options.iconAnchor || size && { x: size[0], y: size[1] });
if (size instanceof L.Point) size = [size.x, size.y];
@@ -214,10 +213,28 @@ export default function leafletImage(map, callback) {
im.crossOrigin = '';
im.onload = function () {
ctx.drawImage(this, x, y, size[0], size[1]);
ctx.font = '600 14px "Font Awesome 6 Free"';
ctx.fillStyle = "#ffffff";
ctx.fillText(icon, x + 10.2, y + 24);
if (marker._icon.classList.contains("leaflet-grid-label")) {
const label = marker._icon.children[0].textContent;
ctx.font = '600 8px "Font Awesome 6 Free"';
ctx.fillStyle = "#000";
ctx.fillText(label, x + 4, y + 12);
} else {
const waypointName = marker.options.meta?.waypointName;
const icon = getComputedStyle(marker._icon.children[0], ':before').getPropertyValue('content').substring(1, 2) ?? "\uf111";
ctx.drawImage(this, x, y, size[0], size[1]);
ctx.font = '600 14px "Font Awesome 6 Free"';
ctx.fillStyle = "#ffffff";
ctx.fillText(icon, x + 10.2, y + 24);
if (waypointName) {
ctx.fillStyle = "#313131"
ctx.roundRect(x - 8, y - 18, waypointName.length * 7, 20, 5)
ctx.fill();
ctx.font = '600 12px "IBMPlexSans"';
ctx.fillStyle = "#ffffff";
ctx.fillText(waypointName, x, y - 4);
}
}
callback(null, {
canvas: canvas
});

View File

@@ -2,8 +2,15 @@
import { page } from "$app/stores";
import Button from "$lib/components/base/button.svelte";
import Select from "$lib/components/base/select.svelte";
import LogoText from "$lib/components/logo/logo_text.svelte";
import MapWithElevation from "$lib/components/trail/map_with_elevation.svelte";
import { trail } from "$lib/stores/trail_store";
import { currentUser } from "$lib/stores/user_store";
import {
formatDistance,
formatElevation,
formatTimeHHMM,
} from "$lib/util/format_util";
import {
calculatePixelPerMeter,
calculateScaleFactor,
@@ -16,14 +23,7 @@
import "leaflet/dist/leaflet.css";
import QrCodeWithLogo from "qrcode-with-logos";
import { onMount, tick } from "svelte";
import { currentUser } from "$lib/stores/user_store";
import {
formatDistance,
formatElevation,
formatTimeHHMM,
} from "$lib/util/format_util";
import LogoTextLight from "$lib/components/logo/logo_text_light.svelte";
import LogoText from "$lib/components/logo/logo_text.svelte";
import { _ } from "svelte-i18n";
let map: Map;
@@ -164,9 +164,13 @@
}
</script>
<main class="grid grid-cols-1 md:grid-cols-[400px_1fr] gap-x-1 gap-y-4">
<svelte:head>
<title>{$_("print")} | wanderer</title>
</svelte:head>
<main class="grid grid-cols-1 md:grid-cols-[400px_1fr] gap-x-1 gap-y-4 items-start">
<div
class="print-details px-6 space-y-4 border border-input-border rounded-3xl"
class="print-details px-6 space-y-4 pb-4 border border-input-border rounded-3xl"
>
<div id="images"></div>
<h1 class="text-4xl font-bold">Print Trail</h1>
@@ -205,6 +209,7 @@
legend: false,
time: false,
hotline: false,
graticule: true,
height: 150,
}}
on:zoomend={(e) => updateScale(e.detail)}
@@ -216,6 +221,24 @@
alt="QR Code"
/>
<div class="basis-full mx-4">
<div
class="flex mt-1 gap-4 text-sm text-gray-500 whitespace-nowrap"
>
<span
><i class="fa fa-left-right mr-2"
></i>{formatDistance($trail.distance)}</span
>
<span
><i class="fa fa-up-down mr-2"
></i>{formatElevation(
$trail.elevation_gain,
)}</span
>
<span
><i class="fa fa-clock mr-2"
></i>{formatTimeHHMM($trail.duration)}</span
>
</div>
<svg width="100%" height="50" id="ruler"> </svg>
<span class="text-sm"
>Scale: &nbsp 1 : {Math.round(scale)}</span
@@ -224,32 +247,13 @@
</MapWithElevation>
</div>
<div class="pt-4 -mt-4 border-t border-t-input-border z-10">
<div class="float-right"><LogoText></LogoText></div>
<div class="float-right"><LogoText height={48}></LogoText></div>
<h4 class="font-semibold">{$trail.name}</h4>
<h5>
<h4 class="text-sm font-semibold">{$trail.name}</h4>
<h5 class="text-sm">
<i class="fa fa-location-dot mr-2"></i>{$trail.location ||
"-"}
</h5>
<div
class="flex mt-1 gap-4 text-sm text-gray-500 whitespace-nowrap"
>
<span
><i class="fa fa-left-right mr-2"></i>{formatDistance(
$trail.distance,
)}</span
>
<span
><i class="fa fa-up-down mr-2"></i>{formatElevation(
$trail.elevation_gain,
)}</span
>
<span
><i class="fa fa-clock mr-2"></i>{formatTimeHHMM(
$trail.duration,
)}</span
>
</div>
</div>
</div>
</div>