import { useEffect, useRef, useState, useMemo } from 'react'; import { PART_DEFS, STANDARD_PIPE_SIZES, snapPipeSize } from '../parts/catalog'; import { useBuilder } from '../store/builderStore'; import { useSimulation, computeReservoirWaterStats } from '../simulation/flowSimulator'; import { computeRoomMetrics } from '../environment/roomMetrics'; import type { PlacedPart, Vec3 } from '../types'; import { ColorSwatchRow } from './ColorPopover'; const rad2deg = (r: number) => Math.round((r * 180) / Math.PI); const deg2rad = (d: number) => (d * Math.PI) / 180; const ELECTRICAL_PARTS = new Set([ 'pump', 'airCompressor', 'waterChiller', 'growLight', 'inlineFan', 'circulationFan', ]); function formatDurationHelper(mins: number) { if (mins < 60) return `${mins}m`; const hrs = Math.floor(mins / 60); const remaining = mins % 60; return remaining > 0 ? `${hrs}h ${remaining}m` : `${hrs}h`; } /** * PropertiesPanel — right sidebar inspector. * One part selected: full inspector (pump quick-settings first for pumps). * Multiple parts: compact group panel (counts, color, shared actions). */ export function PropertiesPanel() { const selectedIds = useBuilder((s) => s.selectedIds); return ( ); } function EmptyState() { const growthStage = useBuilder((s) => s.growthStage); const setGrowthStage = useBuilder((s) => s.setGrowthStage); return (
{/* Global Growth Stage selector */}

Crop Growth Stage

Select a part to edit its properties.
  • Click select · drag to move
  • ⇧Click add/remove from selection
  • ⌘Click change color
  • Drag connector dots to snap pipes
  • ⇧Drag move vertically (3D view)
  • ⌥Drag clone while dragging
  • Space play / pause water
  • R rotate 90°   Q/E lower / raise
  • D duplicate   delete
  • ⌘Z undo   ⇧⌘Z redo
  • Esc deselect / cancel
); } function Kbd({ children }: { children: React.ReactNode }) { return ( {children} ); } // --------------------------------------------------------------------------- // Multi-selection panel // --------------------------------------------------------------------------- function MultiInspector({ ids }: { ids: string[] }) { const parts = useBuilder((s) => s.parts); const removeParts = useBuilder((s) => s.removeParts); const duplicateParts = useBuilder((s) => s.duplicateParts); const counts = new Map(); for (const id of ids) { const part = parts[id]; if (!part) continue; const label = PART_DEFS[part.type].label; counts.set(label, (counts.get(label) ?? 0) + 1); } return (
    {[...counts.entries()].map(([label, n]) => (
  • {label} × {n}
  • ))}

