Production readiness, SEO enhancements, and Engine V2 upgrades

This commit is contained in:
drjones
2026-06-13 19:10:10 -07:00
parent 8333e241c6
commit 44d89d205c
34 changed files with 23521 additions and 162056 deletions

View File

@@ -0,0 +1,53 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
public render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center p-4">
<div className="bg-zinc-900 border border-zinc-800 p-8 rounded-xl max-w-lg w-full shadow-2xl">
<h1 className="text-2xl font-bold text-rose-500 mb-4 flex items-center gap-3">
<span className="text-3xl"></span> System Crash Detected
</h1>
<p className="text-zinc-400 mb-6 leading-relaxed">
The physics engine or rendering canvas encountered an unrecoverable error.
We've caught the crash to prevent data loss.
</p>
<div className="bg-zinc-950 p-4 rounded-md mb-6 overflow-auto text-xs font-mono text-zinc-500 border border-zinc-800">
{this.state.error?.message || "Unknown Error"}
</div>
<button
onClick={() => window.location.reload()}
className="w-full py-3 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg font-medium transition-colors"
>
Restart Application
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -457,6 +457,18 @@ function GrowTentCard({ part }: { part: PlacedPart }) {
</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>
@@ -515,7 +527,7 @@ function ReservoirCard({ part }: { part: PlacedPart }) {
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<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'}`}>
@@ -545,6 +557,14 @@ function ReservoirCard({ part }: { part: PlacedPart }) {
</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 */}

View File

@@ -155,10 +155,10 @@ export function StatusPanel() {
</span>
</div>
<div className="mt-1 text-[11px] text-zinc-400 tabular-nums">
{room.volumeFt3.toFixed(0)} ft3 · {room.exhaustCfm.toFixed(0)} exhaust CFM
{room.volumeFt3.toFixed(0)} ft3 · {room.exhaustCfm.toFixed(0)} exhaust · {room.intakeCfm.toFixed(0)} intake CFM
</div>
<div className="text-[10px] text-zinc-500 tabular-nums">
{room.airChangesPerMinute.toFixed(2)} exchanges/min · {room.airChangesPerHour.toFixed(1)} ACH
{room.airChangesPerMinute.toFixed(2)} exchanges/min · {room.airChangesPerHour.toFixed(1)} ACH · {room.netFreshAirCfm.toFixed(0)} fresh-air CFM
</div>
<div className="mt-1 text-[10px] text-zinc-600 tabular-nums">
circ {room.circulationCfm.toFixed(0)} CFM · light {room.lightWatts.toFixed(0)} W · CO2 {room.co2TankCount} tank{room.co2TankCount === 1 ? '' : 's'}
@@ -205,6 +205,9 @@ export function StatusPanel() {
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-amber-400">
{m.roomPPFD.toFixed(0)}
</span>
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-zinc-300">
ACH {m.airChangesPerHour.toFixed(1)}
</span>
</div>
);
})()}
@@ -239,6 +242,14 @@ export function StatusPanel() {
</div>
) : null;
})()}
{(() => {
const runtime = sim.reservoirRuntime[stats.id];
return runtime && runtime.gallonsHeldInSystem > 0.02 ? (
<div className="mb-1 text-[9px] font-mono text-zinc-500">
{runtime.gallonsHeldInSystem.toFixed(2)} gal held in primed lines
</div>
) : null;
})()}
<div className="flex items-baseline justify-between">
<span className="text-xs font-semibold text-zinc-200">
{stats.label}

View File

