towerops/assets/js/hooks/sites_map.ts
Graham McIntire 3bd4ed303c perf(assets): split big hooks into per-page chunks
app.js was bundling Chart.js (~200 KB), Cytoscape (~500 KB) and four
heavy hooks (SensorChart, NetworkMap, WeathermapViewer, SitesMap)
into a single 1.25 MB blob that every page paid for.

Extract each into its own module under assets/js/hooks/ and reference
them through the existing lazyHook() wrapper so the implementation is
fetched only when a hook actually mounts. esbuild --splitting then
factors the shared cytoscape vendor blob into a chunk that's only
loaded on topology / weathermap pages.

After-bundle layout (minified):
  app.js                  297.8 KB   (was ~1250 KB; -76%)
  chunk-LJGWK7RH (cyto)   555.1 KB   loaded on topology pages only
  sensor_chart            325.3 KB   loaded on sensor pages only
  network_map              24.4 KB   topology hook glue
  weathermap               13.0 KB   weathermap hook glue
  sites_map                 4.1 KB   site map hook glue
  coverage_hooks           25.0 KB   coverage hook glue

LeafletMap also moved to sites_map.ts and now uses ensureLeaflet
instead of polling for window.L with setTimeout.
2026-05-07 07:58:38 -05:00

152 lines
4.4 KiB
TypeScript

// Lazy-loaded sites map hook (Leaflet). Leaflet itself is loaded on
// demand via ensureLeaflet (no bundling), so this chunk is small.
import { ensureLeaflet } from "../lib/leaflet"
declare const L: any
export const SitesMap = {
map: null as any,
markers: null as any,
mounted(this: any) {
ensureLeaflet().then(() => {
const sitesData = JSON.parse(this.el.dataset.sites || '[]')
this.initializeLeaflet(sitesData)
this.handleEvent("site_clicked", (payload: any) => {
this.pushEvent("site_clicked", payload)
})
}).catch((e: any) => {
console.error('SitesMap: Leaflet failed to load', e)
})
},
updated(this: any) {
if (this.map) {
const sitesData = JSON.parse(this.el.dataset.sites || '[]')
this.updateSites(sitesData)
}
},
destroyed(this: any) {
if (this.map) {
this.map.remove()
this.map = null
this.markers = null
}
},
initializeLeaflet(this: any, sites: any[]) {
this.map = L.map(this.el, {
zoomControl: true,
scrollWheelZoom: true
})
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 18
}).addTo(this.map)
this.updateSites(sites)
},
updateSites(this: any, sites: any[]) {
if (!this.map) return
if (this.markers) {
this.map.removeLayer(this.markers)
}
this.markers = L.layerGroup()
if (sites.length === 0) {
this.map.setView([39.8283, -98.5795], 4)
this.markers.addTo(this.map)
return
}
const bounds = L.latLngBounds([])
sites.forEach((site: any) => {
if (site.latitude && site.longitude) {
const marker = L.marker([site.latitude, site.longitude])
let popupContent = `<div class="p-2">
<h3 class="font-semibold text-lg mb-1">${site.name}</h3>`
if (site.description) {
popupContent += `<p class="text-sm text-gray-600 mb-1">${site.description}</p>`
}
if (site.address) {
popupContent += `<p class="text-xs text-gray-500 mb-1">${site.address}</p>`
}
popupContent += `<p class="text-xs text-gray-500 mb-2">
${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}
</p>`
if (site.device_count > 0) {
popupContent += `<p class="text-xs text-blue-600 mb-2">
${site.device_count} device${site.device_count !== 1 ? 's' : ''}
</p>`
}
popupContent += `<button
data-site-id="${site.id}"
data-action="view-site"
class="text-xs bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700"
>
View Site →
</button></div>`
marker.bindPopup(popupContent)
marker.on('popupopen', () => {
const button = marker.getPopup()?.getElement()?.querySelector('[data-action="view-site"]') as HTMLElement
if (button) {
button.addEventListener('click', () => {
this.pushEvent("site_clicked", { site_id: button.dataset.siteId })
})
}
})
this.markers.addLayer(marker)
bounds.extend([site.latitude, site.longitude])
}
})
this.markers.addTo(this.map)
if (bounds.isValid()) {
this.map.fitBounds(bounds, { padding: [20, 20] })
}
}
}
// Single-marker map for site detail page. Loads Leaflet via ensureLeaflet
// rather than relying on a global script polled with setTimeout.
export const LeafletMap = {
map: null as any,
mounted(this: any) {
const lat = parseFloat(this.el.dataset.lat)
const lng = parseFloat(this.el.dataset.lng)
const zoom = parseInt(this.el.dataset.zoom || '14')
const title = this.el.dataset.markerTitle || ''
if (isNaN(lat) || isNaN(lng)) return
ensureLeaflet().then(() => {
this.map = L.map(this.el, { scrollWheelZoom: false, zoomControl: true })
this.map.setView([lat, lng], zoom)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
}).addTo(this.map)
L.marker([lat, lng]).addTo(this.map).bindPopup(title)
setTimeout(() => this.map.invalidateSize(), 200)
}).catch((e: any) => {
console.error('LeafletMap: Leaflet failed to load', e)
})
},
destroyed(this: any) {
if (this.map) { this.map.remove(); this.map = null }
}
}