maps.chandlerswift.com/main.js

332 lines
11 KiB
JavaScript

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 = `
<details ${category.layers.filter(l => l.enabled).length > 0 ? "open" : ""}>
<summary>${category.name}</summary>
${category.details ? "<p>" + category.details + "</p>" : ""}
<ul></ul>
</details>
`;
for (let layer of category.layers) {
const li = document.createElement("li");
li.innerHTML = `
<label><input type="checkbox"> ${layer.name}</label>
`;
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 = 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 = `
<details>
<summary>Custom</summary>
<label>Layer Name: <input type="text"></label><br>
<label>Layer URL: <input type="url"></label><br>
<label>Color (optional): <input type="color" value="#AA5AF0"></label><br>
<button>Add</button>
<small>(must be in GeoJSON format)</small>
<ul></ul>
</details>`;
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],
[180, 180, 180],
[170, 150, 110],
[255, 180, 50],
[240, 50, 50],
[130, 50, 20],
[110, 110, 110],
[255, 220, 90],
];
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 = `
<label><input type="checkbox" checked><div class="color-badge" style="background-color: rgb(${color.join(', ')});"></div> ${name}</label>
`;
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 = `<table style="margin: 0.5em; border-collapse: collapse;">`;
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 = `<a href="${value}">${value}</a>`
}
table += `<tr><td style="border: 1px solid;">${key}</td><td style="border: 1px solid;">${value}</td></tr>`;
}
table += `</table>`;
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 = `<a href="https://git.chandlerswift.com/chandlerswift/maps.chandlerswift.com/commit/${import.meta.env.VITE_GIT_COMMIT_HASH}">Source code</a>
<!--
${import.meta.env.VITE_FILE_DATES}
-->`;