Chandler Swift
041ebaabe0
"'ar' in Menards" means that all of the Arkansas survey markers show up when I'm looking for Menards.
332 lines
11 KiB
JavaScript
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://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}
|
|
-->`;
|