Files
venus/venus-html5-app/src/app/Marine2/components/views/custom/TideAnalysisView.tsx
2026-03-20 13:27:02 +00:00

647 lines
22 KiB
TypeScript

import React, { useMemo } from "react"
import MainLayout from "../../ui/MainLayout"
import { observer } from "mobx-react"
import { useTideService, useMeteoblueForecast, TidePoint, SunMoonData } from "../../../utils/hooks/use-custom-mqtt"
import { parseSunTimeTs } from "../../../utils/sun-utils"
const M_TO_FT = 3.28084
const STATUS_LABELS: Record<number, string> = {
0: "Idle",
1: "Calibrating",
2: "Ready",
3: "Error",
}
function fmtDepth(meters: number | null | undefined, imperial: boolean): string {
if (meters == null || isNaN(meters)) return "--"
const val = imperial ? meters * M_TO_FT : meters
return val.toFixed(1)
}
function smoothPath(pts: [number, number][], tension = 0.3): string {
if (pts.length < 2) return ""
if (pts.length === 2) {
return "M" + pts[0][0] + "," + pts[0][1] + "L" + pts[1][0] + "," + pts[1][1]
}
let d = "M" + pts[0][0] + "," + pts[0][1]
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(0, i - 1)]
const p1 = pts[i]
const p2 = pts[i + 1]
const p3 = pts[Math.min(pts.length - 1, i + 2)]
const cp1x = p1[0] + (p2[0] - p0[0]) * tension
const cp1y = p1[1] + (p2[1] - p0[1]) * tension
const cp2x = p2[0] - (p3[0] - p1[0]) * tension
const cp2y = p2[1] - (p3[1] - p1[1]) * tension
d += " C" + cp1x + "," + cp1y + " " + cp2x + "," + cp2y + " " + p2[0] + "," + p2[1]
}
return d
}
function formatTimeShort(ts: number, utc: boolean): string {
const d = new Date(ts * 1000)
const h = utc ? d.getUTCHours() : d.getHours()
if (h === 0) return "12a"
if (h < 12) return h + "a"
if (h === 12) return "12p"
return h - 12 + "p"
}
function formatDay(ts: number, utc: boolean): string {
const d = new Date(ts * 1000)
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
return days[utc ? d.getUTCDay() : d.getDay()]
}
function formatDayDate(ts: number, utc: boolean): string {
const d = new Date(ts * 1000)
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
const day = utc ? d.getUTCDay() : d.getDay()
const date = utc ? d.getUTCDate() : d.getDate()
return days[day] + " " + date
}
function formatTimestamp(ts: number, utc: boolean): string {
const d = new Date(ts * 1000)
if (utc) {
return d.toISOString().replace("T", " ").slice(0, 16) + " UTC"
}
const month = (d.getMonth() + 1).toString().padStart(2, "0")
const day = d.getDate().toString().padStart(2, "0")
const h = d.getHours().toString().padStart(2, "0")
const m = d.getMinutes().toString().padStart(2, "0")
return month + "/" + day + " " + h + ":" + m
}
interface NightInterval {
start: number
end: number
}
function buildNightIntervals(sunmoon: SunMoonData | undefined): NightInterval[] {
if (!sunmoon?.time || !sunmoon.sunrise || !sunmoon.sunset) return []
const events: { ts: number; type: "rise" | "set" }[] = []
for (let i = 0; i < sunmoon.time.length; i++) {
const dateStr = sunmoon.time[i]
const riseTs = parseSunTimeTs(dateStr, sunmoon.sunrise[i])
if (riseTs != null) events.push({ ts: riseTs, type: "rise" })
const setTs = parseSunTimeTs(dateStr, sunmoon.sunset[i])
if (setTs != null) events.push({ ts: setTs, type: "set" })
}
events.sort((a, b) => a.ts - b.ts)
if (events.length === 0) return []
const intervals: NightInterval[] = []
const firstRise = events.find((e) => e.type === "rise")
if (firstRise) {
const hasPriorSet = events.some((e) => e.type === "set" && e.ts < firstRise.ts)
if (!hasPriorSet) {
const firstSet = events.find((e) => e.type === "set")
const approxPriorSet = firstSet ? firstSet.ts - 86400 : firstRise.ts - 12 * 3600
intervals.push({ start: approxPriorSet, end: firstRise.ts })
}
}
for (let i = 0; i < events.length; i++) {
if (events[i].type === "set") {
const nextRise = events.find((e, j) => j > i && e.type === "rise")
intervals.push({ start: events[i].ts, end: nextRise ? nextRise.ts : Infinity })
}
}
return intervals
}
interface AnalysisChartProps {
observed: TidePoint[]
stationPredicted: TidePoint[]
localPredicted: TidePoint[]
nightIntervals: NightInterval[]
width: number
height: number
imperial: boolean
utc: boolean
}
const AnalysisChart: React.FC<AnalysisChartProps> = ({
observed,
stationPredicted,
localPredicted,
nightIntervals,
width,
height,
imperial,
utc,
}) => {
const chartData = useMemo(() => {
const allPoints = [...observed, ...stationPredicted, ...localPredicted]
if (allPoints.length === 0) return null
const allTs = allPoints.map((p) => p.ts)
const minTs = Math.min(...allTs)
const maxTs = Math.max(...allTs)
const visiblePoints = allPoints
const allD = visiblePoints.map((p) => p.depth)
if (allD.length === 0) return null
const minD = Math.min(...allD)
const maxD = Math.max(...allD)
const padLeft = 50
const padRight = 24
const padTop = 30
const padBot = 42
const chartW = width - padLeft - padRight
const chartH = height - padTop - padBot
const depthMargin = (maxD - minD || 1) * 0.1
const depthRange = (maxD - minD || 1) + depthMargin * 2
const tsRange = maxTs - minTs || 1
const scaleX = (ts: number) => padLeft + ((ts - minTs) / tsRange) * chartW
const scaleY = (d: number) => padTop + chartH - ((d - minD + depthMargin) / depthRange) * chartH
const obsPts: [number, number][] = observed.map((p) => [scaleX(p.ts), scaleY(p.depth)])
const stationPts: [number, number][] = stationPredicted.map((p) => [scaleX(p.ts), scaleY(p.depth)])
const localPts: [number, number][] = localPredicted.map((p) => [scaleX(p.ts), scaleY(p.depth)])
const nowTs = Date.now() / 1000
const nowX = scaleX(nowTs)
const gridLines: { x: number; label: string }[] = []
const step = 6 * 3600
const firstGrid = Math.ceil(minTs / step) * step
for (let t = firstGrid; t <= maxTs; t += step) {
gridLines.push({ x: scaleX(t), label: formatTimeShort(t, utc) })
}
const dayBoundaries: { x: number; dayLabel: string; dateLabel: string }[] = []
const startDate = new Date(minTs * 1000)
const firstMidnight = new Date(startDate)
if (utc) {
firstMidnight.setUTCHours(0, 0, 0, 0)
if (firstMidnight.getTime() / 1000 <= minTs) firstMidnight.setUTCDate(firstMidnight.getUTCDate() + 1)
} else {
firstMidnight.setHours(0, 0, 0, 0)
if (firstMidnight.getTime() / 1000 <= minTs) firstMidnight.setDate(firstMidnight.getDate() + 1)
}
for (let t = firstMidnight.getTime() / 1000; t <= maxTs; t += 86400) {
dayBoundaries.push({
x: scaleX(t),
dayLabel: formatDay(t, utc),
dateLabel: formatDayDate(t, utc),
})
}
const depthGridLines: { y: number; label: string }[] = []
const dRange = maxD - minD || 1
const dStep = dRange > 10 ? 5 : dRange > 5 ? 2 : dRange > 2 ? 1 : 0.5
const firstDGrid = Math.ceil((minD - depthMargin) / dStep) * dStep
for (let d = firstDGrid; d <= maxD + depthMargin; d += dStep) {
const displayD = imperial ? d * M_TO_FT : d
depthGridLines.push({ y: scaleY(d), label: displayD.toFixed(1) })
}
const nightRects: { x: number; width: number }[] = []
for (const interval of nightIntervals) {
const startClamp = Math.max(interval.start, minTs)
const endClamp = Math.min(interval.end, maxTs)
if (startClamp >= endClamp) continue
const x = scaleX(startClamp)
nightRects.push({ x, width: scaleX(endClamp) - x })
}
return {
obsPts,
stationPts,
localPts,
nowX,
gridLines,
dayBoundaries,
depthGridLines,
nightRects,
padTop,
padLeft,
padRight,
chartH,
}
}, [observed, stationPredicted, localPredicted, nightIntervals, width, height, imperial, utc])
if (!chartData) {
return (
<div className="flex items-center justify-center h-full">
<span className="text-content-secondary text-sm">No tide data yet</span>
</div>
)
}
const {
obsPts,
stationPts,
localPts,
nowX,
gridLines,
dayBoundaries,
depthGridLines,
nightRects,
padTop,
padLeft,
padRight,
chartH,
} = chartData
return (
<svg width={width} height={height} className="select-none">
{nightRects.map((r, i) => (
<rect key={"night" + i} x={r.x} y={padTop} width={r.width} height={chartH} fill="black" fillOpacity={0.07} />
))}
{dayBoundaries.map((db, i) => (
<g key={"db" + i}>
<line
x1={db.x}
y1={padTop}
x2={db.x}
y2={padTop + chartH}
stroke="currentColor"
strokeOpacity={0.35}
strokeWidth={2}
/>
<text
x={db.x}
y={height - 4}
textAnchor="middle"
fill="currentColor"
fillOpacity={0.7}
fontSize={11}
fontWeight="bold"
>
{db.dateLabel}
</text>
</g>
))}
{gridLines.map((g, i) => (
<g key={"tg" + i}>
<line
x1={g.x}
y1={padTop}
x2={g.x}
y2={padTop + chartH}
stroke="currentColor"
strokeOpacity={0.1}
strokeWidth={0.5}
/>
<text x={g.x} y={height - 16} textAnchor="middle" fill="currentColor" fillOpacity={0.6} fontSize={10}>
{g.label}
</text>
</g>
))}
{depthGridLines.map((g, i) => (
<g key={"dg" + i}>
<line
x1={padLeft}
y1={g.y}
x2={width - padRight}
y2={g.y}
stroke="currentColor"
strokeOpacity={0.1}
strokeWidth={0.5}
/>
<text x={18} y={g.y + 3} fill="currentColor" fillOpacity={0.5} fontSize={9}>
{g.label}
</text>
</g>
))}
{stationPts.length > 1 && (
<path
d={smoothPath(stationPts)}
fill="none"
stroke="#9ca3af"
strokeWidth={1.5}
strokeDasharray="4 4"
strokeOpacity={0.7}
/>
)}
{localPts.length > 1 && (
<path
d={smoothPath(localPts)}
fill="none"
stroke="#818cf8"
strokeWidth={1.5}
strokeDasharray="6 3"
strokeOpacity={0.7}
/>
)}
{obsPts.length > 1 && <path d={smoothPath(obsPts)} fill="none" stroke="#22d3ee" strokeWidth={2} />}
<line
x1={nowX}
y1={padTop}
x2={nowX}
y2={padTop + chartH}
stroke="#f59e0b"
strokeOpacity={0.7}
strokeWidth={2}
strokeDasharray="4 3"
/>
<text
x={nowX}
y={padTop - 3}
textAnchor="middle"
fill="#f59e0b"
fillOpacity={0.8}
fontSize={10}
fontWeight="bold"
>
now
</text>
<text
x={8}
y={padTop + chartH / 2}
textAnchor="middle"
fill="currentColor"
fillOpacity={0.6}
fontSize={12}
transform={`rotate(-90, 8, ${padTop + chartH / 2})`}
>
{imperial ? "Depth (ft)" : "Depth (m)"}
</text>
<text
x={padLeft + (width - padLeft - padRight) / 2}
y={height - 2}
textAnchor="middle"
fill="currentColor"
fillOpacity={0.6}
fontSize={12}
>
Time
</text>
</svg>
)
}
const AutoSizeAnalysisChart: React.FC<{
observed: TidePoint[]
stationPredicted: TidePoint[]
localPredicted: TidePoint[]
nightIntervals: NightInterval[]
imperial: boolean
utc: boolean
}> = ({ observed, stationPredicted, localPredicted, nightIntervals, imperial, utc }) => {
const containerRef = React.useRef<HTMLDivElement>(null)
const [size, setSize] = React.useState({ width: 300, height: 150 })
React.useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry) {
setSize({
width: Math.floor(entry.contentRect.width),
height: Math.floor(entry.contentRect.height),
})
}
})
ro.observe(el)
return () => ro.disconnect()
}, [])
return (
<div ref={containerRef} className="w-full h-full">
<AnalysisChart
observed={observed}
stationPredicted={stationPredicted}
localPredicted={localPredicted}
nightIntervals={nightIntervals}
width={size.width}
height={size.height}
imperial={imperial}
utc={utc}
/>
</div>
)
}
const TideAnalysisView = () => {
const tide = useTideService()
const meteo = useMeteoblueForecast()
const [utc, setUtc] = React.useState(false)
const depthUnit = tide.imperial ? "ft" : "m"
const nightIntervals = useMemo(() => buildNightIntervals(meteo.forecast?.sunmoon), [meteo.forecast?.sunmoon])
const statusText = tide.status !== null ? STATUS_LABELS[tide.status] || "Unknown" : "Connecting"
const statusColor =
tide.status === 2
? "text-content-victronGreen"
: tide.status === 3
? "text-content-victronRed"
: "text-content-secondary"
const observedData = useMemo(() => {
const hist = tide.fullDepthHistory.length > 0 ? tide.fullDepthHistory : tide.depthHistory
if (tide.currentDepth == null) return hist
const nowTs = Date.now() / 1000
const last = hist[hist.length - 1]
if (last && nowTs - last.ts < 60) return hist
return [...hist, { ts: nowTs, depth: tide.currentDepth }]
}, [tide.fullDepthHistory, tide.depthHistory, tide.currentDepth])
const adaptationStatusText = useMemo(() => {
if (tide.localStatus === 2) {
const dtMin = tide.localTimeOffset != null ? Math.round(tide.localTimeOffset / 60) : null
const dtStr = dtMin != null ? (dtMin > 0 ? "+" : "") + dtMin + "m" : "--"
const kStr = tide.localAmpScale != null ? tide.localAmpScale.toFixed(2) : "--"
return "Time + Range (\u0394t:" + dtStr + ", k:" + kStr + ")"
}
if (tide.localStatus === 1) {
const dtMin = tide.localTimeOffset != null ? Math.round(tide.localTimeOffset / 60) : null
const dtStr = dtMin != null ? (dtMin > 0 ? "+" : "") + dtMin + "m" : "--"
return "Time only (\u0394t:" + dtStr + ")"
}
return "None"
}, [tide.localStatus, tide.localTimeOffset, tide.localAmpScale])
return (
<MainLayout title="Tide Analysis">
<div className="h-full w-full overflow-hidden flex flex-col gap-1 p-1">
{/* Status panel */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 px-2 py-1">
<div className="flex items-center gap-1">
<span className="text-2xs text-content-secondary">Status</span>
<span className={`text-xs font-medium ${statusColor}`}>{statusText}</span>
</div>
{tide.currentDepth != null && (
<div className="flex items-center gap-1">
<span className="text-2xs text-content-secondary">Depth</span>
<span className="text-xs font-bold text-content-victronCyan">
{fmtDepth(tide.currentDepth, tide.imperial)}
{depthUnit}
</span>
</div>
)}
<div className="flex items-center gap-1">
<span className="text-2xs text-content-secondary">Chart Depth</span>
<span className="text-xs text-content-primary">
{fmtDepth(tide.chartDepth, tide.imperial)}
{depthUnit}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-2xs text-content-secondary">Adaptation</span>
<span className="text-xs text-content-victronBlue">{adaptationStatusText}</span>
</div>
{tide.localMatchCount != null && tide.localMatchCount > 0 && (
<div className="flex items-center gap-1">
<span className="text-2xs text-content-secondary">Matches</span>
<span className="text-xs text-content-primary">{tide.localMatchCount}</span>
</div>
)}
<div className="flex items-center gap-1">
<span className="text-2xs text-content-secondary">{tide.isStationary ? "Stationary" : "Moving"}</span>
<span
className={`w-2 h-2 rounded-full ${
tide.isStationary ? "bg-content-victronGreen" : "bg-content-victronYellow"
}`}
/>
</div>
{tide.lastModelRun != null && tide.lastModelRun > 0 && (
<div className="flex items-center gap-1">
<span className="text-2xs text-content-secondary">Model</span>
<span className="text-xs text-content-secondary">{formatTimestamp(tide.lastModelRun, utc)}</span>
</div>
)}
<div className="flex items-center gap-1 ml-auto">
<button
onClick={() => tide.setUnits(!tide.imperial)}
className="text-2xs px-1.5 py-0.5 rounded border border-content-secondary/30 text-content-secondary
hover:text-content-primary hover:border-content-primary/50 transition-colors"
>
{tide.imperial ? "ft" : "m"}
</button>
<button
onClick={() => setUtc(!utc)}
className="text-2xs px-1.5 py-0.5 rounded border border-content-secondary/30 text-content-secondary
hover:text-content-primary hover:border-content-primary/50 transition-colors"
>
{utc ? "UTC" : "Local"}
</button>
</div>
</div>
{/* Station info */}
{tide.dataSource && (
<div className="bg-surface-secondary/50 rounded px-2 py-1">
<div className="flex items-center gap-3">
<span className="text-2xs text-content-secondary uppercase tracking-wider">Station</span>
{tide.stationName ? (
<>
<span className="text-xs font-medium text-content-primary">{tide.stationName}</span>
{tide.stationType === "S" && (
<span className="text-2xs px-1 py-px rounded bg-content-victronBlue/20 text-content-victronBlue">
subordinate
</span>
)}
{tide.stationDistance != null && (
<span className="text-2xs text-content-secondary">{tide.stationDistance.toFixed(0)} km</span>
)}
<span className="text-2xs text-content-secondary/60">{tide.stationId}</span>
{tide.stationType === "S" && tide.stationRefId && (
<span className="text-2xs text-content-secondary">ref: {tide.stationRefId}</span>
)}
</>
) : (
<span className="text-xs text-content-primary">
{tide.dataSource === "grid"
? "Coastal Grid Model"
: tide.dataSource === "custom"
? "Custom"
: tide.dataSource}
</span>
)}
<span className="text-2xs text-content-secondary/60 ml-auto">Source: {tide.dataSource}</span>
</div>
{tide.nearbyStations.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{tide.nearbyStations.map((ns) => {
const isSelected = tide.stationId === ns.id
const isOverride = tide.stationOverride === ns.id
return (
<button
key={ns.id}
onClick={() => tide.setStationOverride(isOverride ? "" : ns.id)}
className={
"text-2xs px-1.5 py-0.5 rounded border transition-colors " +
(isSelected
? "border-content-victronCyan/50 text-content-victronCyan bg-content-victronCyan/10"
: "border-content-secondary/20 text-content-secondary hover:text-content-primary hover:border-content-primary/40")
}
title={ns.id + (ns.type === "S" ? " (subordinate)" : "") + " — " + ns.distance + " km"}
>
{ns.name}
<span className="opacity-50 ml-1">{ns.distance}km</span>
{ns.type === "S" && <span className="opacity-40 ml-0.5">S</span>}
</button>
)
})}
</div>
)}
</div>
)}
{/* Data summary */}
<div className="flex items-center gap-4 px-2 py-0.5">
<span className="text-2xs text-content-secondary">
Observed: {observedData.length} pts
{observedData.length > 0 && " (" + Math.round((Date.now() / 1000 - observedData[0].ts) / 3600) + "h)"}
</span>
<span className="text-2xs text-content-secondary">Station: {tide.stationPredictions.length} pts</span>
{tide.localPredictions.length > 0 && (
<span className="text-2xs text-content-secondary">Local: {tide.localPredictions.length} pts</span>
)}
<div className="flex gap-3 ml-auto">
<div className="flex items-center gap-1">
<span className="w-3 h-0.5 bg-[#22d3ee] inline-block rounded" />
<span className="text-2xs text-content-secondary">Observed</span>
</div>
<div className="flex items-center gap-1">
<span className="w-3 h-0.5 inline-block rounded" style={{ borderBottom: "1.5px dashed #9ca3af" }} />
<span className="text-2xs text-content-secondary">Station</span>
</div>
{tide.localPredictions.length > 0 && (
<div className="flex items-center gap-1">
<span className="w-3 h-0.5 inline-block rounded" style={{ borderBottom: "1.5px dashed #818cf8" }} />
<span className="text-2xs text-content-secondary">Local</span>
</div>
)}
</div>
</div>
{/* Chart */}
<div className="flex-1 bg-surface-secondary rounded-md min-h-0 p-1">
<div className="w-full h-full" style={{ minHeight: 120 }}>
<AutoSizeAnalysisChart
observed={observedData}
stationPredicted={tide.stationPredictions}
localPredicted={tide.localPredictions}
nightIntervals={nightIntervals}
imperial={tide.imperial}
utc={utc}
/>
</div>
</div>
</div>
</MainLayout>
)
}
export default observer(TideAnalysisView)