feat(projects): location map sidebar replaces Upcoming Actions on overview

The right column of every project's overview page now shows a Leaflet
map of its monitoring locations instead of the Upcoming Actions panel.
Operators get an immediate visual of where their locations sit relative
to each other and to nearby sites — much more useful at-a-glance than
the list of pending schedule actions, which sits one tab deeper anyway.

Map behavior
- Pin per active monitoring location with parseable "lat,lon" coords.
  Removed locations don't pin (their state is historical).
- Auto-fits bounds to show all pins, with 20px padding.  Single-pin
  projects center at zoom 14.
- Tooltip on pin hover: location name.
- Click pin → scrolls the matching card into view in the locations list
  and flashes an orange ring around it (uses the same data-location-id
  the drag-handle code added in commit 52dd6c3).
- scrollWheelZoom disabled to prevent accidental zoom-in when scrolling
  the page.
- Locations without coordinates surface as a small inline hint below
  the map ("N locations not shown: name1, name2").
- All-coords-missing projects hide the map block entirely and show a
  "set coordinates" hint instead.

Discovery preserved: if the project has pending scheduled actions, a
small "{N} upcoming actions →" link appears in the map card header
that switches to the Schedules tab.  Operators who care about the
queue still find it instantly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 05:27:27 +00:00
parent d297412d8a
commit 17c988c1ee
@@ -78,22 +78,124 @@
</div> </div>
</div> </div>
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <!-- Location Map — replaces the old Upcoming Actions panel for the
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Upcoming Actions</h3> overview. Operators get a quick visual of where their locations
sit relative to each other. Pins clickable → scroll to + flash
the matching card. Locations without coordinates land in a
"missing coords" hint below the map.
For projects with scheduled monitoring activity, the full
Upcoming Actions list is still available on the Schedules tab. -->
<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="space-y-3"> <a href="javascript:void(0)" onclick="switchTab('schedules')"
{% for action in upcoming_actions %} class="text-xs text-seismo-orange hover:text-seismo-navy whitespace-nowrap">
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3"> {{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
<p class="font-medium text-gray-900 dark:text-white">{{ action.action_type }}</p> </a>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.scheduled_time|local_datetime }} {{ timezone_abbr() }}</p>
{% if action.description %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.description }}</p>
{% endif %} {% endif %}
</div> </div>
{% endfor %} <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);"></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>
{% else %} <div id="project-location-map-missing" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2"></div>
<p class="text-sm text-gray-500 dark:text-gray-400">No scheduled actions.</p>
{% endif %}
</div> </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);
const markers = [];
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));
markers.push(marker);
bounds.push(loc.latlon);
});
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>