647 lines
22 KiB
TypeScript
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)
|