import './style.css'; import {Map, View} from 'ol'; import {fromLonLat, get, getTransform, toLonLat, transform} from 'ol/proj.js'; import {defaults as defaultControls} from 'ol/control.js'; import Overlay from 'ol/Overlay.js'; import { applyTransform } from 'ol/extent.js'; import ContextMenu from 'ol-contextmenu'; import ToggleMenuControl from './ui/controls.js'; import layerCategories from './layers/index.js'; import VectorLayer from 'ol/layer/Vector'; import {Vector as VectorSource} from 'ol/source.js'; import GeoJSON from 'ol/format/GeoJSON.js'; import {Style} from 'ol/style.js'; import Icon from 'ol/style/Icon.js'; import qs from 'qs'; import pin from './generic-pin.svg?url'; // from https://openlayers.org/en/latest/examples/popup.html const container = document.getElementById('popup'); const content = document.getElementById('popup-content'); const closer = document.getElementById('popup-closer'); const popupOverlay = new Overlay({ element: container, autoPan: { animation: { duration: 250, }, }, }); const contextMenu = new ContextMenu({ width: 170, defaultItems: false, items: [ { text: 'Open on OSM', callback: obj => { const coords = transform(obj.coordinate, 'EPSG:3857', 'EPSG:4326') window.open( `https://www.openstreetmap.org/#map=${map.getView().getZoom()}/${coords[1]}/${coords[0]}`, "_blank", ); }, }, { text: 'Copy lat/long', callback: async function(obj){ const coords = transform(obj.coordinate, 'EPSG:3857', 'EPSG:4326') try { await navigator.clipboard.writeText(`${coords[1]}, ${coords[0]}`); } catch (error) { console.error(error.message); } }, }, { text: 'Edit with iD', callback: function(obj) { const coords = toLonLat(obj.coordinate); window.location.href = `https://www.openstreetmap.org/edit?editor=id#map=${map.getView().getZoom()}/${coords[1]}/${coords[0]}`; }, }, { text: 'Edit with JOSM', callback: function(obj) { const [minx, miny, maxx, maxy] = applyTransform(map.getView().calculateExtent(), getTransform('EPSG:3857', 'EPSG:4326')); const url = `http://127.0.0.1:8111/load_and_zoom?left=${minx}&top=${maxy}&right=${maxx}&bottom=${miny}`; // inspiration from // https://github.com/openstreetmap/openstreetmap-website/blob/27f1fbcb580db21ca1276b9f2c40a6e1571cd90b/app/assets/javascripts/index.js#L257 const iframe = document.createElement('iframe'); iframe.setAttribute('src', url); iframe.style.display = 'none'; console.log(iframe); iframe.addEventListener('load', iframe.remove); document.body.append(iframe); }, } ], }); const map = new Map({ controls: defaultControls().extend([new ToggleMenuControl(), contextMenu]), target: 'map', layers: [], overlays: [popupOverlay], view: new View({ center: fromLonLat([-93.24151, 44.80376]), zoom: 10, }) }); // Basic reactivity binding: like vue.js, just worse :) // // This implements some basic reactivity so that I can add and remove layers. // Nothing too fancy, at this point. Eventually, I'll likely pull in proper Vue, // as I aim for more complex interactions, like layer ordering, color selection, // custom layer imports, and more. for (let category of layerCategories) { const catDiv = document.createElement("div"); catDiv.innerHTML = `
l.enabled).length > 0 ? "open" : ""}> ${category.name} ${category.details ? "

" + category.details + "

" : ""}
`; for (let layer of category.layers) { const li = document.createElement("li"); li.innerHTML = ` `; li.querySelector("input").addEventListener("change", function(e){ if (e.target.checked) { map.getLayers().push(layer.layer); } else { map.getLayers().remove(layer.layer); } }); catDiv.querySelector("ul").appendChild(li); } document.querySelector("aside").appendChild(catDiv); } const urlParams = qs.parse(window.location.search, { ignoreQueryPrefix: true }); const urlLayers = ('layer' in urlParams) ? (typeof urlParams.layer === 'string' ? [urlParams.layer] : urlParams.layer) : []; for (let category of layerCategories) { for (let layer of category.layers) { if (urlLayers.includes(layer.name)) { layer.enabled = true; } if (layer.enabled) { map.addLayer(layer.layer); // I'm a bit rusty on the definitions, but this might be O(n^2), and definitely wouldn't have to be. // Keep checkbox state in sync for (const label of document.querySelectorAll("aside label")) { if (label.innerText.trim() == layer.name) { label.querySelector("input[type=checkbox]").checked = true; } } } } } const customLayerDiv = document.createElement("div"); customLayerDiv.innerHTML = `
Custom


(must be in GeoJSON format)
`; const labelInput = customLayerDiv.querySelector('input[type=text]'); const sourceInput = customLayerDiv.querySelector('input[type=url]'); const colorInput = customLayerDiv.querySelector('input[type=color]'); customLayerDiv.querySelector("button").addEventListener("click", function(){ if (!sourceInput.value.toLowerCase().endsWith(".geojson")) { if (!confirm("Input URL doesn't end in .geojson, so is probably not a valid GeoJSON file. Do you want to continue anyway?")) { return; } } newCustomLayer(labelInput.value, sourceInput.value, colorInput.value.substring(1)); }); document.querySelector("aside").appendChild(customLayerDiv); // borrowed from https://github.com/ChartsCSS/charts.css/blob/main/src/general/_variables.scss#L7; randomly ordered let colors = [ [170, 90, 240], [90, 165, 255], [100, 210, 80], [255, 180, 50], [240, 50, 50], [130, 50, 20], [255, 220, 90], [180, 180, 180], [170, 150, 110], [110, 110, 110], ]; let used_colors = []; // HACK // TIL that [1,1] != [1,1], since arrays are objects, and objects are equal iff // they are the same object. Array.prototype.equals = function(other) { return this.length == other.length && this.every((e, i) => e === other[i]); }; function newCustomLayer(name, sourceURL, colorString) { let color; if (colorString) { color = [ parseInt(colorString.substr(0,2),16), parseInt(colorString.substr(2,2),16), parseInt(colorString.substr(4,2),16), ]; if (color.length != 3 || color.some(Number.isNaN)) { alert("Invalid color provided; using random color instead."); color = null; } } if (!color) { let available_colors = colors.filter(c => !used_colors.some(i => i.equals(c))); if (available_colors) { color = available_colors[0]; } else { color = [0, 0, 0]; // If we run out of colors, fall back to black } } used_colors.push(color); const li = document.createElement("li"); const layer = new VectorLayer({ source: new VectorSource({ // In case people put in layers that don't serve proper CORS headers, we // wrap them in this proxy so they Just Work. url: `https://corsproxy.io/?${encodeURIComponent(sourceURL)}`, format: new GeoJSON, }), style: new Style({ image: new Icon({ anchor: [0.5, 1], src: pin, color: color, }), }), }); li.innerHTML = ` `; li.querySelector("input").addEventListener("change", function(e){ if (e.target.checked) { map.getLayers().push(layer); } else { map.getLayers().remove(layer); } }); map.getLayers().push(layer); customLayerDiv.querySelector("ul").appendChild(li); // Update input to make sure it's not suggesting an already-used color let available_colors = colors.filter(c => !used_colors.some(i => i.equals(c))); available_colors.push([0, 0, 0]); // Ensure we always have at least one color available document.querySelector('input[type=color]').value = '#' + available_colors[0].map(x => x.toString(16).padStart(2, '0')).join(''); } if (urlParams.customLayer) { for (let customLayer of urlParams.customLayer) { newCustomLayer(customLayer['name'], customLayer['url'], customLayer['color']) } } let location_set = false; if (urlLayers.length > 0) { location_set = true; map.once('loadend', function() { const layers = map.getLayers(); map.getView().fit(layers.item(layers.getLength() - 1).getSource().getExtent(), {padding: [20, 20, 20, 20]}); }); } function objectToTable(o) { // TODO: hack hack hack let table = ``; for (let [key, value] of Object.entries(o)) { if (typeof value === "object" && value !== null) { value = objectToTable(value); } if (typeof value === "string" && /^https?:\/\//.test(value)) { value = `${value}` } table += ``; } table += `
${key}${value}
`; return table; } // from https://openlayers.org/en/latest/examples/icon.html map.on('click', function (evt) { let layer; const feature = map.forEachFeatureAtPixel(evt.pixel, function (feature, l) { layer = l; return feature; }); if (!feature) { return; } if (layer.hasOwnProperty('customPopup')) { content.innerHTML = layer.customPopup(feature); } else { // exclude geometry -- https://stackoverflow.com/a/208106 const {geometry: _, ...featureData} = feature.getProperties(); content.innerHTML = objectToTable(featureData); } if (layer.hasOwnProperty('customPopupCallback')) { layer.customPopupCallback(feature); } popupOverlay.setPosition(evt.coordinate); closer.onclick = function (){ popupOverlay.setPosition(undefined); if (layer.hasOwnProperty('destroyPopupCallback')) { layer.destroyPopupCallback(feature); } closer.blur(); return false; }; }); new ResizeObserver(() => map.updateSize()).observe(document.getElementById("map")); window.map = map; document.getElementById('source').innerHTML = `Source code `;