lawrencedeerclub/map.html
Chandler Swift a9d7404f85
Refine tracks
This includes drawing custom and labeling custom lines with JOSM, which
this commit introduces, and adding a style function to display them
differently on the map. Per Eric's feedback, I also adjusted the weight
of the lines at different zoom levels so they're not overpowering at
lower zoom levels.
2025-11-08 17:52:09 -06:00

333 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lawrence Deer Club Maps</title>
<link rel="stylesheet" href="leaflet/leaflet.css" />
<script src="leaflet/leaflet.js"></script>
<script src="turf.min.js"></script>
<style>
html, body {
margin: 0;
padding: 0;
}
#map {
height: 100vh;
width: 100vw;
}
div.popup dt {
font-weight: bold;
}
.label {
font-weight: bold;
text-align: center;
}
.label div {
text-shadow: 0 0 6px white, 0 0 6px white, 0 0 6px white, 0 0 6px white, 0 0 6px white, 0 0 6px white;
transform: translate(-50%, -50%);
}
.leaflet-popup-content-wrapper {
height: min(800px, 60vh);
overflow-y: auto;
}
#popupcontainer {
position: relative;
z-index: 1000;
}
#popupcontainer .leaflet-popup-content-wrapper {
height: unset;
}
#popupcontainer .leaflet-popup {
transform: none !important;
position: fixed;
width: 100vw;
height: 100vh;
right: 0;
left: 0 !important;
top: 1em;
bottom: 1em !important;
overflow: auto;
}
#popupcontainer .leaflet-popup-content {
width: initial;
}
/* BEGIN lightbox */
/* simplified from https://jekyllcodex.org/without-plugin/lightbox/ */
#lightbox {
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
background: rgba(0,0,0,0.85);
z-index: 9999999;
line-height: 0;
cursor: pointer;
display: none;
}
#lightbox .img {
position: relative;
top: 50%;
left: 50%;
transform: translate(-50%,-50%);
max-width: 100%;
max-height: 100%;
}
#lightbox .img img {
opacity: 0;
pointer-events: none;
width: auto;
}
@media screen and (min-width: 1200px) {
#lightbox .img {
max-width: 1200px;
}
}
@media screen and (min-height: 1200px) {
#lightbox .img {
max-height: 1200px;
}
}
#lightbox #close {
height: 50px;
width: 50px;
position: fixed;
cursor: pointer;
text-decoration: none;
z-index: 99;
right: 0;
top: 0;
}
#lightbox #close:after, #lightbox #close:before {
position: absolute;
margin-top: 22px;
margin-left: 14px;
content: "";
height: 3px;
background: white;
width: 23px;
transform-origin: 50% 50%;
transform: rotate(-45deg);
}
#lightbox #close:after {
transform: rotate(45deg);
}
#lightbox, #lightbox * {
user-select: none;
}
/* END lightbox */
</style>
</head>
<body>
<div id="map"></div>
<div id="popupcontainer" class="leaflet-container"></div>
<div id="lightbox" onclick="this.style.display = 'none';"></div>
<script>
function displayLightboxOnClick(target, event) {
event.preventDefault();
document.getElementById('lightbox').innerHTML = `
<a id="close"></a>
<div class="img" style="background: url('${target.getAttribute('href')}') center center / contain no-repeat;">
<img src="${target.getAttribute('href')}">
</div>`;
document.getElementById('lightbox').style.display = 'block';
}
function collectPlatEdges(featureCollection) {
const segments = new Map();
const addSegment = (start, end) => {
if (!start || !end || start.length !== 2 || end.length !== 2) {
return;
}
if (start[0] === end[0] && start[1] === end[1]) {
return;
}
const forwardKey = `${start[0]},${start[1]}|${end[0]},${end[1]}`;
const backwardKey = `${end[0]},${end[1]}|${start[0]},${start[1]}`;
const key = forwardKey < backwardKey ? forwardKey : backwardKey;
if (!segments.has(key)) {
segments.set(key, { from: start, to: end });
}
};
const visitRing = (ring) => {
if (!Array.isArray(ring) || ring.length < 2) {
return;
}
for (let i = 0; i < ring.length - 1; i++) {
addSegment(ring[i], ring[i + 1]);
}
};
for (const feature of featureCollection.features) {
if (!feature || !feature.geometry) {
continue;
}
const { type, coordinates } = feature.geometry;
if (type === "Polygon") {
for (const ring of coordinates) {
visitRing(ring);
}
} else if (type === "MultiPolygon") {
for (const polygon of coordinates) {
for (const ring of polygon) {
visitRing(ring);
}
}
}
}
const features = [];
for (const segment of segments.values()) {
features.push(turf.lineString([segment.from, segment.to]));
}
return turf.featureCollection(features);
}
(async function() {
const map = L.map('map', {
minZoom: 15,
maxZoom: 21,
maxBounds: [
[47.517085, -93.427584],
[47.457925, -93.340026],
],
});
// L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
// maxZoom: 19,
// attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
// }).addTo(map);
L.tileLayer('./satellite/{z}/{x}/{y}.jpg', {
minNativeZoom: 12,
maxNativeZoom: 20,
maxZoom: 21,
bounds: [
[47.517085, -93.427584],
[47.457925, -93.340026],
],
}).addTo(map);
// Make these requests in parallel
const track_req_promise = fetch('data/track.geojson');
const plats_req_promise = fetch('data/plats.geojson');
const data_req_promise = fetch('data/data.json');
const track_req = await track_req_promise;
const plats_req = await plats_req_promise;
const data_req = await data_req_promise;
const track = await track_req.json();
const plats = await plats_req.json();
const data = await data_req.json();
const platFeatures = Array.isArray(plats.features) ? plats.features : [];
const filteredPlats = {
type: "FeatureCollection",
features: platFeatures.filter(feature => {
const name = feature?.properties?.TAO_NAME;
if (typeof name !== "string") {
return false;
}
return !name.toLowerCase().includes("lawrence deer club");
}),
};
if (filteredPlats.features.length > 0) {
// draw deduplicated plat boundaries with a subtle stroke so the merged shapes stay legible
const platEdges = collectPlatEdges(filteredPlats);
if (platEdges.features.length > 0) {
L.geoJSON(platEdges, {
style: {
weight: 2,
dashArray: "15 25", // px on, px off
},
interactive: false,
}).addTo(map);
}
const dissolvedPlats = turf.dissolve(filteredPlats, { propertyName: "TAO_NAME" });
if (dissolvedPlats && dissolvedPlats.features) {
L.geoJSON(dissolvedPlats, {
// style: {
// color: "#004f9f",
// weight: 2,
// fillOpacity: 0.1,
// },
onEachFeature(feature, layer) {
if (!feature || !feature.geometry) {
return;
}
// use center of mass when possible so labels stay visually centered
let labelPoint = turf.centerOfMass(feature);
if (!turf.booleanPointInPolygon(labelPoint, feature)) {
labelPoint = turf.pointOnFeature(feature);
}
const [lng, lat] = labelPoint.geometry.coordinates;
L.marker([lat, lng], {
icon: L.divIcon({
iconSize: null,
className: "label",
html: "<div>" + feature.properties.TAO_NAME + "</div>",
}),
interactive: false,
}).addTo(map);
},
}).addTo(map);
}
}
const trackLayer = L.geoJSON(track).addTo(map);
window.trackLayer = trackLayer;
map.on('zoomend', function() {
trackLayer.setStyle(function (feature) { // zoom 15 - 21
switch (feature.properties.type) {
case 'road':
return { color: '#654321', weight: (map.getZoom() - 13) * 2 };
case 'trail':
return { color: 'maroon', weight: (map.getZoom() - 14) / 2 + 1 };
case 'shooting_lane':
return { color: 'black', weight: Math.max((map.getZoom() - 16), 1), dashArray: '8,12' };
default:
console.log("Unknown trail type: " + feature.properties.trail_type);
}
});
});
for (let stand of data.stands) {
if (stand.location) {
const marker = L.marker(stand.location).addTo(map);
const popupContentWrapper = document.createElement('div');
popupContentWrapper.classList.add('popup');
let attributesString = '<dl>';
for (let [attribute, value] of Object.entries(stand.attributes)) {
attributesString += `<dt>${attribute}</dt><dd>${value}</dd>`;
}
attributesString += '</dl>';
popupContentWrapper.innerHTML = `
<h2>${stand.name}</h2>
<a href="images/${stand.image}" onclick="displayLightboxOnClick(this, event)"><img style="width: min(80vw, 300px);" src="images/thumbs/${stand.image}"></a>
${attributesString}
`;
let popupOptions = {};
// TODO: evaluate this when creating popup instead of at
// page instantiation time -- if the page is resized, this
// won't keep up with those changes.
if (window.visualViewport.width < 600) {
popupOptions.pane = document.getElementById('popupcontainer');
}
marker.bindPopup(popupContentWrapper, popupOptions);
}
}
map.fitBounds([
[47.4865,-93.4068],
[47.4992,-93.3746],
]);
})();
</script>
</body>
</html>