show rainfall data
This commit is contained in:
@@ -17,6 +17,8 @@ const colors = {
|
|||||||
uvi: "#f4d35e",
|
uvi: "#f4d35e",
|
||||||
light: "#b8f2e6",
|
light: "#b8f2e6",
|
||||||
precip: "#4ea8de",
|
precip: "#4ea8de",
|
||||||
|
rain: "#4ea8de",
|
||||||
|
rainStart: "#f77f00",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatNumber(value, digits) {
|
function formatNumber(value, digits) {
|
||||||
@@ -74,6 +76,69 @@ function safeDate(value) {
|
|||||||
return Number.isNaN(dt.getTime()) ? null : dt;
|
return Number.isNaN(dt.getTime()) ? null : dt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hourBucketMs(ts, tz) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
if (tz === "utc") {
|
||||||
|
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours());
|
||||||
|
}
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRainIncrements(points) {
|
||||||
|
const out = [];
|
||||||
|
let prev = null;
|
||||||
|
for (const p of points) {
|
||||||
|
const t = new Date(p.ts).getTime();
|
||||||
|
if (Number.isNaN(t)) continue;
|
||||||
|
if (p.rain_mm === null || p.rain_mm === undefined) {
|
||||||
|
out.push({ x: t, y: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let delta = null;
|
||||||
|
if (prev !== null) {
|
||||||
|
delta = p.rain_mm - prev;
|
||||||
|
if (delta < 0) delta = 0;
|
||||||
|
}
|
||||||
|
prev = p.rain_mm;
|
||||||
|
out.push({ x: t, y: delta });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRollingSum(points, windowMs) {
|
||||||
|
const out = [];
|
||||||
|
let sum = 0;
|
||||||
|
let j = 0;
|
||||||
|
const values = points.map((p) => ({
|
||||||
|
x: p.x,
|
||||||
|
y: p.y == null ? 0 : p.y,
|
||||||
|
}));
|
||||||
|
for (let i = 0; i < values.length; i++) {
|
||||||
|
const cur = values[i];
|
||||||
|
sum += cur.y;
|
||||||
|
while (values[j].x < cur.x - windowMs) {
|
||||||
|
sum -= values[j].y;
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
out.push({ x: cur.x, y: sum });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeHourlySums(points, tz) {
|
||||||
|
const buckets = new Map();
|
||||||
|
for (const p of points) {
|
||||||
|
if (p.y == null) continue;
|
||||||
|
const bucket = hourBucketMs(p.x, tz);
|
||||||
|
if (bucket === null) continue;
|
||||||
|
buckets.set(bucket, (buckets.get(bucket) || 0) + p.y);
|
||||||
|
}
|
||||||
|
return Array.from(buckets.entries())
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([x, y]) => ({ x, y }));
|
||||||
|
}
|
||||||
|
|
||||||
function startOfDay(date, tz) {
|
function startOfDay(date, tz) {
|
||||||
if (tz === "utc") {
|
if (tz === "utc") {
|
||||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||||
@@ -476,6 +541,38 @@ function renderDashboard(data) {
|
|||||||
};
|
};
|
||||||
upsertChart("chart-power", powerChart);
|
upsertChart("chart-power", powerChart);
|
||||||
|
|
||||||
|
const rainOptions = baseOptions(range);
|
||||||
|
rainOptions.scales.y1 = {
|
||||||
|
position: "right",
|
||||||
|
ticks: { color: "#a4c4c4" },
|
||||||
|
grid: { drawOnChartArea: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const rainIncs = computeRainIncrements(obsFiltered);
|
||||||
|
const rainRolling = computeRollingSum(rainIncs, 24 * 60 * 60 * 1000);
|
||||||
|
const rainHourly = computeHourlySums(rainIncs, state.tz);
|
||||||
|
const rainTitle = document.getElementById("chart-rain-title");
|
||||||
|
const isHourly = state.range === "6h";
|
||||||
|
if (rainTitle) {
|
||||||
|
rainTitle.textContent = isHourly ? "Rain (hourly sums)" : "Rain (24h rolling)";
|
||||||
|
}
|
||||||
|
|
||||||
|
const rainChart = {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: isHourly ? "rain hourly sum (mm)" : "rain rolling 24h (mm)",
|
||||||
|
data: isHourly ? rainHourly : rainRolling,
|
||||||
|
borderColor: colors.rain,
|
||||||
|
yAxisID: "y",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: rainOptions,
|
||||||
|
};
|
||||||
|
upsertChart("chart-rain", rainChart);
|
||||||
|
|
||||||
const precipChart = {
|
const precipChart = {
|
||||||
type: "bar",
|
type: "bar",
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -146,6 +146,15 @@
|
|||||||
<canvas id="chart-power"></canvas>
|
<canvas id="chart-power"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="chart-card" data-chart="chart-rain">
|
||||||
|
<div class="chart-header">
|
||||||
|
<div class="chart-title" id="chart-rain-title">Rain (24h rolling)</div>
|
||||||
|
<button class="chart-link" data-chart="chart-rain" title="Copy chart link">Share</button>
|
||||||
|
</div>
|
||||||
|
<div class="chart-canvas">
|
||||||
|
<canvas id="chart-rain"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="chart-card wide" data-chart="chart-precip">
|
<div class="chart-card wide" data-chart="chart-precip">
|
||||||
<div class="chart-header">
|
<div class="chart-header">
|
||||||
<div class="chart-title">Precipitation (forecast)</div>
|
<div class="chart-title">Precipitation (forecast)</div>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ type ObservationPoint struct {
|
|||||||
LightLux *float64 `json:"light_lux,omitempty"`
|
LightLux *float64 `json:"light_lux,omitempty"`
|
||||||
BatteryMV *float64 `json:"battery_mv,omitempty"`
|
BatteryMV *float64 `json:"battery_mv,omitempty"`
|
||||||
SupercapV *float64 `json:"supercap_v,omitempty"`
|
SupercapV *float64 `json:"supercap_v,omitempty"`
|
||||||
|
RainMM *float64 `json:"rain_mm,omitempty"`
|
||||||
|
RainStart *int64 `json:"rain_start,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ForecastPoint struct {
|
type ForecastPoint struct {
|
||||||
@@ -66,7 +68,9 @@ func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, start,
|
|||||||
max(uvi) AS uvi_max,
|
max(uvi) AS uvi_max,
|
||||||
max(light_lux) AS light_lux_max,
|
max(light_lux) AS light_lux_max,
|
||||||
avg(battery_mv) AS battery_mv_avg,
|
avg(battery_mv) AS battery_mv_avg,
|
||||||
avg(supercap_v) AS supercap_v_avg
|
avg(supercap_v) AS supercap_v_avg,
|
||||||
|
avg(rain_mm) AS rain_mm_avg,
|
||||||
|
max(rain_start) AS rain_start_max
|
||||||
FROM observations_ws90
|
FROM observations_ws90
|
||||||
WHERE site = $1
|
WHERE site = $1
|
||||||
AND ts >= $2
|
AND ts >= $2
|
||||||
@@ -87,8 +91,10 @@ func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, start,
|
|||||||
ts time.Time
|
ts time.Time
|
||||||
temp, rh, wind, gust sql.NullFloat64
|
temp, rh, wind, gust sql.NullFloat64
|
||||||
dir, uvi, light, battery, supercap sql.NullFloat64
|
dir, uvi, light, battery, supercap sql.NullFloat64
|
||||||
|
rainMM sql.NullFloat64
|
||||||
|
rainStart sql.NullInt64
|
||||||
)
|
)
|
||||||
if err := rows.Scan(&ts, &temp, &rh, &wind, &gust, &dir, &uvi, &light, &battery, &supercap); err != nil {
|
if err := rows.Scan(&ts, &temp, &rh, &wind, &gust, &dir, &uvi, &light, &battery, &supercap, &rainMM, &rainStart); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
points = append(points, ObservationPoint{
|
points = append(points, ObservationPoint{
|
||||||
@@ -102,6 +108,8 @@ func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, start,
|
|||||||
LightLux: nullFloatPtr(light),
|
LightLux: nullFloatPtr(light),
|
||||||
BatteryMV: nullFloatPtr(battery),
|
BatteryMV: nullFloatPtr(battery),
|
||||||
SupercapV: nullFloatPtr(supercap),
|
SupercapV: nullFloatPtr(supercap),
|
||||||
|
RainMM: nullFloatPtr(rainMM),
|
||||||
|
RainStart: nullIntPtr(rainStart),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if rows.Err() != nil {
|
if rows.Err() != nil {
|
||||||
@@ -123,7 +131,9 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo
|
|||||||
uvi,
|
uvi,
|
||||||
light_lux,
|
light_lux,
|
||||||
battery_mv,
|
battery_mv,
|
||||||
supercap_v
|
supercap_v,
|
||||||
|
rain_mm,
|
||||||
|
rain_start
|
||||||
FROM observations_ws90
|
FROM observations_ws90
|
||||||
WHERE site = $1
|
WHERE site = $1
|
||||||
ORDER BY ts DESC
|
ORDER BY ts DESC
|
||||||
@@ -134,8 +144,10 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo
|
|||||||
ts time.Time
|
ts time.Time
|
||||||
temp, rh, wind, gust sql.NullFloat64
|
temp, rh, wind, gust sql.NullFloat64
|
||||||
dir, uvi, light, battery, supercap sql.NullFloat64
|
dir, uvi, light, battery, supercap sql.NullFloat64
|
||||||
|
rainMM sql.NullFloat64
|
||||||
|
rainStart sql.NullInt64
|
||||||
)
|
)
|
||||||
err := d.Pool.QueryRow(ctx, query, site).Scan(&ts, &temp, &rh, &wind, &gust, &dir, &uvi, &light, &battery, &supercap)
|
err := d.Pool.QueryRow(ctx, query, site).Scan(&ts, &temp, &rh, &wind, &gust, &dir, &uvi, &light, &battery, &supercap, &rainMM, &rainStart)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -154,6 +166,8 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo
|
|||||||
LightLux: nullFloatPtr(light),
|
LightLux: nullFloatPtr(light),
|
||||||
BatteryMV: nullFloatPtr(battery),
|
BatteryMV: nullFloatPtr(battery),
|
||||||
SupercapV: nullFloatPtr(supercap),
|
SupercapV: nullFloatPtr(supercap),
|
||||||
|
RainMM: nullFloatPtr(rainMM),
|
||||||
|
RainStart: nullIntPtr(rainStart),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,3 +303,11 @@ func nullFloatPtr(v sql.NullFloat64) *float64 {
|
|||||||
val := v.Float64
|
val := v.Float64
|
||||||
return &val
|
return &val
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nullIntPtr(v sql.NullInt64) *int64 {
|
||||||
|
if !v.Valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
val := v.Int64
|
||||||
|
return &val
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user