825c7370b8
Reverse direction of the existing pin→card flash on the project overview map. Hovering a location card now enlarges + reddens the matching pin on the map and opens its tooltip. Mouse-out reverts. Why hover instead of click: clicking the card title navigates to the location detail page, so any flash effect would never be visible. Hover is the right interaction here. Event delegation on document means cards that appear after htmx swaps (e.g. after a reorder, remove/restore, or assign-modal close) still get the behavior without rewiring. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
251 lines
12 KiB
HTML
251 lines
12 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>
|
||
<!-- `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>
|