From f873bc1c77c326f19a65e361bb891708f4ded13d Mon Sep 17 00:00:00 2001 From: Polen Date: Wed, 8 Apr 2026 10:05:35 -0400 Subject: [PATCH] optimise request --- main.go | 149 ++++++++++++++++++++++++++++++++++---------------- static/map.js | 47 +++++++++++----- 2 files changed, 135 insertions(+), 61 deletions(-) diff --git a/main.go b/main.go index b6223ec..05a3cc0 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,33 @@ import ( _ "modernc.org/sqlite" ) +// gzipResponseWriter wraps http.ResponseWriter and compresses the response body +// with gzip when the client supports it. +type gzipResponseWriter struct { + http.ResponseWriter + gz *gzip.Writer +} + +func (g *gzipResponseWriter) Write(b []byte) (int, error) { + return g.gz.Write(b) +} + +// withGzip wraps an http.HandlerFunc so that responses are gzip-compressed when +// the client sends Accept-Encoding: gzip. +func withGzip(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + h(w, r) + return + } + w.Header().Set("Content-Encoding", "gzip") + w.Header().Set("Vary", "Accept-Encoding") + gz := gzip.NewWriter(w) + defer gz.Close() + h(&gzipResponseWriter{ResponseWriter: w, gz: gz}, r) + } +} + //go:embed static/* var staticFiles embed.FS @@ -83,10 +110,14 @@ type StatsResponse struct { Snapshots []Snapshot `json:"snapshots"` } -// In-memory cache +// In-memory cache. All fields are populated atomically under cacheMu.Lock(). var ( - cacheMu sync.RWMutex - cachedResp *StationsResponse + cacheMu sync.RWMutex + cachedResp *StationsResponse + cachedStationsJSON []byte // pre-serialised resp.Stations + cachedDeltasJSON []byte // pre-serialised buildDeltas() result + cachedRegions []string // sorted, deduplicated region names + cachedLastUpdated string // human-readable French date string ) // Fetch cooldown state: prevents request-triggered fetches from @@ -134,9 +165,9 @@ func main() { // HTML page routes http.HandleFunc("/", handleRoot) - http.HandleFunc("/map", handleMapPage) - http.HandleFunc("/stats", handleStatsPage) - http.HandleFunc("/stats/content", handleStatsContent) + http.HandleFunc("/map", withGzip(handleMapPage)) + http.HandleFunc("/stats", withGzip(handleStatsPage)) + http.HandleFunc("/stats/content", withGzip(handleStatsContent)) // JSON API routes (kept for backwards-compatibility) http.HandleFunc("/api/stations", handleStations) @@ -145,10 +176,18 @@ func main() { http.HandleFunc("/api/stats/region", handleRegionStats) http.HandleFunc("/api/station-deltas", handleStationDeltas) + // staticHandler serves embedded static assets with a 1-day Cache-Control header. + // The assets are immutable per binary build, so a long TTL is safe. + staticFS := http.FileServer(http.FS(staticSub)) + staticHandler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=86400") + staticFS.ServeHTTP(w, r) + } + // Static assets (style.css, map.js, stats.js, etc.) - http.Handle("/style.css", http.FileServer(http.FS(staticSub))) - http.Handle("/map.js", http.FileServer(http.FS(staticSub))) - http.Handle("/stats.js", http.FileServer(http.FS(staticSub))) + http.HandleFunc("/style.css", staticHandler) + http.HandleFunc("/map.js", staticHandler) + http.HandleFunc("/stats.js", staticHandler) log.Printf("Listening on http://localhost:%s", port) log.Fatal(http.ListenAndServe(":"+port, nil)) @@ -243,6 +282,10 @@ func handleMapPage(w http.ResponseWriter, r *http.Request) { cacheMu.RLock() resp := cachedResp + stationsJSON := cachedStationsJSON + deltasJSON := cachedDeltasJSON + regions := cachedRegions + lastUpdated := cachedLastUpdated cacheMu.RUnlock() if resp == nil { @@ -251,45 +294,6 @@ func handleMapPage(w http.ResponseWriter, r *http.Request) { return } - // Build sorted unique region list from in-memory station data. - regionSet := map[string]struct{}{} - for _, s := range resp.Stations { - if s.Region != "" { - regionSet[s.Region] = struct{}{} - } - } - regions := make([]string, 0, len(regionSet)) - for r := range regionSet { - regions = append(regions, r) - } - sort.Strings(regions) - - // Encode station and delta data for inline script injection. - stationsJSON, err := json.Marshal(resp.Stations) - if err != nil { - http.Error(w, "encoding stations", http.StatusInternalServerError) - return - } - - deltas, err := buildDeltas() - if err != nil { - log.Printf("build deltas: %v", err) - deltas = map[string]StationDelta{} - } - deltasJSON, err := json.Marshal(deltas) - if err != nil { - http.Error(w, "encoding deltas", http.StatusInternalServerError) - return - } - - lastUpdated := "" - if resp.LastUpdated != "" { - if t, err := time.Parse(time.RFC3339, resp.LastUpdated); err == nil { - lastUpdated = "Dernière mise à jour: " + t.In(time.FixedZone("ET", -4*3600)). - Format("2 January 2006, 15:04") - } - } - data := mapPageData{ Page: "map", StationsJSON: template.JS(stationsJSON), @@ -784,6 +788,55 @@ func fetchAndStore() { return } + // Pre-compute derived data that every map page render needs. + // This runs once per fetch (every ~5 min) rather than on every HTTP request. + + // Serialise stations JSON. + stationsJSON, err := json.Marshal(resp.Stations) + if err != nil { + log.Printf("marshal stations: %v", err) + stationsJSON = []byte("[]") + } + + // Build and serialise deltas JSON. + deltas, err := buildDeltas() + if err != nil { + log.Printf("build deltas: %v", err) + deltas = map[string]StationDelta{} + } + deltasJSON, err := json.Marshal(deltas) + if err != nil { + log.Printf("marshal deltas: %v", err) + deltasJSON = []byte("{}") + } + + // Build sorted region list. + regionSet := map[string]struct{}{} + for _, s := range resp.Stations { + if s.Region != "" { + regionSet[s.Region] = struct{}{} + } + } + regions := make([]string, 0, len(regionSet)) + for reg := range regionSet { + regions = append(regions, reg) + } + sort.Strings(regions) + + // Format last-updated string in French. + lastUpdated := "" + if t, parseErr := time.Parse(time.RFC3339, resp.LastUpdated); parseErr == nil { + lastUpdated = "Dernière mise à jour: " + t.In(time.FixedZone("ET", -4*3600)). + Format("2 January 2006, 15:04") + } + + cacheMu.Lock() + cachedStationsJSON = stationsJSON + cachedDeltasJSON = deltasJSON + cachedRegions = regions + cachedLastUpdated = lastUpdated + cacheMu.Unlock() + // Compute and persist global aggregate. snap := computeSnapshot(resp) diff --git a/static/map.js b/static/map.js index 7bf63e0..5b175d8 100644 --- a/static/map.js +++ b/static/map.js @@ -119,8 +119,8 @@ return true; }); const prices = scopedStations.map(s => s[currentFuel]).filter(p => p > 0); - minPrice = prices.length ? Math.min(...prices) : 0; - maxPrice = prices.length ? Math.max(...prices) : 300; + minPrice = prices.length ? prices.reduce((m, p) => p < m ? p : m, prices[0]) : 0; + maxPrice = prices.length ? prices.reduce((m, p) => p > m ? p : m, prices[0]) : 300; const slider = document.getElementById('price-slider'); slider.min = Math.floor(minPrice); @@ -136,7 +136,15 @@ document.getElementById('max-price').textContent = maxPrice.toFixed(1) + '¢'; const GREY = '#9ca3af'; - allMarkers = allStations.map(s => { + // Only create Leaflet marker objects for stations that pass the current + // region/Costco filter — avoids allocating ~3000 markers when a region is selected. + allMarkers = allStations.map((s, i) => { + const inRegion = !currentRegion || s.region === currentRegion; + const isCostco = (s.brand || '').toLowerCase() === 'costco'; + if (!inRegion || (isCostco && !showCostco)) { + return null; + } + const price = s[currentFuel]; const fill = price > 0 ? priceColor(price, minPrice, maxPrice) : GREY; const label = price > 0 ? price.toFixed(1) : '—'; @@ -146,14 +154,24 @@ }); const marker = L.marker([s.lat, s.lng], { icon }); marker.fuelPrices = { regular: s.regular, super: s.super, diesel: s.diesel }; - const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(s.address + ', Québec')}`; - const d = stationDeltas[s.address] || {}; - const eh = d.elapsedHours != null ? d.elapsedHours : null; - let popup = `${s.name}
${brandBadgeHtml(s.brand)}
${s.address}
`; - popup += `
Régulier: ${s.regular.toFixed(1)}¢/L${priceDeltaHtml(d.regular, eh)}`; - if (s.super > 0) popup += `
Super: ${s.super.toFixed(1)}¢/L${priceDeltaHtml(d.super, eh)}`; - if (s.diesel > 0) popup += `
Diesel: ${s.diesel.toFixed(1)}¢/L${priceDeltaHtml(d.diesel, eh)}`; - marker.bindPopup(popup); + + // Lazily build popup HTML on first open to avoid doing string work + // for every station during initial render. + let popupBuilt = false; + marker.on('popupopen', function () { + if (popupBuilt) return; + popupBuilt = true; + const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(s.address + ', Québec')}`; + const d = stationDeltas[s.address] || {}; + const eh = d.elapsedHours != null ? d.elapsedHours : null; + let popup = `${s.name}
${brandBadgeHtml(s.brand)}
${s.address}
`; + popup += `
Régulier: ${s.regular.toFixed(1)}¢/L${priceDeltaHtml(d.regular, eh)}`; + if (s.super > 0) popup += `
Super: ${s.super.toFixed(1)}¢/L${priceDeltaHtml(d.super, eh)}`; + if (s.diesel > 0) popup += `
Diesel: ${s.diesel.toFixed(1)}¢/L${priceDeltaHtml(d.diesel, eh)}`; + marker.getPopup().setContent(popup); + }); + marker.bindPopup(''); + return marker; }); @@ -165,15 +183,18 @@ const toRemove = []; allStations.forEach((s, i) => { + const marker = allMarkers[i]; + // Markers for filtered-out stations (wrong region/Costco) are null. + if (!marker) return; const price = s[currentFuel]; const inRegion = !currentRegion || s.region === currentRegion; const isCostco = (s.brand || '').toLowerCase() === 'costco'; const visible = inRegion && (price <= 0 || price <= priceMax) && (!isCostco || showCostco); if (visible && !visibleSet.has(i)) { - toAdd.push(allMarkers[i]); + toAdd.push(marker); visibleSet.add(i); } else if (!visible && visibleSet.has(i)) { - toRemove.push(allMarkers[i]); + toRemove.push(marker); visibleSet.delete(i); } });