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()
|
||||
staticFiles := http.FileServer(http.FS(sub))
|
||||
mux.HandleFunc("/api/dashboard", ws.handleDashboard)
|
||||
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
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{
|
||||
Addr: addr,
|
||||
@@ -196,3 +198,10 @@ func parseTimeParam(v string) (time.Time, error) {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function updateText(id, text) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = text;
|
||||
@@ -430,48 +434,123 @@ function lastNonNull(points, key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function computeRainProbability(latest, pressureTrend1h) {
|
||||
if (!latest) {
|
||||
function classifyRainProbability(prob) {
|
||||
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;
|
||||
}
|
||||
|
||||
let prob = 0.1;
|
||||
if (pressureTrend1h !== null && pressureTrend1h !== undefined) {
|
||||
if (pressureTrend1h <= -3.0) {
|
||||
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;
|
||||
}
|
||||
const dewPointC = computeDewPointC(tempC, rh);
|
||||
if (dewPointC === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (latest.rh !== null && latest.rh !== undefined) {
|
||||
if (latest.rh >= 95) {
|
||||
prob += 0.2;
|
||||
} else if (latest.rh >= 90) {
|
||||
prob += 0.15;
|
||||
} else if (latest.rh >= 85) {
|
||||
prob += 0.1;
|
||||
const saturationSpread = Math.max(0, tempC - dewPointC);
|
||||
const humidityFactor = clamp((rh - 55) / 45, 0, 1);
|
||||
const pressureFactor = clamp((1016 - pressureHpa) / 18, 0, 1);
|
||||
const saturationFactor = clamp((6 - saturationSpread) / 6, 0, 1);
|
||||
|
||||
const score = 0.45 * humidityFactor + 0.35 * pressureFactor + 0.2 * saturationFactor;
|
||||
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) {
|
||||
prob += 0.05;
|
||||
const prob = rainProb ? rainProb.prob : null;
|
||||
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));
|
||||
|
||||
let label = "Low";
|
||||
if (prob >= 0.6) {
|
||||
label = "High";
|
||||
} else if (prob >= 0.35) {
|
||||
label = "Medium";
|
||||
}
|
||||
|
||||
return { prob, label };
|
||||
if (sunEl) sunEl.classList.toggle("active", sunActive);
|
||||
if (cloudEl) cloudEl.classList.toggle("active", cloudActive);
|
||||
if (rainEl) rainEl.classList.toggle("active", rainActive);
|
||||
if (textEl) textEl.textContent = label;
|
||||
}
|
||||
|
||||
function extendForecastTo(points, endTime) {
|
||||
@@ -607,12 +686,13 @@ function renderDashboard(data) {
|
||||
const forecast = filterRange(forecastAll, rangeStart, rangeEnd);
|
||||
const forecastLine = extendForecastTo(forecast, rangeEnd);
|
||||
const lastPressureTrend = lastNonNull(obsFiltered, "pressure_trend_1h");
|
||||
const rainProb = computeRainProbability(latest, lastPressureTrend);
|
||||
const rainProb = computeRainProbability(latest);
|
||||
if (rainProb) {
|
||||
updateText("live-rain-prob", `${Math.round(rainProb.prob * 100)}% (${rainProb.label})`);
|
||||
} else {
|
||||
updateText("live-rain-prob", "--");
|
||||
}
|
||||
updateWeatherIcons(latest, rainProb);
|
||||
updateText("baro-outlook", describeBarometer(latest ? latest.pressure_hpa : null, lastPressureTrend));
|
||||
|
||||
const obsTemps = obsFiltered.map((p) => p.temp_c);
|
||||
@@ -757,9 +837,12 @@ function renderDashboard(data) {
|
||||
upsertChart("chart-power", powerChart);
|
||||
|
||||
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 = {
|
||||
position: "right",
|
||||
ticks: { color: "#a4c4c4" },
|
||||
ticks: { color: colors.forecast },
|
||||
title: { display: true, text: "Forecast Rain (mm)", color: colors.forecast },
|
||||
grid: { drawOnChartArea: false },
|
||||
};
|
||||
|
||||
@@ -788,7 +871,7 @@ function renderDashboard(data) {
|
||||
label: "forecast precip (mm)",
|
||||
data: series(forecastRain, "precip_mm"),
|
||||
backgroundColor: colors.forecast,
|
||||
yAxisID: "y",
|
||||
yAxisID: "y1",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -796,6 +879,31 @@ function renderDashboard(data) {
|
||||
};
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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="/vendor/fonts/fonts.css">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -50,6 +48,15 @@
|
||||
<div class="panel-meta" id="last-updated">--</div>
|
||||
</div>
|
||||
<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="label">Temp C</div>
|
||||
<div class="value" id="live-temp">--</div>
|
||||
@@ -189,12 +196,21 @@
|
||||
<canvas id="chart-rain"></canvas>
|
||||
</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>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/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/chart.umd.min.js"></script>
|
||||
<script src="/vendor/js/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -177,6 +177,38 @@ body {
|
||||
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 {
|
||||
display: grid;
|
||||
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