maps.chandlerswift.com/main.js
Chandler Swift 07d55783fd
Switch CORS proxy
Sometime between December 4th…
https://web.archive.org/web/20241204202205/https://corsproxy.io/

…and December 5th…

https://web.archive.org/web/20241205230048/https://corsproxy.io/

corsproxy.io started charging for access and returning 403s. (Based on
the claim of 900TB/mo of traffic, that's probably not terribly
surprising!)

For now, we switch to another free one. At some point, I'll probably
set one up of my own to not have to deal with this again, but I'll need
to figure out how to deal with authentication first to prevent abuse.
2024-12-25 12:38:11 -06:00

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 = ('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 = `
<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],
[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://api.allorigins.win/raw?url=${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}
-->`;