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);
}
});