451 lines
18 KiB
HTML
451 lines
18 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%);
|
|
}
|
|
.label-hidden {
|
|
opacity: 0;
|
|
}
|
|
.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 */
|
|
|
|
/* BEGIN legend */
|
|
.legend {
|
|
padding: 6px 8px;
|
|
font: 14px/16px Arial, Helvetica, sans-serif;
|
|
background: white;
|
|
background: rgba(255,255,255,0.8);
|
|
box-shadow: 0 0 15px rgba(0,0,0,0.2);
|
|
border-radius: 5px;
|
|
line-height: 18px;
|
|
color: #555;
|
|
}
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 4px;
|
|
}
|
|
.legend-item:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
.legend-line {
|
|
display: inline-block;
|
|
width: 28px;
|
|
height: 0;
|
|
border-radius: 4px;
|
|
}
|
|
.legend-line.road {
|
|
border-top: 6px solid #8b5a2b;
|
|
}
|
|
.legend-line.trail {
|
|
border-top: 3px solid #800000;
|
|
}
|
|
.legend-line.shooting_lane {
|
|
border-top: 3px dashed #000000;
|
|
}
|
|
.legend-icon {
|
|
width: 20px;
|
|
height: 20px;
|
|
object-fit: contain;
|
|
}
|
|
/* END legend */
|
|
</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: '© <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);
|
|
|
|
const platLabelEntries = []; // Cache label markers paired with their plat polygons
|
|
|
|
function updatePlatLabelVisibility() {
|
|
for (const entry of platLabelEntries) {
|
|
const { marker, layer } = entry;
|
|
const labelEl = marker.getElement();
|
|
if (!labelEl) {
|
|
continue;
|
|
}
|
|
const textEl = labelEl.querySelector('div');
|
|
if (!textEl) {
|
|
continue;
|
|
}
|
|
const bounds = layer.getBounds();
|
|
if (!bounds.isValid()) {
|
|
labelEl.classList.add('label-hidden');
|
|
continue;
|
|
}
|
|
labelEl.classList.remove('label-hidden');
|
|
// Compare rendered label size in pixels against the plat's projected bounding box
|
|
const sw = map.latLngToLayerPoint(bounds.getSouthWest());
|
|
const ne = map.latLngToLayerPoint(bounds.getNorthEast());
|
|
const platWidth = Math.abs(ne.x - sw.x);
|
|
const platHeight = Math.abs(ne.y - sw.y);
|
|
const labelWidth = textEl.offsetWidth;
|
|
const labelHeight = textEl.offsetHeight;
|
|
const fits = labelWidth <= platWidth && labelHeight <= platHeight;
|
|
if (fits) {
|
|
labelEl.classList.remove('label-hidden');
|
|
} else {
|
|
labelEl.classList.add('label-hidden');
|
|
}
|
|
}
|
|
}
|
|
|
|
map.on('zoomend', updatePlatLabelVisibility);
|
|
map.on('moveend', updatePlatLabelVisibility);
|
|
map.on('resize', updatePlatLabelVisibility);
|
|
|
|
// 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,
|
|
}).once('add', function() {
|
|
platLabelEntries.push({ marker: this, layer });
|
|
updatePlatLabelVisibility();
|
|
}).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 poi of data) {
|
|
if (poi.location) {
|
|
const icon = L.icon({
|
|
iconUrl: `images/icons/${poi.type}.svg`,
|
|
iconSize: [16, 16],
|
|
iconAnchor: [8, 16],
|
|
popupAnchor: [0, -12],
|
|
});
|
|
const marker = L.marker(poi.location, {icon}).addTo(map);
|
|
map.on('zoom', () => {
|
|
icon.options.iconSize = [map.getZoom() * 8 - 104, map.getZoom() * 8 - 104];
|
|
icon.options.iconAnchor = [icon.options.iconSize[0] / 2, icon.options.iconSize[1]];
|
|
icon.options.popupAnchor = [0, -icon.options.iconSize[1] * 3 / 4];
|
|
marker.setIcon(icon);
|
|
});
|
|
const popupContentWrapper = document.createElement('div');
|
|
popupContentWrapper.classList.add('popup');
|
|
let attributesString = '<dl>';
|
|
for (let [attribute, value] of Object.entries(poi.attributes)) {
|
|
attributesString += `<dt>${attribute}</dt><dd>${value}</dd>`;
|
|
}
|
|
attributesString += '</dl>';
|
|
popupContentWrapper.innerHTML = `
|
|
<h2>${poi.name}</h2>
|
|
<a href="images/${poi.image}" onclick="displayLightboxOnClick(this, event)"><img style="width: min(80vw, 300px);" src="images/thumbs/${poi.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);
|
|
}
|
|
}
|
|
|
|
const legend = L.control({position: 'bottomright'});
|
|
legend.onAdd = function (map) {
|
|
const div = L.DomUtil.create('div', 'legend');
|
|
div.innerHTML = `
|
|
<div class="legend-item"><span class="legend-line road"></span>Road</div>
|
|
<div class="legend-item"><span class="legend-line trail"></span>Trail</div>
|
|
<div class="legend-item"><span class="legend-line shooting_lane"></span>Shooting lane</div>
|
|
<div class="legend-item"><img class="legend-icon" src="images/icons/castle-stand.svg" alt="Castle icon">Castle stand</div>
|
|
<div class="legend-item"><img class="legend-icon" src="images/icons/ladder-stand.svg" alt="Ladder icon">Ladder stand</div>
|
|
<div class="legend-item"><img class="legend-icon" src="images/icons/barrel-stand.svg" alt="Barrel icon">Barrel stand</div>
|
|
<div class="legend-item"><img class="legend-icon" src="images/icons/shack.svg" alt="Shack icon">Shack</div>
|
|
<div class="legend-item"><img class="legend-icon" src="images/icons/outhouse.svg" alt="Outhouse icon">Outhouse</div>
|
|
<div class="legend-item"><img class="legend-icon" src="images/icons/fruit-tree.svg" alt="Fruit tree icon">Fruit tree</div>
|
|
`;
|
|
return div;
|
|
};
|
|
legend.addTo(map);
|
|
|
|
map.fitBounds([
|
|
[47.4865,-93.4068],
|
|
[47.4992,-93.3746],
|
|
]);
|
|
|
|
map.whenReady(updatePlatLabelVisibility);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|