merge v0.12.0 #51
@@ -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>
|
||||||
@@ -78,173 +78,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Location Map — replaces the old Upcoming Actions panel for the
|
{# Location map — uses the reusable partial that fetches from
|
||||||
overview. Operators get a quick visual of where their locations
|
/api/projects/{p}/locations-json. Same render is reused on the
|
||||||
sit relative to each other. Pins clickable → scroll to + flash
|
deeper Vibration tab so both surfaces stay in sync. #}
|
||||||
the matching card. Locations without coordinates land in a
|
{% with project_id=project.id %}
|
||||||
"missing coords" hint below the map.
|
{% include 'partials/projects/location_map.html' %}
|
||||||
For projects with scheduled monitoring activity, the full
|
{% endwith %}
|
||||||
Upcoming Actions list is still available on the Schedules tab. -->
|
</div>
|
||||||
<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>
|
|
||||||
{% if upcoming_actions %}
|
{% if upcoming_actions %}
|
||||||
|
<div class="mt-3 text-xs text-right text-gray-500 dark:text-gray-400">
|
||||||
<a href="javascript:void(0)" onclick="switchTab('schedules')"
|
<a href="javascript:void(0)" onclick="switchTab('schedules')"
|
||||||
class="text-xs text-seismo-orange hover:text-seismo-navy whitespace-nowrap">
|
class="text-seismo-orange hover:text-seismo-navy">
|
||||||
{{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
|
{{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
<!-- `isolation: isolate` forces a new stacking context so Leaflet's
|
|
||||||
internal z-indexes (panes at 200-700, controls at 800) stay
|
|
||||||
contained inside this div instead of leaking into the root
|
|
||||||
stacking context and rendering over modals (which have z-50). -->
|
|
||||||
<div id="project-location-map" class="w-full rounded-lg border border-gray-200 dark:border-gray-700"
|
|
||||||
style="height: 320px; background: rgba(0,0,0,0.05); isolation: isolate;"></div>
|
|
||||||
<div id="project-location-map-empty" 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="project-location-map-missing" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
// Build location data from server-side render. Skip removed
|
|
||||||
// locations (their pins would clutter the active operations view)
|
|
||||||
// and skip ones without parseable coordinates.
|
|
||||||
const locationsRaw = [
|
|
||||||
{% for loc in locations %}
|
|
||||||
{% if not loc.removed_at %}
|
|
||||||
{
|
|
||||||
id: {{ loc.id | tojson }},
|
|
||||||
name: {{ loc.name | tojson }},
|
|
||||||
coords: {{ loc.coordinates | tojson if loc.coordinates else 'null' }},
|
|
||||||
}{% if not loop.last %},{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
];
|
|
||||||
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
const withCoords = [];
|
|
||||||
const withoutCoords = [];
|
|
||||||
for (const loc of locationsRaw) {
|
|
||||||
const xy = parseCoords(loc.coords);
|
|
||||||
if (xy) withCoords.push({ ...loc, latlon: xy });
|
|
||||||
else withoutCoords.push(loc);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyMsg = document.getElementById('project-location-map-empty');
|
|
||||||
const missingMsg = document.getElementById('project-location-map-missing');
|
|
||||||
const mapEl = document.getElementById('project-location-map');
|
|
||||||
if (!mapEl) return;
|
|
||||||
|
|
||||||
if (withCoords.length === 0) {
|
|
||||||
// Hide the map block and show a hint. Don't init Leaflet at all.
|
|
||||||
mapEl.classList.add('hidden');
|
|
||||||
emptyMsg.classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialise Leaflet. `L` is loaded globally by base.html.
|
|
||||||
const map = L.map(mapEl, { scrollWheelZoom: false });
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
||||||
attribution: '© OpenStreetMap',
|
|
||||||
maxZoom: 18,
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
// Marker store keyed by location id so card-click can find + flash
|
|
||||||
// the matching pin (bidirectional highlight).
|
|
||||||
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', () => _flashLocationCard(loc.id));
|
|
||||||
markersById[loc.id] = marker;
|
|
||||||
bounds.push(loc.latlon);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wire up the reverse direction: hovering a card highlights its
|
|
||||||
// pin on the map. Hover-based instead of click-based because
|
|
||||||
// clicking the card navigates to the location detail page, so the
|
|
||||||
// flash would never be visible. Event delegation so cards that
|
|
||||||
// appear later (htmx swap or reorder) still work.
|
|
||||||
let _hoverPinId = null;
|
|
||||||
document.addEventListener('mouseover', (e) => {
|
|
||||||
const card = e.target.closest('.location-card');
|
|
||||||
if (!card) return;
|
|
||||||
const locId = card.dataset.locationId;
|
|
||||||
if (locId === _hoverPinId) return;
|
|
||||||
if (_hoverPinId) _unhighlightPin(_hoverPinId);
|
|
||||||
_highlightPin(locId);
|
|
||||||
_hoverPinId = locId;
|
|
||||||
});
|
|
||||||
document.addEventListener('mouseout', (e) => {
|
|
||||||
const card = e.target.closest('.location-card');
|
|
||||||
if (!card) return;
|
|
||||||
// Only un-highlight if the mouse actually left the card,
|
|
||||||
// not just moved to a child element.
|
|
||||||
const related = e.relatedTarget && e.relatedTarget.closest('.location-card');
|
|
||||||
if (related === card) return;
|
|
||||||
if (_hoverPinId) {
|
|
||||||
_unhighlightPin(_hoverPinId);
|
|
||||||
_hoverPinId = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function _highlightPin(locId) {
|
|
||||||
const marker = markersById[locId];
|
|
||||||
if (!marker) return;
|
|
||||||
marker.setStyle({ radius: 12, fillColor: '#dc2626', weight: 3 });
|
|
||||||
marker.openTooltip();
|
|
||||||
marker.bringToFront();
|
|
||||||
}
|
|
||||||
|
|
||||||
function _unhighlightPin(locId) {
|
|
||||||
const marker = markersById[locId];
|
|
||||||
if (!marker) return;
|
|
||||||
marker.setStyle({ radius: 8, fillColor: '#f48b1c', weight: 2 });
|
|
||||||
marker.closeTooltip();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bounds.length === 1) {
|
|
||||||
map.setView(bounds[0], 14);
|
|
||||||
} else {
|
|
||||||
map.fitBounds(bounds, { padding: [20, 20] });
|
|
||||||
}
|
|
||||||
// Without this the map renders into a 0×0 area when the partial
|
|
||||||
// first lands via htmx (container size not yet stable).
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Briefly highlight the matching card to confirm the click.
|
|
||||||
function _flashLocationCard(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);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -101,7 +101,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 lg:col-span-2">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2>
|
||||||
<button onclick="openLocationModal('vibration')"
|
<button onclick="openLocationModal('vibration')"
|
||||||
@@ -119,6 +120,16 @@
|
|||||||
<div class="text-center py-8 text-gray-500">Loading locations...</div>
|
<div class="text-center py-8 text-gray-500">Loading locations...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Reusable location map — fetches from /locations-json
|
||||||
|
on its own. Hovering any of the location cards on the
|
||||||
|
left highlights the matching pin on this map. #}
|
||||||
|
<div>
|
||||||
|
{% with project_id=project_id, location_type='vibration', map_height='450px' %}
|
||||||
|
{% include 'partials/projects/location_map.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user