aboutsummaryrefslogtreecommitdiffstats
path: root/main.js
diff options
context:
space:
mode:
Diffstat (limited to 'main.js')
-rw-r--r--main.js328
1 files changed, 328 insertions, 0 deletions
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 */