added shaded wind trend

reworked labels on  12h and 24h wind arcs
This commit is contained in:
2026-03-17 03:24:33 +00:00
parent 85784add84
commit 1ec0cb06a0
7 changed files with 328 additions and 74 deletions

View File

@@ -44,6 +44,9 @@ GUST_WINDOW = 600 # 10 minutes
# Window for wind direction history buffer (seconds)
WIND_DIR_HISTORY_WINDOW = 1800 # 30 minutes
# Window for sector-based wind trend tracker (seconds)
WIND_TREND_WINDOW = 3600 # 60 minutes
# =============================================================================
# PATH CONFIGURATION
# =============================================================================

View File

@@ -54,6 +54,7 @@ from config import (
OBSERVATION_MIN_INTERVAL, STATION_UPDATE_INTERVAL,
GPS_MOVEMENT_THRESHOLD_METERS,
WIND_AVG_WINDOW, GUST_WINDOW, WIND_DIR_HISTORY_WINDOW,
WIND_TREND_WINDOW,
LOGGING_CONFIG,
)
@@ -311,6 +312,50 @@ class WindDirHistory:
return json.dumps(out)
class WindTrendTracker:
"""Sector-based wind trend: aggregates speed by directional sector over a
rolling window so the UI can render a heatmap ring showing wind shifts."""
SECTOR_COUNT = 120 # 3° per sector
def __init__(self, window_seconds=3600.0):
self.window_seconds = window_seconds
self.sector_size = 360.0 / self.SECTOR_COUNT
self.samples = deque()
def add_sample(self, direction_deg, speed_ms):
now = time.time()
sector = int(direction_deg / self.sector_size) % self.SECTOR_COUNT
self.samples.append((now, sector, speed_ms))
self._prune(now)
def _prune(self, now):
cutoff = now - self.window_seconds
while self.samples and self.samples[0][0] < cutoff:
self.samples.popleft()
def to_json(self):
"""Compact JSON: {s: sector_count, w: window_ms,
d: [[idx, last_seen_ms, peak_spd, count], ...]}"""
self._prune(time.time())
sectors = {}
for ts, sector, spd in self.samples:
if sector not in sectors:
sectors[sector] = {"last": ts, "peak": spd or 0, "n": 0}
entry = sectors[sector]
if ts > entry["last"]:
entry["last"] = ts
if spd is not None and spd > entry["peak"]:
entry["peak"] = spd
entry["n"] += 1
data = []
for idx, info in sorted(sectors.items()):
data.append([idx, int(info["last"] * 1000),
round(info["peak"], 2), info["n"]])
return json.dumps({"s": self.SECTOR_COUNT,
"w": int(self.window_seconds * 1000), "d": data})
# ---------------------------------------------------------------------------
# Main controller
# ---------------------------------------------------------------------------
@@ -334,6 +379,7 @@ class WindyStationController:
self.wind_averager = WindSpeedAverager(WIND_AVG_WINDOW)
self.gust_tracker = WindGustTracker(GUST_WINDOW)
self.wind_dir_history = WindDirHistory(WIND_DIR_HISTORY_WINDOW)
self.wind_trend = WindTrendTracker(WIND_TREND_WINDOW)
# Timing state
self.last_observation_time = 0
@@ -416,6 +462,7 @@ class WindyStationController:
self.dbus_service.add_path('/LastReportTime', 0,
gettextcallback=self._report_time_text)
self.dbus_service.add_path('/WindDirHistory', '[]')
self.dbus_service.add_path('/WindTrend', '{}')
# Writable settings
self.dbus_service.add_path('/Settings/Enabled', 1,
@@ -901,6 +948,7 @@ class WindyStationController:
self.gust_tracker.add_sample(wind_ms)
if wind_dir is not None:
self.wind_dir_history.add_sample(wind_dir, wind_ms)
self.wind_trend.add_sample(wind_dir, wind_ms)
self.last_wind_sample = now
# Update current conditions on D-Bus (for GUI display)
@@ -918,6 +966,8 @@ class WindyStationController:
if now - self.last_dir_history_publish >= 15.0:
self.dbus_service['/WindDirHistory'] = (
self.wind_dir_history.to_json())
self.dbus_service['/WindTrend'] = (
self.wind_trend.to_json())
self.last_dir_history_publish = now
# GPS position

BIN
venus-data.zip Normal file

Binary file not shown.

View File

@@ -206,6 +206,7 @@ const CompactWeatherCard: React.FC<CompactWeatherCardProps> = ({ onClick }) => {
windDirection={meteo.windDirection}
heading={nav.heading}
windDirHistory={ws.windDirHistory}
windTrend={ws.windTrend}
imperial={ws.imperial}
forecastArc12h={forecastArcs.arc12h}
forecastArc24h={forecastArcs.arc24h}

View File

@@ -219,6 +219,7 @@ const WeatherView = () => {
windDirection={meteo.windDirection}
heading={nav.heading}
windDirHistory={ws.windDirHistory}
windTrend={ws.windTrend}
imperial={imperial}
forecastArc12h={forecastArcs.arc12h}
forecastArc24h={forecastArcs.arc24h}

View File

@@ -1,4 +1,5 @@
import React, { useRef, useState, useEffect, useMemo } from "react"
import type { WindTrendData } from "../../../utils/hooks/use-custom-mqtt"
export interface ForecastArcData {
startDir: number
@@ -21,6 +22,7 @@ interface WindCompassProps {
windDirection: number | null
heading: number | null
windDirHistory: { time: number; dir: number; spd?: number }[]
windTrend?: WindTrendData | null
imperial: boolean
forecastArc12h?: ForecastArcData | null
forecastArc24h?: ForecastArcData | null
@@ -31,8 +33,8 @@ interface WindCompassProps {
const CX = 200
const CY = 200
const COMPASS_R = 155
const TREND_R = 175
const TREND_THICKNESS = 12
const TREND_THICKNESS = 10
const TREND_R = COMPASS_R + TREND_THICKNESS / 2 + 1
const TICK_OUTER = COMPASS_R
const TICK_INNER_MAJOR = COMPASS_R - 14
const TICK_INNER_MINOR = COMPASS_R - 8
@@ -46,10 +48,11 @@ const CARDINALS = [
{ label: "W", deg: 270 },
] as const
const FORECAST_ARC_THICKNESS = 10
const ARC_24H_THICKNESS = 8
const FORECAST_ARC_R = COMPASS_R - 18
const ARC_24H_R = FORECAST_ARC_R - FORECAST_ARC_THICKNESS / 2 - ARC_24H_THICKNESS / 2 - 1
const FORECAST_ARC_THICKNESS = 16
const ARC_24H_THICKNESS = 14
const FORECAST_ARC_R = COMPASS_R - 20
const ARC_GAP = 4
const ARC_24H_R = FORECAST_ARC_R - FORECAST_ARC_THICKNESS / 2 - ARC_GAP - ARC_24H_THICKNESS / 2
function polarToXY(cx: number, cy: number, r: number, angleDeg: number) {
const rad = ((angleDeg - 90) * Math.PI) / 180
@@ -193,12 +196,106 @@ const LIGHTNING_COLORS: Record<string, string> = {
imminent: "#ef4444",
}
interface TrendArcSegment {
startDeg: number
endDeg: number
color: string
opacity: number
}
const FADE_DEPTH = 3
const FADE_CURVE = [0.2, 0.45, 0.75]
function buildTrendSegments(trend: WindTrendData): TrendArcSegment[] {
if (!trend.data.length) return []
const now = Date.now()
const n = trend.sectors
const sectorSize = 360 / n
const sectorData: Record<number, { color: string; opacity: number }> = {}
for (const s of trend.data) {
const age = Math.min(1, Math.max(0, (now - s.lastSeen) / trend.window))
sectorData[s.idx] = {
color: windSpeedColor(s.peakSpd * MS_TO_KTS),
opacity: Math.max(0.15, 1.0 - age * 0.85),
}
}
const occupiedIndices = Object.keys(sectorData).map(Number)
const occupied = new Set(occupiedIndices)
function distToEdge(idx: number, dir: -1 | 1): number {
for (let d = 1; d <= FADE_DEPTH; d++) {
const wrapped = (((idx + dir * d) % n) + n) % n
if (!occupied.has(wrapped)) return d - 1
}
return FADE_DEPTH
}
const segments: TrendArcSegment[] = []
for (const idx of occupiedIndices) {
const info = sectorData[idx]
const dLeft = distToEdge(idx, -1)
const dRight = distToEdge(idx, 1)
const minDist = Math.min(dLeft, dRight)
const fadeMul = minDist >= FADE_DEPTH ? 1.0 : FADE_CURVE[minDist]
segments.push({
startDeg: idx * sectorSize,
endDeg: (idx + 1) * sectorSize,
color: info.color,
opacity: info.opacity * fadeMul,
})
}
for (const idx of occupiedIndices) {
const info = sectorData[idx]
for (let d = 1; d <= 2; d++) {
for (let di = 0; di < 2; di++) {
const dir = di === 0 ? -1 : 1
const neighbor = (((idx + dir * d) % n) + n) % n
if (occupied.has(neighbor)) continue
const ghostOpacity = info.opacity * (d === 1 ? 0.12 : 0.04)
if (ghostOpacity < 0.02) continue
segments.push({
startDeg: neighbor * sectorSize,
endDeg: (neighbor + 1) * sectorSize,
color: info.color,
opacity: ghostOpacity,
})
}
}
}
segments.sort((a, b) => a.startDeg - b.startDeg)
const merged: TrendArcSegment[] = []
let cur = { ...segments[0] }
for (let i = 1; i < segments.length; i++) {
const next = segments[i]
const adj = Math.abs(next.startDeg - cur.endDeg) < 0.01
const sameColor = next.color === cur.color
const simOp = Math.abs(next.opacity - cur.opacity) < 0.06
if (adj && sameColor && simOp) {
cur.endDeg = next.endDeg
cur.opacity = Math.min(cur.opacity, next.opacity)
} else {
merged.push(cur)
cur = { ...next }
}
}
merged.push(cur)
return merged
}
const WindCompass: React.FC<WindCompassProps> = ({
windSpeed,
windGust,
windDirection,
heading,
windDirHistory,
windTrend,
imperial,
forecastArc12h,
forecastArc24h,
@@ -210,10 +307,13 @@ const WindCompass: React.FC<WindCompassProps> = ({
if (imperial) return (ms * 1.94384).toFixed(0)
return ms.toFixed(1)
}
const speedUnit = imperial ? "kts" : "m/s"
const trendData = computeWindDirSpread(windDirHistory)
const trendSegments = useMemo(
() => (windTrend && windTrend.data.length > 0 ? buildTrendSegments(windTrend) : null),
[windTrend],
)
const ticks = useMemo(() => {
const result: React.ReactNode[] = []
for (let deg = 0; deg < 360; deg += 5) {
@@ -283,17 +383,29 @@ const WindCompass: React.FC<WindCompassProps> = ({
</radialGradient>
</defs>
{/* Trend arc */}
{trendData && (
<path
d={arcPath(CX, CY, TREND_R, trendData.startDir, trendData.endDir)}
fill="none"
stroke={windSpeedColor(trendData.avgSpdKts)}
strokeWidth={TREND_THICKNESS}
strokeLinecap="round"
opacity={0.7}
/>
)}
{/* Wind trend ring — sector heatmap when available, single arc fallback */}
{trendSegments
? trendSegments.map((seg, i) => (
<path
key={`ws${i}`}
d={arcPath(CX, CY, TREND_R, seg.startDeg, seg.endDeg)}
fill="none"
stroke={seg.color}
strokeWidth={TREND_THICKNESS}
strokeLinecap="butt"
opacity={seg.opacity}
/>
))
: trendData && (
<path
d={arcPath(CX, CY, TREND_R, trendData.startDir, trendData.endDir)}
fill="none"
stroke={windSpeedColor(trendData.avgSpdKts)}
strokeWidth={TREND_THICKNESS}
strokeLinecap="round"
opacity={0.7}
/>
)}
{/* Compass ring */}
<circle cx={CX} cy={CY} r={COMPASS_R} fill="none" stroke="#444" strokeWidth={1.5} />
@@ -301,48 +413,15 @@ const WindCompass: React.FC<WindCompassProps> = ({
{/* Tick marks */}
{ticks}
{/* Forecast guide circles and labels */}
{/* Forecast guide circles */}
{(forecastArc12h || forecastArc24h) && (
<>
<circle cx={CX} cy={CY} r={FORECAST_ARC_R} fill="none" stroke="#555" strokeWidth={0.5} opacity={0.4} />
<circle cx={CX} cy={CY} r={ARC_24H_R} fill="none" stroke="#555" strokeWidth={0.5} opacity={0.4} />
{(() => {
const labelAngle = 315
const p12 = polarToXY(CX, CY, FORECAST_ARC_R, labelAngle)
const p24 = polarToXY(CX, CY, ARC_24H_R, labelAngle)
return (
<>
<text
x={p12.x}
y={p12.y}
textAnchor="middle"
dominantBaseline="central"
fill="#777"
fontSize={8}
fontFamily="sans-serif"
fontWeight={600}
>
12h
</text>
<text
x={p24.x}
y={p24.y}
textAnchor="middle"
dominantBaseline="central"
fill="#777"
fontSize={8}
fontFamily="sans-serif"
fontWeight={600}
>
24h
</text>
</>
)
})()}
<circle cx={CX} cy={CY} r={FORECAST_ARC_R} fill="none" stroke="#555" strokeWidth={0.5} opacity={0.3} />
<circle cx={CX} cy={CY} r={ARC_24H_R} fill="none" stroke="#555" strokeWidth={0.5} opacity={0.3} />
</>
)}
{/* 12h forecast direction arc (outer, colored by peak gust) */}
{/* 12h forecast arc */}
{forecastArc12h && (
<path
d={arcPath(CX, CY, FORECAST_ARC_R, forecastArc12h.startDir, forecastArc12h.endDir)}
@@ -354,7 +433,7 @@ const WindCompass: React.FC<WindCompassProps> = ({
/>
)}
{/* 24h forecast direction arc (inner, dashed when high wind present) */}
{/* 24h forecast arc */}
{forecastArc24h && (
<path
d={arcPath(CX, CY, ARC_24H_R, forecastArc24h.startDir, forecastArc24h.endDir)}
@@ -374,6 +453,29 @@ const WindCompass: React.FC<WindCompassProps> = ({
<g
transform={`rotate(${boatHeading}, ${CX}, ${CY}) translate(${CX}, ${CY}) scale(1.5) translate(${-CX}, ${-CY})`}
>
{/* Starboard (green, right side) bow fill */}
<path
d={`
M ${CX} ${CY - 65}
C ${CX + 8} ${CY - 55}, ${CX + 10} ${CY - 48}, ${CX + 12} ${CY - 42}
L ${CX} ${CY - 42}
Z
`}
fill="#22c55e"
opacity={0.45}
/>
{/* Port (red, left side) bow fill */}
<path
d={`
M ${CX} ${CY - 65}
C ${CX - 8} ${CY - 55}, ${CX - 10} ${CY - 48}, ${CX - 12} ${CY - 42}
L ${CX} ${CY - 42}
Z
`}
fill="#ef4444"
opacity={0.45}
/>
{/* Hull outline */}
<path
d={`
M ${CX} ${CY - 65}
@@ -389,12 +491,12 @@ const WindCompass: React.FC<WindCompassProps> = ({
Z
`}
fill="none"
stroke="#888"
stroke="currentColor"
strokeWidth={1.7}
opacity={0.7}
opacity={0.8}
/>
{/* Bow marker */}
<line x1={CX} y1={CY - 65} x2={CX} y2={CY - 75} stroke="#888" strokeWidth={1.7} opacity={0.7} />
<line x1={CX} y1={CY - 65} x2={CX} y2={CY - 75} stroke="currentColor" strokeWidth={3} opacity={0.8} />
</g>
{/* Wind direction arrow with "T" label */}
@@ -410,6 +512,8 @@ const WindCompass: React.FC<WindCompassProps> = ({
Z
`}
fill="#22a55e"
stroke="#000"
strokeWidth={1.5}
opacity={0.9}
/>
<text
@@ -455,6 +559,78 @@ const WindCompass: React.FC<WindCompassProps> = ({
</g>
)}
{/* Forecast arc pill labels — rendered above compass/boat/wind layers */}
{forecastArc12h &&
(() => {
let midDeg = (forecastArc12h.startDir + forecastArc12h.endDir) / 2
let sweep = forecastArc12h.endDir - forecastArc12h.startDir
if (sweep < 0) sweep += 360
if (sweep > 180) midDeg = (midDeg + 180) % 360
const mid = polarToXY(CX, CY, FORECAST_ARC_R, midDeg)
return (
<g>
<rect
x={mid.x - 12}
y={mid.y - 9}
width={24}
height={18}
rx={9}
fill="var(--c-surface-secondary, #F5F5F5)"
stroke="#aaa"
strokeWidth={0.8}
opacity={0.95}
/>
<text
x={mid.x}
y={mid.y}
textAnchor="middle"
dominantBaseline="central"
fill="#999"
fontSize={12}
fontWeight={700}
fontFamily="sans-serif"
>
12
</text>
</g>
)
})()}
{forecastArc24h &&
(() => {
let midDeg = (forecastArc24h.startDir + forecastArc24h.endDir) / 2
let sweep = forecastArc24h.endDir - forecastArc24h.startDir
if (sweep < 0) sweep += 360
if (sweep > 180) midDeg = (midDeg + 180) % 360
const mid = polarToXY(CX, CY, ARC_24H_R, midDeg)
return (
<g>
<rect
x={mid.x - 12}
y={mid.y - 9}
width={24}
height={18}
rx={9}
fill="var(--c-surface-secondary, #F5F5F5)"
stroke="#aaa"
strokeWidth={0.8}
opacity={0.95}
/>
<text
x={mid.x}
y={mid.y}
textAnchor="middle"
dominantBaseline="central"
fill="#999"
fontSize={12}
fontWeight={700}
fontFamily="sans-serif"
>
24
</text>
</g>
)
})()}
{/* Center overlay to improve wind speed readability */}
<circle cx={CX} cy={CY} r={90} fill="url(#compassCenterFade)" />
<circle cx={CX} cy={CY} r={78} fill="none" stroke="#888" strokeWidth={0.5} opacity={0.25} />
@@ -475,23 +651,10 @@ const WindCompass: React.FC<WindCompassProps> = ({
>
{fmtSpeed(windSpeed)}
</text>
<text
x={CX}
y={CY + 30}
textAnchor="middle"
dominantBaseline="central"
fill="currentColor"
opacity={0.5}
fontSize={21}
fontFamily="sans-serif"
>
{speedUnit}
</text>
{/* Gust speed */}
<text
x={CX}
y={CY + 58}
y={CY + 30}
textAnchor="middle"
dominantBaseline="central"
fill="#eab308"

View File

@@ -177,8 +177,22 @@ const WINDY_PATHS = [
"/Settings/Units",
"/Status",
"/WindDirHistory",
"/WindTrend",
]
export interface WindTrendSector {
idx: number
lastSeen: number
peakSpd: number
count: number
}
export interface WindTrendData {
sectors: number
window: number
data: WindTrendSector[]
}
export function useWindyStation() {
const { values, setValue, isConnected } = useCustomService("windystation", WINDY_PATHS)
@@ -192,6 +206,27 @@ export function useWindyStation() {
}
}, [rawDirHistory])
const rawWindTrend = values["/WindTrend"]
const windTrend: WindTrendData | null = useMemo(() => {
if (!rawWindTrend || typeof rawWindTrend !== "string") return null
try {
const parsed = JSON.parse(rawWindTrend)
if (!parsed.d || !Array.isArray(parsed.d)) return null
return {
sectors: parsed.s as number,
window: parsed.w as number,
data: parsed.d.map((row: number[]) => ({
idx: row[0],
lastSeen: row[1],
peakSpd: row[2],
count: row[3],
})),
}
} catch {
return null
}
}, [rawWindTrend])
return {
connected: values["/Connected"] === 1,
windSpeed: values["/WindSpeed"] as number | null,
@@ -204,6 +239,7 @@ export function useWindyStation() {
imperial: values["/Settings/Units"] === 1,
status: values["/Status"] as number | null,
windDirHistory,
windTrend,
setEnabled: (v: boolean) => setValue("/Settings/Enabled", v ? 1 : 0),
isConnected,
}