Files
hydro-builder-app/src/components/PropertiesPanel.tsx

990 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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° &nbsp; <Kbd>Q</Kbd>/<Kbd>E</Kbd> lower / raise</li>
<li><Kbd>D</Kbd> duplicate &nbsp; <Kbd></Kbd> delete</li>
<li><Kbd>Z</Kbd> undo &nbsp; <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>
);
}