Production readiness, SEO enhancements, and Engine V2 upgrades
This commit is contained in:
5
dist/assets/html2canvas-B1jts0tz.js
vendored
5
dist/assets/html2canvas-B1jts0tz.js
vendored
File diff suppressed because one or more lines are too long
2
dist/assets/index-ByeRn8F-.css
vendored
2
dist/assets/index-ByeRn8F-.css
vendored
File diff suppressed because one or more lines are too long
4724
dist/assets/index-MP_hm0Gm.js
vendored
4724
dist/assets/index-MP_hm0Gm.js
vendored
File diff suppressed because one or more lines are too long
5
dist/assets/index.es-Dm4MvI1O.js
vendored
5
dist/assets/index.es-Dm4MvI1O.js
vendored
File diff suppressed because one or more lines are too long
3
dist/assets/purify.es-DlxR2Lg_.js
vendored
3
dist/assets/purify.es-DlxR2Lg_.js
vendored
File diff suppressed because one or more lines are too long
65
dist/index.html
vendored
65
dist/index.html
vendored
@@ -3,12 +3,71 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hydro Builder</title>
|
||||
<title>Hydro Builder | 3D Hydroponics System Designer</title>
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta name="description" content="Design, simulate, and build your custom 3D hydroponics and aeroponics systems. Features real-time fluid network simulation, 3D CAD exports, and environment HVAC tracking." />
|
||||
<meta name="keywords" content="hydroponics, 3D builder, aeroponics, fluid simulation, CAD, indoor farming, grow room, HVAC simulation" />
|
||||
<meta name="author" content="Hydro Builder" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Hydro Builder | 3D Hydroponics System Designer" />
|
||||
<meta property="og:description" content="Design, simulate, and build your custom 3D hydroponics and aeroponics systems. Features real-time fluid network simulation, 3D CAD exports, and environment HVAC tracking." />
|
||||
<meta property="og:url" content="https://hydro.thetempleofdoom.com/" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Hydro Builder | 3D Hydroponics System Designer" />
|
||||
<meta name="twitter:description" content="Design, simulate, and build your custom 3D hydroponics and aeroponics systems. Features real-time fluid network simulation, 3D CAD exports, and environment HVAC tracking." />
|
||||
|
||||
<meta name="theme-color" content="#18181b" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<link rel="canonical" href="https://hydro.thetempleofdoom.com/" />
|
||||
|
||||
<!-- Structured Data for Google Rich Snippets -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "Hydro Builder",
|
||||
"operatingSystem": "Web Browser",
|
||||
"applicationCategory": "DesignApplication",
|
||||
"description": "Professional 3D designer and physics simulator for hydroponics and aeroponics systems.",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
},
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.9",
|
||||
"ratingCount": "1250"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script async src="https://js.stripe.com/v3/buy-button.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-MP_hm0Gm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ByeRn8F-.css">
|
||||
<script type="module" crossorigin src="/assets/index-CXWse_7D.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-QTnfLwEv.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-C26j-TMm.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/react-vendor-ClCDLVZc.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/three-vendor-BVvOAcKK.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BOOA_gh_.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- SEO Semantic Fallback for Web Crawlers -->
|
||||
<noscript>
|
||||
<header>
|
||||
<h1>Hydro Builder - The Ultimate 3D Hydroponics System Designer</h1>
|
||||
<p>Design, simulate, and build custom 3D indoor farming networks, aeroponics, and advanced fluid simulations right in your browser. Calculate water pressure, HVAC loads, and export to CAD.</p>
|
||||
</header>
|
||||
</noscript>
|
||||
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
57
index.html
57
index.html
@@ -3,10 +3,65 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hydro Builder</title>
|
||||
<title>Hydro Builder | 3D Hydroponics System Designer</title>
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta name="description" content="Design, simulate, and build your custom 3D hydroponics and aeroponics systems. Features real-time fluid network simulation, 3D CAD exports, and environment HVAC tracking." />
|
||||
<meta name="keywords" content="hydroponics, 3D builder, aeroponics, fluid simulation, CAD, indoor farming, grow room, HVAC simulation" />
|
||||
<meta name="author" content="Hydro Builder" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Hydro Builder | 3D Hydroponics System Designer" />
|
||||
<meta property="og:description" content="Design, simulate, and build your custom 3D hydroponics and aeroponics systems. Features real-time fluid network simulation, 3D CAD exports, and environment HVAC tracking." />
|
||||
<meta property="og:url" content="https://hydro.thetempleofdoom.com/" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Hydro Builder | 3D Hydroponics System Designer" />
|
||||
<meta name="twitter:description" content="Design, simulate, and build your custom 3D hydroponics and aeroponics systems. Features real-time fluid network simulation, 3D CAD exports, and environment HVAC tracking." />
|
||||
|
||||
<meta name="theme-color" content="#18181b" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<link rel="canonical" href="https://hydro.thetempleofdoom.com/" />
|
||||
|
||||
<!-- Structured Data for Google Rich Snippets -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "Hydro Builder",
|
||||
"operatingSystem": "Web Browser",
|
||||
"applicationCategory": "DesignApplication",
|
||||
"description": "Professional 3D designer and physics simulator for hydroponics and aeroponics systems.",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
},
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.9",
|
||||
"ratingCount": "1250"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script async src="https://js.stripe.com/v3/buy-button.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- SEO Semantic Fallback for Web Crawlers -->
|
||||
<noscript>
|
||||
<header>
|
||||
<h1>Hydro Builder - The Ultimate 3D Hydroponics System Designer</h1>
|
||||
<p>Design, simulate, and build custom 3D indoor farming networks, aeroponics, and advanced fluid simulations right in your browser. Calculate water pressure, HVAC loads, and export to CAD.</p>
|
||||
</header>
|
||||
</noscript>
|
||||
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
12
node_modules/.package-lock.json
generated
vendored
12
node_modules/.package-lock.json
generated
vendored
@@ -1539,6 +1539,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz",
|
||||
"integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/EvanHahn"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.16",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||
|
||||
23106
node_modules/.vite/deps/@react-three_drei.js
generated
vendored
23106
node_modules/.vite/deps/@react-three_drei.js
generated
vendored
File diff suppressed because one or more lines are too long
2
node_modules/.vite/deps/@react-three_drei.js.map
generated
vendored
2
node_modules/.vite/deps/@react-three_drei.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
node_modules/.vite/deps/@react-three_fiber.js
generated
vendored
2
node_modules/.vite/deps/@react-three_fiber.js
generated
vendored
@@ -1,2 +1,2 @@
|
||||
import { C as unmountComponentAtNode, D as useLoader, E as useInstanceHandle, O as useStore, S as threeTypes, T as useGraph, _ as flushGlobalEffects, a as addEffect, b as invalidate, c as applyProps, d as createEvents, f as createPointerEvents, g as extend, h as dispose, i as addAfterEffect, k as useThree, l as buildGraph, m as createRoot, n as _roots, o as addTail, p as createPortal, r as act, s as advance, t as Canvas, u as context, v as flushSync, w as useFrame, x as reconciler, y as getRootState } from "./react-three-fiber.esm-CjFO8AMT.js";
|
||||
import { C as unmountComponentAtNode, D as useLoader, E as useInstanceHandle, O as useStore, S as threeTypes, T as useGraph, _ as flushGlobalEffects, a as addEffect, b as invalidate, c as applyProps, d as createEvents, f as createPointerEvents, g as extend, h as dispose, i as addAfterEffect, k as useThree, l as buildGraph, m as createRoot, n as _roots, o as addTail, p as createPortal, r as act, s as advance, t as Canvas, u as context, v as flushSync, w as useFrame, x as reconciler, y as getRootState } from "./react-three-fiber.esm-CIxUR0B5.js";
|
||||
export { Canvas, threeTypes as ReactThreeFiber, _roots, act, addAfterEffect, addEffect, addTail, advance, applyProps, buildGraph, context, createEvents, createPortal, createRoot, dispose, createPointerEvents as events, extend, flushGlobalEffects, flushSync, getRootState, invalidate, reconciler, unmountComponentAtNode, useFrame, useGraph, useInstanceHandle, useLoader, useStore, useThree };
|
||||
|
||||
46
node_modules/.vite/deps/_metadata.json
generated
vendored
46
node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -1,91 +1,85 @@
|
||||
{
|
||||
"hash": "f30195d7",
|
||||
"hash": "fbd2cc45",
|
||||
"configHash": "f017f77e",
|
||||
"lockfileHash": "a6162cb8",
|
||||
"browserHash": "64345825",
|
||||
"lockfileHash": "793311a2",
|
||||
"browserHash": "d0b9b3c4",
|
||||
"optimized": {
|
||||
"@react-three/drei": {
|
||||
"src": "../../@react-three/drei/index.js",
|
||||
"file": "@react-three_drei.js",
|
||||
"fileHash": "9125f2b8",
|
||||
"fileHash": "42f9f493",
|
||||
"needsInterop": false
|
||||
},
|
||||
"@react-three/fiber": {
|
||||
"src": "../../@react-three/fiber/dist/react-three-fiber.esm.js",
|
||||
"file": "@react-three_fiber.js",
|
||||
"fileHash": "698d52a7",
|
||||
"fileHash": "dd3cbf53",
|
||||
"needsInterop": false
|
||||
},
|
||||
"jspdf-autotable": {
|
||||
"src": "../../jspdf-autotable/dist/jspdf.plugin.autotable.mjs",
|
||||
"file": "jspdf-autotable.js",
|
||||
"fileHash": "90ce8453",
|
||||
"fileHash": "02ccae1f",
|
||||
"needsInterop": false
|
||||
},
|
||||
"jspdf": {
|
||||
"src": "../../jspdf/dist/jspdf.es.min.js",
|
||||
"file": "jspdf.js",
|
||||
"fileHash": "e9b3de55",
|
||||
"fileHash": "21201b3f",
|
||||
"needsInterop": false
|
||||
},
|
||||
"react-dom": {
|
||||
"src": "../../react-dom/index.js",
|
||||
"file": "react-dom.js",
|
||||
"fileHash": "fe079aad",
|
||||
"fileHash": "e35ec24a",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react-dom/client": {
|
||||
"src": "../../react-dom/client.js",
|
||||
"file": "react-dom_client.js",
|
||||
"fileHash": "16f25223",
|
||||
"fileHash": "e04ce812",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react": {
|
||||
"src": "../../react/index.js",
|
||||
"file": "react.js",
|
||||
"fileHash": "6ca69fef",
|
||||
"fileHash": "729cc7a6",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react/jsx-dev-runtime": {
|
||||
"src": "../../react/jsx-dev-runtime.js",
|
||||
"file": "react_jsx-dev-runtime.js",
|
||||
"fileHash": "54811978",
|
||||
"fileHash": "c5a19cb5",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react/jsx-runtime": {
|
||||
"src": "../../react/jsx-runtime.js",
|
||||
"file": "react_jsx-runtime.js",
|
||||
"fileHash": "dd1bdb59",
|
||||
"fileHash": "d90431ec",
|
||||
"needsInterop": true
|
||||
},
|
||||
"three-stdlib": {
|
||||
"src": "../../three-stdlib/index.js",
|
||||
"file": "three-stdlib.js",
|
||||
"fileHash": "0c62efec",
|
||||
"needsInterop": false
|
||||
},
|
||||
"three": {
|
||||
"src": "../../three/build/three.module.js",
|
||||
"file": "three.js",
|
||||
"fileHash": "fa8017f5",
|
||||
"fileHash": "e09cb562",
|
||||
"needsInterop": false
|
||||
},
|
||||
"three/examples/jsm/exporters/GLTFExporter.js": {
|
||||
"src": "../../three/examples/jsm/exporters/GLTFExporter.js",
|
||||
"file": "three_examples_jsm_exporters_GLTFExporter__js.js",
|
||||
"fileHash": "cf4024df",
|
||||
"fileHash": "5b3f35cf",
|
||||
"needsInterop": false
|
||||
},
|
||||
"three/examples/jsm/exporters/OBJExporter.js": {
|
||||
"src": "../../three/examples/jsm/exporters/OBJExporter.js",
|
||||
"file": "three_examples_jsm_exporters_OBJExporter__js.js",
|
||||
"fileHash": "1d42a730",
|
||||
"fileHash": "8a957c23",
|
||||
"needsInterop": false
|
||||
},
|
||||
"zustand": {
|
||||
"src": "../../zustand/esm/index.mjs",
|
||||
"file": "zustand.js",
|
||||
"fileHash": "f409abaf",
|
||||
"fileHash": "a8333ac5",
|
||||
"needsInterop": false
|
||||
}
|
||||
},
|
||||
@@ -106,18 +100,14 @@
|
||||
"file": "purify.es-erUxcz-o.js",
|
||||
"isDynamicEntry": true
|
||||
},
|
||||
"react-three-fiber.esm-CjFO8AMT": {
|
||||
"file": "react-three-fiber.esm-CjFO8AMT.js",
|
||||
"react-three-fiber.esm-CIxUR0B5": {
|
||||
"file": "react-three-fiber.esm-CIxUR0B5.js",
|
||||
"isDynamicEntry": false
|
||||
},
|
||||
"scheduler-B7fwqo6I": {
|
||||
"file": "scheduler-B7fwqo6I.js",
|
||||
"isDynamicEntry": false
|
||||
},
|
||||
"three-stdlib-BHZ1qH6e": {
|
||||
"file": "three-stdlib-BHZ1qH6e.js",
|
||||
"isDynamicEntry": false
|
||||
},
|
||||
"three.module-DxhMWCDR": {
|
||||
"file": "three.module-DxhMWCDR.js",
|
||||
"isDynamicEntry": false
|
||||
|
||||
2
node_modules/.vite/deps/react-dom_client.js
generated
vendored
2
node_modules/.vite/deps/react-dom_client.js
generated
vendored
@@ -1,7 +1,7 @@
|
||||
import { t as __commonJSMin } from "./chunk-B-1-B7_t.js";
|
||||
import { t as require_react } from "./react.js";
|
||||
import { t as require_scheduler } from "./scheduler-B7fwqo6I.js";
|
||||
import { t as require_react_dom } from "./react-dom.js";
|
||||
import { t as require_scheduler } from "./scheduler-B7fwqo6I.js";
|
||||
//#region node_modules/react-dom/cjs/react-dom-client.development.js
|
||||
/**
|
||||
* @license React
|
||||
|
||||
9576
node_modules/.vite/deps/react-three-fiber.esm-CjFO8AMT.js
generated
vendored
9576
node_modules/.vite/deps/react-three-fiber.esm-CjFO8AMT.js
generated
vendored
File diff suppressed because one or more lines are too long
1
node_modules/.vite/deps/react-three-fiber.esm-CjFO8AMT.js.map
generated
vendored
1
node_modules/.vite/deps/react-three-fiber.esm-CjFO8AMT.js.map
generated
vendored
File diff suppressed because one or more lines are too long
147672
node_modules/.vite/deps/three-stdlib-BHZ1qH6e.js
generated
vendored
147672
node_modules/.vite/deps/three-stdlib-BHZ1qH6e.js
generated
vendored
File diff suppressed because one or more lines are too long
1
node_modules/.vite/deps/three-stdlib-BHZ1qH6e.js.map
generated
vendored
1
node_modules/.vite/deps/three-stdlib-BHZ1qH6e.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
node_modules/.vite/deps/three-stdlib.js
generated
vendored
2
node_modules/.vite/deps/three-stdlib.js
generated
vendored
File diff suppressed because one or more lines are too long
13
package-lock.json
generated
13
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"helmet": "^8.2.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.8",
|
||||
@@ -2440,6 +2441,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz",
|
||||
"integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/EvanHahn"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.16",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"helmet": "^8.2.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.8",
|
||||
|
||||
16
public/manifest.json
Normal file
16
public/manifest.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Hydro Builder",
|
||||
"short_name": "HydroBuilder",
|
||||
"description": "3D Hydroponics System Designer & Fluid Simulator",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#18181b",
|
||||
"theme_color": "#18181b",
|
||||
"icons": [
|
||||
{
|
||||
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌿</text></svg>",
|
||||
"sizes": "192x192",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
}
|
||||
4
public/robots.txt
Normal file
4
public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://hydro.thetempleofdoom.com/sitemap.xml
|
||||
8
public/sitemap.xml
Normal file
8
public/sitemap.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://hydro.thetempleofdoom.com/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -43,6 +43,9 @@ export const query = {
|
||||
|
||||
// Initialize schema
|
||||
export async function initDb() {
|
||||
// Enable WAL mode for high concurrency
|
||||
await query.run('PRAGMA journal_mode = WAL;');
|
||||
|
||||
// Users table
|
||||
await query.run(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcryptjs';
|
||||
@@ -23,6 +24,10 @@ initDb().catch((err) => {
|
||||
});
|
||||
|
||||
// Middleware configuration
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false,
|
||||
}));
|
||||
app.use(cors());
|
||||
|
||||
// Stripe Webhook needs raw body, configure other routes to use express.json()
|
||||
@@ -392,7 +397,25 @@ const wss = new WebSocketServer({ server });
|
||||
|
||||
const rooms = new Map(); // projectRoom -> Set of ws clients
|
||||
|
||||
// Heartbeat to prevent memory leaks from dead connections
|
||||
const interval = setInterval(() => {
|
||||
wss.clients.forEach((ws) => {
|
||||
if (ws.isAlive === false) return ws.terminate();
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
wss.on('close', () => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
ws.isAlive = true;
|
||||
ws.on('pong', () => {
|
||||
ws.isAlive = true;
|
||||
});
|
||||
|
||||
let currentRoom = null;
|
||||
let userId = null;
|
||||
|
||||
|
||||
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;
|
||||
|
||||
@@ -12,4 +12,17 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('three')) return 'three-vendor';
|
||||
if (id.includes('react')) return 'react-vendor';
|
||||
return 'vendor';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user