Initial project import
This commit is contained in:
303
src/components/StatusPanel.tsx
Normal file
303
src/components/StatusPanel.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSimulation, computeReservoirWaterStats } from '../simulation/flowSimulator';
|
||||
import { useBuilder } from '../store/builderStore';
|
||||
import { computeRoomMetrics } from '../environment/roomMetrics';
|
||||
import type { WarningLevel } from '../types';
|
||||
|
||||
const LEVEL_STYLE: Record<WarningLevel, string> = {
|
||||
error: 'border-rose-900/60 bg-rose-950/60 text-rose-300',
|
||||
warning: 'border-amber-900/60 bg-amber-950/50 text-amber-300',
|
||||
info: 'border-sky-900/60 bg-sky-950/50 text-sky-300',
|
||||
};
|
||||
|
||||
const LEVEL_ICON: Record<WarningLevel, string> = {
|
||||
error: '⛔',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️',
|
||||
};
|
||||
|
||||
/**
|
||||
* StatusPanel — bottom bar showing live simulation results:
|
||||
* per-pump delivered flow, system totals, and diagnostics.
|
||||
*/
|
||||
export function StatusPanel() {
|
||||
const sim = useSimulation();
|
||||
const partCount = useBuilder((s) => Object.keys(s.parts).length);
|
||||
const parts = useBuilder((s) => s.parts);
|
||||
const setSelected = useBuilder((s) => s.setSelected);
|
||||
const simRunning = useBuilder((s) => s.simRunning);
|
||||
const toggleSimRunning = useBuilder((s) => s.toggleSimRunning);
|
||||
const heatmapMode = useBuilder((s) => s.heatmapMode);
|
||||
const toggleHeatmapMode = useBuilder((s) => s.toggleHeatmapMode);
|
||||
const roomMetrics = useMemo(() => computeRoomMetrics(parts), [parts]);
|
||||
|
||||
const reservoirs = useMemo(() => {
|
||||
return Object.values(parts).filter((p) => p.type === 'reservoir');
|
||||
}, [parts]);
|
||||
|
||||
const reservoirStats = useMemo(() => {
|
||||
return reservoirs.map((res) => computeReservoirWaterStats(res, parts));
|
||||
}, [reservoirs, parts]);
|
||||
|
||||
const lph = sim.totalGph * 3.785;
|
||||
const flowing = sim.pumps.some((p) => p.running);
|
||||
const peakVelocity = Math.max(0, ...Object.values(sim.velocities));
|
||||
const peakPressure = Math.max(0, ...Object.values(sim.pressures));
|
||||
|
||||
return (
|
||||
<footer className="flex h-36 shrink-0 border-t border-zinc-800 bg-zinc-950/90 backdrop-blur">
|
||||
{/* System stats */}
|
||||
<div className="flex w-56 shrink-0 flex-col justify-center gap-1 border-r border-zinc-800 px-4">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Simulation
|
||||
</h3>
|
||||
<div className="text-2xl font-bold text-sky-400 tabular-nums">
|
||||
{sim.totalGph.toFixed(0)}
|
||||
<span className="ml-1 text-xs font-medium text-zinc-500">GPH</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 tabular-nums">
|
||||
≈ {lph.toFixed(0)} L/h · {partCount} parts · {sim.connectionCount} connections
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-600 tabular-nums">
|
||||
peak {peakVelocity.toFixed(1)} ft/s · {peakPressure.toFixed(1)} psi
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<button
|
||||
onClick={toggleSimRunning}
|
||||
title={simRunning ? 'Pause simulation (Space)' : 'Resume simulation (Space)'}
|
||||
className={`inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-semibold transition hover:brightness-125 ${
|
||||
!simRunning
|
||||
? 'bg-amber-950 text-amber-400 ring-1 ring-amber-800'
|
||||
: flowing
|
||||
? 'bg-emerald-950 text-emerald-400 ring-1 ring-emerald-800'
|
||||
: 'bg-zinc-900 text-zinc-500 ring-1 ring-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
{simRunning && flowing && (
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-60" />
|
||||
)}
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
||||
</span>
|
||||
{!simRunning ? '⏸ Paused' : flowing ? 'Water flowing' : 'System idle'}
|
||||
</button>
|
||||
|
||||
<label className="inline-flex cursor-pointer items-center gap-1 text-[10px] font-semibold text-zinc-400 hover:text-zinc-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={heatmapMode}
|
||||
onChange={toggleHeatmapMode}
|
||||
className="accent-sky-500 cursor-pointer h-3 w-3 rounded border-zinc-700 bg-zinc-850"
|
||||
/>
|
||||
<span>Heatmap</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pumps */}
|
||||
<div className="flex w-72 shrink-0 flex-col gap-1.5 overflow-y-auto border-r border-zinc-800 px-4 py-2.5">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">Pumps</h3>
|
||||
{sim.pumps.length === 0 && (
|
||||
<p className="text-xs text-zinc-600">No pumps in the system.</p>
|
||||
)}
|
||||
{sim.pumps.map((p) => (
|
||||
<button
|
||||
key={p.pumpId}
|
||||
onClick={() => setSelected(p.pumpId)}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-900/70 px-2.5 py-1.5 text-left hover:border-zinc-600"
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-xs font-semibold text-zinc-200">
|
||||
{p.gph > 0 ? `${p.gph.toFixed(0)} GPH` : 'No flow'}
|
||||
<span className="ml-1 text-[10px] font-normal text-zinc-500">
|
||||
/ {p.ratedGph} rated
|
||||
</span>
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-zinc-500">
|
||||
head {p.headFt.toFixed(1)}/{p.maxHeadFt} ft
|
||||
</span>
|
||||
</div>
|
||||
{/* Efficiency bar */}
|
||||
<div className="mt-1 h-1 overflow-hidden rounded-full bg-zinc-800">
|
||||
<div
|
||||
className={`h-full rounded-full ${p.gph / p.ratedGph > 0.5 ? 'bg-emerald-500' : p.gph > 0 ? 'bg-amber-500' : 'bg-rose-600'}`}
|
||||
style={{ width: `${Math.max(2, (p.gph / p.ratedGph) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Diagnostics */}
|
||||
<div className="flex w-80 shrink-0 flex-col gap-1 overflow-y-auto border-r border-zinc-800 px-4 py-2.5">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Environment
|
||||
</h3>
|
||||
{roomMetrics.length === 0 ? (
|
||||
<p className="text-xs text-zinc-600">Add a grow tent/room to track air exchange.</p>
|
||||
) : (
|
||||
roomMetrics.map((room) => (
|
||||
<button
|
||||
key={room.roomId}
|
||||
onClick={() => setSelected(room.roomId)}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-900/70 px-2.5 py-1.5 text-left hover:border-zinc-600"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-zinc-200">{room.label}</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
||||
room.sealed
|
||||
? 'bg-emerald-950 text-emerald-300 ring-1 ring-emerald-800'
|
||||
: 'bg-sky-950 text-sky-300 ring-1 ring-sky-800'
|
||||
}`}
|
||||
>
|
||||
{room.sealed ? 'Sealed' : 'Open air'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-400 tabular-nums">
|
||||
{room.volumeFt3.toFixed(0)} ft3 · {room.exhaustCfm.toFixed(0)} exhaust CFM
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-500 tabular-nums">
|
||||
{room.airChangesPerMinute.toFixed(2)} exchanges/min · {room.airChangesPerHour.toFixed(1)} ACH
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-600 tabular-nums">
|
||||
circ {room.circulationCfm.toFixed(0)} CFM · light {room.lightWatts.toFixed(0)} W · CO2 {room.co2TankCount} tank{room.co2TankCount === 1 ? '' : 's'}
|
||||
</div>
|
||||
<div className="mt-1 flex gap-1.5 text-[9px] font-mono">
|
||||
<span className="rounded bg-zinc-900 px-1 py-0.5 text-emerald-400 border border-zinc-800">
|
||||
VPD {room.roomVPD.toFixed(2)} kPa
|
||||
</span>
|
||||
<span className="rounded bg-zinc-900 px-1 py-0.5 text-amber-400 border border-zinc-800">
|
||||
DLI {room.roomDLI.toFixed(1)}
|
||||
</span>
|
||||
<span className="rounded bg-zinc-900 px-1 py-0.5 text-sky-400 border border-zinc-800">
|
||||
CO₂ {room.roomCo2} ppm
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Room Readout */}
|
||||
<div className="flex w-56 shrink-0 flex-col justify-center gap-1.5 border-r border-zinc-800 px-4 py-2.5">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Room Readout
|
||||
</h3>
|
||||
{roomMetrics.length === 0 ? (
|
||||
<p className="text-xs text-zinc-600">No room placed</p>
|
||||
) : (() => {
|
||||
const m = roomMetrics[0];
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 text-[10px] font-mono tabular-nums">
|
||||
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-rose-400">
|
||||
🌡️ {m.roomTempF.toFixed(0)}°F
|
||||
</span>
|
||||
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-sky-400">
|
||||
💧 {m.roomRH.toFixed(0)}%
|
||||
</span>
|
||||
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-emerald-400">
|
||||
VPD {m.roomVPD.toFixed(2)}
|
||||
</span>
|
||||
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-sky-400">
|
||||
CO₂ {m.roomCo2}
|
||||
</span>
|
||||
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-amber-400">
|
||||
☀️ {m.roomPPFD.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Aeration & Water */}
|
||||
<div className="flex w-72 shrink-0 flex-col gap-1.5 overflow-y-auto border-r border-zinc-800 px-4 py-2.5">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Aeration & Water
|
||||
</h3>
|
||||
{reservoirStats.length === 0 && (
|
||||
<p className="text-xs text-zinc-600">No reservoirs in the system.</p>
|
||||
)}
|
||||
{reservoirStats.map((stats) => (
|
||||
<button
|
||||
key={stats.id}
|
||||
onClick={() => setSelected(stats.id)}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-900/70 px-2.5 py-1.5 text-left hover:border-zinc-600"
|
||||
>
|
||||
{(() => {
|
||||
const runtime = sim.reservoirRuntime[stats.id];
|
||||
return runtime ? (
|
||||
<div className="mb-1 flex items-center justify-between text-[9px] font-mono">
|
||||
<span className={`${runtime.empty ? 'text-rose-400' : 'text-sky-400'}`}>
|
||||
{runtime.gallonsCurrent.toFixed(1)} / {runtime.gallonsCapacity.toFixed(1)} gal
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
{runtime.minutesRemaining === null
|
||||
? 'steady'
|
||||
: `${runtime.minutesRemaining.toFixed(1)} min left`}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-xs font-semibold text-zinc-200">
|
||||
{stats.label}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-zinc-500">
|
||||
{stats.volGal.toFixed(1)} Gal
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between text-[10px] text-zinc-400">
|
||||
<span>Aeration: {stats.activeAerationGph} GPH</span>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[9px] font-bold ${
|
||||
stats.tdo >= 8.0
|
||||
? 'bg-emerald-950 text-emerald-400 border border-emerald-800/30'
|
||||
: stats.tdo >= 7.0
|
||||
? 'bg-sky-950 text-sky-300 border border-sky-800/30'
|
||||
: 'bg-amber-950 text-amber-400 border border-amber-800/30 animate-pulse'
|
||||
}`}
|
||||
>
|
||||
{stats.tdo.toFixed(1)} mg/L
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-between text-[9px] text-zinc-500 font-mono">
|
||||
<span className={stats.tempF > 73.0 ? 'text-amber-400 font-semibold' : 'text-zinc-500'}>
|
||||
Temp: {stats.tempF.toFixed(1)}°F
|
||||
</span>
|
||||
<span className={stats.ph < 5.5 || stats.ph > 6.5 ? 'text-amber-400 font-semibold' : 'text-zinc-500'}>
|
||||
pH: {stats.ph.toFixed(1)}
|
||||
</span>
|
||||
<span className={stats.ec < 0.8 || stats.ec > 2.0 ? 'text-amber-400 font-semibold' : 'text-zinc-500'}>
|
||||
EC: {stats.ec.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1 overflow-y-auto px-4 py-2.5">
|
||||
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||||
Diagnostics
|
||||
</h3>
|
||||
{sim.warnings.length === 0 ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-emerald-400">
|
||||
✓ System healthy — no issues detected.
|
||||
</p>
|
||||
) : (
|
||||
sim.warnings.map((w, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => w.partId && setSelected(w.partId)}
|
||||
className={`rounded-md border px-2.5 py-1 text-left text-[11px] leading-snug ${LEVEL_STYLE[w.level]} ${
|
||||
w.partId ? 'cursor-pointer hover:brightness-125' : 'cursor-default'
|
||||
}`}
|
||||
>
|
||||
{LEVEL_ICON[w.level]} {w.message}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user