17c988c1ee
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>
202 lines
9.6 KiB
HTML
202 lines
9.6 KiB
HTML
<!-- Project Dashboard -->
|
||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
|
||
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||
<div>
|
||
<h2 class="text-2xl font-semibold text-gray-900 dark:text-white">{{ project.name }}</h2>
|
||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||
{% if project_type %}
|
||
{{ project_type.name }}
|
||
{% else %}
|
||
Project
|
||
{% endif %}
|
||
</p>
|
||
</div>
|
||
{% if project.status == 'upcoming' %}
|
||
<span class="px-3 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
|
||
{% elif project.status == 'active' %}
|
||
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
||
{% elif project.status == 'on_hold' %}
|
||
<span class="px-3 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
||
{% elif project.status == 'completed' %}
|
||
<span class="px-3 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
|
||
{% elif project.status == 'archived' %}
|
||
<span class="px-3 py-1 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Archived</span>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{% if project.description %}
|
||
<p class="text-gray-600 dark:text-gray-400 mt-4 max-w-3xl">{{ project.description }}</p>
|
||
{% endif %}
|
||
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">Locations</p>
|
||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">{{ locations | length }}</p>
|
||
</div>
|
||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">Assigned Units</p>
|
||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">{{ assigned_units | length }}</p>
|
||
</div>
|
||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">Active Sessions</p>
|
||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">{{ active_sessions | length }}</p>
|
||
</div>
|
||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">Completed Sessions</p>
|
||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">{{ completed_sessions_count }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||
{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}
|
||
NRLs
|
||
{% else %}
|
||
Locations
|
||
{% endif %}
|
||
</h3>
|
||
<button onclick="openLocationModal('{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}sound{% elif 'vibration_monitoring' in modules and 'sound_monitoring' not in modules %}vibration{% endif %}')" class="text-sm text-seismo-orange hover:text-seismo-navy">
|
||
{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}
|
||
Add NRL
|
||
{% else %}
|
||
Add Location
|
||
{% endif %}
|
||
</button>
|
||
</div>
|
||
<div id="project-locations"
|
||
hx-get="/api/projects/{{ project.id }}/locations{% if 'sound_monitoring' in modules and 'vibration_monitoring' not in modules %}?location_type=sound{% endif %}"
|
||
hx-trigger="load"
|
||
hx-swap="innerHTML">
|
||
<div class="animate-pulse space-y-3">
|
||
<div class="bg-gray-200 dark:bg-gray-700 h-16 rounded-lg"></div>
|
||
<div class="bg-gray-200 dark:bg-gray-700 h-16 rounded-lg"></div>
|
||
<div class="bg-gray-200 dark:bg-gray-700 h-16 rounded-lg"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Location Map — replaces the old Upcoming Actions panel for the
|
||
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 %}
|
||
<a href="javascript:void(0)" onclick="switchTab('schedules')"
|
||
class="text-xs text-seismo-orange hover:text-seismo-navy whitespace-nowrap">
|
||
{{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
|
||
</a>
|
||
{% endif %}
|
||
</div>
|
||
<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 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);
|
||
|
||
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>
|