aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--index.html1
-rw-r--r--main.js328
-rw-r--r--package-lock.json9
-rw-r--r--package.json1
-rw-r--r--style.css54
5 files changed, 389 insertions, 4 deletions
diff --git a/index.html b/index.html
index f4dec03..e782d0a 100644
--- a/index.html
+++ b/index.html
@@ -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>
diff --git a/main.js b/main.js
index cab07b7..aeff199 100644
--- a/main.js
+++ b/main.js
@@ -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",
diff --git a/style.css b/style.css
index cf7fc86..fde3b81 100644
--- a/style.css
+++ b/style.css
@@ -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;