feat(projects): reusable location-map partial + add map to Vibration tab
The map sidebar that replaced Upcoming Actions on the project overview
is now also on the deeper Vibration tab — operators get the same
spatial context when they drill into vibration monitoring locations.
Refactor
- New partial templates/partials/projects/location_map.html.
Self-contained: includes the map div + a self-fetch script that
pulls coords from /api/projects/{p}/locations-json on load.
Accepts:
- project_id (required)
- map_height (default "320px")
- location_type ('vibration' | 'sound' | none = all)
- project_dashboard.html: ~150 lines of inline map JS deleted, replaced
with {% include 'partials/projects/location_map.html' %}. Identical
behavior, less duplication.
- projects/detail.html Vibration tab: locations list converted to a
2/3 + 1/3 grid; right column hosts the same map partial filtered
to location_type=vibration with a taller 450px viewport.
Bidirectional hover-highlight (card ↔ pin) works on both surfaces
since the partial registers its own document-level mouseover/mouseout
handlers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
<!-- Reusable project-location map.
|
||||
|
||||
Renders a Leaflet map with one pin per active monitoring location for
|
||||
the given project. Fetches data from /api/projects/{p}/locations-json
|
||||
on load.
|
||||
|
||||
Required context variable:
|
||||
project_id — UUID string
|
||||
|
||||
Optional context variable:
|
||||
map_height — CSS height (default "320px")
|
||||
location_type — filter the fetched list (default: all types)
|
||||
|
||||
Hover any .location-card on the page with a matching
|
||||
data-location-id → highlights the pin. Click a pin → scrolls
|
||||
the matching card into view + flashes an orange ring.
|
||||
|
||||
isolation:isolate on the container forces a new stacking context so
|
||||
Leaflet's internal z-indexes (200-800) stay contained inside the
|
||||
card instead of leaking out and rendering over modals (z-50).
|
||||
-->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Location Map</h3>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500" id="loc-map-status-{{ project_id }}"></span>
|
||||
</div>
|
||||
<div id="loc-map-{{ project_id }}"
|
||||
class="w-full rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
style="height: {{ map_height | default('320px') }}; background: rgba(0,0,0,0.05); isolation: isolate;">
|
||||
</div>
|
||||
<div id="loc-map-empty-{{ project_id }}" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2 italic text-center">
|
||||
No location coordinates set. Edit a location and add a <code class="font-mono">lat,lon</code> pair to see it here.
|
||||
</div>
|
||||
<div id="loc-map-missing-{{ project_id }}" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const projectId = {{ project_id | tojson }};
|
||||
const locationType = {{ (location_type | default(none)) | tojson }};
|
||||
const mapEl = document.getElementById('loc-map-' + projectId);
|
||||
const emptyMsg = document.getElementById('loc-map-empty-' + projectId);
|
||||
const missingMsg = document.getElementById('loc-map-missing-' + projectId);
|
||||
const statusEl = document.getElementById('loc-map-status-' + projectId);
|
||||
if (!mapEl) return;
|
||||
|
||||
function parseCoords(s) {
|
||||
if (!s) return null;
|
||||
const parts = String(s).split(',').map(x => parseFloat(x.trim()));
|
||||
if (parts.length !== 2 || parts.some(isNaN)) return null;
|
||||
const [lat, lon] = parts;
|
||||
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
|
||||
return [lat, lon];
|
||||
}
|
||||
|
||||
async function init() {
|
||||
statusEl.textContent = 'loading…';
|
||||
let locs;
|
||||
try {
|
||||
const qs = locationType ? `?location_type=${encodeURIComponent(locationType)}` : '';
|
||||
const r = await fetch(`/api/projects/${projectId}/locations-json${qs}`);
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
locs = await r.json();
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'failed';
|
||||
mapEl.innerHTML = `<div class="flex items-center justify-center h-full text-sm text-red-500">Map load failed: ${e.message}</div>`;
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = '';
|
||||
|
||||
const withCoords = [];
|
||||
const withoutCoords = [];
|
||||
for (const loc of locs) {
|
||||
const xy = parseCoords(loc.coordinates);
|
||||
if (xy) withCoords.push({ ...loc, latlon: xy });
|
||||
else withoutCoords.push(loc);
|
||||
}
|
||||
|
||||
if (withCoords.length === 0) {
|
||||
mapEl.classList.add('hidden');
|
||||
emptyMsg.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const map = L.map(mapEl, { scrollWheelZoom: false });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 18,
|
||||
}).addTo(map);
|
||||
|
||||
const markersById = {};
|
||||
const bounds = [];
|
||||
withCoords.forEach(loc => {
|
||||
const marker = L.circleMarker(loc.latlon, {
|
||||
radius: 8, fillColor: '#f48b1c', color: '#fff',
|
||||
weight: 2, opacity: 1, fillOpacity: 0.9,
|
||||
}).addTo(map);
|
||||
marker.bindTooltip(loc.name, { direction: 'top', offset: [0, -6] });
|
||||
marker.on('click', () => _lmFlashCard(loc.id));
|
||||
markersById[loc.id] = marker;
|
||||
bounds.push(loc.latlon);
|
||||
});
|
||||
|
||||
if (bounds.length === 1) map.setView(bounds[0], 14);
|
||||
else map.fitBounds(bounds, { padding: [20, 20] });
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
|
||||
if (withoutCoords.length > 0) {
|
||||
const names = withoutCoords.map(l => l.name).join(', ');
|
||||
missingMsg.textContent = `${withoutCoords.length} location${withoutCoords.length === 1 ? '' : 's'} not shown (no coordinates): ${names}`;
|
||||
missingMsg.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Hover any .location-card on the page → highlight matching pin.
|
||||
let hoverPinId = null;
|
||||
document.addEventListener('mouseover', (e) => {
|
||||
const card = e.target.closest('.location-card');
|
||||
if (!card) return;
|
||||
const locId = card.dataset.locationId;
|
||||
if (!markersById[locId] || locId === hoverPinId) return;
|
||||
if (hoverPinId) _unhighlight(hoverPinId);
|
||||
_highlight(locId);
|
||||
hoverPinId = locId;
|
||||
});
|
||||
document.addEventListener('mouseout', (e) => {
|
||||
const card = e.target.closest('.location-card');
|
||||
if (!card) return;
|
||||
const related = e.relatedTarget && e.relatedTarget.closest('.location-card');
|
||||
if (related === card) return;
|
||||
if (hoverPinId) {
|
||||
_unhighlight(hoverPinId);
|
||||
hoverPinId = null;
|
||||
}
|
||||
});
|
||||
|
||||
function _highlight(locId) {
|
||||
const m = markersById[locId]; if (!m) return;
|
||||
m.setStyle({ radius: 12, fillColor: '#dc2626', weight: 3 });
|
||||
m.openTooltip();
|
||||
m.bringToFront();
|
||||
}
|
||||
function _unhighlight(locId) {
|
||||
const m = markersById[locId]; if (!m) return;
|
||||
m.setStyle({ radius: 8, fillColor: '#f48b1c', weight: 2 });
|
||||
m.closeTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
function _lmFlashCard(locId) {
|
||||
const card = document.querySelector(`.location-card[data-location-id="${locId}"]`);
|
||||
if (!card) return;
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
card.classList.add('ring-2', 'ring-seismo-orange');
|
||||
setTimeout(() => card.classList.remove('ring-2', 'ring-seismo-orange'), 1500);
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user