remove web dependencies
This commit is contained in:
@@ -47,6 +47,7 @@ func runWebServer(ctx context.Context, d *db.DB, site providers.Site, model, add
|
|||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
staticFiles := http.FileServer(http.FS(sub))
|
||||||
mux.HandleFunc("/api/dashboard", ws.handleDashboard)
|
mux.HandleFunc("/api/dashboard", ws.handleDashboard)
|
||||||
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -58,7 +59,8 @@ func runWebServer(ctx context.Context, d *db.DB, site providers.Site, model, add
|
|||||||
mux.HandleFunc("/chart/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/chart/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
serveIndex(w, sub)
|
serveIndex(w, sub)
|
||||||
})
|
})
|
||||||
mux.Handle("/", http.FileServer(http.FS(sub)))
|
mux.Handle("/vendor/", withCacheControl("public, max-age=31536000, immutable", staticFiles))
|
||||||
|
mux.Handle("/", staticFiles)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
@@ -196,3 +198,10 @@ func parseTimeParam(v string) (time.Time, error) {
|
|||||||
}
|
}
|
||||||
return time.Time{}, errors.New("unsupported time format")
|
return time.Time{}, errors.New("unsupported time format")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withCacheControl(cacheControl string, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", cacheControl)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -363,6 +363,10 @@ function sum(values) {
|
|||||||
return seen ? total : null;
|
return seen ? total : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
function updateText(id, text) {
|
function updateText(id, text) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.textContent = text;
|
if (el) el.textContent = text;
|
||||||
@@ -430,48 +434,123 @@ function lastNonNull(points, key) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeRainProbability(latest, pressureTrend1h) {
|
function classifyRainProbability(prob) {
|
||||||
if (!latest) {
|
if (prob >= 0.6) return "High";
|
||||||
|
if (prob >= 0.35) return "Medium";
|
||||||
|
return "Low";
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDewPointC(tempC, rh) {
|
||||||
|
if (tempC === null || tempC === undefined || rh === null || rh === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const safeRh = clamp(rh, 1, 100);
|
||||||
|
const a = 17.625;
|
||||||
|
const b = 243.04;
|
||||||
|
const gamma = Math.log(safeRh / 100) + (a * tempC) / (b + tempC);
|
||||||
|
return (b * gamma) / (a - gamma);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRainProbabilityFromInputs(tempC, rh, pressureHpa) {
|
||||||
|
if (tempC === null || tempC === undefined || rh === null || rh === undefined || pressureHpa === null || pressureHpa === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let prob = 0.1;
|
const dewPointC = computeDewPointC(tempC, rh);
|
||||||
if (pressureTrend1h !== null && pressureTrend1h !== undefined) {
|
if (dewPointC === null) {
|
||||||
if (pressureTrend1h <= -3.0) {
|
return null;
|
||||||
prob += 0.5;
|
|
||||||
} else if (pressureTrend1h <= -2.0) {
|
|
||||||
prob += 0.35;
|
|
||||||
} else if (pressureTrend1h <= -1.0) {
|
|
||||||
prob += 0.2;
|
|
||||||
} else if (pressureTrend1h <= -0.5) {
|
|
||||||
prob += 0.1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (latest.rh !== null && latest.rh !== undefined) {
|
const saturationSpread = Math.max(0, tempC - dewPointC);
|
||||||
if (latest.rh >= 95) {
|
const humidityFactor = clamp((rh - 55) / 45, 0, 1);
|
||||||
prob += 0.2;
|
const pressureFactor = clamp((1016 - pressureHpa) / 18, 0, 1);
|
||||||
} else if (latest.rh >= 90) {
|
const saturationFactor = clamp((6 - saturationSpread) / 6, 0, 1);
|
||||||
prob += 0.15;
|
|
||||||
} else if (latest.rh >= 85) {
|
const score = 0.45 * humidityFactor + 0.35 * pressureFactor + 0.2 * saturationFactor;
|
||||||
prob += 0.1;
|
const prob = clamp(0.02 + 0.93 * score, 0.02, 0.98);
|
||||||
|
|
||||||
|
return { prob, label: classifyRainProbability(prob) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRainProbability(latest) {
|
||||||
|
if (!latest) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
return computeRainProbabilityFromInputs(latest.temp_c, latest.rh, latest.pressure_hpa);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRainProbabilitySeries(points) {
|
||||||
|
const out = [];
|
||||||
|
for (const p of points) {
|
||||||
|
const t = new Date(p.ts).getTime();
|
||||||
|
if (Number.isNaN(t)) continue;
|
||||||
|
const rp = computeRainProbabilityFromInputs(p.temp_c, p.rh, p.pressure_hpa);
|
||||||
|
out.push({
|
||||||
|
x: t,
|
||||||
|
y: rp ? Math.round(rp.prob * 1000) / 10 : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWeatherIcons(latest, rainProb) {
|
||||||
|
const sunEl = document.getElementById("live-icon-sun");
|
||||||
|
const cloudEl = document.getElementById("live-icon-cloud");
|
||||||
|
const rainEl = document.getElementById("live-icon-rain");
|
||||||
|
const textEl = document.getElementById("live-weather-text");
|
||||||
|
|
||||||
|
[sunEl, cloudEl, rainEl].forEach((el) => {
|
||||||
|
if (el) el.classList.remove("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!latest) {
|
||||||
|
if (textEl) textEl.textContent = "--";
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (latest.wind_m_s !== null && latest.wind_m_s !== undefined && latest.wind_m_s >= 6) {
|
const prob = rainProb ? rainProb.prob : null;
|
||||||
prob += 0.05;
|
const rh = latest.rh;
|
||||||
|
const pressure = latest.pressure_hpa;
|
||||||
|
const uvi = latest.uvi;
|
||||||
|
|
||||||
|
let sunActive = false;
|
||||||
|
let cloudActive = false;
|
||||||
|
let rainActive = false;
|
||||||
|
let label = "Partly cloudy";
|
||||||
|
|
||||||
|
if (prob !== null && prob >= 0.6) {
|
||||||
|
rainActive = true;
|
||||||
|
cloudActive = true;
|
||||||
|
label = "Rain likely";
|
||||||
|
} else if (
|
||||||
|
(prob !== null && prob >= 0.35) ||
|
||||||
|
(rh !== null && rh !== undefined && rh >= 80) ||
|
||||||
|
(pressure !== null && pressure !== undefined && pressure <= 1008)
|
||||||
|
) {
|
||||||
|
cloudActive = true;
|
||||||
|
label = "Cloudy";
|
||||||
|
} else if (
|
||||||
|
uvi !== null &&
|
||||||
|
uvi !== undefined &&
|
||||||
|
uvi >= 4 &&
|
||||||
|
rh !== null &&
|
||||||
|
rh !== undefined &&
|
||||||
|
rh < 75 &&
|
||||||
|
pressure !== null &&
|
||||||
|
pressure !== undefined &&
|
||||||
|
pressure >= 1012
|
||||||
|
) {
|
||||||
|
sunActive = true;
|
||||||
|
label = "Sunny";
|
||||||
|
} else {
|
||||||
|
sunActive = true;
|
||||||
|
cloudActive = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
prob = Math.max(0.05, Math.min(0.95, prob));
|
if (sunEl) sunEl.classList.toggle("active", sunActive);
|
||||||
|
if (cloudEl) cloudEl.classList.toggle("active", cloudActive);
|
||||||
let label = "Low";
|
if (rainEl) rainEl.classList.toggle("active", rainActive);
|
||||||
if (prob >= 0.6) {
|
if (textEl) textEl.textContent = label;
|
||||||
label = "High";
|
|
||||||
} else if (prob >= 0.35) {
|
|
||||||
label = "Medium";
|
|
||||||
}
|
|
||||||
|
|
||||||
return { prob, label };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extendForecastTo(points, endTime) {
|
function extendForecastTo(points, endTime) {
|
||||||
@@ -607,12 +686,13 @@ function renderDashboard(data) {
|
|||||||
const forecast = filterRange(forecastAll, rangeStart, rangeEnd);
|
const forecast = filterRange(forecastAll, rangeStart, rangeEnd);
|
||||||
const forecastLine = extendForecastTo(forecast, rangeEnd);
|
const forecastLine = extendForecastTo(forecast, rangeEnd);
|
||||||
const lastPressureTrend = lastNonNull(obsFiltered, "pressure_trend_1h");
|
const lastPressureTrend = lastNonNull(obsFiltered, "pressure_trend_1h");
|
||||||
const rainProb = computeRainProbability(latest, lastPressureTrend);
|
const rainProb = computeRainProbability(latest);
|
||||||
if (rainProb) {
|
if (rainProb) {
|
||||||
updateText("live-rain-prob", `${Math.round(rainProb.prob * 100)}% (${rainProb.label})`);
|
updateText("live-rain-prob", `${Math.round(rainProb.prob * 100)}% (${rainProb.label})`);
|
||||||
} else {
|
} else {
|
||||||
updateText("live-rain-prob", "--");
|
updateText("live-rain-prob", "--");
|
||||||
}
|
}
|
||||||
|
updateWeatherIcons(latest, rainProb);
|
||||||
updateText("baro-outlook", describeBarometer(latest ? latest.pressure_hpa : null, lastPressureTrend));
|
updateText("baro-outlook", describeBarometer(latest ? latest.pressure_hpa : null, lastPressureTrend));
|
||||||
|
|
||||||
const obsTemps = obsFiltered.map((p) => p.temp_c);
|
const obsTemps = obsFiltered.map((p) => p.temp_c);
|
||||||
@@ -757,9 +837,12 @@ function renderDashboard(data) {
|
|||||||
upsertChart("chart-power", powerChart);
|
upsertChart("chart-power", powerChart);
|
||||||
|
|
||||||
const rainOptions = baseOptions(range);
|
const rainOptions = baseOptions(range);
|
||||||
|
rainOptions.scales.y.ticks.color = colors.rain;
|
||||||
|
rainOptions.scales.y.title = { display: true, text: "Observed Rain (mm)", color: colors.rain };
|
||||||
rainOptions.scales.y1 = {
|
rainOptions.scales.y1 = {
|
||||||
position: "right",
|
position: "right",
|
||||||
ticks: { color: "#a4c4c4" },
|
ticks: { color: colors.forecast },
|
||||||
|
title: { display: true, text: "Forecast Rain (mm)", color: colors.forecast },
|
||||||
grid: { drawOnChartArea: false },
|
grid: { drawOnChartArea: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -788,7 +871,7 @@ function renderDashboard(data) {
|
|||||||
label: "forecast precip (mm)",
|
label: "forecast precip (mm)",
|
||||||
data: series(forecastRain, "precip_mm"),
|
data: series(forecastRain, "precip_mm"),
|
||||||
backgroundColor: colors.forecast,
|
backgroundColor: colors.forecast,
|
||||||
yAxisID: "y",
|
yAxisID: "y1",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -796,6 +879,31 @@ function renderDashboard(data) {
|
|||||||
};
|
};
|
||||||
upsertChart("chart-rain", rainChart);
|
upsertChart("chart-rain", rainChart);
|
||||||
|
|
||||||
|
const rainProbOptions = baseOptions(range);
|
||||||
|
rainProbOptions.scales.y.min = 0;
|
||||||
|
rainProbOptions.scales.y.max = 100;
|
||||||
|
rainProbOptions.scales.y.ticks.color = colors.rain;
|
||||||
|
rainProbOptions.scales.y.ticks.callback = (value) => `${value}%`;
|
||||||
|
rainProbOptions.scales.y.title = { display: true, text: "Probability (%)", color: colors.rain };
|
||||||
|
|
||||||
|
const rainProbChart = {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "predicted rain probability (%)",
|
||||||
|
data: buildRainProbabilitySeries(obsFiltered),
|
||||||
|
borderColor: colors.rain,
|
||||||
|
backgroundColor: "rgba(78, 168, 222, 0.18)",
|
||||||
|
fill: true,
|
||||||
|
yAxisID: "y",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: rainProbOptions,
|
||||||
|
};
|
||||||
|
upsertChart("chart-rain-prob", rainProbChart);
|
||||||
|
|
||||||
|
|
||||||
updateSingleChartMode();
|
updateSingleChartMode();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,7 @@
|
|||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<link rel="manifest" href="/site.webmanifest">
|
<link rel="manifest" href="/site.webmanifest">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="stylesheet" href="/vendor/fonts/fonts.css">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Space+Grotesk:wght@400;600&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="/styles.css">
|
<link rel="stylesheet" href="/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -50,6 +48,15 @@
|
|||||||
<div class="panel-meta" id="last-updated">--</div>
|
<div class="panel-meta" id="last-updated">--</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="live-grid">
|
<div class="live-grid">
|
||||||
|
<div class="metric metric-weather">
|
||||||
|
<div class="label">Weather</div>
|
||||||
|
<div class="weather-icons" aria-label="weather icons">
|
||||||
|
<span class="weather-icon" id="live-icon-sun" title="Sunny">☀️</span>
|
||||||
|
<span class="weather-icon" id="live-icon-cloud" title="Cloudy">☁️</span>
|
||||||
|
<span class="weather-icon" id="live-icon-rain" title="Rain">🌧️</span>
|
||||||
|
</div>
|
||||||
|
<div class="weather-caption" id="live-weather-text">--</div>
|
||||||
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<div class="label">Temp C</div>
|
<div class="label">Temp C</div>
|
||||||
<div class="value" id="live-temp">--</div>
|
<div class="value" id="live-temp">--</div>
|
||||||
@@ -189,12 +196,21 @@
|
|||||||
<canvas id="chart-rain"></canvas>
|
<canvas id="chart-rain"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="chart-card" data-chart="chart-rain-prob">
|
||||||
|
<div class="chart-header">
|
||||||
|
<div class="chart-title">Predicted Rain Probability (Observed Inputs)</div>
|
||||||
|
<button class="chart-link" data-chart="chart-rain-prob" title="Copy chart link">Share</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart-canvas">
|
||||||
|
<canvas id="chart-rain-prob"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
<script src="/vendor/js/chart.umd.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
<script src="/vendor/js/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -177,6 +177,38 @@ body {
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric-weather .label {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-icons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.28;
|
||||||
|
filter: saturate(0.65);
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease, filter 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-icon.active {
|
||||||
|
opacity: 1;
|
||||||
|
filter: saturate(1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-caption {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.callouts {
|
.callouts {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
31
cmd/ingestd/web/vendor/fonts/fonts.css
vendored
Normal file
31
cmd/ingestd/web/vendor/fonts/fonts.css
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: "IBM Plex Mono";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/vendor/fonts/ibm-plex-mono-400.ttf") format("truetype");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IBM Plex Mono";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/vendor/fonts/ibm-plex-mono-600.ttf") format("truetype");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Space Grotesk";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/vendor/fonts/space-grotesk-400.ttf") format("truetype");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Space Grotesk";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/vendor/fonts/space-grotesk-600.ttf") format("truetype");
|
||||||
|
}
|
||||||
BIN
cmd/ingestd/web/vendor/fonts/ibm-plex-mono-400.ttf
vendored
Normal file
BIN
cmd/ingestd/web/vendor/fonts/ibm-plex-mono-400.ttf
vendored
Normal file
Binary file not shown.
BIN
cmd/ingestd/web/vendor/fonts/ibm-plex-mono-600.ttf
vendored
Normal file
BIN
cmd/ingestd/web/vendor/fonts/ibm-plex-mono-600.ttf
vendored
Normal file
Binary file not shown.
BIN
cmd/ingestd/web/vendor/fonts/space-grotesk-400.ttf
vendored
Normal file
BIN
cmd/ingestd/web/vendor/fonts/space-grotesk-400.ttf
vendored
Normal file
Binary file not shown.
BIN
cmd/ingestd/web/vendor/fonts/space-grotesk-600.ttf
vendored
Normal file
BIN
cmd/ingestd/web/vendor/fonts/space-grotesk-600.ttf
vendored
Normal file
Binary file not shown.
20
cmd/ingestd/web/vendor/js/chart.umd.min.js
vendored
Normal file
20
cmd/ingestd/web/vendor/js/chart.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
cmd/ingestd/web/vendor/js/chartjs-adapter-date-fns.bundle.min.js
vendored
Normal file
7
cmd/ingestd/web/vendor/js/chartjs-adapter-date-fns.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user