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 | |
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.
-rw-r--r-- | index.html | 1 | ||||
-rw-r--r-- | main.js | 328 | ||||
-rw-r--r-- | package-lock.json | 9 | ||||
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | style.css | 54 |
5 files changed, 389 insertions, 4 deletions
@@ -11,6 +11,7 @@ <div id="map-control-container" aria-hidden="true"> <div id="layer-selection-panel"></div> <div id="map-legend-panel"></div> + <div id="measure-panel"></div> <div id="map-menu"></div> </div> <div id="popup"></div> @@ -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 */ diff --git a/package-lock.json b/package-lock.json index f25d658..ca0a6f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "AGPL-3.0-or-later", "dependencies": { + "@fontsource/inter": "^5.2.6", "bootstrap": "5.3.x", "bootstrap-icons": "1.13.x", "ol": "10.6.x", @@ -604,6 +605,14 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fontsource/inter": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.6.tgz", + "integrity": "sha512-CZs9S1CrjD0jPwsNy9W6j0BhsmRSQrgwlTNkgQXTsAeDRM42LBRLo3eo9gCzfH4GvV7zpyf78Ozfl773826csw==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", diff --git a/package.json b/package.json index f08c3c9..6011349 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "vite": "^6.3.5" }, "dependencies": { + "@fontsource/inter": "^5.2.6", "bootstrap": "5.3.x", "bootstrap-icons": "1.13.x", "ol": "10.6.x", @@ -194,11 +194,13 @@ body.inprogress { #map-control-container[aria-hidden="true"], #layer-selection-panel[aria-hidden="true"], -#map-legend-panel[aria-hidden="true"] { +#map-legend-panel[aria-hidden="true"], +#measure-panel[aria-hidden="true"] { display: none; } #layer-selection-panel, -#map-legend-panel { +#map-legend-panel, +#measure-panel { position: relative; margin-left: 0; margin-right: var(--map-menu-spacing); @@ -219,6 +221,9 @@ body.inprogress { #map-legend-panel { max-width: 410px; } +#measure-panel { + max-width: 15em; +} @keyframes fade-in { from { opacity: 0; @@ -233,7 +238,8 @@ body.inprogress { user-select: text; /* eslint-disable-line css/use-baseline */ } #layer-selection-panel[aria-hidden="false"], -#map-legend-panel[aria-hidden="false"] { +#map-legend-panel[aria-hidden="false"], +#measure-panel[aria-hidden="false"] { animation: fade-in .25s ease-in-out both; display: block; } @@ -255,7 +261,8 @@ body.inprogress { display: none; } #layer-selection-panel, - #map-legend-panel { + #map-legend-panel, + #measure-panel { margin: var(--map-menu-spacing) 0 0 auto; } } @@ -267,10 +274,12 @@ body.inprogress { @media screen and (max-width: 200px) { #layer-selection-button, #map-legend-button, + #measure-button, #age-filter-button, #export-to-image, #layer-selection-panel, #map-legend-panel, + #measure-panel, #map-menu .ol-full-screen { display: none; } @@ -484,6 +493,43 @@ body.inprogress { -moz-user-select: text; user-select: text; /* eslint-disable-line css/use-baseline */ } +#measure-panel .h5 { + font-size: 1.25rem; + margin-bottom: .75rem; +} +#age-filter-modal .btn-group > .btn-check:not(:checked) + label.btn:hover, +#measure-panel .btn-group > .btn-check:not(:checked) + label.btn:hover { + background: color-mix(in srgb, var(--bs-btn-bg) 35%, var(--bs-btn-hover-bg)); /* eslint-disable-line css/use-baseline */ + color: var(--bs-btn-hover-color); +} +#measure-panel .measure-value { + font-family: Inter; + font-variant-numeric: tabular-nums; + text-align: right; + --measure-value-padding: .75rem; + --measure-value-border-width: 3px; + --bs-border-opacity: .75; + border-width: var(--measure-value-border-width); + border-style: solid; + background: rgb(from var(--bs-secondary-bg-subtle) r g b / calc(alpha*.30)); + height: calc(var(--bs-body-line-height) * 1.25rem + 2*var(--measure-value-padding) + 2*var(--measure-value-border-width)); + padding: var(--measure-value-padding) var(--measure-value-padding); + margin: calc(var(--measure-value-padding)*1.5) 0; + overflow: hidden; + -webkit-user-select: text; + -moz-user-select: text; + user-select: text; /* eslint-disable-line css/use-baseline */ +} +#measure-panel .measure-value :not(.measure-unit) { + font-size: 125%; + font-weight: 400; +} +#measure-panel .measure-value .measure-unit:before { + content: '\00A0'; /* U+00A0 NO-BREAK SPACE */ +} +#measure-panel .btn-group { + width: 100%; +} .popover { --bs-popover-header-padding-x: .75rem; |