optimise request
This commit is contained in:
parent
0a62520dbc
commit
f873bc1c77
2 changed files with 135 additions and 61 deletions
149
main.go
149
main.go
|
|
@ -21,6 +21,33 @@ import (
|
||||||
_ "modernc.org/sqlite"
|
_ "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/*
|
//go:embed static/*
|
||||||
var staticFiles embed.FS
|
var staticFiles embed.FS
|
||||||
|
|
||||||
|
|
@ -83,10 +110,14 @@ type StatsResponse struct {
|
||||||
Snapshots []Snapshot `json:"snapshots"`
|
Snapshots []Snapshot `json:"snapshots"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-memory cache
|
// In-memory cache. All fields are populated atomically under cacheMu.Lock().
|
||||||
var (
|
var (
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
cachedResp *StationsResponse
|
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
|
// Fetch cooldown state: prevents request-triggered fetches from
|
||||||
|
|
@ -134,9 +165,9 @@ func main() {
|
||||||
|
|
||||||
// HTML page routes
|
// HTML page routes
|
||||||
http.HandleFunc("/", handleRoot)
|
http.HandleFunc("/", handleRoot)
|
||||||
http.HandleFunc("/map", handleMapPage)
|
http.HandleFunc("/map", withGzip(handleMapPage))
|
||||||
http.HandleFunc("/stats", handleStatsPage)
|
http.HandleFunc("/stats", withGzip(handleStatsPage))
|
||||||
http.HandleFunc("/stats/content", handleStatsContent)
|
http.HandleFunc("/stats/content", withGzip(handleStatsContent))
|
||||||
|
|
||||||
// JSON API routes (kept for backwards-compatibility)
|
// JSON API routes (kept for backwards-compatibility)
|
||||||
http.HandleFunc("/api/stations", handleStations)
|
http.HandleFunc("/api/stations", handleStations)
|
||||||
|
|
@ -145,10 +176,18 @@ func main() {
|
||||||
http.HandleFunc("/api/stats/region", handleRegionStats)
|
http.HandleFunc("/api/stats/region", handleRegionStats)
|
||||||
http.HandleFunc("/api/station-deltas", handleStationDeltas)
|
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.)
|
// Static assets (style.css, map.js, stats.js, etc.)
|
||||||
http.Handle("/style.css", http.FileServer(http.FS(staticSub)))
|
http.HandleFunc("/style.css", staticHandler)
|
||||||
http.Handle("/map.js", http.FileServer(http.FS(staticSub)))
|
http.HandleFunc("/map.js", staticHandler)
|
||||||
http.Handle("/stats.js", http.FileServer(http.FS(staticSub)))
|
http.HandleFunc("/stats.js", staticHandler)
|
||||||
|
|
||||||
log.Printf("Listening on http://localhost:%s", port)
|
log.Printf("Listening on http://localhost:%s", port)
|
||||||
log.Fatal(http.ListenAndServe(":"+port, nil))
|
log.Fatal(http.ListenAndServe(":"+port, nil))
|
||||||
|
|
@ -243,6 +282,10 @@ func handleMapPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
cacheMu.RLock()
|
cacheMu.RLock()
|
||||||
resp := cachedResp
|
resp := cachedResp
|
||||||
|
stationsJSON := cachedStationsJSON
|
||||||
|
deltasJSON := cachedDeltasJSON
|
||||||
|
regions := cachedRegions
|
||||||
|
lastUpdated := cachedLastUpdated
|
||||||
cacheMu.RUnlock()
|
cacheMu.RUnlock()
|
||||||
|
|
||||||
if resp == nil {
|
if resp == nil {
|
||||||
|
|
@ -251,45 +294,6 @@ func handleMapPage(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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{
|
data := mapPageData{
|
||||||
Page: "map",
|
Page: "map",
|
||||||
StationsJSON: template.JS(stationsJSON),
|
StationsJSON: template.JS(stationsJSON),
|
||||||
|
|
@ -784,6 +788,55 @@ func fetchAndStore() {
|
||||||
return
|
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.
|
// Compute and persist global aggregate.
|
||||||
snap := computeSnapshot(resp)
|
snap := computeSnapshot(resp)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,8 +119,8 @@
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
const prices = scopedStations.map(s => s[currentFuel]).filter(p => p > 0);
|
const prices = scopedStations.map(s => s[currentFuel]).filter(p => p > 0);
|
||||||
minPrice = prices.length ? Math.min(...prices) : 0;
|
minPrice = prices.length ? prices.reduce((m, p) => p < m ? p : m, prices[0]) : 0;
|
||||||
maxPrice = prices.length ? Math.max(...prices) : 300;
|
maxPrice = prices.length ? prices.reduce((m, p) => p > m ? p : m, prices[0]) : 300;
|
||||||
|
|
||||||
const slider = document.getElementById('price-slider');
|
const slider = document.getElementById('price-slider');
|
||||||
slider.min = Math.floor(minPrice);
|
slider.min = Math.floor(minPrice);
|
||||||
|
|
@ -136,7 +136,15 @@
|
||||||
document.getElementById('max-price').textContent = maxPrice.toFixed(1) + '¢';
|
document.getElementById('max-price').textContent = maxPrice.toFixed(1) + '¢';
|
||||||
|
|
||||||
const GREY = '#9ca3af';
|
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 price = s[currentFuel];
|
||||||
const fill = price > 0 ? priceColor(price, minPrice, maxPrice) : GREY;
|
const fill = price > 0 ? priceColor(price, minPrice, maxPrice) : GREY;
|
||||||
const label = price > 0 ? price.toFixed(1) : '—';
|
const label = price > 0 ? price.toFixed(1) : '—';
|
||||||
|
|
@ -146,14 +154,24 @@
|
||||||
});
|
});
|
||||||
const marker = L.marker([s.lat, s.lng], { icon });
|
const marker = L.marker([s.lat, s.lng], { icon });
|
||||||
marker.fuelPrices = { regular: s.regular, super: s.super, diesel: s.diesel };
|
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] || {};
|
// Lazily build popup HTML on first open to avoid doing string work
|
||||||
const eh = d.elapsedHours != null ? d.elapsedHours : null;
|
// for every station during initial render.
|
||||||
let popup = `<strong>${s.name}</strong><br>${brandBadgeHtml(s.brand)}<br><a href="${mapsUrl}" target="_blank" rel="noopener">${s.address}</a><br>`;
|
let popupBuilt = false;
|
||||||
popup += `<br><strong>Régulier:</strong> ${s.regular.toFixed(1)}¢/L${priceDeltaHtml(d.regular, eh)}`;
|
marker.on('popupopen', function () {
|
||||||
if (s.super > 0) popup += `<br><strong>Super:</strong> ${s.super.toFixed(1)}¢/L${priceDeltaHtml(d.super, eh)}`;
|
if (popupBuilt) return;
|
||||||
if (s.diesel > 0) popup += `<br><strong>Diesel:</strong> ${s.diesel.toFixed(1)}¢/L${priceDeltaHtml(d.diesel, eh)}`;
|
popupBuilt = true;
|
||||||
marker.bindPopup(popup);
|
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 = `<strong>${s.name}</strong><br>${brandBadgeHtml(s.brand)}<br><a href="${mapsUrl}" target="_blank" rel="noopener">${s.address}</a><br>`;
|
||||||
|
popup += `<br><strong>Régulier:</strong> ${s.regular.toFixed(1)}¢/L${priceDeltaHtml(d.regular, eh)}`;
|
||||||
|
if (s.super > 0) popup += `<br><strong>Super:</strong> ${s.super.toFixed(1)}¢/L${priceDeltaHtml(d.super, eh)}`;
|
||||||
|
if (s.diesel > 0) popup += `<br><strong>Diesel:</strong> ${s.diesel.toFixed(1)}¢/L${priceDeltaHtml(d.diesel, eh)}`;
|
||||||
|
marker.getPopup().setContent(popup);
|
||||||
|
});
|
||||||
|
marker.bindPopup('');
|
||||||
|
|
||||||
return marker;
|
return marker;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -165,15 +183,18 @@
|
||||||
const toRemove = [];
|
const toRemove = [];
|
||||||
|
|
||||||
allStations.forEach((s, i) => {
|
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 price = s[currentFuel];
|
||||||
const inRegion = !currentRegion || s.region === currentRegion;
|
const inRegion = !currentRegion || s.region === currentRegion;
|
||||||
const isCostco = (s.brand || '').toLowerCase() === 'costco';
|
const isCostco = (s.brand || '').toLowerCase() === 'costco';
|
||||||
const visible = inRegion && (price <= 0 || price <= priceMax) && (!isCostco || showCostco);
|
const visible = inRegion && (price <= 0 || price <= priceMax) && (!isCostco || showCostco);
|
||||||
if (visible && !visibleSet.has(i)) {
|
if (visible && !visibleSet.has(i)) {
|
||||||
toAdd.push(allMarkers[i]);
|
toAdd.push(marker);
|
||||||
visibleSet.add(i);
|
visibleSet.add(i);
|
||||||
} else if (!visible && visibleSet.has(i)) {
|
} else if (!visible && visibleSet.has(i)) {
|
||||||
toRemove.push(allMarkers[i]);
|
toRemove.push(marker);
|
||||||
visibleSet.delete(i);
|
visibleSet.delete(i);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue