Production readiness, SEO enhancements, and Engine V2 upgrades
This commit is contained in:
53
src/components/ErrorBoundary.tsx
Normal file
53
src/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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: () => [],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ export interface ReservoirRuntimeStatus {
|
||||
gallonsInitial: number;
|
||||
gallonsCurrent: number;
|
||||
gallonsCapacity: number;
|
||||
gallonsHeldInSystem: number;
|
||||
netOutflowGph: number;
|
||||
minutesRemaining: number | null;
|
||||
empty: boolean;
|
||||
|
||||
Reference in New Issue
Block a user