@@ -6,6 +6,8 @@ export interface RoomMetrics {
sealed: boolean;
volumeFt3: number;
exhaustCfm: number;
intakeCfm: number;
netFreshAirCfm: number;
circulationCfm: number;
lightWatts: number;
co2TankCount: number;
@@ -49,6 +51,9 @@ export function computeRoomMetrics(partsMap: Record<string, PlacedPart>): RoomMe
const exhaustCfm = roomParts
.filter((part) => part.type === 'inlineFan' && (part.params.exhaust ?? 1) > 0)
.reduce((sum, part) => sum + (part.params.cfm ?? 0), 0);
const intakeCfm = roomParts
.filter((part) => part.type === 'inlineFan' && (part.params.exhaust ?? 1) <= 0)
.reduce((sum, part) => sum + (part.params.cfm ?? 0), 0);
const circulationCfm = roomParts
.filter((part) => part.type === 'circulationFan')
.reduce((sum, part) => sum + (part.params.cfm ?? 0), 0);
@@ -59,7 +64,15 @@ export function computeRoomMetrics(partsMap: Record<string, PlacedPart>): RoomMe
const co2Cfh = co2.reduce((sum, part) => sum + (part.params.regulatorCfh ?? 0), 0);
const volumeFt3 =
(room.params.width ?? 0) * (room.params.depth ?? 0) * (room.params.height ?? 0);
const airChangesPerMinute = volumeFt3 > 0 ? exhaustCfm / volumeFt3 : 0;
const isSealed = (room.params.sealed ?? 0) > 0;
const leakageAch = Math.max(0, room.params.leakageAch ?? (isSealed ? 0.15 : 0.5));
const leakageCfm = volumeFt3 > 0 ? (leakageAch * volumeFt3) / 60 : 0;
const balancedVentCfm = Math.min(exhaustCfm, intakeCfm);
const forcedExchangeCfm = isSealed
? Math.max(Math.abs(exhaustCfm - intakeCfm), leakageCfm)
: Math.max(exhaustCfm, intakeCfm);
const netFreshAirCfm = isSealed ? forcedExchangeCfm : Math.max(forcedExchangeCfm, balancedVentCfm);
const airChangesPerMinute = volumeFt3 > 0 ? netFreshAirCfm / volumeFt3 : 0;
const canopyArea = (room.params.width ?? 0) * (room.params.depth ?? 0);
const roomPPFD = canopyArea > 0 ? (lightWatts * 2.0) / canopyArea : 0;
@@ -79,23 +92,29 @@ export function computeRoomMetrics(partsMap: Record<string, PlacedPart>): RoomMe
const w = light.params.watts ?? 0;
return sum + (p > 0 ? w : 0);
}, 0);
const roomTempF = 75 + 0.01 * activeLightWatts;
const roomRH = room.params.humidity ?? 60;
const ambientTempF = room.params.ambientTempF ?? 75;
const ambientRh = room.params.humidity ?? 60;
const roomTempF = ambientTempF + 0.01 * activeLightWatts - (!isSealed ? netFreshAirCfm * 0.0025 : 0);
const roomRH = Math.max(20, Math.min(95, ambientRh + (isSealed ? 8 : 2) + activeLightWatts * 0.002 - netFreshAirCfm * 0.01));
const T_c = (roomTempF - 32) * 5 / 9;
const SVP = 0.61078 * Math.exp((17.27 * T_c) / (T_c + 237.3));
const roomVPD = SVP * (1 - roomRH / 100);
const isSealed = (room.params.sealed ?? 0) > 0;
const hasLightsOn = activeLightWatts > 0;
let roomCo2 = 400;
const ambientCo2 = room.params.ambientCo2 ?? 420;
let roomCo2 = ambientCo2;
const co2InjectionCfm = co2Cfh / 60;
if (isSealed && hasLightsOn) {
if (co2.length === 0) {
roomCo2 = 150;
} else {
roomCo2 = 1200;
const enrichmentPotential = volumeFt3 > 0 ? (co2InjectionCfm / volumeFt3) * 1_000_000 : 0;
const ventilationPenalty = airChangesPerMinute * 220;
roomCo2 = Math.max(ambientCo2, Math.min(1500, ambientCo2 + enrichmentPotential - ventilationPenalty));
}
} else if (co2.length > 0) {
roomCo2 = 1200;
const enrichmentPotential = volumeFt3 > 0 ? (co2InjectionCfm / volumeFt3) * 1_000_000 : 0;
roomCo2 = Math.max(ambientCo2, Math.min(1500, ambientCo2 + enrichmentPotential - airChangesPerMinute * 380));
}
// 1. Sensible Heat Gains (BTU/hr)
@@ -118,16 +137,16 @@ export function computeRoomMetrics(partsMap: Record<string, PlacedPart>): RoomMe
// Moisture removal via exhaust ventilation
let moistureVentPintsDay = 0;
if (!isSealed && exhaustCfm > 0) {
moistureVentPintsDay = Math.max(0, exhaustCfm * 1381 * (rho_w_room - rho_w_amb));
if (!isSealed && netFreshAirCfm > 0) {
moistureVentPintsDay = Math.max(0, netFreshAirCfm * 1381 * (rho_w_room - rho_w_amb));
}
const dehumidifierPintsDay = Math.max(0, transpirationPintsDay - moistureVentPintsDay);
// 4. AC Sizing Sizing (BTU/hr)
let ventSensibleBtuHr = 0;
if (!isSealed && exhaustCfm > 0) {
ventSensibleBtuHr = exhaustCfm * 1.08 * (roomTempF - 75);
if (!isSealed && netFreshAirCfm > 0) {
ventSensibleBtuHr = netFreshAirCfm * 1.08 * (roomTempF - ambientTempF);
}
const dehumHeatBtuHr = (dehumidifierPintsDay / 24) * 1200;
@@ -141,6 +160,8 @@ export function computeRoomMetrics(partsMap: Record<string, PlacedPart>): RoomMe
sealed: isSealed,
volumeFt3,
exhaustCfm,
intakeCfm,
netFreshAirCfm,
circulationCfm,
lightWatts,
co2TankCount: co2.length,

View File

@@ -1,10 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import { ErrorBoundary } from './components/ErrorBoundary';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
);

View File

@@ -314,12 +314,16 @@ export const PART_DEFS: Record<PartType, PartDefinition> = {
category: 'Environment',
description: 'Room/tent enclosure for planning air exchange and equipment',
color: '#475569',
defaults: { width: 7, depth: 5, height: 4, sealed: 0 },
defaults: { width: 7, depth: 5, height: 4, sealed: 0, ambientTempF: 75, humidity: 60, ambientCo2: 420, leakageAch: 0.15 },
params: {
width: { label: 'Width', min: 2, max: 20, step: 0.5, unit: 'ft' },
depth: { label: 'Depth', min: 2, max: 20, step: 0.5, unit: 'ft' },
height: { label: 'Height', min: 2, max: 12, step: 0.5, unit: 'ft' },
sealed: { label: 'Sealed room', min: 0, max: 1, step: 1 },
ambientTempF: { label: 'Ambient Temp', min: 45, max: 100, step: 1, unit: '°F' },
humidity: { label: 'Ambient RH', min: 20, max: 90, step: 1, unit: '%' },
ambientCo2: { label: 'Ambient CO₂', min: 250, max: 1200, step: 25, unit: 'PPM' },
leakageAch: { label: 'Leakage ACH', min: 0, max: 3, step: 0.05, unit: 'ACH' },
},
getConnectors: () => [],
},

View File

@@ -139,6 +139,45 @@ function waterPathLength(part: PlacedPart): number {
}
}
function cylinderGallons(lengthFt: number, diameterIn: number): number {
const radiusFt = Math.max(0.1, diameterIn) / 24;
return Math.PI * radiusFt * radiusFt * Math.max(0, lengthFt) * 7.48;
}
/** Approximate wet volume held by a part when fully primed. */
function waterHoldingGallons(part: PlacedPart): number {
const p = part.params;
switch (part.type) {
case 'pipe':
return cylinderGallons(p.length ?? 2, p.diameter ?? 1);
case 'coupling':
return cylinderGallons(0.6, p.diameter ?? 1);
case 'reducer':
return cylinderGallons(0.7, Math.min(p.diameterA ?? 1.5, p.diameterB ?? 1));
case 'cap':
return cylinderGallons(0.25, p.diameter ?? 1);
case 'elbow':
return cylinderGallons(waterPathLength(part), p.diameter ?? 1);
case 'tee':
return cylinderGallons(0.9, p.diameter ?? 1) * 1.18;
case 'valve':
return cylinderGallons(0.7, p.diameter ?? 1) * 1.1;
case 'pump':
return cylinderGallons(0.9, 1);
case 'tower':
return Math.max(0.2, (part.params.height ?? 4) * 0.22);
case 'tray':
return Math.max(0.1, (part.params.length ?? 3) * (part.params.width ?? 1.2) * 0.08 * 7.48);
case 'wallPanel':
return Math.max(0.2, (part.params.height ?? 4) * 0.18);
case 'emitter':
case 'drain':
return 0.03;
default:
return 0;
}
}
// ---------- main simulation ----------
interface GraphNode {
@@ -1207,6 +1246,26 @@ export function applyReservoirRuntime(
(pumpDrawByReservoir[pump.sourceReservoirId] ?? 0) + pump.gph;
}
const heldGallonsByReservoir: Record<string, number> = {};
for (const [partId, gph] of Object.entries(sim.flows)) {
if (gph <= 0.05) continue;
let cur: string | undefined = partId;
let sourceReservoirId: string | undefined;
let guard = 0;
while (cur && guard++ < 200) {
if (parts[cur]?.type === 'reservoir') {
sourceReservoirId = cur;
break;
}
cur = sim.upstream[cur];
}
if (!sourceReservoirId) continue;
const part = parts[partId];
if (!part || part.type === 'reservoir') continue;
heldGallonsByReservoir[sourceReservoirId] =
(heldGallonsByReservoir[sourceReservoirId] ?? 0) + waterHoldingGallons(part);
}
for (const reservoir of Object.values(parts)) {
if (reservoir.type !== 'reservoir') continue;
const initial = reservoirInitialGallons(reservoir);
@@ -1228,6 +1287,7 @@ export function applyReservoirRuntime(
const returned = sim.vesselIO[reservoir.id]?.inGph ?? 0;
const drawn = pumpDrawByReservoir[reservoir.id] ?? 0;
const netOutflowGph = Math.max(0, drawn - returned);
const heldGallons = heldGallonsByReservoir[reservoir.id] ?? 0;
if (simRunning && tick >= 0) {
const dtHours = Math.max(0, now - entry.lastTs) / 3_600_000;
@@ -1240,14 +1300,16 @@ export function applyReservoirRuntime(
}
reservoirRuntimeStore.set(reservoir.id, entry);
const empty = entry.current <= 0.01 && netOutflowGph > 0;
const availableGallons = Math.max(0, entry.current - heldGallons);
const empty = availableGallons <= 0.01 && (netOutflowGph > 0 || heldGallons > 0);
runtime[reservoir.id] = {
partId: reservoir.id,
gallonsInitial: entry.initial,
gallonsCurrent: entry.current,
gallonsCurrent: availableGallons,
gallonsCapacity: entry.capacity,
gallonsHeldInSystem: heldGallons,
netOutflowGph,
minutesRemaining: netOutflowGph > 0 ? (entry.current / netOutflowGph) * 60 : null,
minutesRemaining: netOutflowGph > 0 ? (availableGallons / netOutflowGph) * 60 : null,
empty,
};
}

View File

@@ -170,6 +170,7 @@ export interface ReservoirRuntimeStatus {
gallonsInitial: number;
gallonsCurrent: number;
gallonsCapacity: number;
gallonsHeldInSystem: number;
netOutflowGph: number;
minutesRemaining: number | null;
empty: boolean;