Drag any selected part (or the gizmo) to move the whole group.

); } // --------------------------------------------------------------------------- // Pump quick-settings card // --------------------------------------------------------------------------- /** Pump param keys surfaced in the quick card (hidden from the generic list). */ const PUMP_CARD_KEYS = new Set(['gph', 'maxHeadFt']); const AIR_COMPRESSOR_CARD_KEYS = new Set(['gph', 'frequency']); const RESERVOIR_CARD_KEYS = new Set(['ph', 'ec']); function PumpCard({ part }: { part: PlacedPart }) { const setParam = useBuilder((s) => s.setParam); const sim = useSimulation(); const cardRef = useRef(null); const [flash, setFlash] = useState(false); // Double-clicking a pump in the scene scrolls to + flashes this card. useEffect(() => { const onFlash = (e: Event) => { if ((e as CustomEvent).detail !== part.id) return; cardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); setFlash(true); setTimeout(() => setFlash(false), 1000); }; window.addEventListener('hydro:flash-pump', onFlash); return () => window.removeEventListener('hydro:flash-pump', onFlash); }, [part.id]); const def = PART_DEFS.pump; const on = (part.params.on ?? 1) > 0; const result = sim.pumps.find((p) => p.pumpId === part.id); const gph = part.params.gph ?? def.defaults.gph; const maxHead = part.params.maxHeadFt ?? def.defaults.maxHeadFt; return (
Pump {/* Big power toggle */}
{/* Live delivered flow */}
{result && on ? result.gph.toFixed(0) : '0'} GPH delivered
{result ? `head ${result.headFt.toFixed(1)} / ${result.maxHeadFt} ft` : 'not connected to a reservoir'}
setParam(part.id, 'gph', v)} /> setParam(part.id, 'maxHeadFt', v)} />
); } function AirCompressorCard({ part }: { part: PlacedPart }) { const setParam = useBuilder((s) => s.setParam); const parts = useBuilder((s) => s.parts); const cardRef = useRef(null); const [flash, setFlash] = useState(false); // Double-clicking an air compressor in the scene scrolls to + flashes this card. useEffect(() => { const onFlash = (e: Event) => { if ((e as CustomEvent).detail !== part.id) return; cardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); setFlash(true); setTimeout(() => setFlash(false), 1000); }; window.addEventListener('hydro:flash-airCompressor', onFlash); return () => window.removeEventListener('hydro:flash-airCompressor', onFlash); }, [part.id]); const def = PART_DEFS.airCompressor; const on = (part.params.on ?? 1) > 0; const gph = part.params.gph ?? def.defaults.gph; const frequency = part.params.frequency ?? def.defaults.frequency; // Find nearest reservoir const reservoir = useMemo(() => { let nearest: PlacedPart | null = null; let minDist = Infinity; const pos = part.position; for (const p of Object.values(parts)) { if (p.type === 'reservoir') { const d = Math.hypot(p.position[0] - pos[0], p.position[1] - pos[1], p.position[2] - pos[2]); if (d < minDist) { minDist = d; nearest = p; } } } return nearest; }, [parts, part.position]); // Compute stats if we have a reservoir const stats = useMemo(() => { if (!reservoir) return null; // Find all air compressors targeting this reservoir let totalAerationGph = 0; for (const p of Object.values(parts)) { if (p.type === 'airCompressor' && (p.params.on ?? 1) > 0) { // Find nearest reservoir for this compressor let compNearest: PlacedPart | null = null; let cMinDist = Infinity; for (const res of Object.values(parts)) { if (res.type === 'reservoir') { const d = Math.hypot(res.position[0] - p.position[0], res.position[1] - p.position[1], res.position[2] - p.position[2]); if (d < cMinDist) { cMinDist = d; compNearest = res; } } } if (compNearest && compNearest.id === reservoir.id) { totalAerationGph += p.params.gph ?? 120; } } } const w = reservoir.params.width ?? 2; const d = reservoir.params.depth ?? 1.4; const h = reservoir.params.height ?? 1; const level = reservoir.params.level ?? 70; const volGal = w * d * h * (level / 100) * 7.48; // TDO Calculation let tdo = 5.5; if (totalAerationGph > 0 && volGal > 0) { const ratio = totalAerationGph / volGal; tdo = 5.5 + 3.7 * (1 - Math.exp(-ratio * 2.0)); } return { volGal, totalAerationGph, tdo, }; }, [parts, reservoir]); return (
Air Compressor
{/* Target and Live Dissolved Oxygen */}
Target Reservoir:
{reservoir ? (
{reservoir.label || `Reservoir #${reservoir.id}`} {Math.hypot(reservoir.position[0] - part.position[0], reservoir.position[2] - part.position[2]).toFixed(1)} ft away
{stats && (
Dissolved O₂: = 8.0 ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' : stats.tdo >= 7.0 ? 'bg-sky-500/10 text-sky-400 border border-sky-500/20' : 'bg-amber-500/10 text-amber-400 border border-amber-500/20' }`} > {stats.tdo.toFixed(1)} mg/L
Tank Volume: {stats.volGal.toFixed(1)} Gal
Total Aeration: {stats.totalAerationGph} GPH
)}
) : (
No reservoir in scene!
)}
setParam(part.id, 'gph', v)} /> setParam(part.id, 'frequency', v)} />
); } function GrowTentCard({ part }: { part: PlacedPart }) { const parts = useBuilder((s) => s.parts); const metrics = useMemo(() => { const allMetrics = computeRoomMetrics(parts); return allMetrics.find((m) => m.roomId === part.id); }, [parts, part.id]); if (!metrics) return null; return (
Room Simulation {metrics.volumeFt3.toFixed(0)} ft³
{/* VPD */}
VPD
{metrics.roomVPD.toFixed(2)} kPa
{/* DLI */}
DLI
{metrics.roomDLI.toFixed(1)}
{/* CO2 */}
CO₂
{metrics.roomCo2} PPM
Air Exchange: {metrics.airChangesPerMinute.toFixed(2)} /min
Fresh Air: {metrics.netFreshAirCfm.toFixed(0)} CFM
Intake / Exhaust: {metrics.intakeCfm.toFixed(0)} / {metrics.exhaustCfm.toFixed(0)} CFM
Est. Temp: {metrics.roomTempF.toFixed(1)}°F
Est. RH: {metrics.roomRH}%
Room PPFD: {metrics.roomPPFD.toFixed(0)} μmol/m²/s
AC Sizing: {Math.round(metrics.acSizingBtuHr)} BTU/hr
Dehumidifier: {metrics.dehumidifierPintsDay.toFixed(1)} Pints/day
Sensible Heat: {Math.round(metrics.sensibleHeatGainsBtuHr)} BTU/hr
Latent Cooling: {Math.round(metrics.latentCoolingBtuHr)} BTU/hr
); } function ReservoirCard({ part }: { part: PlacedPart }) { const setParam = useBuilder((s) => s.setParam); const parts = useBuilder((s) => s.parts); const growthStage = useBuilder((s) => s.growthStage); const sim = useSimulation(); const stats = useMemo(() => computeReservoirWaterStats(part, parts), [part, parts]); const runtime = sim.reservoirRuntime[part.id]; const { ecLow, ecHigh } = useMemo(() => { if (growthStage === 'seedling') return { ecLow: 0.4, ecHigh: 0.8 }; if (growthStage === 'flowering') return { ecLow: 1.5, ecHigh: 2.2 }; return { ecLow: 1.0, ecHigh: 1.6 }; // vegetative }, [growthStage]); const isEcAlert = stats.ec < ecLow || stats.ec > ecHigh; return (
Reservoir Health {(runtime?.gallonsCurrent ?? stats.volGal).toFixed(1)} Gal
Runtime
{runtime ? runtime.minutesRemaining === null ? 'Stable' : runtime.empty ? 'Dry' : `${runtime.minutesRemaining.toFixed(1)} min` : 'Stable'}
{runtime?.minutesRemaining === null ? 'No net draw' : runtime?.empty ? 'Source exhausted' : `${runtime.netOutflowGph.toFixed(1)} GPH net draw`}
Volume
{runtime ? `${runtime.gallonsCurrent.toFixed(1)} / ${runtime.gallonsCapacity.toFixed(1)}` : `${stats.volGal.toFixed(1)} / ${stats.volGal.toFixed(1)}`}
Gallons
Line Hold
{runtime ? runtime.gallonsHeldInSystem.toFixed(2) : '0.00'}
Gal in plumbing
{/* Water Stats Grid */}
{/* Temp */}
Temp
73.0 ? 'text-amber-400' : 'text-emerald-400' }`} > {stats.tempF.toFixed(1)}°F
{stats.tempF > 73.0 ? 'Too Warm' : 'Optimal'}
{/* pH */}
pH
6.5 ? 'text-amber-400' : 'text-emerald-400' }`} > {stats.ph.toFixed(1)}
{stats.ph < 5.5 || stats.ph > 6.5 ? 'Lockout' : 'Optimal'}
{/* EC */}
EC
{stats.ec.toFixed(1)}
{isEcAlert ? 'Alert' : 'Optimal'}
setParam(part.id, 'ph', v)} /> setParam(part.id, 'ec', v)} />
); } function QuickSlider({ label, unit, value, meta, onChange, }: { label: string; unit: string; value: number; meta: { min: number; max: number; step: number }; onChange: (v: number) => void; }) { return (
{label} {value} {unit}
onChange(parseFloat(e.target.value))} className="w-full accent-sky-500" />
); } // --------------------------------------------------------------------------- // Single-part inspector // --------------------------------------------------------------------------- function PartInspector({ partId }: { partId: string }) { const part = useBuilder((s) => s.parts[partId]); const parts = useBuilder((s) => s.parts); const updatePart = useBuilder((s) => s.updatePart); const setParam = useBuilder((s) => s.setParam); const removeParts = useBuilder((s) => s.removeParts); const duplicateParts = useBuilder((s) => s.duplicateParts); const rotateParts = useBuilder((s) => s.rotateParts); const pushHistory = useBuilder((s) => s.pushHistory); const sim = useSimulation(); if (!part) return null; const def = PART_DEFS[part.type]; const netPotStatus = part.type === 'netPot' ? sim.netPots.find((p) => p.partId === partId) : undefined; const sourcePart = netPotStatus ? parts[netPotStatus.sourcePartId] : undefined; const setPos = (axis: number, v: number) => { pushHistory(`pos:${partId}:${axis}`); const position = [...part.position] as Vec3; position[axis] = axis === 1 ? Math.max(0, v) : v; updatePart(partId, { position }); }; const setRot = (axis: number, degVal: number) => { pushHistory(`rot:${partId}:${axis}`); const rotation = [...part.rotation] as Vec3; rotation[axis] = deg2rad(degVal); updatePart(partId, { rotation }); }; const paramEntries = Object.entries(def.params).filter( ([key]) => (part.type !== 'pump' || !PUMP_CARD_KEYS.has(key)) && (part.type !== 'airCompressor' || !AIR_COMPRESSOR_CARD_KEYS.has(key)) && (part.type !== 'reservoir' || !RESERVOIR_CARD_KEYS.has(key)), ); return (
{def.label} #{part.id}
updatePart(partId, { label: e.target.value })} className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-2 py-1.5 text-xs text-zinc-200 placeholder-zinc-600 outline-none focus:border-sky-600" />
{part.type === 'pipe' && (
Tip: Drag the glowing blue ends of the pipe in the 3D view to stretch its length.
)} {part.type === 'pump' && } {part.type === 'airCompressor' && } {part.type === 'reservoir' && } {part.type === 'growTent' && } {part.type === 'netPot' && netPotStatus && (
{netPotStatus.overflowing ? 'Pot overflow active' : 'Pot near overflow'}

Water under this pot is coming from {sourcePart ? PART_DEFS[sourcePart.type].label : 'the flooded line'} at{' '} {netPotStatus.inflowGph.toFixed(0)} GPH. Water height is {netPotStatus.waterHeightFt.toFixed(2)} ft and the rim is {netPotStatus.rimHeightFt.toFixed(2)} ft.

Fix: Add drainage or move the pot off the backed-up run.

)}
{(['X', 'Y', 'Z'] as const).map((axis, i) => ( setPos(i, v)} /> ))}
{(['X', 'Y', 'Z'] as const).map((axis, i) => ( setRot(i, v)} /> ))}
rotateParts([partId], 1, Math.PI / 2)}>⟳ 90° Y rotateParts([partId], 2, Math.PI / 2)}>⟳ 90° Z updatePart(partId, { rotation: [0, 0, 0] })}>Reset
{paramEntries.length > 0 && (
{paramEntries.map(([key, meta]) => (
{meta.label} {part.params[key] ?? def.defaults[key]} {meta.unit ? ` ${meta.unit}` : ''}
{key === 'diameter' || key === 'diameterA' || key === 'diameterB' ? ( ) : ( setParam(partId, key, parseFloat(e.target.value))} className="w-full accent-sky-500" /> )}
))}
)} {ELECTRICAL_PARTS.has(part.type) && (
{(part.params.timerEnabled ?? 0) === 1 && (
ON Duration
{formatDurationHelper(part.params.timerOnMin ?? 15)}
OFF Duration
{formatDurationHelper(part.params.timerOffMin ?? 45)}
)}
)}
); } function Section({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
); } function NumberField({ label, value, step, onChange, }: { label: string; value: number; step: number; onChange: (v: number) => void; }) { return ( ); } function MiniBtn({ children, onClick }: { children: React.ReactNode; onClick: () => void }) { return ( ); }