diff --git a/web/src/distancemeasure.ts b/web/src/distancemeasure.ts index b578f77..2ceeb53 100644 --- a/web/src/distancemeasure.ts +++ b/web/src/distancemeasure.ts @@ -21,6 +21,7 @@ class DistanceMeasure implements maplibregl.IControl { type: 'button', 'aria-label': 'DistanceMeasure', 'aria-pressed': 'false', + title: 'Measure distance', }) this._geojson = { type: 'FeatureCollection', diff --git a/web/src/export/export.css b/web/src/export/export.css new file mode 100644 index 0000000..68febc4 --- /dev/null +++ b/web/src/export/export.css @@ -0,0 +1,31 @@ +button.export { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath d='M11.25 9.331V.75a.75.75 0 0 1 1.5 0v8.58l1.949-2.11A.75.75 0 1 1 15.8 8.237l-3.25 3.52a.75.75 0 0 1-1.102 0l-3.25-3.52A.75.75 0 1 1 9.3 7.22l1.949 2.111Z'/%3E%3Cpath d='M2.5 3.75v11.5c0 .138.112.25.25.25h18.5a.25.25 0 0 0 .25-.25V3.75a.25.25 0 0 0-.25-.25h-5.5a.75.75 0 0 1 0-1.5h5.5c.966 0 1.75.784 1.75 1.75v11.5A1.75 1.75 0 0 1 21.25 17h-6.204c.171 1.375.805 2.652 1.769 3.757A.752.752 0 0 1 16.25 22h-8.5a.75.75 0 0 1-.566-1.243c.965-1.105 1.599-2.382 1.77-3.757H2.75A1.75 1.75 0 0 1 1 15.25V3.75C1 2.784 1.784 2 2.75 2h5.5a.75.75 0 0 1 0 1.5h-5.5a.25.25 0 0 0-.25.25ZM10.463 17c-.126 1.266-.564 2.445-1.223 3.5h5.52c-.66-1.055-1.098-2.234-1.223-3.5Z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-size: 18px 18px; + background-position: center; +} + +div.progressWrapper { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.2); + z-index: 10000; + display: flex; + flex-flow: column nowrap; + justify-content: center; + align-items: center; +} + +div.progressWindow { + width: 30%; + text-align: center; + border: 1px solid #aaa; + background-color: white; + padding: 30px; + border-radius: 3px; + box-shadow: 3px 4px rgba(0, 0, 0, 0.2); + font-weight: bold; +} \ No newline at end of file diff --git a/web/src/export/export.ts b/web/src/export/export.ts new file mode 100644 index 0000000..1408794 --- /dev/null +++ b/web/src/export/export.ts @@ -0,0 +1,79 @@ +import maplibregl from 'maplibre-gl' +import './export.css' +import { el, mount, unmount } from 'redom' + +function calculateDim(currentZoom: number, currentWidth: number, targetZoom: number): number { + return Math.round(currentWidth * Math.pow(2, targetZoom - currentZoom)) +} + +class ExportControl implements maplibregl.IControl { + _map?: maplibregl.Map + exportZoom: number = 18 + pixelRatio: number = 4 + + doExport() { + const srcContainer = this._map!._container + let width = srcContainer.clientWidth, + height = srcContainer.clientHeight + const zoom = this._map!.getZoom() + + // Rescale the pixel size of the container so that it covers the same + // area at the target zoom level. + // The eventual pixel size of the exported image is dictated by the pixelRatio passed into MapLibreGL + if (this.exportZoom > this._map!.getZoom()) { + width = calculateDim(zoom, width, this.exportZoom) + height = calculateDim(zoom, height, this.exportZoom) + } + + const destContainer = el('div', { style: { width: width + 'px', height: height + 'px' } }) + const wrapper = el('div', destContainer, { style: { visibility: 'hidden' } }) + mount(document.body, wrapper) + + const progressWrapper = el( + 'div.progressWrapper', + el('div.progressWindow', 'Saving map as image, please wait...') + ) + mount(document.body, progressWrapper) + + const renderMap = new maplibregl.Map({ + container: destContainer, + style: this._map!.getStyle(), + center: this._map!.getCenter(), + pixelRatio: this.pixelRatio, + // The maximum size may be limited by the graphics card. + maxCanvasSize: [16384, 16384], + zoom: this.exportZoom, + // NB: bearing and pitch not supported + interactive: false, + preserveDrawingBuffer: true, + fadeDuration: 0, + attributionControl: false, + }) + + renderMap.once('idle', () => { + const canvas = renderMap.getCanvas() + const a = el('a', { href: canvas.toDataURL(), download: 'map.png' }) + a.click() + a.remove() + renderMap.remove() + unmount(document.body, wrapper) + unmount(document.body, progressWrapper) + }) + } + + onAdd(map: maplibregl.Map): HTMLElement { + this._map = map + const button = el('button.export', { + type: 'button', + title: 'Download image', + }) + button.onclick = () => this.doExport() + return el('div', button, { class: 'maplibregl-ctrl maplibregl-ctrl-group' }) + } + + onRemove(): void { + this._map = undefined + } +} + +export default ExportControl diff --git a/web/src/index.css b/web/src/index.css index a1af444..ad0fd32 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -35,6 +35,7 @@ body { width: 100%; height: 100%; margin: 0px; + overflow: hidden; } header { display: block; diff --git a/web/src/index.ts b/web/src/index.ts index 6899711..ebdb44f 100644 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -10,6 +10,7 @@ import ContextMenu from './contextmenu' import VillagesEditor from './villages' import { roundPosition } from './util' import InstallControl from './installcontrol' +import ExportControl from './export/export' if (import.meta.env.DEV) { map_style.sources.villages.data = 'http://localhost:2342/api/villages.geojson' @@ -81,6 +82,12 @@ class EventMap { this.map.addControl(new InstallControl(), 'top-left') this.map.addControl(new VillagesEditor('villages', 'villages_symbol'), 'top-right') + + // Display edit control only on browsers which are likely to be desktop browsers + if (window.matchMedia('(min-width: 600px)').matches) { + this.map.addControl(new ExportControl(), 'top-right') + } + this.map.addControl(this.layer_switcher, 'top-right') this.url_hash.enable(this.map) @@ -106,7 +113,6 @@ class EventMap { } const em = new EventMap() -window.em = em if (document.readyState != 'loading') { em.init() diff --git a/web/src/villages/index.ts b/web/src/villages/index.ts index 7144eb3..604b44d 100644 --- a/web/src/villages/index.ts +++ b/web/src/villages/index.ts @@ -27,7 +27,7 @@ class VillagesLayer { } this.popup = null - this.button = el('button') + this.button = el('button', { title: 'Place village' }) this._wrapper = el('div', this.button, { class: 'maplibregl-ctrl maplibregl-ctrl-group villages-ctrl', style: 'display:none', @@ -107,6 +107,12 @@ class VillagesLayer { return this._wrapper } + onRemove() { + if (this.popup) { + this.popup.remove() + } + } + createForm() { if (!this._map || !this.villages) return const editor = new PlaceVillageDialog(this._map, this.villages)