990 lines
37 KiB
TypeScript
990 lines
37 KiB
TypeScript
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<string>([
|
||
'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 (
|
||
<aside className="flex w-64 shrink-0 flex-col border-l border-zinc-800 bg-zinc-950/80 backdrop-blur">
|
||
<div className="border-b border-zinc-800 px-4 py-3">
|
||
<h2 className="text-xs font-semibold tracking-widest text-zinc-400 uppercase">
|
||
Inspector
|
||
{selectedIds.length > 1 && (
|
||
<span className="ml-2 rounded-full bg-sky-500/15 px-2 py-0.5 text-[10px] font-bold text-sky-300">
|
||
{selectedIds.length} selected
|
||
</span>
|
||
)}
|
||
</h2>
|
||
</div>
|
||
{selectedIds.length === 0 ? (
|
||
<EmptyState />
|
||
) : selectedIds.length === 1 ? (
|
||
<PartInspector partId={selectedIds[0]} />
|
||
) : (
|
||
<MultiInspector ids={selectedIds} />
|
||
)}
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function EmptyState() {
|
||
const growthStage = useBuilder((s) => s.growthStage);
|
||
const setGrowthStage = useBuilder((s) => s.setGrowthStage);
|
||
return (
|
||
<div className="px-4 py-6 text-xs leading-relaxed text-zinc-600 space-y-4">
|
||
{/* Global Growth Stage selector */}
|
||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-3">
|
||
<h3 className="mb-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">Crop Growth Stage</h3>
|
||
<select
|
||
value={growthStage}
|
||
onChange={(e) => setGrowthStage(e.target.value as any)}
|
||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-2 py-1.5 text-xs text-zinc-200 outline-none focus:border-sky-600 cursor-pointer"
|
||
>
|
||
<option value="seedling">Seedling</option>
|
||
<option value="vegetative">Vegetative</option>
|
||
<option value="flowering">Flowering</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
Select a part to edit its properties.
|
||
<ul className="mt-4 space-y-1.5 text-[11px] text-zinc-700">
|
||
<li><Kbd>Click</Kbd> select · drag to move</li>
|
||
<li><Kbd>⇧Click</Kbd> add/remove from selection</li>
|
||
<li><Kbd>⌘Click</Kbd> change color</li>
|
||
<li>Drag <span className="text-sky-600">connector dots</span> to snap pipes</li>
|
||
<li><Kbd>⇧Drag</Kbd> move vertically (3D view)</li>
|
||
<li><Kbd>⌥Drag</Kbd> clone while dragging</li>
|
||
<li><Kbd>Space</Kbd> play / pause water</li>
|
||
<li><Kbd>R</Kbd> rotate 90° <Kbd>Q</Kbd>/<Kbd>E</Kbd> lower / raise</li>
|
||
<li><Kbd>D</Kbd> duplicate <Kbd>⌫</Kbd> delete</li>
|
||
<li><Kbd>⌘Z</Kbd> undo <Kbd>⇧⌘Z</Kbd> redo</li>
|
||
<li><Kbd>Esc</Kbd> deselect / cancel</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Kbd({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<kbd className="rounded border border-zinc-700 bg-zinc-800 px-1.5 py-0.5 font-mono text-[10px] text-zinc-300">
|
||
{children}
|
||
</kbd>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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<string, number>();
|
||
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 (
|
||
<div className="flex-1 space-y-3 overflow-y-auto px-3.5 py-2.5">
|
||
<Section title="Selection">
|
||
<ul className="space-y-1">
|
||
{[...counts.entries()].map(([label, n]) => (
|
||
<li key={label} className="flex justify-between text-xs text-zinc-300">
|
||
<span>{label}</span>
|
||
<span className="font-mono text-zinc-500">× {n}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<p className="mt-2 text-[10px] leading-relaxed text-zinc-600">
|
||
Drag any selected part (or the gizmo) to move the whole group.
|
||
</p>
|
||
</Section>
|
||
|
||
<Section title="Color">
|
||
<ColorSwatchRow ids={ids} />
|
||
</Section>
|
||
|
||
<div className="flex gap-2 pt-1">
|
||
<button
|
||
onClick={() => duplicateParts(ids)}
|
||
title="Duplicate the whole selection (D)"
|
||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-[11px] font-medium text-zinc-200 hover:bg-zinc-700"
|
||
>
|
||
Duplicate
|
||
</button>
|
||
<button
|
||
onClick={() => removeParts(ids)}
|
||
title="Delete every selected part (⌫)"
|
||
className="flex-1 rounded-md border border-rose-900 bg-rose-950 px-2 py-1 text-[11px] font-medium text-rose-300 hover:bg-rose-900"
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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<HTMLDivElement>(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<string>).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 (
|
||
<div
|
||
ref={cardRef}
|
||
className={`rounded-lg border p-3 transition-all duration-300 ${
|
||
flash
|
||
? 'border-sky-400 bg-sky-950/60 ring-2 ring-sky-400/60'
|
||
: 'border-sky-900/70 bg-sky-950/30'
|
||
}`}
|
||
>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<span className="text-[10px] font-bold tracking-wider text-sky-400 uppercase">Pump</span>
|
||
{/* Big power toggle */}
|
||
<button
|
||
onClick={() => setParam(part.id, 'on', on ? 0 : 1)}
|
||
title={on ? 'Turn pump off' : 'Turn pump on'}
|
||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||
on ? 'bg-emerald-500' : 'bg-zinc-700'
|
||
}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 grid h-5 w-5 place-items-center rounded-full bg-white text-[8px] font-black text-zinc-700 shadow transition-all ${
|
||
on ? 'left-6' : 'left-0.5'
|
||
}`}
|
||
>
|
||
{on ? 'I' : 'O'}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Live delivered flow */}
|
||
<div className="mb-2 rounded-md bg-zinc-900/70 px-2.5 py-1.5">
|
||
<div className="text-lg font-bold tabular-nums text-sky-300">
|
||
{result && on ? result.gph.toFixed(0) : '0'}
|
||
<span className="ml-1 text-[10px] font-medium text-zinc-500">GPH delivered</span>
|
||
</div>
|
||
<div className="text-[10px] text-zinc-500 tabular-nums">
|
||
{result
|
||
? `head ${result.headFt.toFixed(1)} / ${result.maxHeadFt} ft`
|
||
: 'not connected to a reservoir'}
|
||
</div>
|
||
</div>
|
||
|
||
<QuickSlider
|
||
label="Rated flow"
|
||
unit="GPH"
|
||
value={gph}
|
||
meta={def.params.gph}
|
||
onChange={(v) => setParam(part.id, 'gph', v)}
|
||
/>
|
||
<QuickSlider
|
||
label="Max head"
|
||
unit="ft"
|
||
value={maxHead}
|
||
meta={def.params.maxHeadFt}
|
||
onChange={(v) => setParam(part.id, 'maxHeadFt', v)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AirCompressorCard({ part }: { part: PlacedPart }) {
|
||
const setParam = useBuilder((s) => s.setParam);
|
||
const parts = useBuilder((s) => s.parts);
|
||
const cardRef = useRef<HTMLDivElement>(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<string>).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 (
|
||
<div
|
||
ref={cardRef}
|
||
className={`rounded-lg border p-3 transition-all duration-300 ${
|
||
flash
|
||
? 'border-sky-400 bg-sky-950/60 ring-2 ring-sky-400/60'
|
||
: 'border-sky-900/70 bg-sky-950/30'
|
||
}`}
|
||
>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<span className="text-[10px] font-bold tracking-wider text-sky-400 uppercase">Air Compressor</span>
|
||
<button
|
||
onClick={() => setParam(part.id, 'on', on ? 0 : 1)}
|
||
title={on ? 'Turn compressor off' : 'Turn compressor on'}
|
||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||
on ? 'bg-emerald-500' : 'bg-zinc-700'
|
||
}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 grid h-5 w-5 place-items-center rounded-full bg-white text-[8px] font-black text-zinc-700 shadow transition-all ${
|
||
on ? 'left-6' : 'left-0.5'
|
||
}`}
|
||
>
|
||
{on ? 'I' : 'O'}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Target and Live Dissolved Oxygen */}
|
||
<div className="mb-3 rounded-md bg-zinc-900/70 px-2.5 py-1.5 space-y-1">
|
||
<div className="text-[10px] font-semibold text-zinc-400">Target Reservoir:</div>
|
||
{reservoir ? (
|
||
<div>
|
||
<div className="flex justify-between items-center text-xs text-zinc-200">
|
||
<span className="font-medium">{reservoir.label || `Reservoir #${reservoir.id}`}</span>
|
||
<span className="text-[10px] font-mono text-zinc-500">
|
||
{Math.hypot(reservoir.position[0] - part.position[0], reservoir.position[2] - part.position[2]).toFixed(1)} ft away
|
||
</span>
|
||
</div>
|
||
{stats && (
|
||
<div className="mt-1.5 pt-1.5 border-t border-zinc-800/60 space-y-1.5">
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-[10px] text-zinc-500">Dissolved O₂:</span>
|
||
<span
|
||
className={`rounded px-1.5 py-0.5 text-[10px] font-bold ${
|
||
stats.tdo >= 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
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between text-[9px] text-zinc-500">
|
||
<span>Tank Volume:</span>
|
||
<span className="font-mono text-zinc-300">{stats.volGal.toFixed(1)} Gal</span>
|
||
</div>
|
||
<div className="flex justify-between text-[9px] text-zinc-500">
|
||
<span>Total Aeration:</span>
|
||
<span className="font-mono text-zinc-300">{stats.totalAerationGph} GPH</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="text-xs text-amber-400/80 font-medium">No reservoir in scene!</div>
|
||
)}
|
||
</div>
|
||
|
||
<QuickSlider
|
||
label="Air Flow"
|
||
unit="GPH"
|
||
value={gph}
|
||
meta={def.params.gph}
|
||
onChange={(v) => setParam(part.id, 'gph', v)}
|
||
/>
|
||
<QuickSlider
|
||
label="Bubble Frequency"
|
||
unit="Hz"
|
||
value={frequency}
|
||
meta={def.params.frequency}
|
||
onChange={(v) => setParam(part.id, 'frequency', v)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="rounded-lg border border-sky-900/50 bg-sky-950/20 p-3 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-[10px] font-bold tracking-wider text-sky-400 uppercase">Room Simulation</span>
|
||
<span className="font-mono text-xs text-sky-300">{metrics.volumeFt3.toFixed(0)} ft³</span>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{/* VPD */}
|
||
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
|
||
<div className="text-[9px] text-zinc-500 font-bold uppercase">VPD</div>
|
||
<div className="mt-0.5 text-xs font-bold text-emerald-400">
|
||
{metrics.roomVPD.toFixed(2)}
|
||
<span className="text-[8px] font-normal text-zinc-500 ml-0.5">kPa</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* DLI */}
|
||
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
|
||
<div className="text-[9px] text-zinc-500 font-bold uppercase">DLI</div>
|
||
<div className="mt-0.5 text-xs font-bold text-amber-400">
|
||
{metrics.roomDLI.toFixed(1)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* CO2 */}
|
||
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
|
||
<div className="text-[9px] text-zinc-500 font-bold uppercase">CO₂</div>
|
||
<div className="mt-0.5 text-xs font-bold text-sky-400">
|
||
{metrics.roomCo2}
|
||
<span className="text-[8px] font-normal text-zinc-500 ml-0.5">PPM</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="text-[10px] text-zinc-400 space-y-1 pt-1.5 border-t border-zinc-800/60 font-mono">
|
||
<div className="flex justify-between">
|
||
<span>Air Exchange:</span>
|
||
<span className="text-zinc-200">{metrics.airChangesPerMinute.toFixed(2)} /min</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>Fresh Air:</span>
|
||
<span className="text-zinc-200">{metrics.netFreshAirCfm.toFixed(0)} CFM</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>Intake / Exhaust:</span>
|
||
<span className="text-zinc-200">{metrics.intakeCfm.toFixed(0)} / {metrics.exhaustCfm.toFixed(0)} CFM</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>Est. Temp:</span>
|
||
<span className="text-zinc-200">{metrics.roomTempF.toFixed(1)}°F</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>Est. RH:</span>
|
||
<span className="text-zinc-200">{metrics.roomRH}%</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>Room PPFD:</span>
|
||
<span className="text-zinc-200">{metrics.roomPPFD.toFixed(0)} μmol/m²/s</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>AC Sizing:</span>
|
||
<span className="text-zinc-300 font-semibold">{Math.round(metrics.acSizingBtuHr)} BTU/hr</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>Dehumidifier:</span>
|
||
<span className="text-zinc-300 font-semibold">{metrics.dehumidifierPintsDay.toFixed(1)} Pints/day</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>Sensible Heat:</span>
|
||
<span className="text-zinc-500">{Math.round(metrics.sensibleHeatGainsBtuHr)} BTU/hr</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>Latent Cooling:</span>
|
||
<span className="text-emerald-500/80">{Math.round(metrics.latentCoolingBtuHr)} BTU/hr</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="rounded-lg border border-sky-900/50 bg-sky-950/20 p-3 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-[10px] font-bold tracking-wider text-sky-400 uppercase">Reservoir Health</span>
|
||
<span className={`font-mono text-xs ${runtime?.empty ? 'text-rose-300' : 'text-sky-300'}`}>
|
||
{(runtime?.gallonsCurrent ?? stats.volGal).toFixed(1)} Gal
|
||
</span>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
|
||
<div className="text-[9px] text-zinc-500 font-bold uppercase">Runtime</div>
|
||
<div className={`mt-0.5 text-xs font-bold ${runtime?.empty ? 'text-rose-400' : runtime?.minutesRemaining !== null ? 'text-amber-400' : 'text-emerald-400'}`}>
|
||
{runtime
|
||
? runtime.minutesRemaining === null
|
||
? 'Stable'
|
||
: runtime.empty
|
||
? 'Dry'
|
||
: `${runtime.minutesRemaining.toFixed(1)} min`
|
||
: 'Stable'}
|
||
</div>
|
||
<div className="text-[8px] text-zinc-600">
|
||
{runtime?.minutesRemaining === null
|
||
? 'No net draw'
|
||
: runtime?.empty
|
||
? 'Source exhausted'
|
||
: `${runtime.netOutflowGph.toFixed(1)} GPH net draw`}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
|
||
<div className="text-[9px] text-zinc-500 font-bold uppercase">Volume</div>
|
||
<div className="mt-0.5 text-xs font-bold text-sky-300">
|
||
{runtime
|
||
? `${runtime.gallonsCurrent.toFixed(1)} / ${runtime.gallonsCapacity.toFixed(1)}`
|
||
: `${stats.volGal.toFixed(1)} / ${stats.volGal.toFixed(1)}`}
|
||
</div>
|
||
<div className="text-[8px] text-zinc-600">Gallons</div>
|
||
</div>
|
||
|
||
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
|
||
<div className="text-[9px] text-zinc-500 font-bold uppercase">Line Hold</div>
|
||
<div className="mt-0.5 text-xs font-bold text-zinc-200">
|
||
{runtime ? runtime.gallonsHeldInSystem.toFixed(2) : '0.00'}
|
||
</div>
|
||
<div className="text-[8px] text-zinc-600">Gal in plumbing</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Water Stats Grid */}
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{/* Temp */}
|
||
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
|
||
<div className="text-[9px] text-zinc-500 font-bold uppercase">Temp</div>
|
||
<div
|
||
className={`mt-0.5 text-xs font-bold ${
|
||
stats.tempF > 73.0 ? 'text-amber-400' : 'text-emerald-400'
|
||
}`}
|
||
>
|
||
{stats.tempF.toFixed(1)}°F
|
||
</div>
|
||
<div className="text-[8px] text-zinc-600">
|
||
{stats.tempF > 73.0 ? 'Too Warm' : 'Optimal'}
|
||
</div>
|
||
</div>
|
||
|
||
{/* pH */}
|
||
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
|
||
<div className="text-[9px] text-zinc-500 font-bold uppercase">pH</div>
|
||
<div
|
||
className={`mt-0.5 text-xs font-bold ${
|
||
stats.ph < 5.5 || stats.ph > 6.5 ? 'text-amber-400' : 'text-emerald-400'
|
||
}`}
|
||
>
|
||
{stats.ph.toFixed(1)}
|
||
</div>
|
||
<div className="text-[8px] text-zinc-600">
|
||
{stats.ph < 5.5 || stats.ph > 6.5 ? 'Lockout' : 'Optimal'}
|
||
</div>
|
||
</div>
|
||
|
||
{/* EC */}
|
||
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
|
||
<div className="text-[9px] text-zinc-500 font-bold uppercase">EC</div>
|
||
<div
|
||
className={`mt-0.5 text-xs font-bold ${
|
||
isEcAlert ? 'text-amber-400' : 'text-emerald-400'
|
||
}`}
|
||
>
|
||
{stats.ec.toFixed(1)}
|
||
</div>
|
||
<div className="text-[8px] text-zinc-600">
|
||
{isEcAlert ? 'Alert' : 'Optimal'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<QuickSlider
|
||
label="Adjust pH"
|
||
unit=""
|
||
value={part.params.ph ?? 6.0}
|
||
meta={PART_DEFS.reservoir.params.ph}
|
||
onChange={(v) => setParam(part.id, 'ph', v)}
|
||
/>
|
||
<QuickSlider
|
||
label="Adjust EC"
|
||
unit="mS/cm"
|
||
value={part.params.ec ?? 1.2}
|
||
meta={PART_DEFS.reservoir.params.ec}
|
||
onChange={(v) => setParam(part.id, 'ec', v)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="mt-1.5">
|
||
<div className="mb-0.5 flex justify-between text-[11px]">
|
||
<span className="text-zinc-400">{label}</span>
|
||
<span className="font-mono text-sky-400">
|
||
{value} {unit}
|
||
</span>
|
||
</div>
|
||
<input
|
||
type="range"
|
||
min={meta.min}
|
||
max={meta.max}
|
||
step={meta.step}
|
||
value={value}
|
||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||
className="w-full accent-sky-500"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 (
|
||
<div className="flex-1 space-y-3 overflow-y-auto px-3.5 py-2.5">
|
||
<div>
|
||
<div className="mb-1 flex items-center gap-2">
|
||
<span
|
||
className="h-3 w-3 rounded"
|
||
style={{ backgroundColor: part.color ?? def.color }}
|
||
/>
|
||
<span className="text-sm font-semibold text-zinc-200">{def.label}</span>
|
||
<span className="ml-auto font-mono text-[10px] text-zinc-600">#{part.id}</span>
|
||
</div>
|
||
<input
|
||
value={part.label ?? ''}
|
||
placeholder="Name this part…"
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
|
||
{part.type === 'pipe' && (
|
||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-2.5 text-[11px] leading-snug text-zinc-400 mt-2">
|
||
<span className="font-semibold text-zinc-300">Tip:</span> Drag the glowing blue ends of the pipe in the 3D view to stretch its length.
|
||
</div>
|
||
)}
|
||
|
||
{part.type === 'pump' && <PumpCard part={part} />}
|
||
{part.type === 'airCompressor' && <AirCompressorCard part={part} />}
|
||
{part.type === 'reservoir' && <ReservoirCard part={part} />}
|
||
{part.type === 'growTent' && <GrowTentCard part={part} />}
|
||
{part.type === 'netPot' && netPotStatus && (
|
||
<Section title="Hydraulics">
|
||
<div
|
||
className={`rounded-lg border px-3 py-2.5 ${
|
||
netPotStatus.overflowing
|
||
? 'border-rose-900/70 bg-rose-950/40'
|
||
: 'border-amber-900/60 bg-amber-950/30'
|
||
}`}
|
||
>
|
||
<div
|
||
className={`text-xs font-semibold ${
|
||
netPotStatus.overflowing ? 'text-rose-300' : 'text-amber-300'
|
||
}`}
|
||
>
|
||
{netPotStatus.overflowing ? 'Pot overflow active' : 'Pot near overflow'}
|
||
</div>
|
||
<p className="mt-1 text-[11px] leading-snug text-zinc-400">
|
||
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.
|
||
</p>
|
||
<p className="mt-1.5 rounded-md bg-zinc-900/80 px-2 py-1 text-[11px] leading-snug text-zinc-300">
|
||
<span className="font-semibold text-sky-400">Fix: </span>
|
||
Add drainage or move the pot off the backed-up run.
|
||
</p>
|
||
</div>
|
||
</Section>
|
||
)}
|
||
|
||
<Section title="Position (ft)">
|
||
<div className="grid grid-cols-3 gap-1.5">
|
||
{(['X', 'Y', 'Z'] as const).map((axis, i) => (
|
||
<NumberField
|
||
key={axis}
|
||
label={axis}
|
||
value={part.position[i]}
|
||
step={0.25}
|
||
onChange={(v) => setPos(i, v)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
|
||
<Section title="Rotation (°)">
|
||
<div className="grid grid-cols-3 gap-1.5">
|
||
{(['X', 'Y', 'Z'] as const).map((axis, i) => (
|
||
<NumberField
|
||
key={axis}
|
||
label={axis}
|
||
value={rad2deg(part.rotation[i])}
|
||
step={15}
|
||
onChange={(v) => setRot(i, v)}
|
||
/>
|
||
))}
|
||
</div>
|
||
<div className="mt-1.5 flex gap-1.5">
|
||
<MiniBtn onClick={() => rotateParts([partId], 1, Math.PI / 2)}>⟳ 90° Y</MiniBtn>
|
||
<MiniBtn onClick={() => rotateParts([partId], 2, Math.PI / 2)}>⟳ 90° Z</MiniBtn>
|
||
<MiniBtn onClick={() => updatePart(partId, { rotation: [0, 0, 0] })}>Reset</MiniBtn>
|
||
</div>
|
||
</Section>
|
||
|
||
{paramEntries.length > 0 && (
|
||
<Section title="Parameters">
|
||
<div className="space-y-2.5">
|
||
{paramEntries.map(([key, meta]) => (
|
||
<div key={key}>
|
||
<div className="mb-0.5 flex justify-between text-[11px]">
|
||
<span className="text-zinc-400">{meta.label}</span>
|
||
<span className="font-mono text-sky-400">
|
||
{part.params[key] ?? def.defaults[key]}
|
||
{meta.unit ? ` ${meta.unit}` : ''}
|
||
</span>
|
||
</div>
|
||
{key === 'diameter' || key === 'diameterA' || key === 'diameterB' ? (
|
||
<select
|
||
value={String(snapPipeSize(part.params[key] ?? def.defaults[key]))}
|
||
onChange={(e) => setParam(partId, key, parseFloat(e.target.value))}
|
||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-2 py-1 text-[11px] text-zinc-200 outline-none focus:border-sky-600"
|
||
>
|
||
{STANDARD_PIPE_SIZES.map((size) => (
|
||
<option key={size} value={size}>
|
||
{size} in
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : (
|
||
<input
|
||
type="range"
|
||
min={meta.min}
|
||
max={meta.max}
|
||
step={meta.step}
|
||
value={part.params[key] ?? def.defaults[key]}
|
||
onChange={(e) => setParam(partId, key, parseFloat(e.target.value))}
|
||
className="w-full accent-sky-500"
|
||
/>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
)}
|
||
|
||
{ELECTRICAL_PARTS.has(part.type) && (
|
||
<Section title="Smart Timer Schedule">
|
||
<div className="space-y-2 rounded-lg border border-zinc-800 bg-zinc-900/20 p-2.5">
|
||
<label className="flex items-center justify-between text-[11px] cursor-pointer">
|
||
<span className="text-zinc-400 font-semibold">Enable Timer</span>
|
||
<input
|
||
type="checkbox"
|
||
checked={(part.params.timerEnabled ?? 0) === 1}
|
||
onChange={(e) => setParam(partId, 'timerEnabled', e.target.checked ? 1 : 0)}
|
||
className="h-3.5 w-3.5 accent-sky-500 rounded border-zinc-800 bg-zinc-900 cursor-pointer"
|
||
/>
|
||
</label>
|
||
|
||
{(part.params.timerEnabled ?? 0) === 1 && (
|
||
<div className="grid grid-cols-2 gap-2 pt-1 border-t border-zinc-900/50">
|
||
<div>
|
||
<div className="mb-1 text-[9px] font-bold text-zinc-500 uppercase">ON Duration</div>
|
||
<label className="flex items-center gap-1 rounded-md border border-zinc-800 bg-zinc-900 px-1.5 py-0.5">
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
value={part.params.timerOnMin ?? 15}
|
||
onChange={(e) => {
|
||
const v = parseInt(e.target.value, 10);
|
||
if (!Number.isNaN(v) && v > 0) setParam(partId, 'timerOnMin', v);
|
||
}}
|
||
className="w-full bg-transparent text-left font-mono text-[11px] text-zinc-200 outline-none"
|
||
/>
|
||
<span className="text-[10px] text-zinc-500">min</span>
|
||
</label>
|
||
<span className="text-[9px] text-zinc-500 block mt-0.5">
|
||
{formatDurationHelper(part.params.timerOnMin ?? 15)}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<div className="mb-1 text-[9px] font-bold text-zinc-500 uppercase">OFF Duration</div>
|
||
<label className="flex items-center gap-1 rounded-md border border-zinc-800 bg-zinc-900 px-1.5 py-0.5">
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
value={part.params.timerOffMin ?? 45}
|
||
onChange={(e) => {
|
||
const v = parseInt(e.target.value, 10);
|
||
if (!Number.isNaN(v) && v > 0) setParam(partId, 'timerOffMin', v);
|
||
}}
|
||
className="w-full bg-transparent text-left font-mono text-[11px] text-zinc-200 outline-none"
|
||
/>
|
||
<span className="text-[10px] text-zinc-500">min</span>
|
||
</label>
|
||
<span className="text-[9px] text-zinc-500 block mt-0.5">
|
||
{formatDurationHelper(part.params.timerOffMin ?? 45)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Section>
|
||
)}
|
||
|
||
<Section title="Color">
|
||
<ColorSwatchRow ids={[partId]} />
|
||
</Section>
|
||
|
||
<div className="flex gap-2 pt-1">
|
||
<button
|
||
onClick={() => duplicateParts([partId])}
|
||
title="Duplicate this part (D)"
|
||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-[11px] font-medium text-zinc-200 hover:bg-zinc-700"
|
||
>
|
||
Duplicate
|
||
</button>
|
||
<button
|
||
onClick={() => removeParts([partId])}
|
||
title="Delete this part (⌫)"
|
||
className="flex-1 rounded-md border border-rose-900 bg-rose-950 px-2 py-1 text-[11px] font-medium text-rose-300 hover:bg-rose-900"
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||
return (
|
||
<div>
|
||
<h3 className="mb-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
|
||
{title}
|
||
</h3>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NumberField({
|
||
label,
|
||
value,
|
||
step,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
value: number;
|
||
step: number;
|
||
onChange: (v: number) => void;
|
||
}) {
|
||
return (
|
||
<label className="flex items-center gap-1 rounded-md border border-zinc-800 bg-zinc-900 px-1.5">
|
||
<span className="text-[10px] font-bold text-zinc-600">{label}</span>
|
||
<input
|
||
type="number"
|
||
value={Number(value.toFixed(2))}
|
||
step={step}
|
||
onChange={(e) => {
|
||
const v = parseFloat(e.target.value);
|
||
if (!Number.isNaN(v)) onChange(v);
|
||
}}
|
||
className="w-full bg-transparent py-1 text-right font-mono text-[11px] text-zinc-200 outline-none"
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function MiniBtn({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
className="flex-1 rounded border border-zinc-800 bg-zinc-900 px-1 py-0.5 text-[10px] text-zinc-300 hover:bg-zinc-800"
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|