Initial project import

This commit is contained in:
drjones
2026-06-13 17:36:44 -07:00
commit ad2a18cc8d
18471 changed files with 4497570 additions and 0 deletions

View File

@@ -0,0 +1,888 @@
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;
/**
* 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-4 overflow-y-auto px-4 py-3">
<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.5 text-xs 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.5 text-xs 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>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-2 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>
{/* 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-4 overflow-y-auto px-4 py-3">
<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 === '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.5 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>
)}
<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.5 text-xs 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.5 text-xs 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.5 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.5 py-1 text-[10px] text-zinc-300 hover:bg-zinc-800"
>
{children}
</button>
);
}