2023-07-03 00:20:36 -05:00
|
|
|
import './style.css';
|
|
|
|
import {Map, View} from 'ol';
|
2024-02-04 23:58:04 -06:00
|
|
|
import {fromLonLat, get, getTransform, toLonLat, transform} from 'ol/proj.js';
|
2023-07-03 22:13:15 -05:00
|
|
|
import {defaults as defaultControls} from 'ol/control.js';
|
2024-01-29 01:39:46 -06:00
|
|
|
import Overlay from 'ol/Overlay.js';
|
2024-02-04 23:58:04 -06:00
|
|
|
import { applyTransform } from 'ol/extent.js';
|
2023-07-03 22:13:15 -05:00
|
|
|
|
2024-02-02 03:14:58 -06:00
|
|
|
import ContextMenu from 'ol-contextmenu';
|
|
|
|
|
2023-07-03 22:13:15 -05:00
|
|
|
import ToggleMenuControl from './ui/controls.js';
|
2023-07-03 00:20:36 -05:00
|
|
|
|
2023-07-03 19:26:02 -05:00
|
|
|
import layerCategories from './layers/index.js';
|
2023-07-03 15:27:18 -05:00
|
|
|
|
2024-10-19 17:39:05 -05:00
|
|
|
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';
|
|
|
|
|
2024-10-19 18:45:53 -05:00
|
|
|
import qs from 'qs';
|
|
|
|
|
2024-10-19 17:39:05 -05:00
|
|
|
import pin from './generic-pin.svg?url';
|
|
|
|
|
2024-01-29 01:39:46 -06:00
|
|
|
// 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,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
2024-02-02 03:14:58 -06:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
},
|
2024-02-04 23:58:04 -06:00
|
|
|
{
|
|
|
|
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);
|
|
|
|
},
|
|
|
|
}
|
2024-02-02 03:14:58 -06:00
|
|
|
],
|
|
|
|
});
|
|
|
|
|
2023-07-03 00:20:36 -05:00
|
|
|
const map = new Map({
|
2024-02-02 03:14:58 -06:00
|
|
|
controls: defaultControls().extend([new ToggleMenuControl(), contextMenu]),
|
2023-07-03 00:20:36 -05:00
|
|
|
target: 'map',
|
2023-07-03 19:26:02 -05:00
|
|
|
layers: [],
|
2024-01-29 01:39:46 -06:00
|
|
|
overlays: [popupOverlay],
|
2023-07-03 00:20:36 -05:00
|
|
|
view: new View({
|
2023-07-03 01:00:10 -05:00
|
|
|
center: fromLonLat([-93.24151, 44.80376]),
|
|
|
|
zoom: 10,
|
2023-07-03 00:20:36 -05:00
|
|
|
})
|
|
|
|
});
|
2023-07-03 15:27:18 -05:00
|
|
|
|
2023-07-03 19:26:02 -05:00
|
|
|
// 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 = `
|
2023-07-28 08:21:58 -05:00
|
|
|
<details ${category.layers.filter(l => l.enabled).length > 0 ? "open" : ""}>
|
2023-07-25 20:35:57 -05:00
|
|
|
<summary>${category.name}</summary>
|
2023-09-01 22:23:40 -05:00
|
|
|
${category.details ? "<p>" + category.details + "</p>" : ""}
|
2023-07-03 19:26:02 -05:00
|
|
|
<ul></ul>
|
2023-07-25 20:35:57 -05:00
|
|
|
</details>
|
2023-07-03 19:26:02 -05:00
|
|
|
`;
|
|
|
|
for (let layer of category.layers) {
|
|
|
|
const li = document.createElement("li");
|
|
|
|
li.innerHTML = `
|
2024-09-02 13:58:45 -05:00
|
|
|
<label><input type="checkbox"> ${layer.name}</label>
|
2023-07-03 19:26:02 -05:00
|
|
|
`;
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2024-10-19 18:45:53 -05:00
|
|
|
const urlParams = qs.parse(window.location.search, { ignoreQueryPrefix: true });
|
2024-10-19 22:03:35 -05:00
|
|
|
const urlLayers = ('layer' in urlParams) ? (typeof urlParams.layer === 'string' ? [urlParams.layer] : urlParams.layer) : [];
|
2024-03-09 19:54:43 -06:00
|
|
|
|
2023-07-03 19:26:02 -05:00
|
|
|
for (let category of layerCategories) {
|
|
|
|
for (let layer of category.layers) {
|
2024-03-09 19:54:43 -06:00
|
|
|
if (urlLayers.includes(layer.name)) {
|
|
|
|
layer.enabled = true;
|
|
|
|
}
|
2023-07-03 19:26:02 -05:00
|
|
|
if (layer.enabled) {
|
|
|
|
map.addLayer(layer.layer);
|
2024-09-02 13:58:45 -05:00
|
|
|
// 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;
|
|
|
|
}
|
|
|
|
}
|
2023-07-03 19:26:02 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2024-01-29 01:39:46 -06:00
|
|
|
|
2024-10-19 17:39:05 -05:00
|
|
|
const customLayerDiv = document.createElement("div");
|
|
|
|
customLayerDiv.innerHTML = `
|
|
|
|
<details>
|
|
|
|
<summary>Custom</summary>
|
2024-10-19 19:34:33 -05:00
|
|
|
<label>Layer Name: <input type="text"></label><br>
|
|
|
|
<label>Layer URL: <input type="url"></label><br>
|
2024-10-19 20:32:16 -05:00
|
|
|
<label>Color (optional): <input type="color" value="#AA5AF0"></label><br>
|
2024-10-19 17:39:05 -05:00
|
|
|
<button>Add</button>
|
|
|
|
<small>(must be in GeoJSON format)</small>
|
|
|
|
<ul></ul>
|
|
|
|
</details>`;
|
2024-10-19 19:34:33 -05:00
|
|
|
const labelInput = customLayerDiv.querySelector('input[type=text]');
|
|
|
|
const sourceInput = customLayerDiv.querySelector('input[type=url]');
|
|
|
|
const colorInput = customLayerDiv.querySelector('input[type=color]');
|
2024-10-19 17:39:05 -05:00
|
|
|
|
|
|
|
customLayerDiv.querySelector("button").addEventListener("click", function(){
|
2024-10-19 17:39:22 -05:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
2024-10-19 19:34:33 -05:00
|
|
|
newCustomLayer(labelInput.value, sourceInput.value, colorInput.value.substring(1));
|
2024-10-19 17:39:05 -05:00
|
|
|
});
|
|
|
|
document.querySelector("aside").appendChild(customLayerDiv);
|
|
|
|
|
2024-10-19 19:03:40 -05:00
|
|
|
// 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],
|
2024-10-19 20:32:29 -05:00
|
|
|
[180, 180, 180],
|
|
|
|
[170, 150, 110],
|
|
|
|
[110, 110, 110],
|
2024-10-19 19:03:40 -05:00
|
|
|
];
|
2024-10-19 17:39:05 -05:00
|
|
|
|
2024-10-19 20:32:16 -05:00
|
|
|
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]);
|
|
|
|
};
|
|
|
|
|
2024-10-19 17:39:05 -05:00
|
|
|
function newCustomLayer(name, sourceURL, colorString) {
|
|
|
|
let color;
|
|
|
|
if (colorString) {
|
2024-10-19 19:34:33 -05:00
|
|
|
color = [
|
|
|
|
parseInt(colorString.substr(0,2),16),
|
|
|
|
parseInt(colorString.substr(2,2),16),
|
|
|
|
parseInt(colorString.substr(4,2),16),
|
|
|
|
];
|
2024-10-19 17:39:05 -05:00
|
|
|
if (color.length != 3 || color.some(Number.isNaN)) {
|
|
|
|
alert("Invalid color provided; using random color instead.");
|
|
|
|
color = null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (!color) {
|
2024-10-19 20:32:16 -05:00
|
|
|
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
|
|
|
|
}
|
2024-10-19 17:39:05 -05:00
|
|
|
}
|
2024-10-19 20:32:16 -05:00
|
|
|
used_colors.push(color);
|
2024-10-19 17:39:05 -05:00
|
|
|
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 = `
|
2024-10-19 19:05:29 -05:00
|
|
|
<label><input type="checkbox" checked><div class="color-badge" style="background-color: rgb(${color.join(', ')});"></div> ${name}</label>
|
2024-10-19 17:39:05 -05:00
|
|
|
`;
|
|
|
|
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);
|
2024-10-19 20:32:16 -05:00
|
|
|
|
|
|
|
// 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('');
|
2024-10-19 17:39:05 -05:00
|
|
|
}
|
|
|
|
|
2024-10-19 18:45:53 -05:00
|
|
|
if (urlParams.customLayer) {
|
|
|
|
for (let customLayer of urlParams.customLayer) {
|
|
|
|
newCustomLayer(customLayer['name'], customLayer['url'], customLayer['color'])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-09-02 13:58:45 -05:00
|
|
|
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]});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2024-01-29 01:39:46 -06:00
|
|
|
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)) {
|
2024-03-15 00:07:37 -05:00
|
|
|
if (typeof value === "object" && value !== null) {
|
2024-01-29 01:39:46 -06:00
|
|
|
value = objectToTable(value);
|
|
|
|
}
|
2024-03-12 00:09:48 -05:00
|
|
|
if (typeof value === "string" && /^https?:\/\//.test(value)) {
|
2024-01-29 01:39:46 -06:00
|
|
|
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) {
|
2024-01-29 03:18:36 -06:00
|
|
|
let layer;
|
|
|
|
const feature = map.forEachFeatureAtPixel(evt.pixel, function (feature, l) {
|
|
|
|
layer = l;
|
2024-01-29 01:39:46 -06:00
|
|
|
return feature;
|
|
|
|
});
|
|
|
|
if (!feature) {
|
|
|
|
return;
|
|
|
|
}
|
2024-01-29 03:18:36 -06:00
|
|
|
if (layer.hasOwnProperty('customPopup')) {
|
|
|
|
content.innerHTML = layer.customPopup(feature);
|
|
|
|
} else {
|
|
|
|
// exclude geometry -- https://stackoverflow.com/a/208106
|
2024-02-02 03:25:22 -06:00
|
|
|
const {geometry: _, ...featureData} = feature.getProperties();
|
2024-01-29 01:39:46 -06:00
|
|
|
|
2024-01-29 03:18:36 -06:00
|
|
|
content.innerHTML = objectToTable(featureData);
|
|
|
|
}
|
2024-03-12 00:10:03 -05:00
|
|
|
if (layer.hasOwnProperty('customPopupCallback')) {
|
|
|
|
layer.customPopupCallback(feature);
|
|
|
|
}
|
2024-01-29 01:39:46 -06:00
|
|
|
|
|
|
|
popupOverlay.setPosition(evt.coordinate);
|
2024-02-02 21:46:21 -06:00
|
|
|
|
|
|
|
closer.onclick = function (){
|
|
|
|
popupOverlay.setPosition(undefined);
|
|
|
|
if (layer.hasOwnProperty('destroyPopupCallback')) {
|
|
|
|
layer.destroyPopupCallback(feature);
|
|
|
|
}
|
|
|
|
closer.blur();
|
|
|
|
return false;
|
|
|
|
};
|
2024-01-29 01:39:46 -06:00
|
|
|
});
|
2024-02-02 01:50:01 -06:00
|
|
|
|
|
|
|
new ResizeObserver(() => map.updateSize()).observe(document.getElementById("map"));
|
2024-02-02 03:25:36 -06:00
|
|
|
|
|
|
|
window.map = map;
|
2024-02-02 23:03:04 -06:00
|
|
|
|
|
|
|
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}
|
|
|
|
-->`;
|