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"
|
||||
)
|
||||
|
||||
// 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = `<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.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 = `<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;
|
||||
});
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue