diff options
author | Guilhem Moulin <guilhem@fripost.org> | 2025-06-18 18:12:54 +0200 |
---|---|---|
committer | Guilhem Moulin <guilhem@fripost.org> | 2025-06-19 17:22:36 +0200 |
commit | 263f7ce2325a258df149cf29c32f2bbbb97d7924 (patch) | |
tree | 3b075841da85da71dff0b6effb353cb94ea1b4b3 /main.js | |
parent | d1d98433bda4498c6942e2245260278972892a64 (diff) |
Add panel to measure lengths and areas.
Inspired from https://openlayers.org/en/latest/examples/measure.html and
https://openlayers.org/en/latest/examples/draw-features.html .
We use the Inter font https://rsms.me/inter/ for the measurement value
as it has good support for tabular-nums.
Diffstat (limited to 'main.js')
-rw-r--r-- | main.js | 328 |
1 files changed, 328 insertions, 0 deletions
@@ -41,6 +41,11 @@ import Polygon from 'ol/geom/Polygon.js'; import LineString from 'ol/geom/LineString.js'; import Point from 'ol/geom/Point.js'; +import VectorLayer from 'ol/layer/Vector.js'; +import VectorSource from 'ol/source/Vector.js'; +import Draw from 'ol/interaction/Draw.js'; +import { unByKey } from 'ol/Observable.js'; + import CircleStyle from 'ol/style/Circle.js'; import Fill from 'ol/style/Fill.js'; import Icon from 'ol/style/Icon.js'; @@ -54,6 +59,7 @@ import { register as registerProjection } from 'ol/proj/proj4.js'; import { Modal, Popover } from 'bootstrap'; +import '@fontsource/inter/latin-400.css'; import './style.css'; "use strict"; @@ -266,6 +272,7 @@ if (window.location === window.parent.location) { const buttons = Object.fromEntries([ {id: 'layer-selection', title: 'Lagerval', bi: 'stack'}, {id: 'map-legend', title: 'Teckenförklaring', bi: 'list-task'}, + {id: 'measure', title: 'Mät i kartan', bi: 'rulers'}, {id: 'age-filter', title: 'Filtrera objekt efter ålder', bi: 'clock-history'}, ].map(function(x) { const div = document.createElement('div'); @@ -3675,6 +3682,321 @@ const infoMetadataAccordions = []; })(); })(); +/* measurement panel */ +(function() { + const value = document.createTextNode(''), + unit = document.createTextNode(''); + const reset = function() { + source.clear(true); + const f = { LineString: formatLength, Polygon: formatArea }[getMeasureMode()]; + if (f == null) { + value.nodeValue = unit.nodeValue = ''; + } else { + f(0); + } + }; + + const source = new VectorSource({ + wrapX: false, + }); + + let draw; + const buttons = Object.fromEntries([ + { + id: 'cancel', + bi: 'trash', + title: 'Avbryt mätningen', + onclick: function() { + reset(); + Object.values(buttons).forEach((btn) => btn.disabled = true); + draw.abortDrawing(); + }, + }, + { + id: 'undo', + bi: 'arrow-counterclockwise', + title: 'Ta bort sista punkten', + onclick: function() { + draw.removeLastPoint(); + const n = { LineString: 2, Polygon: 3 }[getMeasureMode()] ?? Infinity; + draw.getOverlay().getSource().forEachFeature(function(feature) { + const geom = feature.getGeometry(); + if (geom.getType() === 'LineString') { + /* disable OK button if not enough points have been drawn (excluding cursor) */ + buttons.ok.disabled = geom.getCoordinates().length - 1 < n; + return true; /* stop iterating */ + } + }); + } + }, + { + id: 'ok', + bi: 'check-lg', + title: 'Slutför mätningen', + onclick: () => draw.finishDrawing(), + }, + ].map(function(x, idx, arr) { + const btn = document.createElement('button'); + btn.classList.add('btn', 'btn-outline-' + (idx < arr.length-1 ? 'secondary' : 'primary')); + btn.setAttribute('type', 'button'); + btn.title = x.title; + btn.setAttribute('aria-label', btn.title); + + const i = document.createElement('i'); + i.classList.add('bi', 'bi-' + x.bi); + btn.appendChild(i); + + btn.onclick = x.onclick; + return [x.id, btn]; + })); + + const formatLength = (function() { + const formatters = [ + { maximumFractionDigits: 0 }, + { maximumFractionDigits: 1 }, + { maximumFractionDigits: 2 }, + ] + .map((fmt) => new Intl.NumberFormat(LOCALE, { + ...fmt, + minimumFractionDigits: fmt.maximumFractionDigits ?? 0, + })); + return function(v) { + if (v <= 100) { /* ≤ 100 m */ + unit.nodeValue = 'm'; + value.nodeValue = formatters[1].format(v); + } else if (v <= 5_000) { /* ≤ 5 km */ + unit.nodeValue = 'm'; + value.nodeValue = formatters[0].format(v); + } else { + unit.nodeValue = 'km'; + value.nodeValue = formatters[2].format(v/1000); + } + }; + })(); + const formatArea = (function() { + const formatters = [ + { maximumFractionDigits: 1 }, + { maximumFractionDigits: 2 }, + ] + /* XXX would be nice to use Intl.NumberFormat()'s unit support, but m² and km² are not + * supported currently, see https://github.com/tc39/ecma402/issues/767 */ + .map((fmt) => new Intl.NumberFormat(LOCALE, { + ...fmt, + minimumFractionDigits: fmt.maximumFractionDigits ?? 0, + })); + return function(v) { + if (v < 10_000) { /* < 1 ha */ + unit.nodeValue = 'm²'; + value.nodeValue = formatters[0].format(v); + } else if (v < 100_000_000) { /* < 10000 ha (100 km²) */ + unit.nodeValue = 'ha'; + value.nodeValue = formatters[1].format(v/10_000); + } else { + unit.nodeValue = 'km²'; + value.nodeValue = formatters[1].format(v/1_000_000); + } + }; + })(); + + const setup = (function() { + const styles = { + Point: new Style({ + image: new CircleStyle({ + radius: 6, + fill: new Fill({ + color: [0, 183, 255, 1], + }), + stroke: new Stroke({ + color: [255, 255, 255, 1], + width: .5, + }), + }), + }), + LineString: [ + new Style({ + stroke: new Stroke({ + color: [255, 255, 255, 1], + width: 4, + }), + }), + new Style({ + stroke: new Stroke({ + color: [0, 183, 255, 1], + width: 3, + lineDash: [10, 10], + }), + }), + ], + Polygon: new Style({ + fill: new Fill({ + color: [255, 255, 255, .5], + }), + }), + }; + const layer = new VectorLayer({ + source: source, + visible: false, + style: [ + new Style({ + fill: styles.Polygon.getFill(), + stroke: styles.LineString[0].getStroke(), + }), + (function() { + const s = styles.LineString[1].clone(); + s?.getStroke?.()?.setLineDash?.(null); + return s; + })(), + ], + map: MAP, + }); + + return function(geom_type) { + if (draw != null) { + draw.abortDrawing(); + MAP.removeInteraction(draw); + } + reset(); /* remove features when toggling between geom types */ + Object.values(buttons).forEach((btn) => btn.disabled = true); + if (geom_type == null) { + layer.setVisible(false); + return; + } + draw = new Draw({ + source: source, + type: geom_type, + style: function(feature) { + return styles[ feature.getGeometry().getType() ]; + }, + }); + MAP.addInteraction(draw); + layer.setVisible(true); + + let listener; + draw.on('drawstart', function(event) { + reset(); + buttons.undo.disabled = buttons.cancel.disabled = false; + const geom = event.feature.getGeometry(); + const [isComplete, measure] = { + LineString: [ + /* 2 points drawn + cursor */ + (g) => g.getCoordinates().length >= 3, + (g) => formatLength(g.getLength()), + ], + Polygon: [ + /* 3 points drawn + cursor + 1st point */ + (g) => g.getCoordinates()[0].length >= 5, + (g) => formatArea(g.getArea()), + ], + }[geom.getType()]; + const btnOK = buttons.ok; + listener = geom.on('change', function(event) { + if (btnOK.disabled && isComplete(geom)) { + btnOK.disabled = false; + } + measure(event.target); + }); + }); + draw.on('drawend', function() { + unByKey(listener); + buttons.ok.disabled = buttons.undo.disabled = true; + }); + draw.on('drawabort', function() { + unByKey(listener); + reset(); + Object.values(buttons).forEach((btn) => btn.disabled = true); + }); + }; + })(); + + const modal = document.getElementById('measure-panel'); + modal.classList.add('modal'); + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-hidden', 'true'); + + const content = document.createElement('div'); + modal.appendChild(content); + content.classList.add('modal-content'); + + const body = document.createElement('div'); + content.appendChild(body); + body.classList.add('modal-body'); + + const title = document.createElement('div'); + title.classList.add('h5'); + title.innerHTML = 'Mät i kartan'; + body.appendChild(title); + + const getMeasureMode = (function() { + const btn_group = document.createElement('div'); + btn_group.classList.add('btn-group'); + btn_group.setAttribute('role', 'group'); + btn_group.setAttribute('aria-label', 'Välj geometrityp'); + body.appendChild(btn_group); + + const radios = [ + { id: 'LineString', text: 'Distans' }, + { id: 'Polygon', text: 'Yta' }, + ].map(function(x, idx) { + const radio = document.createElement('input'); + radio.classList.add('btn-check'); + radio.type = 'radio'; + radio.checked = idx == 0; + radio.name = 'measure-geomtype'; + radio.id = 'measure-geomtype-' + x.id; + radio.setAttribute('aria-expanded', 'false'); + radio.setAttribute('autocomplete', 'off'); + btn_group.appendChild(radio); + + const lbl = document.createElement('label'); + lbl.classList.add('btn', 'btn-lg', 'btn-outline-dark'); + lbl.setAttribute('for', radio.id); + lbl.appendChild(document.createTextNode(x.text)); + btn_group.appendChild(lbl); + + radio.onclick = function() { + setup(x.id); + }; + return [x.id, radio]; + }); + + return () => radios.filter( (x) => x[1].checked )?.[0]?.[0]; + })(); + + (function() { + const div = document.createElement('div'); + div.classList.add('measure-value', 'border-secondary', 'rounded-2'); + body.appendChild(div); + const span0 = document.createElement('span'); + span0.appendChild(value); + div.appendChild(span0); + const span1 = document.createElement('span'); + span1.classList.add('measure-unit'); + span1.appendChild(unit); + div.appendChild(span1); + })(); + + (function() { + const btn_group = document.createElement('div'); + btn_group.classList.add('btn-group'); + btn_group.setAttribute('role', 'group'); + body.appendChild(btn_group); + + Object.values(buttons).forEach((btn) => btn_group.appendChild(btn)); + })(); + + document.getElementById('measure-button') + .getElementsByTagName('button')[0] + .addEventListener('click', function(event) { + if (event.currentTarget.getAttribute('aria-expanded') === 'true') { + disposePopover(); + setup(getMeasureMode()); + } else { + setup(null); + } + }); +})(); + + /* popup and feature overlays */ const disposePopover = (function() { /* return an <a> tag with the given URL and optional text */ @@ -4856,7 +5178,13 @@ const disposePopover = (function() { header.appendChild(btnExpand); header.appendChild(btnClose); + const measureButton = document.getElementById('measure-button') + .getElementsByTagName('button')[0]; MAP.on('singleclick', function(event) { + if (measureButton.getAttribute('aria-expanded') === 'true') { + /* skip popover when measuring */ + return; + } disposeFeatureOverlay(); /* dispose any pre-existing popover if not in detached mode */ |