bfcf876d42
Features: - Dark-themed interactive Leaflet map with marker clustering - Combined GPS sources: booking (technician check-in) + photo EXIF + account locations - Collapsible filter bar with search, source toggles, and source type dropdown - Info popups on click showing machine details, work orders, serials, photos - Account location layer toggle (3,487 accounts) - Export filtered data as JSON - Mobile-responsive (40vh map on mobile, filter collapse) - FastAPI backend with in-memory caching - Systemd service for auto-start - NPMPlus reverse proxy at gps.ourpad.casa Stats: 94 unique machines (73 booking, 21 photo), 231 total GPS points, 3,487 account locations
617 lines
21 KiB
HTML
617 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
<title>Canteen GPS Map</title>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
|
|
<style>
|
|
:root {
|
|
--bg: #1a1b26;
|
|
--card: #24283b;
|
|
--border: #334155;
|
|
--text: #a9b1d6;
|
|
--text-dim: #565f89;
|
|
--accent: #7aa2f7;
|
|
--green: #9ece6a;
|
|
--red: #f7768e;
|
|
--orange: #ff9e64;
|
|
--yellow: #e0af68;
|
|
--purple: #bb9af7;
|
|
--cyan: #7dcfff;
|
|
--booking: #f7768e;
|
|
--photo: #7aa2f7;
|
|
--exif: #bb9af7;
|
|
--account: #9ece6a;
|
|
}
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; }
|
|
body { display: flex; flex-direction: column; }
|
|
|
|
/* Header */
|
|
.header {
|
|
background: var(--card);
|
|
padding: 6px 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
border-bottom: 1px solid var(--border);
|
|
flex-shrink: 0;
|
|
z-index: 1001;
|
|
}
|
|
.header h1 { font-size: 16px; font-weight: 600; color: var(--accent); white-space: nowrap; }
|
|
.header .stats-bar { font-size: 12px; color: var(--text-dim); display: flex; gap: 12px; flex: 1; justify-content: center; }
|
|
.header .stats-bar span { white-space: nowrap; }
|
|
.header .stats-bar b { color: var(--text); }
|
|
|
|
.collapse-btn {
|
|
background: none; border: 1px solid var(--border); color: var(--text-dim);
|
|
border-radius: 4px; cursor: pointer; font-size: 11px; padding: 2px 6px;
|
|
line-height: 1.2; transition: transform .2s;
|
|
}
|
|
.collapse-btn:hover { background: #334155; color: var(--text); }
|
|
.collapse-btn.collapsed { transform: rotate(-90deg); }
|
|
|
|
/* Filter bar */
|
|
.filters {
|
|
background: var(--card);
|
|
padding: 6px 12px;
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
border-bottom: 1px solid var(--border);
|
|
transition: max-height .3s ease, padding .3s ease, opacity .3s ease;
|
|
overflow: hidden;
|
|
max-height: 200px;
|
|
opacity: 1;
|
|
flex-shrink: 0;
|
|
}
|
|
.filters.collapsed { max-height: 0; padding: 0 12px; opacity: 0; border-bottom: none; }
|
|
|
|
.filters input, .filters select {
|
|
background: #1a1b26;
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 5px 8px;
|
|
font-size: 13px;
|
|
outline: none;
|
|
}
|
|
.filters input:focus, .filters select:focus { border-color: var(--accent); }
|
|
.filters input { min-width: 140px; }
|
|
.filters select { min-width: 110px; }
|
|
.filters .reset-btn {
|
|
background: #334155;
|
|
color: var(--text);
|
|
border: none;
|
|
border-radius: 4px;
|
|
padding: 5px 10px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
}
|
|
.filters .reset-btn:hover { background: var(--accent); color: #fff; }
|
|
.filters .source-toggle {
|
|
display: flex;
|
|
gap: 1px;
|
|
background: var(--border);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
.filters .source-toggle button {
|
|
background: #1a1b26;
|
|
color: var(--text-dim);
|
|
border: none;
|
|
padding: 5px 10px;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
}
|
|
.filters .source-toggle button.active { background: var(--accent); color: #fff; }
|
|
.filters .source-toggle button:hover:not(.active) { background: #334155; color: var(--text); }
|
|
|
|
.filters .layer-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
cursor: pointer;
|
|
}
|
|
.filters .layer-toggle input { min-width: auto; accent-color: var(--accent); }
|
|
.filters .export-btn {
|
|
background: #1e3a5f;
|
|
color: var(--cyan);
|
|
border: 1px solid #3b82f6;
|
|
border-radius: 4px;
|
|
padding: 5px 10px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
margin-left: auto;
|
|
}
|
|
.filters .export-btn:hover { background: #3b82f6; color: #fff; }
|
|
|
|
.filters .counter {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
padding: 5px 8px;
|
|
background: #1a1b26;
|
|
border-radius: 4px;
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
/* Map */
|
|
#map-container { flex: 1; position: relative; min-height: 0; }
|
|
#map { width: 100%; height: 100%; }
|
|
|
|
/* Legend */
|
|
.legend {
|
|
background: var(--card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 8px 12px;
|
|
position: absolute;
|
|
bottom: 20px;
|
|
right: 10px;
|
|
z-index: 1000;
|
|
font-size: 11px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}
|
|
.legend-item { display: flex; align-items: center; gap: 6px; }
|
|
.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
|
|
/* Cluster overrides */
|
|
.marker-cluster-small { background-color: rgba(59,130,246,0.25); }
|
|
.marker-cluster-small div { background-color: #3b82f6; color: #fff; font-weight: 600; font-size: 12px; }
|
|
.marker-cluster-medium { background-color: rgba(139,92,246,0.25); }
|
|
.marker-cluster-medium div { background-color: #8b5cf6; color: #fff; font-weight: 600; font-size: 13px; }
|
|
.marker-cluster-large { background-color: rgba(245,158,11,0.25); }
|
|
.marker-cluster-large div { background-color: #f59e0b; color: #fff; font-weight: 600; font-size: 14px; }
|
|
|
|
/* Popup dark */
|
|
.leaflet-popup-content-wrapper { background: var(--card); color: var(--text); border-radius: 8px; }
|
|
.leaflet-popup-tip { background: var(--card); }
|
|
.leaflet-popup-close-button { color: var(--text) !important; }
|
|
.popup-content { font-size: 13px; line-height: 1.5; }
|
|
.popup-content h3 { font-size: 14px; color: var(--accent); margin-bottom: 4px; }
|
|
.popup-content .label { color: var(--text-dim); font-size: 10px; text-transform: uppercase; }
|
|
.popup-content .val { color: var(--text); }
|
|
.popup-content .row { display: flex; gap: 12px; margin-top: 4px; }
|
|
.popup-content .source-badge {
|
|
display: inline-block;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
color: #fff;
|
|
}
|
|
.source-booking { background: var(--booking); }
|
|
.source-photo { background: var(--photo); }
|
|
.source-exif_dev { background: var(--exif); }
|
|
.source-account { background: var(--green); }
|
|
|
|
/* Mobile */
|
|
@media (max-width: 768px) {
|
|
.header h1 { font-size: 14px; }
|
|
.header .stats-bar { font-size: 10px; gap: 6px; }
|
|
.filters { padding: 4px 8px; gap: 4px; }
|
|
.filters input { min-width: 100px; flex: 1; }
|
|
.filters select { min-width: 90px; }
|
|
.filters .source-toggle button { padding: 4px 6px; font-size: 10px; }
|
|
.legend { bottom: 10px; right: 6px; font-size: 10px; padding: 6px 8px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="header">
|
|
<button class="collapse-btn" id="collapseFilters" onclick="toggleFilters()" title="Toggle filters">▼</button>
|
|
<h1>🗺️ Canteen GPS Map</h1>
|
|
<div class="stats-bar">
|
|
<span>Machines: <b id="statMachines">-</b></span>
|
|
<span>Booking: <b id="statBooking">-</b></span>
|
|
<span>Photos: <b id="statPhotos">-</b></span>
|
|
<span>Accounts: <b id="statAccounts">-</b></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="filters" id="filterBar">
|
|
<input id="search" type="text" placeholder="Search machine ID, name, account..." oninput="applyFilters()">
|
|
<select id="sourceFilter" onchange="applyFilters()">
|
|
<option value="all">All Sources</option>
|
|
<option value="booking">🔴 Booking GPS</option>
|
|
<option value="photo">🔵 Photo GPS</option>
|
|
<option value="exif_dev">🟣 EXIF GPS</option>
|
|
<option value="account">🟢 Account GPS</option>
|
|
</select>
|
|
<div class="source-toggle" id="sourceBtns">
|
|
<button data-src="booking" class="active" onclick="toggleSource('booking',this)">Booking</button>
|
|
<button data-src="photo" class="active" onclick="toggleSource('photo',this)">Photo</button>
|
|
<button data-src="exif_dev" class="active" onclick="toggleSource('exif_dev',this)">EXIF</button>
|
|
<button data-src="account" onclick="toggleSource('account',this)">Accounts</button>
|
|
</div>
|
|
<label class="layer-toggle">
|
|
<input type="checkbox" id="showAccounts" onchange="toggleAccountLayer()">
|
|
Show account locations
|
|
</label>
|
|
<button class="reset-btn" onclick="resetFilters()">Reset</button>
|
|
<button class="export-btn" onclick="exportData()">📥 Export</button>
|
|
<span class="counter" id="counter">-</span>
|
|
</div>
|
|
|
|
<div id="map-container"><div id="map"></div>
|
|
<div class="legend">
|
|
<div class="legend-item"><span class="legend-dot" style="background:var(--booking)"></span> Booking GPS (technician)</div>
|
|
<div class="legend-item"><span class="legend-dot" style="background:var(--photo)"></span> Photo EXIF GPS</div>
|
|
<div class="legend-item"><span class="legend-dot" style="background:var(--exif)"></span> EXIF Dev GPS</div>
|
|
<div class="legend-item"><span class="legend-dot" style="background:var(--green)"></span> Account GPS</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
|
|
<script>
|
|
// ── State ──
|
|
let allPoints = [];
|
|
let accountPoints = [];
|
|
let stats = {};
|
|
let map;
|
|
let clusterGroup;
|
|
let accountLayer;
|
|
let accountMarkers = [];
|
|
let fitBoundsDone = false;
|
|
|
|
// Source visibility
|
|
let visibleSources = { booking: true, photo: true, exif_dev: true, account: false };
|
|
let filtersCollapsed = localStorage.getItem('gpsMapFiltersCollapsed') === 'true';
|
|
|
|
// Icons
|
|
const sourceIcons = {
|
|
booking: L.divIcon({
|
|
html: '<div style="background:#f7768e;width:18px;height:18px;border-radius:50%;border:3px solid #ff9e64;box-shadow:0 1px 4px rgba(0,0,0,.5);cursor:pointer"></div>',
|
|
iconSize: [18,18], iconAnchor: [9,9], className: ''
|
|
}),
|
|
photo: L.divIcon({
|
|
html: '<div style="background:#7aa2f7;width:18px;height:18px;border-radius:50%;border:3px solid #7dcfff;box-shadow:0 1px 4px rgba(0,0,0,.5);cursor:pointer"></div>',
|
|
iconSize: [18,18], iconAnchor: [9,9], className: ''
|
|
}),
|
|
exif_dev: L.divIcon({
|
|
html: '<div style="background:#bb9af7;width:18px;height:18px;border-radius:50%;border:3px solid #9ece6a;box-shadow:0 1px 4px rgba(0,0,0,.5);cursor:pointer"></div>',
|
|
iconSize: [18,18], iconAnchor: [9,9], className: ''
|
|
}),
|
|
account: L.divIcon({
|
|
html: '<div style="background:#9ece6a;width:16px;height:16px;border-radius:50%;border:2px solid #e0af68;box-shadow:0 1px 4px rgba(0,0,0,.5);cursor:pointer"></div>',
|
|
iconSize: [16,16], iconAnchor: [8,8], className: ''
|
|
}),
|
|
};
|
|
|
|
// ── Initialize Map ──
|
|
function initMap() {
|
|
map = L.map('map', {
|
|
preferCanvas: true,
|
|
zoomControl: true,
|
|
attributionControl: false,
|
|
}).setView([28.5, -82.0], 7);
|
|
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
maxZoom: 19,
|
|
subdomains: 'abcd',
|
|
}).addTo(map);
|
|
|
|
// Cluster group for equipment markers
|
|
clusterGroup = L.markerClusterGroup({
|
|
maxClusterRadius: 50,
|
|
spiderfyOnMaxZoom: true,
|
|
showCoverageOnHover: false,
|
|
zoomToBoundsOnClick: true,
|
|
disableClusteringAtZoom: 16,
|
|
spiderLegPolylineOptions: { weight: 1.5, color: '#475569', opacity: 0.5 },
|
|
});
|
|
map.addLayer(clusterGroup);
|
|
|
|
// Account layer (separate, non-clustered)
|
|
accountLayer = L.layerGroup();
|
|
accountLayer.addTo(map);
|
|
}
|
|
|
|
// ── Load Data ──
|
|
async function loadData() {
|
|
try {
|
|
const resp = await fetch('/api/points?source=all&summary_only=true');
|
|
const data = await resp.json();
|
|
stats = data.stats || {};
|
|
|
|
// Update stat bar
|
|
document.getElementById('statMachines').textContent = data.count || 0;
|
|
document.getElementById('statBooking').textContent = stats.machines_with_booking || 0;
|
|
document.getElementById('statPhotos').textContent = (stats.machines_with_photo || 0) + (stats.machines_with_exif_dev || 0);
|
|
document.getElementById('statAccounts').textContent = stats.account_points || 0;
|
|
|
|
// Also load all individual points for rendering
|
|
const fullResp = await fetch('/api/points?source=all');
|
|
const fullData = await fullResp.json();
|
|
allPoints = fullData.points || [];
|
|
accountPoints = (fullData.stats && fullData.stats.account_points) ? [] : [];
|
|
|
|
renderMarkers();
|
|
} catch (err) {
|
|
console.error('Failed to load data:', err);
|
|
}
|
|
}
|
|
|
|
// ── Render Markers ──
|
|
function renderMarkers() {
|
|
clusterGroup.clearLayers();
|
|
accountLayer.clearLayers();
|
|
accountMarkers = [];
|
|
|
|
let visibleCount = 0;
|
|
const allBounds = [];
|
|
|
|
// Render equipment points (booking + photo + exif)
|
|
for (const p of allPoints) {
|
|
const src = p.source;
|
|
if (src === 'account') {
|
|
// Handle accounts separately
|
|
if (visibleSources.account) {
|
|
const marker = L.marker([p.lat, p.lng], { icon: sourceIcons.account });
|
|
marker.bindPopup(buildPopup(p));
|
|
accountLayer.addLayer(marker);
|
|
accountMarkers.push(marker);
|
|
allBounds.push([p.lat, p.lng]);
|
|
visibleCount++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!visibleSources[src]) continue;
|
|
|
|
const marker = L.marker([p.lat, p.lng], { icon: sourceIcons[src] || sourceIcons.photo });
|
|
marker.bindPopup(buildPopup(p));
|
|
clusterGroup.addLayer(marker);
|
|
allBounds.push([p.lat, p.lng]);
|
|
visibleCount++;
|
|
}
|
|
|
|
document.getElementById('counter').textContent = visibleCount + ' visible';
|
|
|
|
// Fit bounds on first load
|
|
if (!fitBoundsDone && allBounds.length > 0) {
|
|
fitBoundsDone = true;
|
|
map.fitBounds(allBounds, { padding: [30, 30] });
|
|
}
|
|
}
|
|
|
|
// ── Build Popup ──
|
|
function buildPopup(p) {
|
|
const src = p.source;
|
|
const sourceLabel = { booking: '📱 Booking GPS', photo: '📷 Photo EXIF', exif_dev: '📱 EXIF Dev', account: '🏢 Account' }[src] || src;
|
|
const sourceClass = 'source-' + src;
|
|
|
|
let html = '<div class="popup-content">';
|
|
html += '<span class="source-badge ' + sourceClass + '">' + sourceLabel + '</span>';
|
|
|
|
if (p.machine_id) {
|
|
html += '<h3>#' + p.machine_id + '</h3>';
|
|
} else if (p.account_name) {
|
|
html += '<h3>' + escHtml(p.account_name) + '</h3>';
|
|
}
|
|
|
|
html += '<div class="label">GPS</div>';
|
|
html += '<div class="val">' + p.lat.toFixed(5) + ', ' + p.lng.toFixed(5) + '</div>';
|
|
|
|
if (p.machine_id) {
|
|
if (p.canteen_name) {
|
|
html += '<div class="row"><div><div class="label">Name</div><div class="val">' + escHtml(p.canteen_name) + '</div></div></div>';
|
|
}
|
|
if (p.canteen_company || p.account_name) {
|
|
html += '<div class="row"><div><div class="label">Account</div><div class="val">' + escHtml(p.account_name || p.canteen_company) + '</div></div></div>';
|
|
}
|
|
if (p.canteen_make || p.canteen_model) {
|
|
html += '<div class="row"><div><div class="label">Equipment</div><div class="val">' + escHtml((p.canteen_make || '') + ' ' + (p.canteen_model || '')).trim() + '</div></div></div>';
|
|
}
|
|
if (p.serial) {
|
|
html += '<div class="row"><div><div class="label">Serial</div><div class="val">' + escHtml(p.serial) + '</div></div></div>';
|
|
}
|
|
if (p.connect_id) {
|
|
html += '<div class="row"><div><div class="label">Connect ID</div><div class="val">' + escHtml(p.connect_id) + '</div></div></div>';
|
|
}
|
|
}
|
|
|
|
if (p.source === 'booking') {
|
|
if (p.work_order) {
|
|
html += '<div class="row"><div><div class="label">Work Order</div><div class="val">' + escHtml(p.work_order) + '</div></div></div>';
|
|
}
|
|
if (p.arrival_time) {
|
|
html += '<div class="row"><div><div class="label">Arrival</div><div class="val">' + p.arrival_time.substring(0, 16) + '</div></div></div>';
|
|
}
|
|
}
|
|
|
|
if (p.source === 'photo' || p.source === 'exif_dev') {
|
|
if (p.photo_file) {
|
|
html += '<div class="row"><div><div class="label">Photo</div><div class="val">' + escHtml(p.photo_file) + '</div></div></div>';
|
|
}
|
|
if (p.ocr_text) {
|
|
html += '<div class="row"><div><div class="label">OCR</div><div class="val" style="font-size:11px">' + escHtml(p.ocr_text) + '</div></div></div>';
|
|
}
|
|
}
|
|
|
|
if (p.source === 'account') {
|
|
if (p.address_line1) {
|
|
html += '<div class="row"><div><div class="label">Address</div><div class="val">' + escHtml(p.address_line1) + '</div></div></div>';
|
|
}
|
|
if (p.city || p.state) {
|
|
html += '<div class="row"><div><div class="label">Location</div><div class="val">' + escHtml([p.city, p.state, p.zip].filter(Boolean).join(', ')) + '</div></div></div>';
|
|
}
|
|
if (p.phone) {
|
|
html += '<div class="row"><div><div class="label">Phone</div><div class="val">' + escHtml(p.phone) + '</div></div></div>';
|
|
}
|
|
}
|
|
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
// ── Filters ──
|
|
function applyFilters() {
|
|
const search = document.getElementById('search').value.toLowerCase();
|
|
const sourceFilter = document.getElementById('sourceFilter').value;
|
|
|
|
clusterGroup.clearLayers();
|
|
accountLayer.clearLayers();
|
|
accountMarkers = [];
|
|
|
|
let visibleCount = 0;
|
|
const allBounds = [];
|
|
|
|
for (const p of allPoints) {
|
|
const src = p.source;
|
|
|
|
// Source filter
|
|
if (sourceFilter !== 'all' && src !== sourceFilter) continue;
|
|
if (!visibleSources[src]) continue;
|
|
|
|
// Search filter
|
|
if (search) {
|
|
const txt = JSON.stringify(p).toLowerCase();
|
|
if (!txt.includes(search)) continue;
|
|
}
|
|
|
|
if (src === 'account') {
|
|
const marker = L.marker([p.lat, p.lng], { icon: sourceIcons.account });
|
|
marker.bindPopup(buildPopup(p));
|
|
accountLayer.addLayer(marker);
|
|
accountMarkers.push(marker);
|
|
} else {
|
|
const marker = L.marker([p.lat, p.lng], { icon: sourceIcons[src] || sourceIcons.photo });
|
|
marker.bindPopup(buildPopup(p));
|
|
clusterGroup.addLayer(marker);
|
|
}
|
|
allBounds.push([p.lat, p.lng]);
|
|
visibleCount++;
|
|
}
|
|
|
|
document.getElementById('counter').textContent = visibleCount + ' visible';
|
|
}
|
|
|
|
// ── Source Toggle ──
|
|
function toggleSource(src, btn) {
|
|
btn.classList.toggle('active');
|
|
visibleSources[src] = btn.classList.contains('active');
|
|
applyFilters();
|
|
}
|
|
|
|
// ── Account Layer Toggle ──
|
|
function toggleAccountLayer() {
|
|
const show = document.getElementById('showAccounts').checked;
|
|
if (show) {
|
|
// Load accounts if not loaded
|
|
if (accountPoints.length === 0) {
|
|
fetch('/api/accounts')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
accountPoints = data.accounts || [];
|
|
renderAccountLayer();
|
|
});
|
|
} else {
|
|
renderAccountLayer();
|
|
}
|
|
} else {
|
|
accountLayer.clearLayers();
|
|
accountMarkers = [];
|
|
}
|
|
}
|
|
|
|
function renderAccountLayer() {
|
|
accountLayer.clearLayers();
|
|
accountMarkers = [];
|
|
for (const a of accountPoints) {
|
|
const marker = L.marker([a.lat, a.lng], { icon: sourceIcons.account });
|
|
marker.bindPopup(buildPopup(a));
|
|
accountLayer.addLayer(marker);
|
|
accountMarkers.push(marker);
|
|
}
|
|
}
|
|
|
|
// ── Filter Bar Collapse ──
|
|
function toggleFilters() {
|
|
filtersCollapsed = !filtersCollapsed;
|
|
localStorage.setItem('gpsMapFiltersCollapsed', filtersCollapsed);
|
|
const bar = document.getElementById('filterBar');
|
|
const btn = document.getElementById('collapseFilters');
|
|
bar.classList.toggle('collapsed', filtersCollapsed);
|
|
btn.classList.toggle('collapsed', filtersCollapsed);
|
|
setTimeout(function() { if (map) map.invalidateSize(); }, 350);
|
|
}
|
|
|
|
// ── Reset ──
|
|
function resetFilters() {
|
|
document.getElementById('search').value = '';
|
|
document.getElementById('sourceFilter').value = 'all';
|
|
document.getElementById('showAccounts').checked = false;
|
|
accountLayer.clearLayers();
|
|
accountMarkers = [];
|
|
// Reset source toggle buttons
|
|
document.querySelectorAll('#sourceBtns button').forEach(b => {
|
|
b.classList.toggle('active', b.dataset.src !== 'account');
|
|
visibleSources[b.dataset.src] = b.dataset.src !== 'account';
|
|
});
|
|
applyFilters();
|
|
}
|
|
|
|
// ── Export ──
|
|
function exportData() {
|
|
const search = document.getElementById('search').value.toLowerCase();
|
|
const sourceFilter = document.getElementById('sourceFilter').value;
|
|
|
|
let exportPoints = allPoints.filter(p => {
|
|
if (sourceFilter !== 'all' && p.source !== sourceFilter) return false;
|
|
if (!visibleSources[p.source]) return false;
|
|
if (search && !JSON.stringify(p).toLowerCase().includes(search)) return false;
|
|
return true;
|
|
});
|
|
|
|
// Include visible account points
|
|
if (visibleSources.account) {
|
|
exportPoints = exportPoints.concat(accountMarkers.map(m => {
|
|
const ll = m.getLatLng();
|
|
return { source: 'account', lat: ll.lat, lng: ll.lng, account_name: 'Account' };
|
|
}));
|
|
}
|
|
|
|
const json = JSON.stringify(exportPoints, null, 2);
|
|
const blob = new Blob([json], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'canteen-gps-export-' + new Date().toISOString().substring(0, 10) + '.json';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function escHtml(s) {
|
|
const div = document.createElement('div');
|
|
div.textContent = s || '';
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// ── Startup ──
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initMap();
|
|
|
|
// Restore filter collapse state
|
|
if (filtersCollapsed) {
|
|
document.getElementById('filterBar').classList.add('collapsed');
|
|
document.getElementById('collapseFilters').classList.add('collapsed');
|
|
}
|
|
|
|
loadData();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|