From fcade251a663b58d9b0a44f03f8ca1f7fbddf526 Mon Sep 17 00:00:00 2001 From: Mohan K Date: Mon, 24 Aug 2026 15:35:32 +0530 Subject: [PATCH] Initial commit --- .env.example | 34 + .gitignore | 5 + README.md | 163 ++ docs/LINE-1-Digital-Twin-Demo.pptx | Bin 0 -> 496211 bytes docs/architecture.md | 150 ++ docs/demo-script.md | 188 ++ docs/installation.md | 289 +++ package-lock.json | 3846 ++++++++++++++++++++++++++++ package.json | 24 + scripts/apicheck.mjs | 187 ++ scripts/dev.mjs | 64 + scripts/simcheck.mjs | 322 +++ server/ai/context.js | 174 ++ server/ai/copilot.js | 580 +++++ server/ai/rules.js | 290 +++ server/ai/settings.js | 113 + server/analytics/alarms.js | 294 +++ server/analytics/anomaly.js | 129 + server/analytics/trend.js | 100 + server/index.js | 269 ++ server/ingest/mqttSource.js | 100 + server/ingest/opcuaSource.js | 119 + server/ingest/simulatedSource.js | 119 + server/ingest/source.js | 90 + server/package.json | 16 + server/sim/faults.js | 105 + server/sim/kpi.js | 121 + server/sim/line.js | 345 +++ server/sim/stations.js | 309 +++ web/index.html | 12 + web/package.json | 24 + web/src/App.jsx | 71 + web/src/hooks/useTelemetry.js | 98 + web/src/lib/copilotClient.js | 109 + web/src/lib/format.js | 94 + web/src/main.jsx | 5 + web/src/panels/AlarmFeed.jsx | 70 + web/src/panels/Copilot.jsx | 136 + web/src/panels/KpiRow.jsx | 79 + web/src/panels/SettingsPage.jsx | 432 ++++ web/src/panels/StationDetail.jsx | 112 + web/src/panels/StationStrip.jsx | 100 + web/src/panels/TimeControls.jsx | 97 + web/src/panels/TrendChart.jsx | 189 ++ web/src/panels/WhatIfPanel.jsx | 132 + web/src/store.js | 152 ++ web/src/styles.css | 596 +++++ web/src/three/Conveyor.jsx | 99 + web/src/three/Scene.jsx | 171 ++ web/src/three/Station.jsx | 226 ++ web/vite.config.js | 16 + 51 files changed, 11565 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/LINE-1-Digital-Twin-Demo.pptx create mode 100644 docs/architecture.md create mode 100644 docs/demo-script.md create mode 100644 docs/installation.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/apicheck.mjs create mode 100644 scripts/dev.mjs create mode 100644 scripts/simcheck.mjs create mode 100644 server/ai/context.js create mode 100644 server/ai/copilot.js create mode 100644 server/ai/rules.js create mode 100644 server/ai/settings.js create mode 100644 server/analytics/alarms.js create mode 100644 server/analytics/anomaly.js create mode 100644 server/analytics/trend.js create mode 100644 server/index.js create mode 100644 server/ingest/mqttSource.js create mode 100644 server/ingest/opcuaSource.js create mode 100644 server/ingest/simulatedSource.js create mode 100644 server/ingest/source.js create mode 100644 server/package.json create mode 100644 server/sim/faults.js create mode 100644 server/sim/kpi.js create mode 100644 server/sim/line.js create mode 100644 server/sim/stations.js create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/src/App.jsx create mode 100644 web/src/hooks/useTelemetry.js create mode 100644 web/src/lib/copilotClient.js create mode 100644 web/src/lib/format.js create mode 100644 web/src/main.jsx create mode 100644 web/src/panels/AlarmFeed.jsx create mode 100644 web/src/panels/Copilot.jsx create mode 100644 web/src/panels/KpiRow.jsx create mode 100644 web/src/panels/SettingsPage.jsx create mode 100644 web/src/panels/StationDetail.jsx create mode 100644 web/src/panels/StationStrip.jsx create mode 100644 web/src/panels/TimeControls.jsx create mode 100644 web/src/panels/TrendChart.jsx create mode 100644 web/src/panels/WhatIfPanel.jsx create mode 100644 web/src/store.js create mode 100644 web/src/styles.css create mode 100644 web/src/three/Conveyor.jsx create mode 100644 web/src/three/Scene.jsx create mode 100644 web/src/three/Station.jsx create mode 100644 web/vite.config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5b24713 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# --------------------------------------------------------------------------- +# Copilot provider. The server picks the FIRST one that is configured: +# 1. OpenRouter (if OPENROUTER_API_KEY is set) +# 2. Ollama (if reachable at OLLAMA_HOST) +# 3. Rule-based fallback (always works, no config, no network) +# The demo is fully functional with none of these set. +# +# These are the STARTING defaults. The in-app settings page (AI cog in the +# header) overrides them at runtime and persists to .copilot-settings.json; +# 'Reset to defaults' there discards that and falls back to this file. +# --------------------------------------------------------------------------- + +# --- Option 1: OpenRouter (cloud) --- +# OPENROUTER_API_KEY=sk-or-v1-... +# OPENROUTER_MODEL=meta-llama/llama-3.3-70b-instruct + +# --- Option 2: Ollama (local, recommended for on-stage demos) --- +# Start it with: ollama serve +OLLAMA_HOST=http://127.0.0.1:11434 +# Fast + interactive (recommended for live demo): +OLLAMA_MODEL=qwen3.5-4b-32k:latest +# Higher quality, noticeably slower: +# OLLAMA_MODEL=gemma4-26b-32k:latest + +# --- Force the offline rule-based copilot even if a provider is available --- +# Useful for rehearsing the worst case. +# COPILOT_PROVIDER=fallback + +# --- Server --- +PORT=8787 + +# --- Telemetry source: simulated | mqtt | opcua --- +# mqtt and opcua are documented stubs; see server/ingest/ +TELEMETRY_SOURCE=simulated diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca07100 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +*.log +.copilot-settings.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..e67ea6e --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# LINE-1 Digital Twin — customer demo + +A live digital twin of a five-station production line: 3D view, streaming +telemetry, OEE, trend-based failure prediction, injectable faults, what-if +controls, and an LLM copilot that diagnoses the line from its own telemetry. + +Built to be presented from a laptop, offline if necessary. + +```bash +npm install +npm run dev +``` + +Dashboard on **http://localhost:5173**, API on **http://localhost:8787**. +For a single-port build with no dev server: `npm run serve`, then +**http://localhost:8787**. Full setup detail is in +[docs/installation.md](docs/installation.md). + +The line starts **pre-warmed** — 30 simulated minutes of history, a full OEE +window at a realistic ~88%, and anomaly baselines already learned. You are never +presenting a dashboard that just booted. + +## Read this first + +- **[docs/installation.md](docs/installation.md)** — requirements, install, how to + run it, the demo sequence, switching models, and troubleshooting. +- **[docs/LINE-1-Digital-Twin-Demo.pptx](docs/LINE-1-Digital-Twin-Demo.pptx)** — the + step-by-step demonstration deck. 16 slides, one per beat, with the presenter + actions and script in the speaker notes. Every chart is measured data. +- **[docs/demo-script.md](docs/demo-script.md)** — the same 6-minute run of show in + text form: what to click, what to say, and the questions you will get. +- **[docs/architecture.md](docs/architecture.md)** — where the customer's MQTT, + OPC-UA or historian data plugs in. + +## The demo in one paragraph + +The line runs at a believable 88% OEE. You change the oven setpoint and watch a +thermal model respond with a real time constant — that is what makes it a twin +rather than a dashboard. You inject a bearing fault, run the clock at 60×, and the +twin projects the alarm threshold *before* it is crossed. The copilot — which is +never told what you injected — traces vibration to tool wear to reject rate to +OEE. It drafts the work order. You replace the bearing and watch OEE recover. Then +you switch the copilot to its offline rule engine and get the same diagnosis with +no model and no network, which answers "is the AI making this up?" better than any +claim could. + +## What is honest about this + +Worth knowing, because it is what makes the demo survive an engineer in the room. + +- **OEE is ~88%, not 100%.** Micro-stops and unplanned stops are modelled, so the + number moves for reasons you can name. Performance is capped at 100% by + definition, so draining a WIP buffer can never produce an OEE above 100%. +- **Prediction is trend extrapolation, not AI.** A least-squares fit over recent + run time, projected to the threshold, suppressed when r² is too low to trust. + Call it what it is; the AI story is the copilot. +- **The copilot is not told which fault was injected.** Operator injection log + lines are filtered out of its context. The diagnosis is earned from telemetry. +- **Stations block and starve each other** through finite WIP buffers. Stop the + packer and the backup propagates upstream until the whole line is blocked. +- **A dropped sensor holds its last value and says so.** Charts stop rather than + drawing a flat line that would read as a healthy steady state. +- **The copilot always answers.** A dead key, an unreachable host, or a model that + returns an empty response all degrade to the rule engine. + +## Copilot providers and the settings page + +Click **AI ⚙** in the header to open the settings page. It lists the models each +provider actually offers — pulled live from the OpenRouter catalogue (400+, with +context length and price per million) and from your local Ollama library — and +lets you switch provider, model, temperature, output budget and reasoning effort +without restarting anything. The dashboard keeps streaming behind it. + +| Mode | First token | Notes | +|---|---|---| +| **Cloud** — OpenRouter | 2–35 s, highly variable | Best analysis. Default `stealth/ox-alpha`. | +| **Local** — Ollama | ~1–2 s | Run `ollama serve`. Best for the live beat. | +| **Offline rules** | instant | No network, no model. Deterministic. | + +**Measure before you present.** Each model panel has a **Test** button that +reports time-to-first-token and grades it: under 4 s is presentable live, 4–12 s +means a noticeable pause, above that is follow-up-only. Cloud reasoning-model +latency was measured between 2 s and 36 s on consecutive identical calls, so this +is worth checking on the day rather than assuming. + +Changes persist to `.copilot-settings.json` (gitignored), so a model chosen while +preparing survives a restart. **Reset to defaults** discards it and falls back to +`.env`. Settings never contain the API key — that stays in the environment. + +Resolution order when provider is **Auto**: OpenRouter if a key is set, else +Ollama if it answers, else the rule engine. The copilot panel's own selector still +overrides per question. + +Copy `.env.example` to `.env` for the initial defaults. Notes: + +- `OLLAMA_HOST` is often set machine-wide to `0.0.0.0` by Ollama itself. That is a + *bind* address and not dialable — the server normalizes it to `127.0.0.1`. +- Thinking models (the Qwen3 family) are sent `think: false`. Without it they + spend the whole token budget reasoning and return empty content. +- `COPILOT_PROVIDER=fallback` forces the rule engine, for rehearsing the worst case. +- Anything set in the settings page takes precedence over `.env` until you reset it. + +## Verification + +Two suites, no browser needed. Run both after any change to the model. + +```bash +node scripts/simcheck.mjs +``` + +Checks the model tells the truth: signals stay in physical range, faults move OEE +the right way in a controlled A/B against an unfaulted line, packer stoppage +propagates upstream as blocking, a dropped sensor goes stale rather than to zero, +setpoint changes are lagged, runs are reproducible from a seed, and the prediction +arrives *before* the threshold is crossed (~435 s of simulated lead time). + +```bash +node scripts/apicheck.mjs # server must be running +``` + +Checks the WebSocket stream, the control API, fault propagation into the stream, +and that the copilot answers in every provider mode with answers grounded in real +readings. + +## Layout + +``` +server/ + sim/ line.js (part flow, WIP, blocking) · stations.js (physics) + faults.js (injectable profiles) · kpi.js (OEE) + analytics/ trend.js (least squares) · anomaly.js (frozen baselines) + alarms.js (thresholds, latching, predictions) + ingest/ source.js (the seam) · simulatedSource.js + mqttSource.js, opcuaSource.js (documented stubs) + ai/ copilot.js (providers) · context.js (prompt) · rules.js (offline) + settings.js (validated, persisted runtime config) +web/src/ + three/ Scene.jsx, Station.jsx, Conveyor.jsx + panels/ KPIs, station strip, trend charts, alarms, what-if, copilot + SettingsPage.jsx (provider/model picker + latency test) +scripts/ dev.mjs, simcheck.mjs, apicheck.mjs +``` + +The 3D scene loads **no external assets** — no HDRI, no CDN font, no texture. It +is primitives and analytic lights, so it works in a room with no wifi. + +## Notable design decisions + +- **A server owns the simulation**, not the browser. It keeps the API key server- + side, gives the ingest layer a real seam, and makes "swap in your historian" + true rather than aspirational. +- **Signals declare their analytics eligibility.** `cumulative` and `volatile` + flags in `stations.js` exclude a signal from anomaly testing — normal tool wear + accumulation is not an anomaly. +- **Anomalies only alarm in the direction that is bad**, inferred from which + thresholds a signal declares. A tool change dropping wear from 18% to 2% must + not raise an alarm for being *better* than baseline. +- **Separate RNG streams** for signal noise, stall decisions and reject rolls. + Sharing one stream put the reject roll at a correlated phase in the sequence and + measurably biased it — a 2.0% reject rate was firing at 5.7%. +- **Single dark theme, deliberately.** This is an ops dashboard on a projector. + Chart colours come from a validated palette; status colours are reserved and + always paired with a text label, never carrying meaning by hue alone. diff --git a/docs/LINE-1-Digital-Twin-Demo.pptx b/docs/LINE-1-Digital-Twin-Demo.pptx new file mode 100644 index 0000000000000000000000000000000000000000..c44a7f796de7094c2bdbd8525756d4fc7e83f7f7 GIT binary patch literal 496211 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 vE&JD3MBbwc2UdxC;M2YTllD literal 0 HcmV?d00001 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..434ff56 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,150 @@ +# Architecture — and where your data plugs in + +The one-pager for the engineer who asks *"can it take our data?"* + +## Shape + +``` + ┌──────────────────── telemetry sources ─────────────────────┐ + │ │ + │ SimulatedSource MqttSource OpcUaSource │ + │ (working) (stub) (stub) │ + │ │ │ │ │ + └────────┴────────────────────┴───────────────────┴──────────┘ + │ + TelemetrySource ← the seam + emits "frames" + │ + ┌───────────────┴────────────────┐ + │ │ + AnalyticsEngine WebSocket /ws ──► React dashboard + · threshold alarms │ · 3D scene + · learned baselines / z-scores │ · trend charts + · least-squares trend projection │ · alarms + · alarm latching │ · what-if controls + │ │ + └──────────► frame.analytics ─────┘ + │ + Copilot ──► OpenRouter / Ollama / rule engine + (context = analytics conclusions, not raw floats) +``` + +Everything above the seam consumes **frames** and has no idea where they came +from. That is the whole design: swapping the simulator for your plant means +implementing one class. + +## The seam + +`server/ingest/source.js` defines `TelemetrySource`. A source does three things: + +1. **`start()`** — connect, subscribe, begin producing. +2. **`emit(frame)`** — hand a frame upward. Called on your cadence. +3. **`capabilities`** — declare what it supports (`timeControl`, + `faultInjection`, `setpointControl`). The UI reads this and hides controls a + read-only source cannot honour, rather than showing buttons that do nothing. + +A frame is station states, signal values, WIP levels, KPI rollups and the +analytics block. `server/ingest/simulatedSource.js` is the reference +implementation; read it alongside the stub you are filling in. + +## Connecting your plant + +Both adapters are written out as documented stubs with the real interface, the tag +map, and the traps called out. They throw an honest "not configured" error rather +than pretending to work. + +### MQTT — `server/ingest/mqttSource.js` + +``` +npm i mqtt +``` + +Fill `TAG_MAP` with your topics, implement `start()`, set `TELEMETRY_SOURCE=mqtt` +and `MQTT_URL`. If your broker speaks **Sparkplug B** (most industrial ones do), +add `sparkplug-payload` and map metric aliases from NBIRTH/NDATA instead of topic +strings. + +### OPC-UA — `server/ingest/opcuaSource.js` + +``` +npm i node-opcua +``` + +Browse your server's address space, fill `NODE_MAP` with real NodeIds (never guess +them — they are namespace-qualified and installation-specific), implement +`start()`, set `TELEMETRY_SOURCE=opcua` and `OPCUA_ENDPOINT`. + +### Historian / CSV + +Simplest of the three and often the best first step: read the export, sort by +timestamp, and emit frames on a timer with a scrub control. You get real customer +data in the twin without touching plant networks or waiting on IT. + +## Four things that bite when the data is real + +These are called out in the stubs too, because each one is a way a twin starts +lying: + +**Staleness.** Track last-seen per tag and mark a station offline when its tags go +quiet. A stale value rendered as live is worse than a gap. The demo already models +this — the `INS-04 sensor dropout` fault holds the last value and flags the +station rather than drawing zeros, and the charts stop rather than continuing a +flat line that would read as a healthy steady state. + +**Status codes.** OPC-UA gives you `StatusCode` per value. A Bad or Uncertain +reading must not become a data point. + +**Timestamps.** Prefer the *source* timestamp — when the PLC sampled the sensor — +over the server timestamp, which is when the message reached you. Under load they +diverge, and trend slopes computed on arrival times are wrong. + +**Units.** Vibration in in/s, temperature in °F, pressure in psi are all common. +Convert at the boundary, never downstream, or every threshold in the system is +quietly wrong. + +## What the analytics layer needs from you + +Nothing changes. `TrendTracker` and `BaselineBank` work on timestamped values +regardless of origin. Two things worth knowing: + +- **Baselines are learned then frozen.** A continuously adapting baseline absorbs + a slow ramp, so the exact failure mode this is built to catch would never raise + a z-score. Freezing means "different from how this machine normally behaves". +- **Signals declare their own analytics eligibility.** In + `server/sim/stations.js`, `cumulative: true` (tool wear, part counters) and + `volatile: true` (belt speed, setpoints) exclude a signal from anomaly testing — + normal accumulation is not an anomaly, and normal state swings are not either. + Both remain covered by thresholds and trend projection. When you add your + signals, set these flags next to the definition. + +## Writing back to the plant + +`OpcUaSource` is read-only on purpose, and its `capabilities` say so. OPC-UA can +write to a PLC, and a twin that closes the loop is genuinely valuable — but it +needs interlocks, an audit trail, rate limits and the customer's explicit +sign-off. Do not enable it because the demo UI has a slider. + +## Deployment + +One Node process plus a static bundle: + +``` +npm run build # web/dist +node server/index.js # serves the API and the telemetry socket +``` + +Runs on a plant-floor box or an industrial PC. With Ollama or the rule-based +copilot it needs no internet at all — which for most plant networks is not a +preference but a requirement. + +## Honest limits of the demo + +Worth saying out loud, because the engineer will work it out anyway: + +- The physics is first-order lags, a PID and accumulating wear — enough that + signals move the way an engineer expects and stay coupled. It is not FEA or CFD, + and it is not calibrated against a real machine. +- The five stations are a plausible generic line, not the customer's process. +- Prediction is trend extrapolation. There is no trained model and no failure + history behind it. With real historical failure data you could do considerably + better — and that is the natural next conversation, not a gap to hide. diff --git a/docs/demo-script.md b/docs/demo-script.md new file mode 100644 index 0000000..fb1f964 --- /dev/null +++ b/docs/demo-script.md @@ -0,0 +1,188 @@ +# Demo script — 6 minutes + +One story, told once, with a clear beginning and end: *the twin sees a bearing +failing before it fails, explains why in plain language, and lets you test a fix +before touching the plant.* + +Resist the urge to show every feature. The four faults exist so you can answer +"what else can it do?" — not so you can fire all of them. + +**Before you walk in:** `npm run dev`, open `http://localhost:5173`, confirm the +header shows *Telemetry live* and the copilot badge shows a provider. Leave the +clock at **1×**. Press **Reset** if anyone has been clicking around — the line +comes back pre-warmed to a realistic ~88% OEE. + +--- + +## 0:00 — What they are looking at (45s) + +> "This is a live digital twin of a five-station production line — infeed, +> machining, curing oven, vision inspection, packing. Everything on this screen is +> being computed from a running model of the line, not replayed from a video." + +Orbit the 3D view once, slowly. Click **CNC-02** in the 3D scene — the detail +charts below change with it. + +> "OEE is 88%. Availability is 100% — nothing has broken down this shift. +> Performance is 91%, and that's micro-stops. Quality is 97.6%. That's a well-run +> line having a normal day." + +**Why this beat matters:** the baseline has to be believable before the failure +means anything. If they accept 88%, they will accept everything that follows. + +## 0:45 — Show that it is a model, not a dashboard (60s) + +In **What-if controls**, drag **Oven setpoint** from 305 to 330 °C. Point at the +Zone temperatures chart, then at Burner duty. + +> "Watch the response. It doesn't jump — it takes about half a minute of run time +> to settle, and ten seconds in it's only 44% of the way there. And look at burner +> duty: it spikes from 68 to 85% and then settles back to 74% as the controller +> finds the new equilibrium. That's a thermal model with a real time constant and +> a controller on top. A dashboard would just redraw a number." + +Measured: 305 °C → 316 at 10 s → 328 at 25 s → within 1 °C of setpoint by 30 s. + +Drag it back to 305. + +> "That's the point of a twin: you can ask 'what happens if' without doing it to +> the actual plant." + +## 1:45 — Inject the fault and compress time (45s) + +Click **Inject** on *CNC-02 bearing degradation*. Then set the clock to **60×**. + +> "I've just started a spindle bearing degrading. In a real plant this plays out +> over days. I'm running the clock at 60×, so we'll watch it in about thirty +> seconds. Everything else stays physical — the clock is the only thing I sped up." + +Say the 60× out loud. Point at the sim clock. Never let them think this is +real-time. + +Watch the **Bearing Vibration** chart climb toward the dashed **Warn 3.5** line. + +## 2:30 — The prediction (60s) + +A purple **Trend** card appears above the charts, and an alarm appears in the feed. + +> "There it is. Vibration is at 2.9 and rising 0.18 per minute. Nothing is over +> limit yet — but it's telling me I hit the 4.5 alarm limit in about 27 minutes of +> run time." + +If asked how — and someone always asks: + +> "It's a least-squares fit over the last twenty minutes, extrapolated to the +> limit, and it only reports when the fit is good enough to mean anything — that +> r² of 0.65 is the fit quality. It's a regression, not a black box. I can show +> you the arithmetic." + +**Do not call this AI.** Calling a straight-line fit "AI prediction" is the fastest +way to lose the engineer in the room. + +## 3:30 — The copilot (90s) + +Let it cross 4.5. OEE is now falling visibly. Click **Explain this** on the +bearing alarm. + +While it thinks: + +> "This is reading the live telemetry — states, readings against limits, the OEE +> breakdown, the measured trends. What it is *not* told is which fault I injected. +> It has to work that out from the data, same as your engineer would." + +Read the answer out. It will trace vibration → tool wear → rejects → OEE. + +> "Notice it separates what's measured from what's inferred, and it traced the +> whole chain: the vibration is raising tool wear, which is pushing parts out of +> tolerance, which is why the reject rate went from 2% to 5% — and that's what's +> dragging OEE down, not the machine stopping." + +Then click **Draft a maintenance work order**. + +> "And it writes the work order. That goes to your CMMS." + +**If the cloud model is slow:** switch the selector to **Local** before the +meeting. See *Provider choice* below — a 20-second silence kills this beat. + +## 5:00 — The intervention (45s) + +> "So: I know the bearing is going, I know roughly when, and I know what it's +> costing me. Let me act on it." + +Click **Clear** on the bearing fault, then **Tool change**. Set the clock to +**20×**. + +> "Bearing replaced, fresh tooling. Watch the reject rate come back down and OEE +> recover. The twin closed the loop — it found it, explained it, and confirmed the +> fix worked." + +## 5:45 — Land it (30s) + +> "Two things to take away. This is running on a simulated line today, but the +> ingest layer is built to take your data — MQTT, OPC-UA, or a historian export — +> and nothing above that layer changes when we swap it. Second, the reasoning is +> reproducible: I can turn the language model off entirely and the same diagnosis +> comes out of a rule engine." + +Switch the copilot to **Offline rules** and ask *"Why is OEE down?"* — it answers +instantly. + +> "Same conclusion, no model, no network. The AI makes it conversational. It isn't +> where the analysis comes from." + +That last move is worth more than it looks: it is the answer to "is the AI just +making this up?", and it lands better as a demonstration than as a claim. + +--- + +## Provider choice — decide before you present + +Open **AI ⚙** in the header, pick a model, and hit **Test**. It reports +time-to-first-token and tells you whether that model is presentable live. Do this +on the demo machine, on the demo network, before the meeting — cloud latency +measured anywhere between 2 s and 36 s on consecutive identical calls. + +| Setting | First token | Use it when | +|---|---|---| +| **Cloud** (`stealth/ox-alpha`) | 2–35 s, variable | Best analysis. Good for the follow-up conversation, risky for the live beat. | +| **Local** (Ollama) | ~1–2 s | The live walkthrough. Needs `ollama serve` running. | +| **Offline rules** | instant | No network at all. Your safety net, and the credibility proof at the end. | + +The copilot panel shows which one answered every message, so you always know. The +selector there overrides the default per question, so you can run the walkthrough +on Local and switch to Cloud for a deeper answer when someone pushes. + +Your choice is saved, so set it once while preparing and it will still be there. + +## Questions you will get + +**"Is this our real data?"** No — it is a simulated line. The adapters for your +data are in `server/ingest/`, and `docs/architecture.md` shows exactly where they +plug in. Nothing above that layer changes. + +**"How does it predict the failure?"** Least-squares fit over recent run time, +extrapolated to the alarm threshold, suppressed when r² is too low to trust. Open +`server/analytics/trend.js` if they want to see it. + +**"Could it control the line?"** Technically yes over OPC-UA; deliberately +read-only here. Writing setpoints to a PLC needs interlocks, an audit trail and +their sign-off. Say that — it builds more trust than saying yes. + +**"Why is OEE only 88% at the start?"** Because a line that reads 100% is a line +nobody believes. Micro-stops and unplanned stops are modelled, so the number moves +for reasons you can name. + +**"Can it run on the plant floor / offline?"** Yes. It is one Node process and a +static front end, and with the local model or the rule engine it needs no internet +at all. + +## If something goes wrong + +- **Charts empty / "Disconnected"** — the server died. `npm run dev` again; the + dashboard reconnects on its own and the server comes back pre-warmed. +- **Copilot returns nothing** — switch to **Offline rules** and carry on. This is + why that mode exists. +- **The line is in a strange state** — press **Reset**. It clears faults, learned + baselines and history, and re-warms to a clean ~88% OEE in under a second. +- **The 3D is choppy** — drop the clock to 20×. The model is fine; it is the + render loop competing with the projector. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..62e6bda --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,289 @@ +# Installation and running the demo + +Everything needed to get the LINE-1 Digital Twin running on a laptop and present +it, including the copilot and the offline fallback. + +If you only want the run-of-show — what to click and what to say — that is +[demo-script.md](demo-script.md) and the +[slide deck](LINE-1-Digital-Twin-Demo.pptx). This file is setup and operation. + +--- + +## 1. Requirements + +| | | +|---|---| +| **Node.js** | 20 or newer. Built and tested on **24.4.1**. | +| **npm** | Ships with Node. Tested on **11.13.0**. | +| **Disk** | ~250 MB for `node_modules`, plus models if you use Ollama. | +| **GPU** | Not required. The 3D view is primitives and runs on integrated graphics. | +| **Internet** | Only for `npm install` and the cloud copilot. The demo itself runs fully offline. | + +Optional, for the fast local copilot: + +| | | +|---|---| +| **Ollama** | Any recent version. Needed only for the **Local** copilot mode. | + +Check what you have: + +```bash +node -v && npm -v +``` + +## 2. Install + +From the repository root — **not** from `server/` or `web/`. This is an npm +workspaces repo, and one install at the root covers both packages. + +```bash +npm install +``` + +Expect ~240 packages and about 25 seconds. There should be no build step and no +native compilation. + +## 3. Configure the copilot (optional) + +**The demo works with no configuration at all.** With nothing set, the copilot +falls back to a deterministic rule engine that answers instantly, offline, and +never fails. Skip this section entirely if you just want to see the twin. + +Copy the template and edit it: + +```bash +cp .env.example .env +``` + +The server resolves a provider at startup in this order: + +1. **OpenRouter** if `OPENROUTER_API_KEY` is set +2. **Ollama** if it answers at `OLLAMA_HOST` +3. **Rule engine** otherwise + +```bash +# Cloud — best analysis, slowest and most variable to first token +OPENROUTER_API_KEY=sk-or-v1-... +OPENROUTER_MODEL=stealth/ox-alpha + +# Local — fast, needs `ollama serve` running +OLLAMA_HOST=http://127.0.0.1:11434 +OLLAMA_MODEL=qwen3.5-4b-32k:latest + +# Force the rule engine, for rehearsing the worst case +# COPILOT_PROVIDER=fallback +``` + +`.env` is gitignored. Keep the key out of commits and out of screen shares. + +**You do not have to edit this file to change models.** Once running, the in-app +settings page (**AI ⚙** in the header) lists every model each provider offers and +switches at runtime. See §7. + +### If you are using Ollama + +```bash +ollama serve +ollama pull qwen3.5-4b-32k +``` + +One gotcha worth knowing: Ollama commonly sets `OLLAMA_HOST=0.0.0.0` machine-wide. +That is a *bind* address, not a destination — you cannot connect to it. The server +normalizes it to `127.0.0.1` automatically, and the settings page shows both the +configured value and the address actually being dialled. + +## 4. Run it + +### For the demo (recommended) + +```bash +npm run dev +``` + +Starts the API on **:8787** and the Vite dev server on **:5173**, prefixing each +process's output. One Ctrl+C stops both. + +Open **http://localhost:5173**. + +You should see, within a couple of seconds: + +- Header: **Telemetry live** with a green dot, and a source badge reading `simulated` +- Header: an **AI ⚙** button naming the active provider +- **OEE around 87–90%** — not 100% +- All five stations green, **Alarms: all clear** +- Trend charts already populated with history + +The line starts **pre-warmed**: 30 simulated minutes are run before the first +frame is served, so the rolling OEE window is full, the anomaly baselines are +learned, and the charts have history. You are never presenting a dashboard that +just booted. + +### Single port, no dev server + +For a plant-floor box, a clean rehearsal, or handing someone a link: + +```bash +npm run serve +``` + +Builds the front end and serves everything from **http://localhost:8787** — one +Node process, one port, no Vite. Use `npm run build` and `npm start` separately if +you prefer. + +Once `web/dist` exists, the API process serves it too, so `npm run dev` will log +`serving built front end`. That is harmless — but :8787 then shows whatever was +last built, which can be stale. In development always use **:5173**, which is +served live by Vite. + +### Other scripts + +| Command | What it does | +|---|---| +| `npm run dev` | API + dev server together (the normal way) | +| `npm run dev:server` | API only, on :8787 | +| `npm run dev:web` | Front end only, on :5173 (needs the API running) | +| `npm run build` | Build the front end to `web/dist` | +| `npm start` | Serve API, and `web/dist` if it has been built | +| `npm run serve` | `build` then `start` | +| `npm run simcheck` | Verify the simulation model (no browser needed) | +| `npm run apicheck` | Verify the server API (server must be running) | + +## 5. Verify the install + +Two suites. Neither needs a browser, and both are worth running once after +install so you know the machine is good. + +```bash +npm run simcheck +``` + +Runs the model headless and asserts what the demo actually claims: signals stay +inside their physical ranges, faults move OEE the right way in a controlled A/B +against an unfaulted line, a packer stoppage propagates upstream as blocking, a +dropped sensor goes stale rather than to zero, setpoint changes are lagged, runs +are reproducible from a seed, and the prediction arrives *before* the threshold is +crossed. Ends with `ALL CHECKS PASSED`. + +```bash +npm run apicheck # in a second terminal, with the server running +``` + +Exercises the WebSocket stream, the control API, fault propagation into the +stream, and the copilot in every provider mode — checking answers are grounded in +real readings rather than generic prose. Ends with `ALL API CHECKS PASSED`. + +## 6. Running the demo + +The full run-of-show with timings and wording is in +[demo-script.md](demo-script.md). This is the mechanical sequence. + +### Ten minutes before + +1. `npm run dev`, open **http://localhost:5173**. +2. Confirm the header shows **Telemetry live**. +3. **Pick your copilot and measure it.** Open **AI ⚙**, choose a provider, click + **Test**. Under 4 s to first token is presentable live; 4–12 s means a + noticeable pause; above that, keep it for follow-up questions only. Do this on + the demo machine, on the demo network — cloud latency has measured anywhere + between 2 s and 36 s on consecutive identical calls. +4. Press **Reset** if anyone has been clicking. The line re-warms to ~87% OEE, + all clear, in well under a second. +5. Leave the clock at **1×**. You speed it up during the demo, not before. +6. Rehearse once with **wifi off** so you know what the fallback looks like. + +### The sequence + +| Step | Action | What to watch | +|---|---|---| +| **1** | Orbit the 3D view. Click **CNC-02**. | Detail charts follow the selection. OEE ~87%. | +| **2** | What-if → drag **Oven setpoint** 305 → 330 °C | Zone 2 lags: 316 °C at 10 s, settles ~30 s. Burner duty spikes 68→85→74%. Drag back to 305. | +| **3** | What-if → **Inject** *CNC-02 bearing degradation*. Set clock **60×**. | Bearing vibration begins climbing toward the dashed Warn 3.5 line. | +| **4** | Wait ~10 simulated minutes | A purple **Trend** card appears: reaches the 4.5 limit in ~40 min of run time, while vibration is still ~2.8. | +| **5** | Click **Explain this** on the bearing alarm, then the **work order** chip | The copilot traces vibration → tool wear → rejects → OEE. It is never told which fault you injected. | +| **6** | **Clear** the fault, click **Tool change**, clock to **20×** | Reject rate falls, OEE recovers. | +| **7** | Copilot selector → **Offline rules**, ask *"Why is OEE down?"* | Same diagnosis in ~40 ms, no model, no network. | + +### Resetting between runs + +**Reset** in the header clears injected faults, learned baselines and client-side +history, then re-warms the line. Use it between back-to-back demos rather than +restarting the server. + +## 7. Switching models at runtime + +**AI ⚙** in the header opens the settings page. The dashboard keeps streaming +behind it, so nothing is interrupted. + +- **Provider** — Auto / Cloud / Local / Offline rules +- **Model** — every model each provider offers. OpenRouter's catalogue arrives + live with context length and price per million; the Ollama list comes from your + local library with parameter size and quantization. Filter, or type an id. +- **Test** — measures time to first token and grades it +- **Generation** — temperature, max output tokens, reasoning effort +- **Ollama host** — shows the configured value and the address actually dialled + +Choices persist to `.copilot-settings.json` (gitignored), so a model picked while +preparing survives a restart. **Reset to defaults** discards that file and falls +back to `.env`. Settings never contain the API key. + +Note on reasoning models: reasoning tokens count against the output budget on most +providers, so a reasoning model on a small budget can spend the lot thinking and +return nothing. Keep effort **low** and the budget generous. Ollama thinking +models are sent `think: false` for the same reason. + +## 8. Troubleshooting + +**`Disconnected` in the header, charts empty.** +The API died. Restart with `npm run dev`; the dashboard reconnects on its own with +backoff and the server comes back pre-warmed. No page reload needed. + +**Port already in use.** +Set `PORT` in `.env` for the API. For the dev server, change `server.port` in +`web/vite.config.js` — and its proxy target if you moved the API. + +**Copilot returns nothing, or the panel shows a note about an empty answer.** +Expected behaviour, not a break: it fell back to the rule engine and still +answered. Usually a reasoning model exhausting its token budget. Raise max output +tokens or lower reasoning effort in the settings page, or switch to **Local**. + +**Copilot says Ollama is unreachable.** +`ollama serve` is not running, or the host is wrong. The settings page shows the +address being dialled — check that first. `0.0.0.0` in your environment is normal +and handled. + +**OEE reads 100% and there are no alarms.** +The pre-warm did not run. Check the startup log for `pre-warmed 30 simulated +minutes`. If it is absent, `PREWARM_SEC` has been overridden in +`server/ingest/simulatedSource.js`. + +**Server exits immediately with a long message about MQTT or OPC-UA.** +`TELEMETRY_SOURCE` is set to a stub adapter. Set `TELEMETRY_SOURCE=simulated` in +`.env`. The adapters are documented stubs — see [architecture.md](architecture.md). + +**3D view is choppy.** +Drop the clock to 20×. The model is fine; the render loop is competing with the +projector. The scene loads no external assets, so this is never a network issue. + +**The line is in a strange state.** +Press **Reset**. + +## 9. What is where + +``` +server/ + sim/ line.js (part flow, WIP, blocking) · stations.js (physics) + faults.js (injectable profiles) · kpi.js (OEE) + analytics/ trend.js (least squares) · anomaly.js (frozen baselines) + alarms.js (thresholds, latching, predictions) + ingest/ source.js (the seam) · simulatedSource.js + mqttSource.js, opcuaSource.js (documented stubs) + ai/ copilot.js · context.js · rules.js · settings.js +web/src/ + three/ Scene.jsx, Station.jsx, Conveyor.jsx + panels/ KPIs, station strip, charts, alarms, what-if, copilot, settings +scripts/ dev.mjs · simcheck.mjs · apicheck.mjs +docs/ demo-script.md · architecture.md · installation.md · the deck +``` + +Connecting real plant data is [architecture.md](architecture.md) — the ingest +seam, both adapter stubs, and the four things that bite when the data is real. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ea3e091 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3846 @@ +{ + "name": "digitaltwin-demo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "digitaltwin-demo", + "version": "1.0.0", + "workspaces": [ + "server", + "web" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@react-three/drei": { + "version": "10.7.8", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.8.tgz", + "integrity": "sha512-rJXyuzLm2Xq0kafHuR47ajDGbOe/pEhzIr4m8E8zwzQs0iNjloFDqBwRhrXmP/w+onLeYyN3EYPFW/cwWK/4yA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.7.0.tgz", + "integrity": "sha512-EWm9FwcaOZQu/ExFW5rggoCMM1NJet5YbxVxKaOE+KSncrjU0Wx7017qSyGFvupviK89nMYGCWU3BIK4dI1clw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.185.4", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.4.tgz", + "integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz", + "integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT", + "peer": true + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, + "node_modules/digitaltwin-server": { + "resolved": "server", + "link": true + }, + "node_modules/digitaltwin-web": { + "resolved": "web", + "link": true + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.412", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz", + "integrity": "sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.51.0.tgz", + "integrity": "sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hls.js": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.1.tgz", + "integrity": "sha512-DlzIkeBAS9IIQ432k3BUf3HlwbsR0+trB1i2lDdN2gUkNkrehFurh0/48M5c1/EjlDkdGng1gwZIpwyPxvdZ/g==", + "license": "Apache-2.0" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/three": { + "version": "0.180.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", + "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", + "license": "MIT" + }, + "node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz", + "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==", + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/troika-three-text": { + "version": "0.52.5", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.5.tgz", + "integrity": "sha512-Ry3jRhic9pzcY4JduSvRRyDmVOSqEW19gT4vtK+aCiPNVcDlmkxvGG0YbFd36RTDq1wExOupXnvNF/j1oiHHDA==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.5", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.5", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.5.tgz", + "integrity": "sha512-WsePbcX8RtfidRfsxK1eCZCjF81ZDzAKHH/evLs0hdV2wpoCb0vArGZHdzdOJrSS3k4zfdtbKDaBh8+phkrYnw==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "server": { + "name": "digitaltwin-server", + "version": "1.0.0", + "dependencies": { + "dotenv": "^17.2.1", + "express": "^5.1.0", + "ws": "^8.18.3" + } + }, + "web": { + "name": "digitaltwin-web", + "version": "1.0.0", + "dependencies": { + "@react-three/drei": "^10.7.6", + "@react-three/fiber": "^9.3.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "recharts": "^3.2.1", + "three": "^0.180.0", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@vitejs/plugin-react": "^5.0.2", + "vite": "^7.1.5" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..246ae54 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "digitaltwin-demo", + "version": "1.0.0", + "private": true, + "description": "Digital twin demo - simulated factory production line with live telemetry, trend-based failure prediction and a local LLM copilot", + "type": "module", + "workspaces": [ + "server", + "web" + ], + "scripts": { + "dev": "node scripts/dev.mjs", + "dev:server": "npm run dev -w server", + "dev:web": "npm run dev -w web", + "simcheck": "node scripts/simcheck.mjs", + "build": "npm run build -w web", + "apicheck": "node scripts/apicheck.mjs", + "start": "node server/index.js", + "serve": "npm run build && node server/index.js" + }, + "engines": { + "node": ">=20" + } +} diff --git a/scripts/apicheck.mjs b/scripts/apicheck.mjs new file mode 100644 index 0000000..1e60701 --- /dev/null +++ b/scripts/apicheck.mjs @@ -0,0 +1,187 @@ +/** + * End-to-end server verification: WebSocket streaming, the control API, and the + * copilot (including its offline fallback). + * + * Start the server first, then: node scripts/apicheck.mjs + */ + +import WebSocket from 'ws'; + +const BASE = process.env.BASE || 'http://localhost:8787'; +let failures = 0; + +function check(name, ok, detail = '') { + if (!ok) failures++; + console.log(` [${ok ? 'PASS' : 'FAIL'}] ${name}${detail ? ' ' + detail : ''}`); +} + +async function post(path, body) { + const res = await fetch(`${BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body || {}), + }); + return { status: res.status, json: await res.json().catch(() => null) }; +} + +async function get(path) { + const res = await fetch(`${BASE}${path}`); + return { status: res.status, json: await res.json().catch(() => null) }; +} + +/** Collect n frames from the websocket. */ +function collectFrames(n, timeoutMs = 15000) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`${BASE.replace('http', 'ws')}/ws`); + const frames = []; + const timer = setTimeout(() => { ws.close(); reject(new Error(`timed out after ${frames.length} frames`)); }, timeoutMs); + ws.on('message', (raw) => { + const msg = JSON.parse(raw.toString()); + if (msg.type === 'frame') frames.push(msg.frame); + if (frames.length >= n) { clearTimeout(timer); ws.close(); resolve(frames); } + }); + ws.on('error', (err) => { clearTimeout(timer); reject(err); }); + }); +} + +/** Read an SSE copilot response to completion. */ +async function askCopilot(question, timeoutMs = 120000) { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), timeoutMs); + try { + const res = await fetch(`${BASE}/api/copilot/chat`, { + method: 'POST', + signal: ctl.signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ question, history: [] }), + }); + let text = ''; + let meta = null; + let notes = []; + let buf = ''; + const dec = new TextDecoder(); + for await (const chunk of res.body) { + buf += dec.decode(chunk, { stream: true }); + let i; + while ((i = buf.indexOf('\n\n')) >= 0) { + const line = buf.slice(0, i).trim(); + buf = buf.slice(i + 2); + if (!line.startsWith('data:')) continue; + const obj = JSON.parse(line.slice(5).trim()); + if (obj.type === 'token') text += obj.text; + if (obj.type === 'meta') meta = obj; + if (obj.type === 'note') notes.push(obj.text); + } + } + return { text, meta, notes }; + } finally { + clearTimeout(timer); + } +} + +console.log(`\n=== A. Metadata and health (${BASE}) ===`); +const meta = await get('/api/meta'); +check('GET /api/meta returns 200', meta.status === 200); +check('five stations described', meta.json?.stations?.length === 5, (meta.json?.stations || []).map((s) => s.id).join(',')); +check('four fault profiles offered', meta.json?.faults?.length === 4); +check('source reports its capabilities', Boolean(meta.json?.source?.capabilities?.faultInjection)); +console.log(` copilot: ${meta.json?.copilot?.label} (${meta.json?.copilot?.detail})`); + +const health = await get('/api/health'); +check('GET /api/health ok', health.status === 200 && health.json.ok === true); + +console.log('\n=== B. WebSocket streaming ==='); +{ + await post('/api/control/speed', { speed: 1 }); + const frames = await collectFrames(3); + check('received 3 frames', frames.length === 3); + const f = frames[frames.length - 1]; + check('frame has stations, kpi, analytics', Boolean(f.stations && f.kpi && f.analytics)); + check('frame carries analytics arrays', + Array.isArray(f.analytics.alarms) && Array.isArray(f.analytics.predictions)); + check('simulated time advances between frames', frames[2].t > frames[0].t, + `${frames[0].t.toFixed(1)}s -> ${frames[2].t.toFixed(1)}s`); + check('every station reports a state', f.stations.every((s) => typeof s.state === 'string')); + check('sim block reports speed and tick', f.sim.tickMs > 0 && typeof f.sim.speed === 'number'); +} + +console.log('\n=== C. Control API ==='); +{ + const sp = await post('/api/control/setpoint', { value: 325 }); + check('POST setpoint accepted', sp.status === 200 && sp.json.result === 325, JSON.stringify(sp.json)); + + const clamp = await post('/api/control/setpoint', { value: 9999 }); + check('setpoint is clamped, not rejected', clamp.json.result === 360, `got ${clamp.json.result}`); + + const ls = await post('/api/control/line-speed', { value: 115 }); + check('POST line-speed accepted', ls.json.result === 115); + + const speed = await post('/api/control/speed', { speed: 60 }); + check('POST speed 60x accepted', speed.json.result === 60); + + const badSpeed = await post('/api/control/speed', { speed: 999 }); + check('invalid speed rejected with 400', badSpeed.status === 400, badSpeed.json?.error); + + const badFault = await post('/api/control/fault', { id: 'nope', action: 'inject' }); + check('unknown fault rejected with 400', badFault.status === 400, badFault.json?.error); + + await post('/api/control/setpoint', { value: 305 }); + await post('/api/control/line-speed', { value: 100 }); +} + +console.log('\n=== D. Fault injection propagates to the stream ==='); +{ + await post('/api/control/speed', { speed: 60 }); + await post('/api/control/fault', { id: 'packer-jam', action: 'inject' }); + const frames = await collectFrames(8); + const last = frames[frames.length - 1]; + const pkg = last.stations.find((s) => s.id === 'PKG-05'); + check('packer shows fault state in the stream', pkg.state === 'fault', pkg.state); + check('a critical alarm is raised', + last.analytics.alarms.some((a) => a.severity === 'critical'), + last.analytics.alarms.map((a) => `${a.severity}:${a.station}`).join(', ')); + check('fault is listed on the frame', last.faults.some((f) => f.id === 'packer-jam')); + + await post('/api/control/fault', { id: 'packer-jam', action: 'clear' }); + const after = await collectFrames(6); + const pkg2 = after[after.length - 1].stations.find((s) => s.id === 'PKG-05'); + check('packer recovers after the fault is cleared', pkg2.state !== 'fault', pkg2.state); +} + +console.log('\n=== E. Copilot ==='); +{ + // Build up a real condition so the answers have something to work with. + await post('/api/control/fault', { id: 'bearing-degradation', action: 'inject' }); + await post('/api/control/speed', { speed: 60 }); + await new Promise((r) => setTimeout(r, 12000)); + await post('/api/control/speed', { speed: 1 }); + + const state = await get('/api/state'); + const vib = state.json.stations.find((s) => s.id === 'CNC-02').signals.vibration; + console.log(` CNC-02 vibration is now ${vib.toFixed(2)} mm/s; alarms: ${state.json.analytics.alarms.length}; predictions: ${state.json.analytics.predictions.length}`); + + const qs = [ + 'What is wrong with CNC-02?', + 'Why is OEE down?', + 'Draft a maintenance work order.', + 'What happens if I raise line speed by 10%?', + ]; + for (const q of qs) { + const ans = await askCopilot(q); + const ok = ans.text.trim().length > 40; + check(`answered: "${q}"`, ok, ok ? `${ans.text.trim().length} chars via ${ans.meta?.provider}` : JSON.stringify(ans).slice(0, 200)); + if (ans.notes.length) console.log(` note: ${ans.notes[0]}`); + console.log(` -> ${ans.text.trim().split('\n')[0].slice(0, 160)}`); + } + + // The answer must be grounded in real telemetry, not generic prose. + const diag = await askCopilot('What is wrong with CNC-02?'); + check('answer cites the actual station', /CNC-02/i.test(diag.text)); + check('answer cites a real measurement', /\d+\.\d+/.test(diag.text)); + + await post('/api/control/fault', { id: 'bearing-degradation', action: 'clear' }); + await post('/api/control/reset', {}); +} + +console.log(`\n${failures === 0 ? 'ALL API CHECKS PASSED' : failures + ' CHECK(S) FAILED'}\n`); +process.exit(failures === 0 ? 0 : 1); diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 0000000..a5c6e84 --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,64 @@ +/** + * Run the server and the web dev server together, with one Ctrl+C to stop both. + * + * A tiny spawner instead of a dependency like concurrently: two fewer packages to + * install, and it behaves predictably on Windows, which is where this demo runs. + */ + +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + +const targets = [ + { name: 'server', args: ['run', 'dev', '-w', 'server'], color: '\x1b[36m' }, + { name: 'web', args: ['run', 'dev', '-w', 'web'], color: '\x1b[35m' }, +]; + +const children = []; +let shuttingDown = false; + +for (const t of targets) { + const child = spawn(npm, t.args, { cwd: root, shell: true, stdio: ['ignore', 'pipe', 'pipe'] }); + children.push(child); + + const prefix = `${t.color}[${t.name}]\x1b[0m `; + const pipe = (stream) => { + let buf = ''; + stream.on('data', (chunk) => { + buf += chunk.toString(); + let i; + while ((i = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, i); + buf = buf.slice(i + 1); + if (line.trim()) process.stdout.write(prefix + line + '\n'); + } + }); + }; + pipe(child.stdout); + pipe(child.stderr); + + child.on('exit', (code) => { + if (shuttingDown) return; + console.log(`${prefix}exited with code ${code}. Shutting down.`); + shutdown(); + }); +} + +function shutdown() { + if (shuttingDown) return; + shuttingDown = true; + for (const c of children) { + if (!c.killed) c.kill(); + } + setTimeout(() => process.exit(0), 300); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + +console.log('\n Digital twin demo starting…'); +console.log(' Dashboard: \x1b[1mhttp://localhost:5173\x1b[0m'); +console.log(' API: http://localhost:8787\n'); diff --git a/scripts/simcheck.mjs b/scripts/simcheck.mjs new file mode 100644 index 0000000..1d39d78 --- /dev/null +++ b/scripts/simcheck.mjs @@ -0,0 +1,322 @@ +/** + * Headless simulator verification. No UI, no dependencies. + * + * A simulator that lies is worse than no demo, so this asserts the behaviour the + * demo actually claims: signals stay physical, faults move OEE the right way, + * and a packer stop propagates upstream as blocking. + * + * node scripts/simcheck.mjs + */ + +import { ProductionLine } from '../server/sim/line.js'; +import { STATION_SPECS } from '../server/sim/stations.js'; +import { AnalyticsEngine } from '../server/analytics/alarms.js'; + +let failures = 0; +function check(name, ok, detail = '') { + const mark = ok ? 'PASS' : 'FAIL'; + if (!ok) failures++; + console.log(` [${mark}] ${name}${detail ? ' ' + detail : ''}`); +} + +function run(line, simSeconds) { + const tick = 0.5; + for (let i = 0; i < simSeconds / tick; i++) line.step(tick); + return line.snapshot(); +} + +/** Every signal must stay inside its declared physical range. */ +function checkRanges(snap, label) { + let worst = null; + for (const st of snap.stations) { + const spec = STATION_SPECS.find((s) => s.id === st.id); + for (const g of spec.signals) { + const v = st.signals[g.key]; + if (!Number.isFinite(v)) { worst = `${st.id}.${g.key} = ${v}`; break; } + // Allow a hair of overshoot on lag+noise, but nothing structural. + const slack = (g.max - g.min) * 0.02; + if (v < g.min - slack || v > g.max + slack) { + worst = `${st.id}.${g.key} = ${v.toFixed(2)} outside [${g.min}, ${g.max}]`; + break; + } + } + if (worst) break; + } + check(`${label}: all signals in physical range`, !worst, worst || ''); +} + +console.log('\n=== 1. Baseline: 15 simulated minutes at nominal ==='); +{ + const line = new ProductionLine(); + const snap = run(line, 900); + checkRanges(snap, 'baseline'); + + const k = snap.kpi; + console.log(` OEE ${(k.oee * 100).toFixed(1)}% A ${(k.availability * 100).toFixed(1)}% P ${(k.performance * 100).toFixed(1)}% Q ${(k.quality * 100).toFixed(1)}%`); + console.log(` throughput ${k.throughputPerHour.toFixed(0)}/h scrap ${(k.scrapRate * 100).toFixed(2)}% energy ${k.energyKw.toFixed(1)} kW`); + console.log(` buffers [${snap.buffers.join(', ')}] states: ${snap.stations.map((s) => s.id + '=' + s.state).join(' ')}`); + + // World-class OEE is ~85%; a typical plant is 60-75%. Anything near 100% reads + // as a fabricated demo, which is the one thing this number must not do. + check('baseline OEE is credible (65-93%)', k.oee > 0.65 && k.oee < 0.93, `got ${(k.oee * 100).toFixed(1)}%`); + check('performance never exceeds 100%', k.performance <= 1, `got ${(k.performance * 100).toFixed(1)}%`); + check('availability reflects real stoppages (<100%)', k.availability <= 1); + check('baseline scrap is low (<5%)', k.scrapRate < 0.05, `got ${(k.scrapRate * 100).toFixed(2)}%`); + check('line is producing', snap.totals.good > 100, `${snap.totals.good} good parts`); + check('oven holds setpoint within 2 C', Math.abs(snap.stations[2].signals.tempDeviation) < 2, + `dev ${snap.stations[2].signals.tempDeviation.toFixed(2)} C`); + check('no station is stuck in an unknown state', + snap.stations.every((s) => ['running', 'starved', 'blocked', 'microstop', 'down'].includes(s.state))); +} + +console.log('\n=== 2. F1 bearing degradation: vibration ramp and OEE impact (A/B) ==='); +{ + // Controlled A/B: two lines from the same seed, identical except that B gets + // the fault at t=300. Comparing a before/after snapshot on one line is not + // valid here, because the rolling KPI window is still filling early in the run. + const control = new ProductionLine(4242); + const faulted = new ProductionLine(4242); + + run(control, 300); + run(faulted, 300); + faulted.injectFault('bearing-degradation'); + + const marks = []; + for (let i = 0; i < 5; i++) { + run(control, 300); + // Track PEAK vibration across the interval, not the instantaneous value at + // the sample instant. A stalled spindle legitimately vibrates less, so an + // instantaneous sample taken during a micro-stop reads low and makes a real + // upward trend look non-monotonic. Real condition monitoring reports peak or + // high-percentile RMS over a window for exactly this reason. + let peakVib = 0; + for (let k = 0; k < 600; k++) { + faulted.step(0.5); + peakVib = Math.max(peakVib, faulted.stationById['CNC-02'].signals.vibration); + } + const snap = faulted.snapshot(); + marks.push({ + t: (i + 1) * 300, + peakVib, + wear: snap.stations[1].signals.toolWear, + rej: snap.stations[3].signals.rejectRate, + oee: snap.kpi.oee, + ctlOee: control.snapshot().kpi.oee, + }); + } + for (const m of marks) { + console.log(` t+${String(m.t).padStart(4)}s peak vib ${m.peakVib.toFixed(2)} mm/s wear ${m.wear.toFixed(1)}% reject ${m.rej.toFixed(2)}% OEE ${(m.oee * 100).toFixed(1)}% (control ${(m.ctlOee * 100).toFixed(1)}%)`); + } + + const finalF = faulted.snapshot(); + const finalC = control.snapshot(); + checkRanges(finalF, 'bearing fault'); + + // The fault profile saturates by design (a spalled bearing does not ramp + // forever), so only assert monotonicity below the alarm threshold. + const preSat = marks.filter((m) => m.peakVib < 4.5); + check('peak vibration rises monotonically until saturation', + preSat.length >= 2 && preSat.every((m, i) => i === 0 || m.peakVib > preSat[i - 1].peakVib), + `${preSat.length} pre-saturation marks`); + check('vibration ends far above baseline', + marks[marks.length - 1].peakVib > marks[0].peakVib + 2, + `${marks[0].peakVib.toFixed(2)} -> ${marks[marks.length - 1].peakVib.toFixed(2)} mm/s`); + check('vibration crosses the 3.5 warn band', marks.some((m) => m.peakVib >= 3.5), + `peak ${marks[marks.length - 1].peakVib.toFixed(2)}`); + check('reject rate rises as a consequence', + marks[marks.length - 1].rej > marks[0].rej + 0.5, + `${marks[0].rej.toFixed(2)}% -> ${marks[marks.length - 1].rej.toFixed(2)}%`); + check('OEE is materially worse than the control line', + finalF.kpi.oee < finalC.kpi.oee - 0.05, + `faulted ${(finalF.kpi.oee * 100).toFixed(1)}% vs control ${(finalC.kpi.oee * 100).toFixed(1)}%`); + // Cumulative counts, not the rolling window: a ~250-part window at a few + // percent reject is too small a sample to compare reliably. + const scrapF = finalF.totals.rejected / finalF.totals.produced; + const scrapC = finalC.totals.rejected / finalC.totals.produced; + check('cumulative scrap is higher than the control line', scrapF > scrapC, + `faulted ${(scrapF * 100).toFixed(2)}% vs control ${(scrapC * 100).toFixed(2)}%`); + check('performance is worse than the control line (spindle chatter stalls the cut)', + finalF.kpi.performance < finalC.kpi.performance, + `faulted ${(finalF.kpi.performance * 100).toFixed(1)}% vs control ${(finalC.kpi.performance * 100).toFixed(1)}%`); +} + +console.log('\n=== 3. F2 oven burner fault: cure runs cold, quality drops ==='); +{ + const line = new ProductionLine(); + run(line, 300); + const before = line.snapshot(); + line.injectFault('oven-burner'); + const after = run(line, 900); + checkRanges(after, 'oven fault'); + + const ovn = after.stations.find((s) => s.id === 'OVN-03'); + console.log(` zone2 ${ovn.signals.zone2Temp.toFixed(1)} C (setpoint ${ovn.signals.setpoint}) dev ${ovn.signals.tempDeviation.toFixed(1)} C duty ${ovn.signals.burnerDuty.toFixed(1)}%`); + console.log(` reject ${after.stations[3].signals.rejectRate.toFixed(2)}% quality ${(after.kpi.quality * 100).toFixed(1)}% OEE ${(after.kpi.oee * 100).toFixed(1)}%`); + + check('zone 2 sags below setpoint', ovn.signals.tempDeviation < -8, `dev ${ovn.signals.tempDeviation.toFixed(1)} C`); + check('burner duty saturates trying to compensate', ovn.signals.burnerDuty > 95, `${ovn.signals.burnerDuty.toFixed(1)}%`); + check('reject rate climbs from the cold cure', after.stations[3].signals.rejectRate > 4, + `${after.stations[3].signals.rejectRate.toFixed(2)}%`); + check('quality falls vs baseline', after.kpi.quality < before.kpi.quality - 0.01, + `${(before.kpi.quality * 100).toFixed(1)}% -> ${(after.kpi.quality * 100).toFixed(1)}%`); +} + +console.log('\n=== 4. F3 packer jam: blocking propagates upstream ==='); +{ + const line = new ProductionLine(); + run(line, 300); + const before = line.snapshot(); + line.injectFault('packer-jam'); + + const seen = []; + for (let i = 0; i < 6; i++) { + const snap = run(line, 30); + seen.push({ t: snap.t, states: snap.stations.map((s) => s.state), buffers: [...snap.buffers] }); + } + for (const s of seen) { + console.log(` t=${s.t.toFixed(0)}s buffers [${s.buffers.join(',')}] ${s.states.join(' ')}`); + } + const final = line.snapshot(); + checkRanges(final, 'packer jam'); + + check('packer is in fault', final.stations[4].state === 'fault'); + check('downstream buffer fills to capacity', final.buffers[3] >= final.bufferCapacity, + `buffer[3] = ${final.buffers[3]}/${final.bufferCapacity}`); + check('blocking reached the inspection station', + seen.some((s) => s.states[3] === 'blocked')); + check('blocking propagated all the way to the conveyor', + final.stations[0].state === 'blocked', + `CONV-01 = ${final.stations[0].state}`); + check('all buffers backed up', final.buffers.every((b) => b >= final.bufferCapacity), + `[${final.buffers.join(',')}]`); + // 300 s of uptime then 180 s of jam inside a 600 s window is ~62% by + // definition, so assert a meaningful drop rather than a collapse. + check('availability drops sharply', final.kpi.availability < before.kpi.availability - 0.2, + `${(before.kpi.availability * 100).toFixed(0)}% -> ${(final.kpi.availability * 100).toFixed(0)}%`); + check('output rate falls to zero', final.stations[4].signals.unitsPerMin < 1, + `${final.stations[4].signals.unitsPerMin.toFixed(2)} u/min`); +} + +console.log('\n=== 5. F4 sensor dropout: values go stale, not zero ==='); +{ + const line = new ProductionLine(); + run(line, 300); + const before = line.snapshot().stations[3].signals.rejectRate; + line.injectFault('sensor-dropout'); + const after = run(line, 240).stations[3]; + + console.log(` reject rate before ${before.toFixed(3)}% after ${after.signals.rejectRate.toFixed(3)}% online=${after.online}`); + check('station reports offline', after.online === false); + check('value held stale rather than zeroed', Math.abs(after.signals.rejectRate - before) < 1e-9); +} + +console.log('\n=== 6. What-if: oven setpoint change produces a lagged response ==='); +{ + const line = new ProductionLine(); + run(line, 300); + const t0 = line.snapshot().stations[2].signals.zone2Temp; + line.setSetpoint(330); + const t1 = run(line, 15).stations[2].signals.zone2Temp; + const t2 = run(line, 285).stations[2].signals.zone2Temp; + + console.log(` zone2: ${t0.toFixed(1)} C -> +15s ${t1.toFixed(1)} C -> +300s ${t2.toFixed(1)} C (setpoint 330)`); + check('response is lagged, not instant', t1 < t0 + 20, `+15s only reached ${t1.toFixed(1)} C`); + check('eventually settles near the new setpoint', Math.abs(t2 - 330) < 3, `${t2.toFixed(1)} C`); +} + +console.log('\n=== 7. Determinism: same seed reproduces the same run ==='); +{ + const a = new ProductionLine(1234); + const b = new ProductionLine(1234); + a.injectFault('bearing-degradation'); + b.injectFault('bearing-degradation'); + const sa = run(a, 600); + const sb = run(b, 600); + check('identical vibration trace', + sa.stations[1].signals.vibration === sb.stations[1].signals.vibration, + `${sa.stations[1].signals.vibration.toFixed(6)} vs ${sb.stations[1].signals.vibration.toFixed(6)}`); + check('identical part counts', sa.totals.good === sb.totals.good, `${sa.totals.good} vs ${sb.totals.good}`); +} + +console.log('\n=== 8. Analytics: baseline, prediction lead time, alarm latching ==='); +{ + const line = new ProductionLine(9001); + const engine = new AnalyticsEngine(); + let out = null; + + const feed = (seconds) => { + for (let i = 0; i < seconds / 0.5; i++) { + line.step(0.5); + out = engine.update(line.snapshot()); + } + return out; + }; + + feed(400); + check('baseline learns from clean running', out.baselineReady); + check('no alarms on a healthy line', out.alarms.length === 0, + out.alarms.map((a) => a.key).join(', ') || 'clean'); + + line.injectFault('bearing-degradation'); + + // Walk forward and record when the prediction appears versus when the signal + // actually crosses the alarm limit. The gap between them is the lead time, + // which is the entire value proposition of the trend layer. + let firstPredictionT = null, crossingT = null, firstAnomalyT = null; + const alarmLimit = 4.5; + for (let i = 0; i < 2400; i++) { + line.step(0.5); + out = engine.update(line.snapshot()); + const vib = line.stationById['CNC-02'].signals.vibration; + const pred = out.predictions.find((p) => p.station === 'CNC-02' && p.signal === 'vibration'); + if (pred && firstPredictionT === null) firstPredictionT = line.simTime; + if (firstAnomalyT === null && out.alarms.some((a) => a.key === 'anom:CNC-02.vibration')) { + firstAnomalyT = line.simTime; + } + if (vib >= alarmLimit && crossingT === null) crossingT = line.simTime; + if (crossingT !== null && firstPredictionT !== null) break; + } + + console.log(` anomaly raised at t=${firstAnomalyT === null ? 'never' : firstAnomalyT.toFixed(0) + 's'}`); + console.log(` prediction first appeared at t=${firstPredictionT === null ? 'never' : firstPredictionT.toFixed(0) + 's'}`); + console.log(` vibration crossed ${alarmLimit} mm/s at t=${crossingT === null ? 'never' : crossingT.toFixed(0) + 's'}`); + + check('a prediction was produced', firstPredictionT !== null); + check('the anomaly was flagged from the learned baseline', firstAnomalyT !== null); + check('prediction arrives BEFORE the alarm threshold is crossed', + firstPredictionT !== null && crossingT !== null && firstPredictionT < crossingT, + firstPredictionT !== null && crossingT !== null + ? `lead time ${(crossingT - firstPredictionT).toFixed(0)} s of simulated run time` : ''); + + const pred = out.predictions.find((p) => p.station === 'CNC-02' && p.signal === 'vibration'); + if (pred) { + console.log(` latest projection: ${pred.label} ${pred.current.toFixed(2)} -> ${pred.threshold} ${pred.unit} in ${pred.eta} (slope ${pred.slopePerMin.toFixed(4)}/min, r2 ${pred.r2.toFixed(3)})`); + check('projection quality is reported and usable', pred.r2 > 0.55, `r2 ${pred.r2.toFixed(3)}`); + check('projection slope is positive', pred.slopePerMin > 0); + } + + // Latching: feed a steady state and confirm the alarm set stops churning. + const seenSets = []; + for (let i = 0; i < 240; i++) { + line.step(0.5); + out = engine.update(line.snapshot()); + seenSets.push(out.alarms.map((a) => a.key).sort().join('|')); + } + const distinct = new Set(seenSets).size; + console.log(` alarm-set changes over 120 s of steady fault: ${distinct} distinct sets`); + check('alarm list does not flap', distinct <= 6, `${distinct} distinct sets`); + check('the bearing alarm is present and severe', + out.alarms.some((a) => a.station === 'CNC-02' && (a.severity === 'major' || a.severity === 'critical')), + out.alarms.filter((a) => a.station === 'CNC-02').map((a) => `${a.severity}:${a.kind}`).join(', ')); + + // Clearing the fault must retire the alarms rather than leaving them stuck on. + line.clearFault('bearing-degradation'); + line.toolChange(); + feed(400); + check('alarms clear after the fault is resolved', + !out.alarms.some((a) => a.station === 'CNC-02'), + out.alarms.map((a) => a.key).join(', ') || 'clean'); +} + +console.log(`\n${failures === 0 ? 'ALL CHECKS PASSED' : failures + ' CHECK(S) FAILED'}\n`); +process.exit(failures === 0 ? 0 : 1); diff --git a/server/ai/context.js b/server/ai/context.js new file mode 100644 index 0000000..b62c68d --- /dev/null +++ b/server/ai/context.js @@ -0,0 +1,174 @@ +/** + * Builds the plant context handed to the copilot. + * + * Two rules govern what goes in here. + * + * First: send CONCLUSIONS, not raw floats. A 4B local model reasons well over + * "vibration is rising 0.04 mm/s per minute and reaches its 4.5 limit in about + * 7 minutes" and very badly over six hundred numbers. The analytics layer has + * already done the arithmetic; the model's job is explanation and judgement. + * + * Second: the copilot is NOT told which fault was injected. Operator injection + * log lines are filtered out, so the model has to diagnose from telemetry the + * way it would in a real plant. It is a weaker demo if the model can read the + * answer off a label, and a dishonest one. + */ + +import { STATION_SPECS } from '../sim/stations.js'; +import { formatDuration } from '../analytics/trend.js'; + +function fmt(v, precision = 1) { + return Number.isFinite(v) ? v.toFixed(precision) : 'n/a'; +} + +function pct(v) { + return Number.isFinite(v) ? `${(v * 100).toFixed(1)}%` : 'n/a'; +} + +/** Where a value sits relative to its declared thresholds. */ +function thresholdNote(g, v) { + if (!Number.isFinite(v)) return ''; + if (g.alarmHigh !== undefined && v >= g.alarmHigh) return ` [ALARM, limit ${g.alarmHigh}]`; + if (g.warnHigh !== undefined && v >= g.warnHigh) return ` [WARN, limit ${g.warnHigh}]`; + if (g.alarmLow !== undefined && v <= g.alarmLow) return ` [ALARM, limit ${g.alarmLow}]`; + if (g.warnLow !== undefined && v <= g.warnLow) return ` [WARN, limit ${g.warnLow}]`; + return ''; +} + +/** + * Assemble the context block. Returns plain text, kept deliberately compact. + */ +export function buildContext(frame) { + if (!frame) return 'No telemetry available yet.'; + + const L = []; + const a = frame.analytics || { alarms: [], predictions: [], trends: [] }; + + L.push(`PLANT CONTEXT - line ${frame.lineId}`); + L.push(`Simulated run time: ${formatDuration(frame.t)} (clock running at ${frame.sim ? frame.sim.speed : 1}x)`); + L.push(''); + + // --- KPIs, with the factor breakdown so the model can attribute the loss --- + const k = frame.kpi; + L.push('OEE (rolling 20 simulated minutes):'); + L.push(` OEE ${pct(k.oee)} = Availability ${pct(k.availability)} x Performance ${pct(k.performance)} x Quality ${pct(k.quality)}`); + L.push(` Throughput ${fmt(k.throughputPerHour, 0)} good units/hour, scrap ${pct(k.scrapRate)}`); + L.push(` Energy ${fmt(k.energyKw)} kW, ${fmt(k.energyPerUnit, 3)} kWh per unit`); + L.push(` Totals since reset: ${k.produced} produced, ${k.good} good, ${k.rejected} rejected`); + L.push(''); + + // --- State vocabulary --- + // Without this the model guesses. Asked why OEE was down it invented "safety + // logic that stalls the spindle" and attributed micro-stops to Availability + // instead of Performance. These are definitions, not hints. + L.push('STATION STATE MEANINGS:'); + L.push(' running - producing normally.'); + L.push(' starved - idle because its input buffer is empty (the constraint is UPSTREAM).'); + L.push(' blocked - idle because its output buffer is full (the constraint is DOWNSTREAM).'); + L.push(' microstop - a brief stall of a few seconds. Normal line behaviour, counts against PERFORMANCE, not Availability.'); + L.push(' down - an unplanned stop of tens of seconds. Counts against AVAILABILITY.'); + L.push(' fault - stopped on a specific fault condition. Counts against AVAILABILITY.'); + L.push(''); + L.push('HOW OEE LOSS IS ATTRIBUTED ON THIS LINE:'); + L.push(' Availability loss = time in "down" or "fault" states only.'); + L.push(' Performance loss = micro-stops and running below the ideal 4.4 s bottleneck cycle.'); + L.push(' Quality loss = parts rejected at INS-04.'); + L.push(' "starved" and "blocked" are not losses in their own right; they are the consequence of a stoppage somewhere else on the line.'); + L.push(''); + + // --- Station states and signals --- + L.push('STATIONS (in process order):'); + for (const spec of STATION_SPECS) { + const st = frame.stations.find((s) => s.id === spec.id); + if (!st) continue; + const stale = st.online ? '' : ' (NOT REPORTING - values are last known)'; + L.push(` ${st.id} ${st.name} - state: ${st.state}${stale}`); + const parts = []; + for (const g of spec.signals) { + if (g.key === 'partsInspected' || g.key === 'downtime') continue; + const v = st.signals[g.key]; + parts.push(`${g.label} ${fmt(v, g.precision)} ${g.unit}${thresholdNote(g, v)}`); + } + L.push(` ${parts.join('; ')}`); + } + L.push(''); + + // --- WIP, which is how blocking and starvation become legible --- + const bufs = frame.buffers + .map((b, i) => `${STATION_SPECS[i].id}->${STATION_SPECS[i + 1].id}: ${b}/${frame.bufferCapacity}`) + .join(', '); + L.push(`WIP BUFFERS: ${bufs}`); + L.push(`Operator setpoints: oven ${frame.controls.setpoint} C, line speed ${frame.controls.lineSpeedPct}%`); + L.push(''); + + // --- Alarms --- + if (a.alarms.length) { + L.push('ACTIVE ALARMS (most severe first):'); + for (const al of a.alarms.slice(0, 12)) { + L.push(` [${al.severity.toUpperCase()}] ${al.station}${al.signal ? '.' + al.signal : ''}: ${al.message}`); + } + } else { + L.push('ACTIVE ALARMS: none.'); + } + L.push(''); + + // --- Trend projections --- + if (a.predictions.length) { + L.push('TREND PROJECTIONS (least-squares fit over recent run time, extrapolated):'); + for (const p of a.predictions) { + L.push(` ${p.station}.${p.signal} (${p.label}): now ${fmt(p.current, 2)} ${p.unit}, rising ${p.slopePerMin.toFixed(4)} ${p.unit}/min, reaches limit ${p.threshold} ${p.unit} in about ${p.eta} of run time (fit quality r2=${p.r2.toFixed(2)})`); + } + L.push(''); + } + + // --- Notable slopes, even where no threshold crossing is projected --- + const notable = (a.trends || []) + .filter((t) => Math.abs(t.slopePerMin) > 1e-4 && t.r2 > 0.5) + .sort((x, y) => y.r2 - x.r2) + .slice(0, 6); + if (notable.length) { + L.push('OTHER MEASURED TRENDS:'); + for (const t of notable) { + const dir = t.slopePerMin > 0 ? 'rising' : 'falling'; + L.push(` ${t.station}.${t.signal} (${t.label}): ${dir} ${Math.abs(t.slopePerMin).toFixed(4)} ${t.unit}/min (r2=${t.r2.toFixed(2)})`); + } + L.push(''); + } + + // --- Recent events, with operator fault injections filtered out --- + const events = (frame.events || []).filter((e) => e.kind !== 'inject').slice(0, 8); + if (events.length) { + L.push('RECENT EVENTS (newest first):'); + for (const e of events) { + L.push(` t=${formatDuration(e.t)} [${e.kind}] ${e.station}: ${e.message}`); + } + L.push(''); + } + + // --- Model relationships, so the copilot can reason causally --- + L.push('KNOWN PROCESS RELATIONSHIPS on this line:'); + L.push(' - CNC-02 bearing vibration accelerates tool wear, and both push parts out of tolerance, raising INS-04 reject rate.'); + L.push(' - High CNC-02 vibration also causes spindle chatter, which stalls the cut and costs Performance.'); + L.push(' - OVN-03 zone 2 is the control zone. If actual temperature deviates from setpoint the cure is out of spec and INS-04 reject rate rises.'); + L.push(' - If OVN-03 burner duty is saturated at 100% and zone 2 is still below setpoint, the oven has lost heating capacity.'); + L.push(' - Stations are linked by finite WIP buffers. A stopped station fills the buffers behind it, so upstream stations become "blocked"; downstream stations become "starved".'); + L.push(' - CNC-02 has the longest cycle time (4.4 s), so it is the line bottleneck.'); + + return L.join('\n'); +} + +export const SYSTEM_PROMPT = `You are the plant copilot for a manufacturing digital twin of production line LINE-1. + +You are given a live telemetry context: station states, sensor readings with their alarm limits, OEE with its Availability/Performance/Quality breakdown, WIP buffer levels, active alarms, and measured trend projections. + +How to answer: +- Be concise and concrete. Two to five short sentences for most questions, or a short bullet list. This is read on a factory dashboard, not in a report. +- Always ground claims in the specific numbers from the context. Cite the station and the value. +- Reason causally using the stated process relationships. Explain WHY, not just WHAT. +- Distinguish a measurement from an inference. "Vibration is 4.2 mm/s" is a measurement; "the spindle bearing is degrading" is an inference from it. +- The trend projections are straight-line extrapolations of recent data, not certainties. Present them as "at the current rate", never as a guarantee. +- If the context does not contain what you need, say so plainly. Do not invent readings, part numbers, timestamps, or history you were not given. +- Do NOT invent mechanisms. Explain causes only using the process relationships and state definitions given below. Never assert control logic, safety interlocks, PLC behaviour or physical mechanisms that are not stated there — a plausible-sounding invented mechanism is the single worst failure mode here, because a plant engineer will catch it. +- Attribute OEE losses strictly according to the attribution rules given below. Do not guess which factor a loss belongs to. +- Times are in simulated run time, because the twin can run faster than real time. Say "of run time" when quoting a projection. +- Never claim to have taken an action. You are advisory; the operator drives the line.`; diff --git a/server/ai/copilot.js b/server/ai/copilot.js new file mode 100644 index 0000000..1bb4aa9 --- /dev/null +++ b/server/ai/copilot.js @@ -0,0 +1,580 @@ +/** + * Copilot provider layer. + * + * Resolution order: OpenRouter if a key is set, else Ollama if it answers, else + * the deterministic rule-based fallback. The fallback is not an error path - it + * is a supported mode, and COPILOT_PROVIDER=fallback selects it deliberately. + * + * The hard requirement is that a question ALWAYS gets an answer. A missing key, + * an unreachable host, a model that 404s, a stream that dies halfway: every one + * of those degrades to the fallback rather than surfacing an error on stage. + */ + +import { buildContext, SYSTEM_PROMPT } from './context.js'; +import { answerFromRules } from './rules.js'; +import { loadSettings, saveSettings, validatePatch, resetSettings } from './settings.js'; + +const PROBE_TIMEOUT_MS = 2000; +const REQUEST_TIMEOUT_MS = 90000; + +/** + * Turn whatever OLLAMA_HOST happens to contain into a dialable origin. + * + * Ollama itself commonly sets OLLAMA_HOST=0.0.0.0 machine-wide to bind all + * interfaces. That is a BIND address, not a destination - you cannot connect to + * it - and because it is a real environment variable it silently overrides any + * default we set here. Also accepts a bare host, a host:port, or a full URL. + */ +export function normalizeOllamaHost(raw) { + let h = String(raw || '').trim(); + if (!h) return 'http://127.0.0.1:11434'; + if (!/^https?:\/\//i.test(h)) h = `http://${h}`; + try { + const u = new URL(h); + if (u.hostname === '0.0.0.0' || u.hostname === '::' || u.hostname === '[::]') { + u.hostname = '127.0.0.1'; + } + if (!u.port) u.port = '11434'; + return u.origin; + } catch { + return 'http://127.0.0.1:11434'; + } +} + +export class Copilot { + constructor(env = process.env) { + this.env = env; + // The key stays in the environment only. It is never part of the settings + // that the settings page can read or write. + this.openRouterKey = (env.OPENROUTER_API_KEY || '').trim(); + this.applySettings(loadSettings(env)); + + this.provider = 'fallback'; + this.model = null; + this.detail = 'not yet detected'; + this.modelCache = new Map(); + } + + /** Adopt a settings object. Does not persist; see configure(). */ + applySettings(settings) { + this.settings = { ...settings }; + this.forced = settings.provider === 'auto' ? null : settings.provider; + this.openRouterModel = settings.openRouterModel; + this.ollamaModel = settings.ollamaModel; + this.ollamaHost = normalizeOllamaHost(settings.ollamaHost); + this.temperature = settings.temperature; + this.maxTokens = settings.maxTokens; + this.reasoningEffort = settings.reasoningEffort; + } + + /** + * Validate, apply, persist and re-detect. + * + * Returns { ok, errors, settings, status }. A rejected patch changes nothing. + */ + async configure(patch) { + const { ok, errors, clean } = validatePatch(patch); + if (!ok) return { ok: false, errors, settings: this.publicSettings(), status: this.status() }; + + this.applySettings({ ...this.settings, ...clean }); + saveSettings(this.settings); + // The chosen host may have changed, so availability has to be re-probed. + const status = await this.detect(); + return { ok: true, errors: [], settings: this.publicSettings(), status }; + } + + /** Restore environment defaults, discarding the saved settings file. */ + async reset() { + resetSettings(); + this.applySettings(loadSettings(this.env)); + const status = await this.detect(); + return { ok: true, errors: [], settings: this.publicSettings(), status }; + } + + /** Settings safe to hand to a browser: never includes the API key. */ + publicSettings() { + return { + ...this.settings, + // Report the normalized host, since that is what actually gets dialled. + resolvedOllamaHost: this.ollamaHost, + openRouterKeyPresent: Boolean(this.openRouterKey), + }; + } + + /** Probe available providers. Safe to call repeatedly. */ + async detect() { + if (this.forced === 'fallback') { + this.provider = 'fallback'; + this.model = null; + this.detail = 'forced by COPILOT_PROVIDER=fallback'; + return this.status(); + } + + if (this.openRouterKey && this.forced !== 'ollama') { + this.provider = 'openrouter'; + this.model = this.openRouterModel; + this.detail = 'OpenRouter API key present'; + return this.status(); + } + + if (this.forced !== 'openrouter') { + const reachable = await this.probeOllama(); + if (reachable) { + this.provider = 'ollama'; + this.model = this.ollamaModel; + this.detail = `Ollama at ${this.ollamaHost}${reachable.hasModel ? '' : ` (warning: model "${this.ollamaModel}" not in the local list)`}`; + return this.status(); + } + } + + this.provider = 'fallback'; + this.model = null; + this.detail = this.openRouterKey + ? 'no provider reachable' + : `no OPENROUTER_API_KEY and Ollama not reachable at ${this.ollamaHost}`; + return this.status(); + } + + async probeOllama() { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), PROBE_TIMEOUT_MS); + try { + const res = await fetch(`${this.ollamaHost}/api/tags`, { signal: ctl.signal }); + if (!res.ok) return null; + const body = await res.json(); + const names = (body.models || []).map((m) => m.name); + return { hasModel: names.includes(this.ollamaModel), names }; + } catch { + return null; + } finally { + clearTimeout(timer); + } + } + + status() { + return { + provider: this.provider, + model: this.model, + detail: this.detail, + // The UI shows this so you always know on stage what is answering. + label: this.provider === 'fallback' + ? 'Rule-based (offline)' + : `${this.provider === 'ollama' ? 'Ollama' : 'OpenRouter'} · ${this.model}`, + }; + } + + /** + * List the models a provider actually offers. + * + * Cached briefly: OpenRouter returns 400+ models and the settings page may be + * opened repeatedly while someone makes up their mind. + */ + async listModels(provider, { force = false } = {}) { + const key = provider; + const cached = this.modelCache.get(key); + if (!force && cached && Date.now() - cached.at < 5 * 60 * 1000) { + return { ...cached.value, cached: true }; + } + + let value; + try { + value = provider === 'openrouter' + ? await this.listOpenRouterModels() + : await this.listOllamaModels(); + } catch (err) { + return { provider, models: [], error: err.message, cached: false }; + } + this.modelCache.set(key, { at: Date.now(), value }); + return { ...value, cached: false }; + } + + async listOpenRouterModels() { + if (!this.openRouterKey) { + throw new Error('No OPENROUTER_API_KEY is set, so the model list cannot be fetched.'); + } + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), 15000); + try { + const res = await fetch('https://openrouter.ai/api/v1/models', { + signal: ctl.signal, + headers: { Authorization: `Bearer ${this.openRouterKey}` }, + }); + if (!res.ok) throw new Error(`OpenRouter returned HTTP ${res.status}`); + const body = await res.json(); + const models = (body.data || []).map((m) => ({ + id: m.id, + name: m.name || m.id, + contextLength: m.context_length || null, + // Prices come back as per-token strings; per-million is what people read. + promptPerM: m.pricing && m.pricing.prompt ? Number(m.pricing.prompt) * 1e6 : null, + completionPerM: m.pricing && m.pricing.completion ? Number(m.pricing.completion) * 1e6 : null, + })).sort((a, b) => a.id.localeCompare(b.id)); + return { provider: 'openrouter', models }; + } finally { + clearTimeout(timer); + } + } + + async listOllamaModels() { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), PROBE_TIMEOUT_MS); + try { + const res = await fetch(`${this.ollamaHost}/api/tags`, { signal: ctl.signal }); + if (!res.ok) throw new Error(`Ollama returned HTTP ${res.status}`); + const body = await res.json(); + const models = (body.models || []).map((m) => ({ + id: m.name, + name: m.name, + sizeBytes: m.size || null, + parameterSize: m.details ? m.details.parameter_size : null, + quantization: m.details ? m.details.quantization_level : null, + family: m.details ? m.details.family : null, + })).sort((a, b) => a.id.localeCompare(b.id)); + return { provider: 'ollama', models }; + } catch (err) { + if (err.name === 'AbortError') { + throw new Error(`Ollama did not respond at ${this.ollamaHost}. Is "ollama serve" running?`); + } + throw new Error(`Cannot reach Ollama at ${this.ollamaHost}: ${err.message}`); + } finally { + clearTimeout(timer); + } + } + + /** + * Measure a provider/model with a trivial prompt. + * + * Time-to-first-token is the number that matters here, not total time: a model + * that takes 30 s to start talking is unusable in front of a customer even if + * the answer is excellent. This exists so that judgement can be made from a + * measurement rather than a guess, before the meeting rather than during it. + */ + async testProvider({ provider, model } = {}) { + const resolved = this.resolve(provider); + const started = Date.now(); + + if (resolved === 'fallback') { + // Nothing to probe: the rule engine is in-process and cannot be unavailable. + return { + ok: true, provider: resolved, model: null, + firstTokenMs: Date.now() - started, totalMs: Date.now() - started, + chars: 0, + sample: 'Rule engine ready. No model, no network, no failure mode.', + }; + } + + const messages = [ + { role: 'system', content: 'You are a terse test endpoint. Reply with exactly: READY' }, + { role: 'user', content: 'Reply with exactly: READY' }, + ]; + + let firstTokenMs = null; + let text = ''; + try { + const iter = resolved === 'openrouter' + ? this.streamOpenRouter(messages, model) + : this.streamOllama(messages, model); + for await (const chunk of iter) { + if (!chunk) continue; + if (firstTokenMs === null) firstTokenMs = Date.now() - started; + text += chunk; + } + } catch (err) { + return { + ok: false, provider: resolved, + model: model || (resolved === 'openrouter' ? this.openRouterModel : this.ollamaModel), + error: err.message, totalMs: Date.now() - started, + }; + } + + const trimmed = text.trim(); + return { + ok: trimmed.length > 0, + provider: resolved, + model: model || (resolved === 'openrouter' ? this.openRouterModel : this.ollamaModel), + firstTokenMs, + totalMs: Date.now() - started, + chars: trimmed.length, + sample: trimmed.slice(0, 120), + error: trimmed.length === 0 + ? 'The model returned an empty response. It may be spending its whole token budget on reasoning — try a higher max tokens or a lower reasoning effort.' + : undefined, + }; + } + + buildMessages(question, history, frame) { + const context = buildContext(frame); + const msgs = [{ role: 'system', content: `${SYSTEM_PROMPT}\n\n---\n\n${context}` }]; + // Keep only the last few turns: a 4B model with a long context degrades fast, + // and the plant context is refreshed every turn anyway. + for (const m of (history || []).slice(-6)) { + if (m && (m.role === 'user' || m.role === 'assistant') && m.content) { + msgs.push({ role: m.role, content: String(m.content).slice(0, 4000) }); + } + } + msgs.push({ role: 'user', content: question }); + return msgs; + } + + /** + * Stream an answer. Yields { type: 'meta'|'token'|'done'|'note' } objects. + * + * Any provider failure yields a 'note' explaining the degradation and then + * streams the fallback answer, so the caller never has to handle an error. + */ + /** + * Which provider to actually use for one request. + * + * A per-request override exists for a practical reason: a strong reasoning + * model can take 15-20 s to first token, which is dead air in front of a + * customer, while a local 4B model answers in about 2 s with shallower + * analysis. Being able to pick per question - fast for the live walkthrough, + * deep for the follow-up discussion - is worth the small amount of plumbing. + */ + resolve(override) { + const want = String(override || '').trim().toLowerCase(); + if (!want || want === 'auto') return this.provider; + if (want === 'fallback') return 'fallback'; + if (want === 'openrouter' && this.openRouterKey) return 'openrouter'; + if (want === 'ollama') return 'ollama'; + return this.provider; + } + + statusFor(provider) { + if (provider === 'fallback') { + return { provider, model: null, detail: 'deterministic rule engine', label: 'Rule-based (offline)' }; + } + if (provider === 'openrouter') { + return { provider, model: this.openRouterModel, detail: 'OpenRouter', label: `OpenRouter · ${this.openRouterModel}` }; + } + return { provider, model: this.ollamaModel, detail: `Ollama at ${this.ollamaHost}`, label: `Ollama · ${this.ollamaModel}` }; + } + + async *stream(question, history, frame, override) { + const q = String(question || '').trim(); + const provider = this.resolve(override); + + if (!q) { + yield { type: 'meta', ...this.statusFor(provider) }; + yield { type: 'token', text: 'Ask me something about the line.' }; + yield { type: 'done' }; + return; + } + + if (provider === 'fallback') { + yield { type: 'meta', ...this.statusFor(provider) }; + const { text } = answerFromRules(q, frame); + yield { type: 'token', text }; + yield { type: 'done' }; + return; + } + + yield { type: 'meta', ...this.statusFor(provider) }; + const messages = this.buildMessages(q, history, frame); + + let produced = ''; + let failure = null; + try { + const iter = provider === 'openrouter' + ? this.streamOpenRouter(messages) + : this.streamOllama(messages); + for await (const text of iter) { + if (!text) continue; + produced += text; + yield { type: 'token', text }; + } + } catch (err) { + failure = err && err.message ? err.message : String(err); + console.error('[copilot] provider failed:', failure); + } + + // An empty answer is a failure even when nothing threw. A thinking model that + // exhausts its token budget mid-reasoning returns a clean 200 with no content, + // and a blank panel is the worst possible outcome in front of a customer. + if (!produced.trim()) { + yield { + type: 'note', + text: failure + ? `${provider} failed (${failure}). Answering from the built-in rule engine instead.` + : `${provider} returned an empty answer. Answering from the built-in rule engine instead.`, + }; + const { text } = answerFromRules(q, frame); + yield { type: 'token', text }; + } else if (failure) { + yield { type: 'note', text: `Stream ended early: ${failure}` }; + } + yield { type: 'done' }; + } + + // --- providers ----------------------------------------------------------- + + async *streamOpenRouter(messages, modelOverride) { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS); + try { + const body = { + model: modelOverride || this.openRouterModel, + messages, + stream: true, + temperature: this.temperature, + // Reasoning tokens count against max_tokens, so a reasoning model on a + // tight budget burns the lot thinking and streams back nothing at all. + // Keeping the budget generous and reasoning minimal stops the + // intermittent empty answers and cuts time-to-first-token, which is the + // difference between a usable and an awkward live demo. + max_tokens: this.maxTokens, + }; + if (this.reasoningEffort !== 'none') { + body.reasoning = { effort: this.reasoningEffort }; + } + + const res = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + signal: ctl.signal, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.openRouterKey}`, + 'X-Title': 'Digital Twin Demo', + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`HTTP ${res.status} ${(await res.text()).slice(0, 200)}`); + } + const strip = makeThinkFilter(); + for await (const line of readLines(res.body)) { + if (!line.startsWith('data:')) continue; + const payload = line.slice(5).trim(); + if (!payload || payload === '[DONE]') continue; + let json; + try { json = JSON.parse(payload); } catch { continue; } + const delta = json.choices && json.choices[0] && json.choices[0].delta; + if (delta && delta.content) yield strip(delta.content); + } + yield strip(null); // flush + } finally { + clearTimeout(timer); + } + } + + /** + * POST to Ollama. `think` of null omits the field entirely. + */ + postOllama(messages, think, signal, modelOverride) { + const body = { + model: modelOverride || this.ollamaModel, + messages, + stream: true, + options: { temperature: this.temperature, num_predict: this.maxTokens }, + }; + if (think !== null) body.think = think; + return fetch(`${this.ollamaHost}/api/chat`, { + method: 'POST', + signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } + + async *streamOllama(messages, modelOverride) { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS); + try { + // Ollama's `think` is a boolean, so the four-level effort setting maps onto + // it: none/low disable reasoning, medium/high enable it. The Qwen3 family + // and other thinking models will otherwise spend the entire token budget + // inside a reasoning block and return EMPTY content (done_reason: + // "length") - a blank copilot panel on stage - and cost ~10x the latency. + const think = this.reasoningEffort === 'medium' || this.reasoningEffort === 'high'; + let res = await this.postOllama(messages, think, ctl.signal, modelOverride); + + if (!res.ok) { + const errBody = await res.text(); + // Models with no reasoning mode reject the flag; retry without it. + if (/think/i.test(errBody)) { + res = await this.postOllama(messages, null, ctl.signal, modelOverride); + if (!res.ok) throw new Error(`HTTP ${res.status} ${(await res.text()).slice(0, 200)}`); + } else { + throw new Error(`HTTP ${res.status} ${errBody.slice(0, 200)}`); + } + } + + const strip = makeThinkFilter(); + for await (const line of readLines(res.body)) { + if (!line.trim()) continue; + let json; + try { json = JSON.parse(line); } catch { continue; } + if (json.error) throw new Error(json.error); + if (json.message && json.message.content) yield strip(json.message.content); + if (json.done) break; + } + yield strip(null); // flush + } finally { + clearTimeout(timer); + } + } +} + +/** Async line reader over a fetch response body stream. */ +async function* readLines(body) { + const decoder = new TextDecoder(); + let buf = ''; + for await (const chunk of body) { + buf += decoder.decode(chunk, { stream: true }); + let idx; + while ((idx = buf.indexOf('\n')) >= 0) { + yield buf.slice(0, idx); + buf = buf.slice(idx + 1); + } + } + if (buf) yield buf; +} + +/** + * Suppress ... reasoning blocks. + * + * Several strong local models (the Qwen3 family among them) emit a reasoning + * block before the answer. Streamed verbatim onto a dashboard it looks like the + * product is malfunctioning, so it is filtered out. Call with null to flush. + */ +function makeThinkFilter() { + const OPEN = ''; + const CLOSE = ''; + let inThink = false; + let buf = ''; + + return (chunk) => { + if (chunk === null) { + const tail = inThink ? '' : buf; + buf = ''; + return tail; + } + buf += chunk; + let out = ''; + + for (;;) { + if (!inThink) { + const i = buf.indexOf(OPEN); + if (i === -1) { + // Hold back a few characters in case a tag straddles two chunks. + const keep = Math.max(0, buf.length - (OPEN.length - 1)); + out += buf.slice(0, keep); + buf = buf.slice(keep); + break; + } + out += buf.slice(0, i); + buf = buf.slice(i + OPEN.length); + inThink = true; + } else { + const j = buf.indexOf(CLOSE); + if (j === -1) { + buf = buf.slice(Math.max(0, buf.length - (CLOSE.length - 1))); + break; + } + buf = buf.slice(j + CLOSE.length); + inThink = false; + } + } + return out; + }; +} diff --git a/server/ai/rules.js b/server/ai/rules.js new file mode 100644 index 0000000..2458708 --- /dev/null +++ b/server/ai/rules.js @@ -0,0 +1,290 @@ +/** + * Deterministic rule-based copilot. + * + * This is the offline fallback, and it is not a stub. On stage, a dead API key or + * bad conference wifi must never turn the copilot panel into an error message, so + * this produces a real, grounded, useful answer from the same analytics the LLM + * would have seen. It is less fluent and cannot handle open-ended questions, but + * it never fails and never invents a number. + * + * It is also the honesty backstop: everything here is traceable to a threshold or + * a regression, so if you are ever asked "is the AI making this up?", you can run + * with COPILOT_PROVIDER=fallback and show the same conclusions without a model. + */ + +import { STATION_SPECS } from '../sim/stations.js'; +import { formatDuration } from '../analytics/trend.js'; + +const STATION_ALIASES = { + 'CONV-01': ['conv', 'conveyor', 'infeed', 'belt'], + 'CNC-02': ['cnc', 'spindle', 'machining', 'mill', 'bearing'], + 'OVN-03': ['ovn', 'oven', 'cure', 'curing', 'burner', 'zone'], + 'INS-04': ['ins', 'inspection', 'vision', 'camera', 'reject', 'quality station'], + 'PKG-05': ['pkg', 'packer', 'packing', 'film', 'wrap'], +}; + +function pct(v) { + return Number.isFinite(v) ? `${(v * 100).toFixed(1)}%` : 'n/a'; +} + +function resolveStation(q) { + const lower = q.toLowerCase(); + for (const [id, words] of Object.entries(STATION_ALIASES)) { + if (lower.includes(id.toLowerCase())) return id; + if (words.some((w) => lower.includes(w))) return id; + } + return null; +} + +function signalLabel(stationId, key) { + const spec = STATION_SPECS.find((s) => s.id === stationId); + const g = spec && spec.signals.find((x) => x.key === key); + return g ? g.label : key; +} + +/** The causal story behind the alarms currently active, where we can name one. */ +function causalNarrative(frame) { + const a = frame.analytics; + const cnc = frame.stations.find((s) => s.id === 'CNC-02'); + const ovn = frame.stations.find((s) => s.id === 'OVN-03'); + const ins = frame.stations.find((s) => s.id === 'INS-04'); + const out = []; + + if (cnc && cnc.signals.vibration > 3.0) { + out.push( + `CNC-02 bearing vibration is ${cnc.signals.vibration.toFixed(2)} mm/s RMS against a 3.5 warn and 4.5 alarm limit, and spindle load has risen to ${cnc.signals.spindleLoad.toFixed(1)}%. That pattern is bearing degradation: it both stalls the cut, costing Performance, and pushes parts out of tolerance, which is why INS-04 reject rate is ${ins ? ins.signals.rejectRate.toFixed(2) : '?'}%.`, + ); + } + + if (ovn && Math.abs(ovn.signals.tempDeviation) > 6) { + const sat = ovn.signals.burnerDuty > 95; + out.push( + `OVN-03 zone 2 is ${ovn.signals.zone2Temp.toFixed(1)} °C against a ${ovn.signals.setpoint} °C setpoint, a deviation of ${ovn.signals.tempDeviation.toFixed(1)} °C, with burner duty at ${ovn.signals.burnerDuty.toFixed(0)}%.` + + (sat + ? ' Duty is saturated and the zone still cannot reach setpoint, which means the oven has lost heating capacity rather than being mistuned. The cure is out of spec, so reject rate downstream is rising.' + : ' The controller is still recovering toward setpoint.'), + ); + } + + const stopped = frame.stations.filter((s) => s.state === 'fault' || s.state === 'down'); + if (stopped.length) { + const blocked = frame.stations.filter((s) => s.state === 'blocked').map((s) => s.id); + out.push( + `${stopped.map((s) => s.id).join(', ')} ${stopped.length > 1 ? 'are' : 'is'} stopped.` + + (blocked.length + ? ` WIP has backed up behind it and ${blocked.join(', ')} ${blocked.length > 1 ? 'are' : 'is'} now blocked, so the whole line has stopped producing.` + : ''), + ); + } + + const offline = frame.stations.filter((s) => !s.online); + if (offline.length) { + out.push( + `${offline.map((s) => s.id).join(', ')} ${offline.length > 1 ? 'are' : 'is'} not reporting. The values shown are the last known readings and should not be trusted as live.`, + ); + } + + if (!out.length && a.predictions.length) { + const p = a.predictions[0]; + out.push( + `Nothing is over limit yet, but ${p.station} ${p.label} is rising ${p.slopePerMin.toFixed(4)} ${p.unit}/min and reaches its ${p.threshold} ${p.unit} limit in about ${p.eta} of run time at the current rate.`, + ); + } + + return out; +} + +function oeeAnswer(frame) { + const k = frame.kpi; + const factors = [ + { name: 'Availability', v: k.availability, why: 'time lost to stoppages' }, + { name: 'Performance', v: k.performance, why: 'the line running slower than its ideal cycle, usually micro-stops' }, + { name: 'Quality', v: k.quality, why: 'parts rejected at inspection' }, + ].sort((a, b) => a.v - b.v); + + const worst = factors[0]; + const lines = [ + `OEE is ${pct(k.oee)} over the last 20 simulated minutes: Availability ${pct(k.availability)} x Performance ${pct(k.performance)} x Quality ${pct(k.quality)}.`, + `The biggest loss is ${worst.name} at ${pct(worst.v)} — ${worst.why}.`, + ]; + + const narrative = causalNarrative(frame); + if (narrative.length) lines.push(narrative[0]); + + lines.push(`Current output is ${k.throughputPerHour.toFixed(0)} good units/hour with ${pct(k.scrapRate)} scrap.`); + return lines.join('\n\n'); +} + +function stationAnswer(frame, stationId) { + const spec = STATION_SPECS.find((s) => s.id === stationId); + const st = frame.stations.find((s) => s.id === stationId); + if (!st) return `I have no telemetry for ${stationId}.`; + + const lines = [`${st.id} ${st.name} is currently ${st.state}${st.online ? '' : ' and NOT REPORTING (values below are stale)'}.`]; + + const issues = []; + for (const g of spec.signals) { + const v = st.signals[g.key]; + if (!Number.isFinite(v)) continue; + if (g.alarmHigh !== undefined && v >= g.alarmHigh) issues.push(`${g.label} ${v.toFixed(g.precision)} ${g.unit} is over its ${g.alarmHigh} alarm limit`); + else if (g.warnHigh !== undefined && v >= g.warnHigh) issues.push(`${g.label} ${v.toFixed(g.precision)} ${g.unit} is over its ${g.warnHigh} warning limit`); + else if (g.alarmLow !== undefined && v <= g.alarmLow) issues.push(`${g.label} ${v.toFixed(g.precision)} ${g.unit} is under its ${g.alarmLow} alarm limit`); + else if (g.warnLow !== undefined && v <= g.warnLow) issues.push(`${g.label} ${v.toFixed(g.precision)} ${g.unit} is under its ${g.warnLow} warning limit`); + } + + lines.push(issues.length ? `Out of limits: ${issues.join('; ')}.` : 'All signals are within limits.'); + + const preds = frame.analytics.predictions.filter((p) => p.station === stationId); + for (const p of preds) { + lines.push(`${p.label} is rising ${p.slopePerMin.toFixed(4)} ${p.unit}/min and would reach its ${p.threshold} ${p.unit} limit in about ${p.eta} of run time if the trend holds.`); + } + + const narrative = causalNarrative(frame).filter((n) => n.includes(stationId)); + lines.push(...narrative); + + const readings = spec.signals + .filter((g) => g.key !== 'partsInspected' && g.key !== 'downtime') + .map((g) => `${g.label} ${st.signals[g.key].toFixed(g.precision)} ${g.unit}`) + .join(', '); + lines.push(`Current readings: ${readings}.`); + + return lines.join('\n\n'); +} + +function predictionAnswer(frame) { + const preds = frame.analytics.predictions; + if (!preds.length) { + return 'No threshold crossings are projected right now. Either nothing is trending toward a limit, or the recent data does not fit a straight line well enough to extrapolate honestly.'; + } + const lines = ['Projected threshold crossings, from a least-squares fit over recent run time:']; + for (const p of preds) { + lines.push(`- ${p.station} ${p.label}: now ${p.current.toFixed(2)} ${p.unit}, rising ${p.slopePerMin.toFixed(4)} ${p.unit}/min, reaches ${p.threshold} ${p.unit} in about ${p.eta} of run time (fit quality r²=${p.r2.toFixed(2)}).`); + } + lines.push('These are straight-line extrapolations of the current trend, not guarantees. A change in load or a maintenance action will change them.'); + return lines.join('\n'); +} + +function workOrderAnswer(frame) { + const a = frame.analytics; + const worst = a.alarms[0]; + const pred = a.predictions[0]; + const cnc = frame.stations.find((s) => s.id === 'CNC-02'); + + const target = worst ? worst.station : pred ? pred.station : 'LINE-1'; + const priority = worst + ? worst.severity === 'critical' ? 'P1 - immediate' : worst.severity === 'major' ? 'P2 - same shift' : 'P3 - planned' + : 'P3 - planned'; + + const lines = [ + 'DRAFT MAINTENANCE WORK ORDER', + `Asset: ${target}${target === 'CNC-02' ? ' (CNC Machining Centre)' : ''}`, + `Priority: ${priority}`, + `Raised at: simulated run time ${formatDuration(frame.t)}`, + '', + 'Observed condition:', + ]; + + if (worst) lines.push(`- ${worst.message}`); + if (cnc && cnc.signals.vibration > 3) { + lines.push(`- Bearing vibration ${cnc.signals.vibration.toFixed(2)} mm/s RMS (baseline approximately 1.6), spindle load ${cnc.signals.spindleLoad.toFixed(1)}%, coolant ${cnc.signals.coolantTemp.toFixed(1)} °C.`); + } + if (pred) { + lines.push(`- ${pred.label} projected to reach ${pred.threshold} ${pred.unit} in about ${pred.eta} of run time at the current rate.`); + } + + lines.push('', 'Recommended action:'); + if (target === 'CNC-02') { + lines.push('- Inspect spindle bearing; take a vibration spectrum to confirm the fault frequency before replacing.'); + lines.push('- Check tooling condition and replace if wear is contributing.'); + lines.push('- Verify coolant flow and temperature.'); + } else if (target === 'OVN-03') { + lines.push('- Inspect zone 2 burner, igniter and gas train; burner duty is saturated, which points at lost heating capacity.'); + lines.push('- Verify zone 2 thermocouple against a reference before condemning the burner.'); + } else if (target === 'PKG-05') { + lines.push('- Clear the film path and inspect the web for tearing; check tension control calibration.'); + } else { + lines.push('- Investigate the alarm above and confirm against local instrumentation.'); + } + + lines.push('', 'Production impact:'); + lines.push(`- OEE ${pct(frame.kpi.oee)} (A ${pct(frame.kpi.availability)} / P ${pct(frame.kpi.performance)} / Q ${pct(frame.kpi.quality)}), scrap ${pct(frame.kpi.scrapRate)}, output ${frame.kpi.throughputPerHour.toFixed(0)} units/h.`); + lines.push('', 'Drafted by the plant copilot from live telemetry. Review before issuing.'); + + return lines.join('\n'); +} + +function whatIfAnswer(frame, q) { + const lower = q.toLowerCase(); + const k = frame.kpi; + const lines = []; + + if (lower.includes('speed') || lower.includes('faster') || lower.includes('rate')) { + const cnc = frame.stations.find((s) => s.id === 'CNC-02'); + lines.push( + `CNC-02 is the bottleneck at a 4.4 s cycle, so line output tracks it directly. Raising line speed shortens every cycle, but it also raises spindle load — currently ${cnc.signals.spindleLoad.toFixed(1)}% — and load rises roughly 22 points per 100% of added speed.`, + ); + if (cnc.signals.vibration > 3) { + lines.push(`With bearing vibration already at ${cnc.signals.vibration.toFixed(2)} mm/s, running faster would accelerate the degradation and increase rejects. I would not raise speed until the bearing is addressed.`); + } else { + lines.push(`Vibration is ${cnc.signals.vibration.toFixed(2)} mm/s and load has headroom, so a modest increase is likely to hold. Watch spindle load against its 85% warning limit and reject rate against 4%.`); + } + lines.push('Use the line speed control in the what-if panel to try it — the twin will show the actual response.'); + return lines.join('\n\n'); + } + + if (lower.includes('setpoint') || lower.includes('temperature') || lower.includes('oven') || lower.includes('hotter') || lower.includes('cooler')) { + const ovn = frame.stations.find((s) => s.id === 'OVN-03'); + lines.push( + `Zone 2 is at ${ovn.signals.zone2Temp.toFixed(1)} °C against a ${ovn.signals.setpoint} °C setpoint with burner duty ${ovn.signals.burnerDuty.toFixed(0)}%. The oven responds with a first-order lag of roughly 50 s per zone, so a setpoint change takes a few minutes of run time to settle, not seconds.`, + ); + lines.push('Reject rate rises once zone 2 deviates more than about 6 °C from setpoint in either direction, so the cure window is the constraint, not the absolute temperature.'); + lines.push('Change the setpoint in the what-if panel and watch the zone 2 trend chart to see the real response.'); + return lines.join('\n\n'); + } + + lines.push(`I can reason about two levers directly: oven setpoint and line speed. Current state is OEE ${pct(k.oee)}, output ${k.throughputPerHour.toFixed(0)} units/h, scrap ${pct(k.scrapRate)}.`); + lines.push('Ask about raising line speed or changing the oven setpoint, or make the change in the what-if panel and watch the twin respond.'); + return lines.join('\n\n'); +} + +function overviewAnswer(frame) { + const a = frame.analytics; + const lines = []; + const narrative = causalNarrative(frame); + + if (a.alarms.length === 0 && narrative.length === 0) { + lines.push(`The line is running clean. OEE ${pct(frame.kpi.oee)}, output ${frame.kpi.throughputPerHour.toFixed(0)} good units/hour, scrap ${pct(frame.kpi.scrapRate)}, no active alarms.`); + lines.push('All five stations are within limits and no threshold crossings are projected.'); + return lines.join('\n\n'); + } + + if (a.alarms.length) { + const top = a.alarms.slice(0, 3); + lines.push(`${a.alarms.length} active alarm${a.alarms.length > 1 ? 's' : ''}. Most severe: ${top.map((x) => `[${x.severity}] ${x.message}`).join(' ')}`); + } + lines.push(...narrative); + lines.push(`OEE is ${pct(frame.kpi.oee)} (A ${pct(frame.kpi.availability)} / P ${pct(frame.kpi.performance)} / Q ${pct(frame.kpi.quality)}), output ${frame.kpi.throughputPerHour.toFixed(0)} units/h.`); + return lines.join('\n\n'); +} + +/** + * Route a question to a templated answer. + * + * Returns { text, provider: 'fallback' }. + */ +export function answerFromRules(question, frame) { + if (!frame) { + return { text: 'No telemetry has arrived yet. Start the simulation and ask again.', provider: 'fallback' }; + } + const q = (question || '').toLowerCase(); + + if (/work order|maintenance order|raise a ticket|wo\b|cmms/.test(q)) return { text: workOrderAnswer(frame), provider: 'fallback' }; + if (/what happens if|what if|should i|raise|increase|decrease|lower|try /.test(q)) return { text: whatIfAnswer(frame, question), provider: 'fallback' }; + if (/oee|availability|performance|quality|efficiency|throughput|scrap/.test(q)) return { text: oeeAnswer(frame), provider: 'fallback' }; + if (/predict|forecast|when will|how long|fail|remaining life|rul/.test(q)) return { text: predictionAnswer(frame), provider: 'fallback' }; + + const station = resolveStation(q); + if (station) return { text: stationAnswer(frame, station), provider: 'fallback' }; + + return { text: overviewAnswer(frame), provider: 'fallback' }; +} diff --git a/server/ai/settings.js b/server/ai/settings.js new file mode 100644 index 0000000..186b1cc --- /dev/null +++ b/server/ai/settings.js @@ -0,0 +1,113 @@ +/** + * Runtime copilot settings. + * + * Precedence: saved settings file > environment > built-in defaults. + * + * Settings are persisted to a small JSON file rather than held only in memory, so + * a model chosen while preparing a demo survives a server restart. They are + * deliberately NOT written back into .env - that file holds the API key, and a + * process that rewrites its own secrets file is a bad habit to build. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +export const SETTINGS_FILE = path.join(HERE, '..', '..', '.copilot-settings.json'); + +/** Fields a client is allowed to change, with validation for each. */ +const FIELDS = { + provider: { + validate: (v) => ['auto', 'openrouter', 'ollama', 'fallback'].includes(v), + error: 'provider must be auto, openrouter, ollama or fallback', + }, + openRouterModel: { + validate: (v) => typeof v === 'string' && v.length > 0 && v.length < 200, + error: 'openRouterModel must be a non-empty model id', + }, + ollamaModel: { + validate: (v) => typeof v === 'string' && v.length > 0 && v.length < 200, + error: 'ollamaModel must be a non-empty model id', + }, + ollamaHost: { + validate: (v) => typeof v === 'string' && v.length > 0 && v.length < 300, + error: 'ollamaHost must be a URL or host:port', + }, + temperature: { + validate: (v) => Number.isFinite(v) && v >= 0 && v <= 2, + error: 'temperature must be between 0 and 2', + }, + maxTokens: { + validate: (v) => Number.isInteger(v) && v >= 128 && v <= 8000, + error: 'maxTokens must be an integer between 128 and 8000', + }, + reasoningEffort: { + validate: (v) => ['none', 'low', 'medium', 'high'].includes(v), + error: 'reasoningEffort must be none, low, medium or high', + }, +}; + +export function defaultsFromEnv(env = process.env) { + return { + // 'auto' resolves by availability at detect() time. + provider: (env.COPILOT_PROVIDER || 'auto').trim().toLowerCase(), + openRouterModel: (env.OPENROUTER_MODEL || 'meta-llama/llama-3.3-70b-instruct').trim(), + ollamaModel: (env.OLLAMA_MODEL || 'qwen3.5-4b-32k:latest').trim(), + ollamaHost: (env.OLLAMA_HOST || 'http://127.0.0.1:11434').trim(), + temperature: 0.3, + maxTokens: 2000, + // Reasoning tokens count against the output budget on most providers, so + // 'low' keeps answers from being swallowed by a reasoning block and keeps + // time-to-first-token usable in a live demo. + reasoningEffort: 'low', + }; +} + +export function loadSettings(env = process.env) { + const base = defaultsFromEnv(env); + try { + const raw = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')); + for (const key of Object.keys(FIELDS)) { + if (raw[key] !== undefined && FIELDS[key].validate(raw[key])) base[key] = raw[key]; + } + } catch { + /* no saved settings yet, or unreadable - environment defaults stand */ + } + return base; +} + +/** + * Validate a patch. Returns { ok, errors, clean }. + * + * Unknown keys are rejected rather than ignored, so a typo in a field name fails + * loudly instead of silently doing nothing. + */ +export function validatePatch(patch) { + const errors = []; + const clean = {}; + for (const [key, value] of Object.entries(patch || {})) { + const field = FIELDS[key]; + if (!field) { errors.push(`unknown setting "${key}"`); continue; } + if (!field.validate(value)) { errors.push(field.error); continue; } + clean[key] = value; + } + return { ok: errors.length === 0, errors, clean }; +} + +export function saveSettings(settings) { + const out = {}; + for (const key of Object.keys(FIELDS)) { + if (settings[key] !== undefined) out[key] = settings[key]; + } + fs.writeFileSync(SETTINGS_FILE, `${JSON.stringify(out, null, 2)}\n`, 'utf8'); + return out; +} + +export function resetSettings() { + try { + fs.unlinkSync(SETTINGS_FILE); + } catch { + /* nothing saved - already at defaults */ + } +} diff --git a/server/analytics/alarms.js b/server/analytics/alarms.js new file mode 100644 index 0000000..bf5ff48 --- /dev/null +++ b/server/analytics/alarms.js @@ -0,0 +1,294 @@ +/** + * The analytics layer: thresholds, anomalies, trend projections, alarm latching. + * + * Alarms are latched with hysteresis. Without it, a noisy signal sitting on a + * threshold produces a flickering alarm list, which on a projector reads as a + * broken product. A condition must hold for RAISE_AFTER seconds before it is + * raised and clear for CLEAR_AFTER seconds before it is dropped. + */ + +import { STATION_SPECS, STATE } from '../sim/stations.js'; +import { TrendTracker, formatDuration } from './trend.js'; +import { BaselineBank } from './anomaly.js'; + +const RAISE_AFTER = 3; +const CLEAR_AFTER = 15; + +/** Sigma from the learned baseline before a signal is called anomalous. */ +const ANOMALY_Z = 4.5; + +/** Only project a threshold crossing this far ahead, in simulated seconds. */ +const PREDICTION_HORIZON = 7200; + +export const SEVERITY_RANK = { critical: 0, major: 1, warning: 2, predictive: 3, info: 4 }; + +/** Signals worth fitting a trend to: they drift, and they have a threshold. */ +function trendableSignals() { + const out = []; + for (const spec of STATION_SPECS) { + for (const g of spec.signals) { + const hasHigh = g.warnHigh !== undefined || g.alarmHigh !== undefined; + if (!hasHigh) continue; + if (g.key === 'motorAmps' || g.key === 'burnerDuty') continue; // state-driven, not drift + out.push({ stationId: spec.id, signal: g }); + } + } + return out; +} + +export class AnalyticsEngine { + constructor() { + this.trends = new Map(); + this.baselines = new BaselineBank({ warmupSec: 240 }); + this.candidates = new Map(); + this.active = new Map(); + this.trendable = trendableSignals(); + } + + reset() { + this.trends.clear(); + this.baselines.reset(); + this.candidates.clear(); + this.active.clear(); + } + + trend(stationId, key) { + const k = `${stationId}.${key}`; + let tr = this.trends.get(k); + if (!tr) { + tr = new TrendTracker(); + this.trends.set(k, tr); + } + return tr; + } + + /** + * Fold one snapshot into the analytics state and return the analytics block. + */ + update(snap) { + const t = snap.t; + const stationById = {}; + for (const st of snap.stations) stationById[st.id] = st; + + // A station in a known-abnormal state must not teach the baseline. + const lineClean = snap.faults.length === 0; + + for (const spec of STATION_SPECS) { + const st = stationById[spec.id]; + if (!st || !st.online) continue; + const stationClean = lineClean && st.state !== STATE.DOWN && st.state !== STATE.FAULT; + for (const g of spec.signals) { + const v = st.signals[g.key]; + this.baselines.update(spec.id, g.key, t, v, stationClean); + } + } + + for (const { stationId, signal } of this.trendable) { + const st = stationById[stationId]; + if (!st || !st.online) continue; + // Only fit while the station is actually producing, so stall dips do not + // flatten or corrupt the slope. + if (st.state !== STATE.RUNNING) continue; + this.trend(stationId, signal.key).update(t, st.signals[signal.key]); + } + + const found = []; + this.collectStateAlarms(snap, found); + this.collectThresholdAlarms(snap, stationById, found); + this.collectAnomalyAlarms(snap, stationById, found); + const predictions = this.collectPredictions(snap, stationById, found); + + const alarms = this.latch(t, found); + + return { + alarms, + predictions, + trends: this.trendSummary(stationById), + baselineReady: [...this.baselines.trackers.values()].some((b) => b.ready), + }; + } + + collectStateAlarms(snap, out) { + for (const st of snap.stations) { + if (!st.online) { + out.push({ + key: `offline:${st.id}`, kind: 'data-quality', severity: 'major', + station: st.id, signal: null, + message: `${st.id} is not reporting. Values shown are the last known reading.`, + }); + } + if (st.state === STATE.FAULT) { + const f = snap.faults.find((x) => x.station === st.id); + out.push({ + key: `fault:${st.id}`, kind: 'stoppage', severity: 'critical', + station: st.id, signal: null, + message: f ? `${st.id} stopped: ${f.label}.` : `${st.id} stopped on fault.`, + }); + } else if (st.state === STATE.DOWN) { + out.push({ + key: `down:${st.id}`, kind: 'stoppage', severity: 'major', + station: st.id, signal: null, + message: `${st.id} unplanned stop.`, + }); + } + } + } + + collectThresholdAlarms(snap, stationById, out) { + for (const spec of STATION_SPECS) { + const st = stationById[spec.id]; + if (!st || !st.online) continue; + // A stopped station drops many signals to zero by design. Alarming on that + // duplicates the stoppage alarm and buries the real cause. + const stopped = st.state === STATE.FAULT || st.state === STATE.DOWN || st.state === STATE.MICROSTOP; + + for (const g of spec.signals) { + const v = st.signals[g.key]; + if (!Number.isFinite(v)) continue; + let severity = null, bound = null, dir = null; + + if (g.alarmHigh !== undefined && v >= g.alarmHigh) { severity = 'major'; bound = g.alarmHigh; dir = 'above'; } + else if (g.warnHigh !== undefined && v >= g.warnHigh) { severity = 'warning'; bound = g.warnHigh; dir = 'above'; } + else if (!stopped && g.alarmLow !== undefined && v <= g.alarmLow) { severity = 'major'; bound = g.alarmLow; dir = 'below'; } + else if (!stopped && g.warnLow !== undefined && v <= g.warnLow) { severity = 'warning'; bound = g.warnLow; dir = 'below'; } + + if (!severity) continue; + out.push({ + key: `thresh:${spec.id}.${g.key}`, kind: 'threshold', severity, + station: spec.id, signal: g.key, + value: v, threshold: bound, + message: `${g.label} ${v.toFixed(g.precision)} ${g.unit} is ${dir} the ${severity === 'major' ? 'alarm' : 'warning'} limit of ${bound} ${g.unit}.`, + }); + } + } + } + + collectAnomalyAlarms(snap, stationById, out) { + const already = new Set(out.filter((a) => a.signal).map((a) => `${a.station}.${a.signal}`)); + for (const spec of STATION_SPECS) { + const st = stationById[spec.id]; + if (!st || !st.online) continue; + if (st.state !== STATE.RUNNING) continue; + + for (const g of spec.signals) { + if (already.has(`${spec.id}.${g.key}`)) continue; + const v = st.signals[g.key]; + const z = this.baselines.z(spec.id, g.key, v); + if (z === null || Math.abs(z) < ANOMALY_Z) continue; + + // Only alarm in the direction that is actually bad, inferred from which + // thresholds the signal declares. Otherwise a tool change (wear drops + // from 18% to 2%) or a genuine quality improvement raises an alarm for + // being *better* than baseline, which trains operators to ignore alarms. + const badHigh = g.warnHigh !== undefined || g.alarmHigh !== undefined; + const badLow = g.warnLow !== undefined || g.alarmLow !== undefined; + if (z > 0 && badLow && !badHigh) continue; + if (z < 0 && badHigh && !badLow) continue; + const tr = this.baselines.get(spec.id, g.key); + out.push({ + key: `anom:${spec.id}.${g.key}`, kind: 'anomaly', severity: 'warning', + station: spec.id, signal: g.key, + value: v, z, + message: `${g.label} is ${Math.abs(z).toFixed(1)} sigma ${z > 0 ? 'above' : 'below'} its learned baseline of ${tr.mean.toFixed(g.precision)} ${g.unit}.`, + }); + } + } + } + + collectPredictions(snap, stationById, out) { + const predictions = []; + for (const { stationId, signal } of this.trendable) { + const st = stationById[stationId]; + if (!st || !st.online) continue; + + const limit = signal.alarmHigh ?? signal.warnHigh; + if (limit === undefined) continue; + const v = st.signals[signal.key]; + if (v >= limit) continue; // already there, no projection needed + + const proj = this.trend(stationId, signal.key).timeToThreshold(limit); + if (!proj || proj.seconds > PREDICTION_HORIZON) continue; + + const p = { + station: stationId, + signal: signal.key, + label: signal.label, + unit: signal.unit, + current: v, + threshold: limit, + seconds: proj.seconds, + eta: formatDuration(proj.seconds), + slopePerMin: proj.fit.slope * 60, + r2: proj.fit.r2, + windowSec: proj.fit.spanSec, + }; + predictions.push(p); + + out.push({ + key: `pred:${stationId}.${signal.key}`, kind: 'prediction', severity: 'predictive', + station: stationId, signal: signal.key, + value: v, threshold: limit, prediction: p, + message: `${signal.label} trending up ${(proj.fit.slope * 60).toFixed(3)} ${signal.unit}/min. At this rate it reaches the ${limit} ${signal.unit} limit in about ${formatDuration(proj.seconds)} of run time.`, + }); + } + return predictions; + } + + /** Per-signal slope summary, used to give the copilot conclusions not raw floats. */ + trendSummary(stationById) { + const out = []; + for (const { stationId, signal } of this.trendable) { + const f = this.trend(stationId, signal.key).fit(); + if (!f || f.r2 < 0.3) continue; + const st = stationById[stationId]; + if (!st) continue; + out.push({ + station: stationId, + signal: signal.key, + label: signal.label, + unit: signal.unit, + current: st.signals[signal.key], + slopePerMin: f.slope * 60, + r2: f.r2, + windowSec: f.spanSec, + }); + } + return out; + } + + /** + * Apply raise/clear hysteresis and return the active alarm list. + */ + latch(t, found) { + const foundByKey = new Map(found.map((a) => [a.key, a])); + + for (const a of found) { + const c = this.candidates.get(a.key); + if (c) { c.lastSeen = t; c.payload = a; } + else this.candidates.set(a.key, { firstSeen: t, lastSeen: t, payload: a }); + } + + for (const [key, c] of this.candidates) { + const stillPresent = foundByKey.has(key); + const act = this.active.get(key); + + if (stillPresent && !act && t - c.firstSeen >= RAISE_AFTER) { + this.active.set(key, { ...c.payload, raisedAt: t }); + } else if (act) { + if (stillPresent) { + // Refresh the payload so values and messages stay live, keep raisedAt. + this.active.set(key, { ...c.payload, raisedAt: act.raisedAt }); + } else if (t - c.lastSeen >= CLEAR_AFTER) { + this.active.delete(key); + this.candidates.delete(key); + } + } else if (!stillPresent && t - c.lastSeen >= CLEAR_AFTER) { + this.candidates.delete(key); + } + } + + return [...this.active.values()].sort( + (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || b.raisedAt - a.raisedAt, + ); + } +} diff --git a/server/analytics/anomaly.js b/server/analytics/anomaly.js new file mode 100644 index 0000000..f73b078 --- /dev/null +++ b/server/analytics/anomaly.js @@ -0,0 +1,129 @@ +/** + * Per-signal anomaly detection against a learned baseline. + * + * The baseline is learned from a warmup period of clean running and then FROZEN. + * That freeze is the important design choice: a continuously adapting baseline + * quietly absorbs a slow ramp, so the exact failure mode this demo is built to + * catch would never raise a z-score. Freezing means "different from how this + * machine normally behaves", which is what an operator actually wants to know. + */ + +export class BaselineTracker { + /** + * warmupSec - simulated seconds of clean data used to learn mean and spread + * minStd - floor on the standard deviation, so a very quiet signal does not + * produce enormous z-scores from rounding-level noise + */ + constructor({ warmupSec = 300, minStd = 1e-3 } = {}) { + this.warmupSec = warmupSec; + this.minStd = minStd; + this.reset(); + } + + reset() { + this.n = 0; + this.sum = 0; + this.sumSq = 0; + this.mean = 0; + this.std = 0; + this.ready = false; + this.startT = null; + } + + /** + * Feed a sample. `clean` should be false when the line is in a known abnormal + * state, so the baseline never learns from a fault it is supposed to detect. + */ + update(t, value, clean = true) { + if (!Number.isFinite(value)) return; + if (this.startT === null) this.startT = t; + + if (!this.ready) { + if (clean) { + this.n += 1; + this.sum += value; + this.sumSq += value * value; + } + if (t - this.startT >= this.warmupSec && this.n > 20) { + this.mean = this.sum / this.n; + const variance = Math.max(0, this.sumSq / this.n - this.mean * this.mean); + this.std = Math.max(this.minStd, Math.sqrt(variance)); + this.ready = true; + } + } + } + + /** Signed z-score, or null while the baseline is still being learned. */ + z(value) { + if (!this.ready || !Number.isFinite(value)) return null; + return (value - this.mean) / this.std; + } +} + +/** + * A bank of baselines keyed by "STATION.signal". + * + * The exclusion list is DERIVED from the asset model rather than hardcoded here, + * so when a signal is added to server/sim/stations.js the decision about whether + * it can be anomaly-tested lives next to its definition. Two kinds are excluded: + * + * cumulative - monotonically accumulating values (tool wear, part counters). + * Normal operation carries them far from any frozen baseline, so a + * z-score reports ordinary accumulation as a fault. Tool wear + * reaching 27% against a learned 18% baseline is not an anomaly, + * it is a Tuesday. + * volatile - values that legitimately swing with station state (belt speed + * goes to zero on every micro-stop) or that are operator inputs + * rather than measurements (a setpoint). + * + * Both remain fully covered by threshold alarms and trend projection, which are + * the appropriate detectors for them. + */ +import { STATION_SPECS } from '../sim/stations.js'; + +const EXCLUDED = new Set( + STATION_SPECS.flatMap((spec) => + spec.signals + .filter((g) => g.cumulative || g.volatile) + .map((g) => `${spec.id}.${g.key}`), + ), +); + +export class BaselineBank { + constructor(opts = {}) { + this.opts = opts; + this.trackers = new Map(); + } + + reset() { + this.trackers.clear(); + } + + key(stationId, signal) { + return `${stationId}.${signal}`; + } + + tracked(stationId, signal) { + return !EXCLUDED.has(this.key(stationId, signal)); + } + + get(stationId, signal) { + const k = this.key(stationId, signal); + let tr = this.trackers.get(k); + if (!tr) { + tr = new BaselineTracker(this.opts); + this.trackers.set(k, tr); + } + return tr; + } + + update(stationId, signal, t, value, clean) { + if (!this.tracked(stationId, signal)) return; + this.get(stationId, signal).update(t, value, clean); + } + + z(stationId, signal, value) { + if (!this.tracked(stationId, signal)) return null; + return this.get(stationId, signal).z(value); + } +} diff --git a/server/analytics/trend.js b/server/analytics/trend.js new file mode 100644 index 0000000..0ab280b --- /dev/null +++ b/server/analytics/trend.js @@ -0,0 +1,100 @@ +/** + * Trend extrapolation. + * + * Ordinary least squares over a decimated window of recent samples. Chosen over + * anything fancier for one reason: when the customer asks "how does it predict + * that?", the answer is one sentence - a straight-line fit over the last N + * minutes, projected to the alarm threshold, reported only when the fit is good + * enough to mean something. + * + * This is deliberately NOT presented as AI. It is a regression. + */ + +export class TrendTracker { + /** + * windowSec - how much history to fit over, in simulated seconds + * sampleEverySec- decimation interval, keeps the fit cheap and less noise-bound + * minR2 - below this the fit is reported as unreliable, no projection + */ + constructor({ windowSec = 1200, sampleEverySec = 10, minR2 = 0.55 } = {}) { + this.windowSec = windowSec; + this.sampleEverySec = sampleEverySec; + this.minR2 = minR2; + this.samples = []; + this.lastSampleT = -Infinity; + } + + reset() { + this.samples = []; + this.lastSampleT = -Infinity; + } + + update(t, value) { + if (!Number.isFinite(value)) return; + if (t - this.lastSampleT < this.sampleEverySec) return; + this.lastSampleT = t; + this.samples.push({ t, v: value }); + const cutoff = t - this.windowSec; + while (this.samples.length && this.samples[0].t < cutoff) this.samples.shift(); + } + + /** Least-squares fit. slope is in units per simulated second. */ + fit() { + const n = this.samples.length; + if (n < 6) return null; + + let st = 0, sv = 0; + for (const s of this.samples) { st += s.t; sv += s.v; } + const mt = st / n, mv = sv / n; + + let stt = 0, stv = 0, svv = 0; + for (const s of this.samples) { + const dt = s.t - mt, dv = s.v - mv; + stt += dt * dt; stv += dt * dv; svv += dv * dv; + } + if (stt === 0) return null; + + const slope = stv / stt; + const r2 = svv === 0 ? 0 : (stv * stv) / (stt * svv); + return { + slope, + intercept: mv - slope * mt, + mean: mv, + n, + r2, + spanSec: this.samples[n - 1].t - this.samples[0].t, + latest: this.samples[n - 1].v, + }; + } + + /** + * Simulated seconds until the fitted line reaches `threshold`. + * + * Returns null when there is no usable projection: too little data, a fit too + * poor to trust, movement in the wrong direction, or the threshold already + * crossed. Returning null is the honest answer and the UI shows nothing. + */ + timeToThreshold(threshold) { + const f = this.fit(); + if (!f) return null; + if (f.r2 < this.minR2) return null; + + const current = f.latest; + const rising = threshold > current; + if (rising && f.slope <= 1e-9) return null; + if (!rising && f.slope >= -1e-9) return null; + + const secs = (threshold - current) / f.slope; + if (!Number.isFinite(secs) || secs <= 0) return null; + return { seconds: secs, fit: f }; + } +} + +/** Human-readable duration for a span of simulated seconds. */ +export function formatDuration(sec) { + if (!Number.isFinite(sec)) return 'unknown'; + if (sec < 90) return `${Math.round(sec)} s`; + if (sec < 5400) return `${(sec / 60).toFixed(1)} min`; + if (sec < 172800) return `${(sec / 3600).toFixed(1)} h`; + return `${(sec / 86400).toFixed(1)} d`; +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..d2d4a32 --- /dev/null +++ b/server/index.js @@ -0,0 +1,269 @@ +/** + * Digital twin demo server. + * + * Owns the telemetry source, broadcasts frames over WebSocket at 2 Hz, exposes a + * small control API for the what-if panel, and proxies the copilot so the API key + * never reaches the browser. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import dotenv from 'dotenv'; +import http from 'node:http'; +import express from 'express'; +import { WebSocketServer } from 'ws'; + +// Load .env from the repo root explicitly. `dotenv/config` resolves relative to +// process.cwd(), and `npm run dev -w server` sets cwd to server/, so the root +// .env was silently ignored - the symptom being a copilot that quietly falls back +// to a different provider than the one you configured. +const HERE = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.join(HERE, '..', '.env') }); +dotenv.config({ path: path.join(HERE, '.env') }); + +import { STATION_SPECS, BUFFER_CAPACITY } from './sim/stations.js'; +import { FAULT_PROFILES } from './sim/faults.js'; +import { KPI_WINDOW_SEC } from './sim/kpi.js'; +import { SimulatedSource, SPEEDS } from './ingest/simulatedSource.js'; +import { MqttSource } from './ingest/mqttSource.js'; +import { OpcUaSource } from './ingest/opcuaSource.js'; +import { Copilot } from './ai/copilot.js'; + +const PORT = Number(process.env.PORT || 8787); +const SOURCE_KIND = (process.env.TELEMETRY_SOURCE || 'simulated').toLowerCase(); + +function createSource() { + switch (SOURCE_KIND) { + case 'mqtt': + return new MqttSource({ + url: process.env.MQTT_URL, + username: process.env.MQTT_USERNAME, + password: process.env.MQTT_PASSWORD, + }); + case 'opcua': + return new OpcUaSource({ + endpoint: process.env.OPCUA_ENDPOINT, + username: process.env.OPCUA_USERNAME, + password: process.env.OPCUA_PASSWORD, + }); + case 'simulated': + return new SimulatedSource({ + seed: process.env.SIM_SEED ? Number(process.env.SIM_SEED) : undefined, + }); + default: + throw new Error(`Unknown TELEMETRY_SOURCE "${SOURCE_KIND}". Use simulated, mqtt or opcua.`); + } +} + +const source = createSource(); +const copilot = new Copilot(); + +const app = express(); +app.use(express.json({ limit: '256kb' })); + +// --- static metadata the UI needs once, not on every frame ------------------- + +app.get('/api/meta', (_req, res) => { + res.json({ + lineId: 'LINE-1', + stations: STATION_SPECS, + faults: FAULT_PROFILES, + bufferCapacity: BUFFER_CAPACITY, + kpiWindowSec: KPI_WINDOW_SEC, + speeds: SPEEDS, + source: { kind: source.name, capabilities: source.capabilities }, + copilot: copilot.status(), + }); +}); + +app.get('/api/health', (_req, res) => { + res.json({ + ok: true, + source: source.name, + hasFrame: Boolean(source.latest), + simTime: source.latest ? source.latest.t : 0, + copilot: copilot.status(), + }); +}); + +app.get('/api/state', (_req, res) => { + if (!source.latest) return res.status(503).json({ error: 'No telemetry yet.' }); + res.json(source.latest); +}); + +// --- control surface -------------------------------------------------------- + +/** Wrap a control action so an unsupported source returns 400, not a 500. */ +function control(handler) { + return (req, res) => { + try { + const result = handler(req); + res.json({ ok: true, result }); + } catch (err) { + res.status(400).json({ ok: false, error: err.message }); + } + }; +} + +app.post('/api/control/speed', control((req) => source.setSpeed(req.body.speed))); +app.post('/api/control/setpoint', control((req) => source.setSetpoint(req.body.value))); +app.post('/api/control/line-speed', control((req) => source.setLineSpeed(req.body.value))); +app.post('/api/control/tool-change', control(() => source.toolChange())); +app.post('/api/control/reset', control(() => source.reset())); + +app.post('/api/control/fault', control((req) => { + const { id, action } = req.body; + if (!FAULT_PROFILES.some((f) => f.id === id)) throw new Error(`Unknown fault "${id}".`); + return action === 'clear' ? source.clearFault(id) : source.injectFault(id); +})); + +// --- copilot --------------------------------------------------------------- + +app.get('/api/copilot/status', async (_req, res) => { + res.json(await copilot.detect()); +}); + +// --- settings --------------------------------------------------------------- + +app.get('/api/settings', (_req, res) => { + res.json({ settings: copilot.publicSettings(), status: copilot.status() }); +}); + +app.post('/api/settings', async (req, res) => { + const result = await copilot.configure(req.body || {}); + // A rejected patch changes nothing, so report it as a client error. + res.status(result.ok ? 200 : 400).json(result); +}); + +app.post('/api/settings/reset', async (_req, res) => { + res.json(await copilot.reset()); +}); + +app.get('/api/copilot/models', async (req, res) => { + const provider = String(req.query.provider || '').toLowerCase(); + if (provider !== 'openrouter' && provider !== 'ollama') { + return res.status(400).json({ error: 'provider must be openrouter or ollama' }); + } + res.json(await copilot.listModels(provider, { force: req.query.force === '1' })); +}); + +app.post('/api/copilot/test', async (req, res) => { + const { provider, model } = req.body || {}; + res.json(await copilot.testProvider({ provider, model })); +}); + +app.post('/api/copilot/chat', async (req, res) => { + const { question, history, provider } = req.body || {}; + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + const send = (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`); + + // Detect client disconnect on the RESPONSE, not the request. Express has + // already consumed the POST body by this point, which ends the request stream + // and fires req 'close' immediately - watching that aborts the stream before + // the first token is ever written. + let closed = false; + res.on('close', () => { closed = true; }); + + try { + for await (const chunk of copilot.stream(question, history, source.latest, provider)) { + if (closed) break; + send(chunk); + } + } catch (err) { + // copilot.stream() degrades internally, so reaching here means something + // unexpected broke. Still return prose rather than a broken stream. + console.error('[copilot] unexpected failure:', err); + if (!closed) send({ type: 'token', text: `The copilot failed unexpectedly: ${err.message}` }); + } + if (!res.writableEnded) { + send({ type: 'end' }); + res.end(); + } +}); + +// --- built front end ------------------------------------------------------- + +/** + * Serve web/dist when it exists, so `npm run build` plus this process is a + * complete, single-port deployment with no dev server and no second origin. + * + * This is what makes the "one Node process, works with no internet" claim true. + * In development Vite serves the front end on :5173 and proxies here instead, so + * this block simply finds no dist directory and does nothing. + */ +const DIST = path.join(HERE, '..', 'web', 'dist'); +if (fs.existsSync(path.join(DIST, 'index.html'))) { + app.use(express.static(DIST)); + + // SPA fallback for anything that is not an API call. Registered as middleware + // rather than app.get('*') because Express 5 rejects a bare '*' path. + app.use((req, res, next) => { + if (req.method !== 'GET' || req.path.startsWith('/api/')) return next(); + res.sendFile(path.join(DIST, 'index.html')); + }); + console.log(`[server] serving built front end from ${DIST}`); +} else { + console.log('[server] no web/dist found - run "npm run build" for single-port mode'); +} + +// --- websocket broadcast --------------------------------------------------- + +const server = http.createServer(app); +const wss = new WebSocketServer({ server, path: '/ws' }); + +wss.on('connection', (ws) => { + // Replay recent history first so the trend charts are populated on load, then + // the current frame so the dashboard renders without waiting for the next tick. + if (source.replay.length) { + ws.send(JSON.stringify({ type: 'history', frames: source.replay })); + } + if (source.latest) { + ws.send(JSON.stringify({ type: 'frame', frame: source.latest })); + } + ws.on('error', (err) => console.error('[ws] client error:', err.message)); +}); + +source.onFrame((frame) => { + const msg = JSON.stringify({ type: 'frame', frame }); + for (const client of wss.clients) { + if (client.readyState === 1) client.send(msg); + } +}); + +// --- startup --------------------------------------------------------------- + +async function main() { + const status = await copilot.detect(); + console.log(`[copilot] ${status.label} - ${status.detail}`); + + try { + await source.start(); + console.log(`[source] ${source.name} started`); + } catch (err) { + // A stub source throws a long explanatory message. Print it and stop, rather + // than serving a dashboard with no data behind it. + console.error(`\n[source] failed to start "${SOURCE_KIND}":\n${err.message}\n`); + process.exit(1); + } + + server.listen(PORT, () => { + console.log(`[server] http://localhost:${PORT} (ws://localhost:${PORT}/ws)`); + }); +} + +for (const sig of ['SIGINT', 'SIGTERM']) { + process.on(sig, async () => { + await source.stop(); + server.close(() => process.exit(0)); + }); +} + +main(); diff --git a/server/ingest/mqttSource.js b/server/ingest/mqttSource.js new file mode 100644 index 0000000..00aa7f1 --- /dev/null +++ b/server/ingest/mqttSource.js @@ -0,0 +1,100 @@ +/** + * MQTT telemetry source - DOCUMENTED STUB. + * + * This is not implemented, and it says so honestly rather than pretending. What + * it does provide is the exact shape of the work: the tag map, the frame + * assembly, and where analytics plugs in. Wiring this to a real broker is a + * day's work, not a rewrite, because everything downstream consumes frames. + * + * To implement: + * 1. npm i mqtt + * 2. Fill TAG_MAP with the customer's actual topic names. + * 3. Implement start() as marked below. + * 4. Set TELEMETRY_SOURCE=mqtt and MQTT_URL in .env + * + * Sparkplug B note: most industrial brokers publish Sparkplug B protobuf rather + * than plain JSON on flat topics. If so, add `sparkplug-payload` to decode + * NBIRTH/NDATA messages and map metric aliases instead of topic strings. + */ + +import { TelemetrySource } from './source.js'; +import { AnalyticsEngine } from '../analytics/alarms.js'; + +/** + * Maps a broker topic to a station signal. + * + * The demo model expects the signals declared in server/sim/stations.js. Any + * topic not mapped here is ignored; any signal not supplied by the broker simply + * has no data, and the UI shows it as such rather than inventing a value. + */ +export const TAG_MAP = { + // 'plant/line1/conveyor01/belt_speed': { station: 'CONV-01', signal: 'beltSpeed' }, + // 'plant/line1/conveyor01/motor_current': { station: 'CONV-01', signal: 'motorAmps' }, + // 'plant/line1/cnc02/vibration_rms': { station: 'CNC-02', signal: 'vibration' }, + // 'plant/line1/cnc02/spindle_load': { station: 'CNC-02', signal: 'spindleLoad' }, + // 'plant/line1/oven03/zone2_pv': { station: 'OVN-03', signal: 'zone2Temp' }, + // 'plant/line1/oven03/zone2_sp': { station: 'OVN-03', signal: 'setpoint' }, + // 'plant/line1/ins04/reject_rate': { station: 'INS-04', signal: 'rejectRate' }, + // 'plant/line1/pkg05/units_per_min': { station: 'PKG-05', signal: 'unitsPerMin' }, +}; + +export class MqttSource extends TelemetrySource { + constructor({ url, username, password, topicPrefix } = {}) { + super('mqtt'); + this.url = url; + this.username = username; + this.password = password; + this.topicPrefix = topicPrefix; + this.analytics = new AnalyticsEngine(); + } + + /** A real broker feed is read-only: you observe the plant, you do not drive it. */ + get capabilities() { + return { timeControl: false, faultInjection: false, setpointControl: false }; + } + + async start() { + throw new Error( + 'MQTT source is not configured. This is a documented stub.\n' + + 'To enable it: npm i mqtt, populate TAG_MAP in server/ingest/mqttSource.js ' + + 'with your topic names, implement start(), then set TELEMETRY_SOURCE=mqtt ' + + 'and MQTT_URL in .env.\n' + + 'Run with TELEMETRY_SOURCE=simulated for the demo.', + ); + + /* Implementation outline: + * + * const mqtt = await import('mqtt'); + * this.client = mqtt.connect(this.url, { username: this.username, password: this.password }); + * this.client.on('connect', () => this.client.subscribe(Object.keys(TAG_MAP))); + * + * // Accumulate the latest value per tag. Industrial tags publish on change, + * // at wildly different rates, so you assemble a frame on a timer rather + * // than trying to emit one per message. + * this.client.on('message', (topic, payload) => { + * const tag = TAG_MAP[topic]; + * if (!tag) return; + * this.values[`${tag.station}.${tag.signal}`] = Number(payload.toString()); + * }); + * + * this.timer = setInterval(() => this.assembleAndEmit(), 500); + * + * assembleAndEmit() builds the same frame shape SimulatedSource emits: + * stations with their signals, KPI rollups (see server/sim/kpi.js - the + * KpiTracker works on any counter source, not just the simulator), then + * this.analytics.update(snapshot) and this.emit(frame). + * + * Two things that bite in the real world: + * - Staleness. Track a per-tag last-seen timestamp and mark a station + * offline when its tags go quiet, exactly as the F4 dropout fault does. + * Never let a stale value render as if it were live. + * - Units. Vibration in in/s, temperature in F, and pressure in psi are all + * common. Convert at the boundary here, not downstream. + */ + } + + async stop() { + if (this.timer) clearInterval(this.timer); + if (this.client) this.client.end(); + } +} diff --git a/server/ingest/opcuaSource.js b/server/ingest/opcuaSource.js new file mode 100644 index 0000000..b62383a --- /dev/null +++ b/server/ingest/opcuaSource.js @@ -0,0 +1,119 @@ +/** + * OPC-UA telemetry source - DOCUMENTED STUB. + * + * OPC-UA is usually the right answer when the customer already has a PLC or SCADA + * layer, because it gives you a browsable address space, real subscriptions, and + * server-side timestamps rather than a flat topic namespace. + * + * To implement: + * 1. npm i node-opcua + * 2. Fill NODE_MAP with the customer's actual NodeIds (browse the server first). + * 3. Implement start() as marked below. + * 4. Set TELEMETRY_SOURCE=opcua and OPCUA_ENDPOINT in .env + * + * For testing without a plant, Prosys OPC-UA Simulation Server or the + * node-opcua sample server both work locally. + */ + +import { TelemetrySource } from './source.js'; +import { AnalyticsEngine } from '../analytics/alarms.js'; + +/** + * Maps an OPC-UA NodeId to a station signal. + * + * NodeIds are namespace-qualified and installation-specific - never guess them. + * Browse the server's address space and read them off. + */ +export const NODE_MAP = { + // 'ns=2;s=Line1.CONV01.BeltSpeed': { station: 'CONV-01', signal: 'beltSpeed' }, + // 'ns=2;s=Line1.CNC02.VibrationRMS': { station: 'CNC-02', signal: 'vibration' }, + // 'ns=2;s=Line1.CNC02.SpindleLoad': { station: 'CNC-02', signal: 'spindleLoad' }, + // 'ns=2;s=Line1.OVN03.Zone2PV': { station: 'OVN-03', signal: 'zone2Temp' }, + // 'ns=2;s=Line1.OVN03.Zone2SP': { station: 'OVN-03', signal: 'setpoint' }, + // 'ns=2;s=Line1.INS04.RejectRate': { station: 'INS-04', signal: 'rejectRate' }, + // 'ns=2;s=Line1.PKG05.UnitsPerMin': { station: 'PKG-05', signal: 'unitsPerMin' }, +}; + +export class OpcUaSource extends TelemetrySource { + constructor({ endpoint, securityMode, username, password } = {}) { + super('opcua'); + this.endpoint = endpoint; + this.securityMode = securityMode; + this.username = username; + this.password = password; + this.analytics = new AnalyticsEngine(); + } + + /** + * Read-only by default, deliberately. + * + * OPC-UA can write back to a PLC, and a twin that can change a real setpoint is + * a genuinely useful thing - but it is also a safety-critical action that needs + * interlocks, an audit trail, and the customer's explicit sign-off. Do not turn + * setpointControl on here because the demo UI has a slider. + */ + get capabilities() { + return { timeControl: false, faultInjection: false, setpointControl: false }; + } + + async start() { + throw new Error( + 'OPC-UA source is not configured. This is a documented stub.\n' + + 'To enable it: npm i node-opcua, populate NODE_MAP in ' + + 'server/ingest/opcuaSource.js with NodeIds browsed from your server, ' + + 'implement start(), then set TELEMETRY_SOURCE=opcua and OPCUA_ENDPOINT in .env.\n' + + 'Run with TELEMETRY_SOURCE=simulated for the demo.', + ); + + /* Implementation outline: + * + * const { OPCUAClient, MessageSecurityMode, SecurityPolicy, AttributeIds, + * ClientSubscription, TimestampsToReturn } = await import('node-opcua'); + * + * this.client = OPCUAClient.create({ endpointMustExist: false }); + * await this.client.connect(this.endpoint); + * this.session = await this.client.createSession( + * this.username ? { userName: this.username, password: this.password } : undefined); + * + * this.subscription = await this.session.createSubscription2({ + * requestedPublishingInterval: 500, + * publishingEnabled: true, + * }); + * + * for (const [nodeId, tag] of Object.entries(NODE_MAP)) { + * const item = await this.subscription.monitor( + * { nodeId, attributeId: AttributeIds.Value }, + * { samplingInterval: 500, queueSize: 10, discardOldest: true }, + * TimestampsToReturn.Both); + * item.on('changed', (dataValue) => { + * // Honour the status code. A Bad or Uncertain value must NOT be + * // rendered as live data - that is how a twin starts lying. + * if (!dataValue.statusCode.isGood()) { + * this.markStale(tag); + * return; + * } + * this.values[`${tag.station}.${tag.signal}`] = { + * value: dataValue.value.value, + * // Prefer the SOURCE timestamp: it is when the PLC sampled the + * // sensor, not when the message happened to reach us. + * t: dataValue.sourceTimestamp ?? dataValue.serverTimestamp, + * }; + * }); + * } + * + * this.timer = setInterval(() => this.assembleAndEmit(), 500); + * + * assembleAndEmit() builds the same frame shape SimulatedSource emits, then + * calls this.analytics.update(snapshot) and this.emit(frame). The analytics + * layer needs no changes at all: TrendTracker and BaselineBank work on + * timestamped values regardless of where they came from. + */ + } + + async stop() { + if (this.timer) clearInterval(this.timer); + if (this.subscription) await this.subscription.terminate(); + if (this.session) await this.session.close(); + if (this.client) await this.client.disconnect(); + } +} diff --git a/server/ingest/simulatedSource.js b/server/ingest/simulatedSource.js new file mode 100644 index 0000000..77966e2 --- /dev/null +++ b/server/ingest/simulatedSource.js @@ -0,0 +1,119 @@ +/** + * The simulated telemetry source: owns the model, the clock, and the analytics. + * + * Real time advances at TICK_MS. Simulated time advances at TICK_MS * speed, so + * the operator can run the plant at 60x and watch a twenty-minute degradation + * play out in twenty seconds. Nothing downstream knows or cares. + */ + +import { ProductionLine } from '../sim/line.js'; +import { AnalyticsEngine } from '../analytics/alarms.js'; +import { TelemetrySource } from './source.js'; + +/** Real milliseconds between broadcast frames. */ +export const TICK_MS = 500; + +/** Selectable clock multipliers. 0 is paused. */ +export const SPEEDS = [0, 1, 5, 20, 60]; + +/** + * Simulated seconds to run before serving the first frame. + * + * The demo must open on a plant that has been running, not one that just booted. + * Without this the rolling OEE window contains a few seconds of loss-free data and + * reads a perfect 100%, which is precisely the "obviously fabricated" impression + * the model works hard to avoid; the learned anomaly baselines are also not ready, + * so nothing can be detected for the first several minutes. + */ +export const PREWARM_SEC = 1800; + +export class SimulatedSource extends TelemetrySource { + constructor({ seed, prewarmSec } = {}) { + super('simulated'); + this.line = new ProductionLine(seed); + this.analytics = new AnalyticsEngine(); + this.speed = 1; + this.timer = null; + this.prewarmSec = Number.isFinite(prewarmSec) ? prewarmSec : PREWARM_SEC; + } + + get capabilities() { + return { timeControl: true, faultInjection: true, setpointControl: true }; + } + + async start() { + if (this.timer) return; + + if (this.prewarmSec > 0) { + const t0 = Date.now(); + const step = (TICK_MS / 1000); + const iterations = Math.floor(this.prewarmSec / step); + // Run through the normal tick path so the replay ring and the analytics + // baselines end up in exactly the state they would reach organically. + for (let i = 0; i < iterations; i++) this.tick(step); + console.log( + `[source] pre-warmed ${(this.prewarmSec / 60).toFixed(0)} simulated minutes in ${Date.now() - t0} ms`, + ); + } else { + // At minimum emit one frame so a connecting client has something to render. + this.tick(0); + } + + this.timer = setInterval(() => this.tick((TICK_MS / 1000) * this.speed), TICK_MS); + } + + async stop() { + if (this.timer) clearInterval(this.timer); + this.timer = null; + } + + tick(simSeconds) { + if (simSeconds > 0) this.line.step(simSeconds); + const snap = this.line.snapshot(); + const analytics = this.analytics.update(snap); + this.emit({ + ...snap, + analytics, + events: this.line.events.slice(-40).reverse(), + sim: { + speed: this.speed, + paused: this.speed === 0, + speeds: SPEEDS, + tickMs: TICK_MS, + source: this.name, + }, + }); + } + + setSpeed(speed) { + const s = Number(speed); + if (!SPEEDS.includes(s)) throw new Error(`Unsupported speed ${speed}. Allowed: ${SPEEDS.join(', ')}`); + this.speed = s; + this.line.logEvent('action', 'LINE-1', s === 0 ? 'Simulation paused.' : `Simulation speed set to ${s}x.`); + return s; + } + + setSetpoint(v) { return this.line.setSetpoint(v); } + setLineSpeed(v) { return this.line.setLineSpeed(v); } + injectFault(id) { return this.line.injectFault(id); } + clearFault(id) { return this.line.clearFault(id); } + toolChange() { return this.line.toolChange(); } + + reset() { + this.line.reset(); + // The learned baselines belong to the old run; keeping them would flag the + // fresh line as anomalous. + this.analytics.reset(); + this.replay = []; + + // Re-warm, or Reset would leave the dashboard showing a perfect 100% OEE and + // an anomaly detector with nothing learned - worse than before the reset. + if (this.prewarmSec > 0) { + const step = TICK_MS / 1000; + const iterations = Math.floor(this.prewarmSec / step); + for (let i = 0; i < iterations; i++) this.tick(step); + } else { + this.tick(0); + } + } +} diff --git a/server/ingest/source.js b/server/ingest/source.js new file mode 100644 index 0000000..30b04d3 --- /dev/null +++ b/server/ingest/source.js @@ -0,0 +1,90 @@ +/** + * The telemetry ingest seam. + * + * Everything downstream of this interface - analytics, alarms, the API, the UI, + * the copilot - only ever sees frames. It has no idea whether those frames came + * from a simulator, an MQTT broker, or an OPC-UA server. + * + * That is the whole point of putting a seam here rather than running the + * simulator in the browser: "can it take our data?" is answered by implementing + * one class, not by rewriting the application. + * + * A frame is the ProductionLine snapshot plus the analytics block: + * { t, wallT, lineId, stations[], buffers[], bufferCapacity, kpi, faults[], + * controls, totals, analytics: { alarms[], predictions[], trends[] }, + * sim: { speed, paused, tickMs, source } } + */ + +/** Frames of history retained for replay to newly connected clients. */ +export const REPLAY_FRAMES = 240; + +export class TelemetrySource { + constructor(name) { + this.name = name; + this.listeners = new Set(); + this.latest = null; + /** + * A short ring of recent frames, trimmed to what the charts need. + * + * Without this, opening the dashboard gives you empty trend charts that take + * minutes of wall-clock to fill - so the first thing a customer sees is a + * dashboard with no history on it. Replaying this on connect means the charts + * are populated the instant the page loads. + */ + this.replay = []; + } + + /** + * What this source supports. The UI hides controls a source cannot honour, so + * a read-only historian replay does not show fault-injection buttons that + * would silently do nothing. + */ + get capabilities() { + return { timeControl: false, faultInjection: false, setpointControl: false }; + } + + onFrame(cb) { + this.listeners.add(cb); + return () => this.listeners.delete(cb); + } + + emit(frame) { + this.latest = frame; + + this.replay.push({ + t: frame.t, + kpi: { + oee: frame.kpi.oee, + availability: frame.kpi.availability, + performance: frame.kpi.performance, + quality: frame.kpi.quality, + }, + stations: frame.stations.map((s) => ({ id: s.id, online: s.online, signals: s.signals })), + }); + if (this.replay.length > REPLAY_FRAMES) this.replay.shift(); + + for (const cb of this.listeners) { + try { + cb(frame); + } catch (err) { + console.error(`[${this.name}] frame listener failed:`, err); + } + } + } + + async start() { + throw new Error(`${this.name}: start() not implemented`); + } + + async stop() {} + + // --- optional control surface; sources that cannot do these should throw --- + + setSpeed() { throw new Error(`${this.name} does not support time control`); } + setSetpoint() { throw new Error(`${this.name} does not support setpoint control`); } + setLineSpeed() { throw new Error(`${this.name} does not support line speed control`); } + injectFault() { throw new Error(`${this.name} does not support fault injection`); } + clearFault() { throw new Error(`${this.name} does not support fault injection`); } + toolChange() { throw new Error(`${this.name} does not support maintenance actions`); } + reset() { throw new Error(`${this.name} does not support reset`); } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..c4aedb2 --- /dev/null +++ b/server/package.json @@ -0,0 +1,16 @@ +{ + "name": "digitaltwin-server", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "index.js", + "scripts": { + "dev": "node index.js", + "start": "node index.js" + }, + "dependencies": { + "dotenv": "^17.2.1", + "express": "^5.1.0", + "ws": "^8.18.3" + } +} diff --git a/server/sim/faults.js b/server/sim/faults.js new file mode 100644 index 0000000..f58c920 --- /dev/null +++ b/server/sim/faults.js @@ -0,0 +1,105 @@ +/** + * Injectable fault profiles. + * + * Each profile turns "time since injection" into a set of physics modifiers that + * stations.js and line.js consume. Faults are progressive where a real fault + * would be progressive - the bearing does not fail the instant you press the + * button, it degrades, which is the whole point of showing trend extrapolation. + */ + +export const FAULT_PROFILES = [ + { + id: 'bearing-degradation', + station: 'CNC-02', + label: 'CNC-02 bearing degradation', + short: 'Bearing wear', + severity: 'major', + headline: true, + description: + 'Spindle bearing begins to spall. Vibration rises exponentially, spindle load creeps up, and out-of-tolerance parts start reaching inspection.', + }, + { + id: 'oven-burner', + station: 'OVN-03', + label: 'OVN-03 zone 2 burner fault', + short: 'Burner fault', + severity: 'major', + description: + 'Zone 2 loses roughly 40% of its heating capacity. The controller saturates trying to hold setpoint, the cure runs cold, and quality falls downstream.', + }, + { + id: 'packer-jam', + station: 'PKG-05', + label: 'PKG-05 film jam', + short: 'Film jam', + severity: 'critical', + description: + 'Film binds then tears, stopping the packer. Work in progress backs up through the line and upstream stations block.', + }, + { + id: 'sensor-dropout', + station: 'INS-04', + label: 'INS-04 sensor dropout', + short: 'Sensor dropout', + severity: 'minor', + description: + 'The inspection station stops reporting. Values go stale rather than to zero, which is what a real dropout looks like and what a twin has to handle honestly.', + }, +]; + +export function faultProfile(id) { + return FAULT_PROFILES.find((f) => f.id === id); +} + +/** Time constant for the bearing ramp, in simulated seconds. */ +const BEARING_TAU = 420; +const BEARING_GAIN = 0.35; +const BEARING_CAP = 3.2; + +/** + * Collapse the set of active faults into physics modifiers. + * + * active: Map of faultId -> { injectedAt } in simulated seconds. + */ +export function computeModifiers(active, simTime) { + const mods = { + bearingVibration: 0, + ovenZone2Capacity: 1, + packerJamPhase: null, + faultedStations: new Set(), + offlineStations: new Set(), + }; + + for (const [id, info] of active) { + const t = Math.max(0, simTime - info.injectedAt); + + switch (id) { + case 'bearing-degradation': + // Exponential ramp: slow to start, unmistakable once it moves. Crosses + // the 3.5 mm/s warn band around 13 simulated minutes. + mods.bearingVibration = Math.min( + BEARING_CAP, + BEARING_GAIN * (Math.exp(t / BEARING_TAU) - 1), + ); + break; + + case 'oven-burner': { + // Capacity degrades over the first minute rather than cliff-edging. + const frac = Math.min(1, t / 60); + mods.ovenZone2Capacity = 1 - 0.38 * frac; + break; + } + + case 'packer-jam': + mods.faultedStations.add('PKG-05'); + mods.packerJamPhase = t < 8 ? 'bind' : 'tear'; + break; + + case 'sensor-dropout': + mods.offlineStations.add('INS-04'); + break; + } + } + + return mods; +} diff --git a/server/sim/kpi.js b/server/sim/kpi.js new file mode 100644 index 0000000..ccab188 --- /dev/null +++ b/server/sim/kpi.js @@ -0,0 +1,121 @@ +/** + * OEE and line KPI rollups. + * + * Everything is computed over a rolling window of simulated time so the numbers + * actually move during a demo. A cumulative-since-reset OEE barely budges in + * twenty minutes, which reads as a broken dashboard. + * + * OEE = Availability x Performance x Quality, using the standard definitions: + * Availability = run time / planned production time + * Performance = (total parts x ideal cycle time) / run time + * Quality = good parts / total parts + */ + +/** Rolling window length, in simulated seconds. */ +export const KPI_WINDOW_SEC = 1200; + +export class KpiTracker { + constructor(idealCycleTime) { + this.idealCycleTime = idealCycleTime; + this.reset(); + } + + reset() { + /** Ring of cumulative counters, so any window is a difference of two samples. */ + this.samples = []; + this.cum = { plannedSec: 0, runSec: 0, total: 0, good: 0, rejected: 0 }; + } + + /** + * Accumulate one simulation sub-step. + * + * lineUp: false while any station is in a fault state (planned time still + * accrues, run time does not - that is what Availability measures). + */ + accumulate(dt, lineUp, producedTotal, producedGood, producedRejected) { + this.cum.plannedSec += dt; + if (lineUp) this.cum.runSec += dt; + this.cum.total = producedTotal; + this.cum.good = producedGood; + this.cum.rejected = producedRejected; + } + + /** Record a window sample. Call once per broadcast tick, not per sub-step. */ + mark(simTime) { + this.samples.push({ t: simTime, ...this.cum }); + while (this.samples.length > 2 && simTime - this.samples[0].t > KPI_WINDOW_SEC) { + this.samples.shift(); + } + } + + /** Compute KPIs over the rolling window. */ + compute(stations, instantPowerKw) { + const first = this.samples[0]; + const last = this.samples[this.samples.length - 1]; + + if (!first || !last || last.t - first.t < 1) { + return { + oee: 0, availability: 0, performance: 0, quality: 0, + throughputPerHour: 0, scrapRate: 0, energyKw: instantPowerKw, + energyPerUnit: 0, windowSec: 0, + produced: this.cum.total, good: this.cum.good, rejected: this.cum.rejected, + }; + } + + const dPlanned = last.plannedSec - first.plannedSec; + const dRun = last.runSec - first.runSec; + const dTotal = last.total - first.total; + const dGood = last.good - first.good; + const dRejected = last.rejected - first.rejected; + + const availability = dPlanned > 0 ? dRun / dPlanned : 0; + + // Performance is capped at 100% by definition: the ideal cycle time is the + // fastest the line can physically go, so exceeding it is impossible. Draining + // a WIP buffer can briefly produce faster than the bottleneck, which would + // otherwise show as OEE above 100% and read as a broken dashboard. + const performance = dRun > 0 ? Math.min(1, (dTotal * this.idealCycleTime) / dRun) : 0; + const quality = dTotal > 0 ? dGood / dTotal : dRun > 0 ? 1 : 0; + const oee = availability * performance * quality; + + const hours = dPlanned / 3600; + const throughputPerHour = hours > 0 ? dGood / hours : 0; + + return { + oee, + availability, + performance, + quality, + throughputPerHour, + scrapRate: dTotal > 0 ? dRejected / dTotal : 0, + energyKw: instantPowerKw, + energyPerUnit: throughputPerHour > 0 ? instantPowerKw / throughputPerHour : 0, + windowSec: dPlanned, + produced: this.cum.total, + good: this.cum.good, + rejected: this.cum.rejected, + }; + } +} + +/** Instantaneous line power draw, kW. */ +export function instantPower(stations) { + let kw = 0; + for (const st of stations) { + switch (st.kind) { + case 'conveyor': + kw += st.signals.motorAmps * 0.62; + break; + case 'cnc': + kw += (st.signals.spindleLoad / 100) * st.power; + break; + case 'oven': + // An oven keeps drawing standby heat even when the line is stopped. + kw += (st.signals.burnerDuty / 100) * st.power + 6; + break; + default: + kw += st.state === 'running' ? st.power : st.power * 0.2; + } + } + return kw; +} diff --git a/server/sim/line.js b/server/sim/line.js new file mode 100644 index 0000000..84fb8ba --- /dev/null +++ b/server/sim/line.js @@ -0,0 +1,345 @@ +/** + * The production line model: discrete part flow over continuous signal physics. + * + * Stations are connected by finite WIP buffers, so they block and starve each + * other. That coupling is what makes the model read as a plant rather than as + * five unrelated gauges - stop the packer and the backup propagates upstream + * until the whole line is blocked. + * + * Stations are stepped downstream-first so that a block resolves within a single + * sub-step rather than crawling one station per tick. + */ + +import { + STATION_SPECS, STATE, BUFFER_CAPACITY, DOWNTIME_STATES, + createStation, seedStation, updateSignals, makeRng, clamp, +} from './stations.js'; +import { computeModifiers, faultProfile } from './faults.js'; +import { KpiTracker, instantPower } from './kpi.js'; + +/** Largest sub-step we will integrate, in simulated seconds. */ +export const MAX_SUBSTEP = 0.5; + +const INSPECTION_INDEX = STATION_SPECS.findIndex((s) => s.kind === 'inspection'); +const IDEAL_CYCLE = Math.max(...STATION_SPECS.map((s) => s.baseCycleTime)); + +/** + * Background loss rates, per station, per simulated second. + * + * Without these the line runs at ~97% OEE, which no plant manager will believe. + * Micro-stops (a jammed part, a sensor re-read, an operator intervention) are the + * single largest OEE loss in most real factories, and unplanned stops are what + * Availability actually measures. Modelling them is more honest than hard-coding + * a plausible-looking OEE number. + */ +const MICRO_STOP_RATE = 1 / 110; +const MICRO_STOP_MIN = 4; +const MICRO_STOP_SPAN = 12; +const UNPLANNED_STOP_RATE = 1 / 20000; +const UNPLANNED_STOP_MIN = 25; +const UNPLANNED_STOP_SPAN = 55; + +export class ProductionLine { + constructor(seed = 0x5eed) { + this.seed = seed; + this.reset(); + } + + reset() { + // Two independent streams. Signal noise is drawn every sub-step in a fixed + // pattern, so sharing one stream with discrete event decisions (reject rolls, + // stall decisions) lands those decisions at a correlated phase in the + // sequence and measurably biases them - a reject roll against a 2.0% rate was + // firing at 5.7%. Keep event randomness on its own stream. + this.rng = makeRng(this.seed); + this.stallRng = makeRng(this.seed ^ 0x9e3779b9); + this.qualityRng = makeRng(this.seed ^ 0x85ebca6b); + this.simTime = 0; + this.stations = STATION_SPECS.map(createStation); + this.stationById = {}; + for (const st of this.stations) { + seedStation(st); + this.stationById[st.id] = st; + } + this.buffers = new Array(this.stations.length - 1).fill(4); + this.activeFaults = new Map(); + this.controls = { setpoint: 305, lineSpeedPct: 100 }; + this.totals = { produced: 0, good: 0, rejected: 0 }; + this.completionTimes = []; + this.events = []; + this.kpi = new KpiTracker(IDEAL_CYCLE); + this.stationById['OVN-03'].signals.setpoint = this.controls.setpoint; + this.logEvent('info', 'system', 'Line reset. Running at nominal setpoints.'); + } + + // -- operator actions ----------------------------------------------------- + + setSetpoint(v) { + const value = clamp(Number(v), 200, 360); + this.controls.setpoint = value; + this.stationById['OVN-03'].signals.setpoint = value; + this.logEvent('action', 'OVN-03', `Oven setpoint changed to ${value.toFixed(0)} °C.`); + return value; + } + + setLineSpeed(pct) { + const value = clamp(Number(pct), 50, 130); + this.controls.lineSpeedPct = value; + this.logEvent('action', 'LINE-1', `Line speed set to ${value.toFixed(0)}%.`); + return value; + } + + injectFault(id) { + const profile = faultProfile(id); + if (!profile) return false; + if (this.activeFaults.has(id)) return true; + this.activeFaults.set(id, { injectedAt: this.simTime }); + // Logged as 'inject' so the copilot context can filter it out: the copilot + // must diagnose from telemetry, not read the answer off an operator log. + this.logEvent('inject', profile.station, `Fault injected: ${profile.label}.`); + return true; + } + + clearFault(id) { + const profile = faultProfile(id); + if (!this.activeFaults.delete(id)) return false; + // Clearing a bearing fault means the bearing was replaced. + if (id === 'bearing-degradation') { + this.stationById['CNC-02'].signals.vibration = 1.6; + } + if (id === 'sensor-dropout') this.stationById['INS-04'].online = true; + this.logEvent('inject', profile ? profile.station : 'system', `Fault cleared: ${profile ? profile.label : id}.`); + return true; + } + + clearAllFaults() { + for (const id of [...this.activeFaults.keys()]) this.clearFault(id); + } + + /** Maintenance intervention: fresh tooling resets wear and its knock-on effects. */ + toolChange() { + this.stationById['CNC-02'].signals.toolWear = 2; + this.logEvent('action', 'CNC-02', 'Tool change completed. Wear counter reset.'); + } + + logEvent(kind, station, message) { + this.events.push({ + id: `${this.simTime.toFixed(1)}-${this.events.length}`, + t: this.simTime, + wallT: Date.now(), + kind, + station, + message, + }); + if (this.events.length > 200) this.events.shift(); + } + + // -- simulation ----------------------------------------------------------- + + /** Advance the model by dt simulated seconds, sub-stepping for stability. */ + step(dt) { + let remaining = dt; + while (remaining > 1e-6) { + const h = Math.min(MAX_SUBSTEP, remaining); + this.subStep(h); + remaining -= h; + } + this.kpi.mark(this.simTime); + } + + subStep(dt) { + this.simTime += dt; + + const mods = computeModifiers(this.activeFaults, this.simTime); + const speedFactor = this.controls.lineSpeedPct / 100; + + for (const st of this.stations) { + st.online = !mods.offlineStations.has(st.id); + } + + // --- discrete part flow, downstream first --- + const last = this.stations.length - 1; + for (let i = last; i >= 0; i--) { + const st = this.stations[i]; + + if (mods.faultedStations.has(st.id)) { + st.state = STATE.FAULT; + continue; + } + + // An in-progress stoppage holds the station regardless of material flow. + if (st._stopUntil > this.simTime) { + st.state = st._stopKind === 'down' ? STATE.DOWN : STATE.MICROSTOP; + continue; + } + if (st._stopKind) { + if (st._stopKind === 'down') { + this.logEvent('info', st.id, 'Unplanned stop cleared, station restarted.'); + } + st._stopKind = null; + } + + const hasInput = i === 0 || this.buffers[i - 1] > 0; + const hasRoom = i === last || this.buffers[i] < BUFFER_CAPACITY; + + if (!hasInput) { + st.state = STATE.STARVED; + continue; + } + if (!hasRoom) { + st.state = STATE.BLOCKED; + continue; + } + + // Only a station that would otherwise be producing can stall. + if (this.maybeStall(st, dt)) { + st.state = st._stopKind === 'down' ? STATE.DOWN : STATE.MICROSTOP; + continue; + } + + st.state = STATE.RUNNING; + const cycle = st.baseCycleTime / speedFactor; + st.progress += dt / cycle; + + while (st.progress >= 1 && (i === last || this.buffers[i] < BUFFER_CAPACITY)) { + st.progress -= 1; + if (i > 0) this.buffers[i - 1] -= 1; + st.completed += 1; + this.onPartCompleted(i, st); + } + if (st.progress >= 1) st.progress = 0.999; // output filled mid-completion + } + + // --- continuous signals --- + const achievedRate = this.recentRate(60); + const ctx = { + rng: this.rng, + speedFactor, + faults: mods, + buffers: this.buffers, + stationById: this.stationById, + achievedRate, + }; + for (const st of this.stations) updateSignals(st, ctx, dt); + + // --- KPI accumulation --- + // Availability counts only real stoppages. Micro-stops are a performance + // loss and deliberately do not count here. + const lineUp = !this.stations.some((st) => DOWNTIME_STATES.has(st.state)); + this.kpi.accumulate(dt, lineUp, this.totals.produced, this.totals.good, this.totals.rejected); + } + + /** + * Decide whether a producing station stalls this sub-step. + * + * Returns true if a stoppage started. Micro-stops are silent - they are normal + * line behaviour, not events worth alarming on. Unplanned stops are logged. + */ + maybeStall(st, dt) { + // A chattering spindle does not only make bad parts, it stalls the cut. This + // is what lets the bearing fault show up in Performance as well as Quality, + // so OEE moves for a reason an engineer can name. + let microRate = MICRO_STOP_RATE; + if (st.kind === 'cnc') { + microRate *= 1 + Math.max(0, st.signals.vibration - 2.4) * 1.6; + } + + if (this.stallRng() < microRate * dt) { + st._stopKind = 'micro'; + st._stopUntil = this.simTime + MICRO_STOP_MIN + this.stallRng() * MICRO_STOP_SPAN; + return true; + } + if (this.stallRng() < UNPLANNED_STOP_RATE * dt) { + st._stopKind = 'down'; + const secs = UNPLANNED_STOP_MIN + this.stallRng() * UNPLANNED_STOP_SPAN; + st._stopUntil = this.simTime + secs; + this.logEvent('fault', st.id, `Unplanned stop, estimated ${secs.toFixed(0)} s.`); + return true; + } + return false; + } + + /** A part finished at station index i. */ + onPartCompleted(i, st) { + const lastIndex = this.stations.length - 1; + + if (st.kind === 'cnc') { + // Tooling wears per part, and a rough bearing chews through it faster. + const vib = st.signals.vibration; + const accel = 1 + Math.max(0, vib - 2.5) * 0.9; + st.signals.toolWear = clamp(st.signals.toolWear + 0.012 * accel, 0, 100); + } + + if (i === INSPECTION_INDEX) { + st.signals.partsInspected += 1; + this.totals.produced += 1; + const rejected = this.qualityRng() < st.signals.rejectRate / 100; + if (rejected) { + this.totals.rejected += 1; + return; // scrapped here, never reaches the packer + } + this.totals.good += 1; + this.buffers[i] += 1; + return; + } + + if (i === lastIndex) { + this.completionTimes.push(this.simTime); + if (this.completionTimes.length > 400) this.completionTimes.shift(); + return; + } + + this.buffers[i] += 1; + } + + /** Packed units per minute over the trailing window, in simulated time. */ + recentRate(windowSec) { + const cutoff = this.simTime - windowSec; + while (this.completionTimes.length && this.completionTimes[0] < cutoff) { + this.completionTimes.shift(); + } + const span = Math.min(windowSec, this.simTime); + if (span < 5) return 0; + return (this.completionTimes.length / span) * 60; + } + + // -- output --------------------------------------------------------------- + + snapshot() { + const powerKw = instantPower(this.stations); + return { + t: this.simTime, + wallT: Date.now(), + lineId: 'LINE-1', + stations: this.stations.map((st) => ({ + id: st.id, + name: st.name, + kind: st.kind, + state: st.state, + online: st.online, + progress: st.progress, + completed: st.completed, + signals: { ...st.signals }, + })), + buffers: [...this.buffers], + bufferCapacity: BUFFER_CAPACITY, + kpi: this.kpi.compute(this.stations, powerKw), + faults: [...this.activeFaults.entries()].map(([id, info]) => { + const p = faultProfile(id); + return { + id, + label: p.label, + short: p.short, + severity: p.severity, + station: p.station, + injectedAt: info.injectedAt, + elapsed: this.simTime - info.injectedAt, + }; + }), + controls: { ...this.controls }, + totals: { ...this.totals }, + }; + } +} + +export { STATION_SPECS, IDEAL_CYCLE }; diff --git a/server/sim/stations.js b/server/sim/stations.js new file mode 100644 index 0000000..2a858cf --- /dev/null +++ b/server/sim/stations.js @@ -0,0 +1,309 @@ +/** + * Station specifications and per-station physics. + * + * A "spec" is static metadata: identity, nominal cycle time, and the signal + * definitions (units, ranges, alarm thresholds) that the UI renders generically. + * A "station" is the mutable runtime object created from a spec. + * + * Physics here is deliberately first-order: lag responses, a PID on the oven, + * and accumulating wear. It is not a CFD model. What matters for the demo is + * that signals move the way an engineer expects them to move, and that they are + * coupled - vibration drives tool wear drives reject rate drives OEE. + */ + +/** First-order lag toward a target. tau in seconds. */ +export function lag(current, target, tau, dt) { + return current + (target - current) * (1 - Math.exp(-dt / tau)); +} + +export function clamp(v, lo, hi) { + return v < lo ? lo : v > hi ? hi : v; +} + +/** Deterministic PRNG so every demo run is reproducible. */ +export function makeRng(seed = 0x5eed) { + let a = seed >>> 0; + return function rng() { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Zero-centred noise within +/- amp. */ +function noise(rng, amp) { + return (rng() * 2 - 1) * amp; +} + +export const STATE = { + RUNNING: 'running', + STARVED: 'starved', + BLOCKED: 'blocked', + /** Brief stall of seconds. Conventionally an OEE *performance* loss. */ + MICROSTOP: 'microstop', + /** Unplanned stop of tens of seconds. An OEE *availability* loss. */ + DOWN: 'down', + /** Operator-injected fault from the what-if panel. */ + FAULT: 'fault', + IDLE: 'idle', +}; + +/** States in which the station is not producing. */ +export const STOPPED_STATES = new Set([STATE.MICROSTOP, STATE.DOWN, STATE.FAULT, STATE.IDLE]); + +/** States that count against Availability rather than Performance. */ +export const DOWNTIME_STATES = new Set([STATE.DOWN, STATE.FAULT]); + +/** Buffer capacity between consecutive stations. Small enough to back up fast. */ +export const BUFFER_CAPACITY = 8; + +/** + * Signal spec fields: + * key, label, unit, min, max - display and chart scaling + * warnHigh/alarmHigh/warnLow/alarmLow - thresholds (all optional) + * precision - decimals to render + * chart - include in station trend charts + * primary - headline signal on the station card + * cumulative - monotonically accumulating (wear, counters). + * Never anomaly-tested: normal operation drifts + * far from any frozen baseline, so a z-score on it + * reports growth as a fault. Thresholds and trend + * projection are the right tools for these. + * volatile - legitimately swings with station state (belt speed + * drops to zero on every micro-stop) or is an operator + * input rather than a measurement. Also not + * anomaly-tested. + */ +export const STATION_SPECS = [ + { + id: 'CONV-01', + name: 'Infeed Conveyor', + kind: 'conveyor', + baseCycleTime: 4.0, + power: 5.5, + signals: [ + { key: 'beltSpeed', label: 'Belt Speed', unit: 'm/min', min: 0, max: 20, precision: 1, chart: true, primary: true, volatile: true }, + { key: 'motorAmps', label: 'Motor Current', unit: 'A', min: 0, max: 24, warnHigh: 16, alarmHigh: 20, precision: 1, chart: true }, + { key: 'infeedQueue', label: 'Infeed Queue', unit: 'pcs', min: 0, max: BUFFER_CAPACITY, precision: 0, chart: true, volatile: true }, + ], + }, + { + id: 'CNC-02', + name: 'CNC Machining Centre', + kind: 'cnc', + baseCycleTime: 4.4, + power: 22, + signals: [ + { key: 'vibration', label: 'Bearing Vibration', unit: 'mm/s RMS', min: 0, max: 6, warnHigh: 3.5, alarmHigh: 4.5, precision: 2, chart: true, primary: true }, + { key: 'spindleLoad', label: 'Spindle Load', unit: '%', min: 0, max: 100, warnHigh: 85, alarmHigh: 95, precision: 1, chart: true }, + { key: 'spindleRpm', label: 'Spindle Speed', unit: 'rpm', min: 0, max: 10000, precision: 0, chart: true, volatile: true }, + { key: 'coolantTemp', label: 'Coolant Temp', unit: '°C', min: 15, max: 80, warnHigh: 52, alarmHigh: 62, precision: 1, chart: true }, + { key: 'toolWear', label: 'Tool Wear', unit: '%', min: 0, max: 100, warnHigh: 75, alarmHigh: 92, precision: 1, chart: true, cumulative: true }, + ], + }, + { + id: 'OVN-03', + name: 'Curing Oven', + kind: 'oven', + baseCycleTime: 4.2, + power: 85, + signals: [ + // Range runs to 450 because a saturated burner genuinely overheats the + // outer zones when the control zone cannot reach setpoint. + { key: 'zone2Temp', label: 'Zone 2 Temp', unit: '°C', min: 0, max: 450, warnHigh: 330, alarmHigh: 350, precision: 1, chart: true, primary: true }, + { key: 'zone1Temp', label: 'Zone 1 Temp', unit: '°C', min: 0, max: 450, warnHigh: 330, alarmHigh: 350, precision: 1, chart: true }, + { key: 'zone3Temp', label: 'Zone 3 Temp', unit: '°C', min: 0, max: 450, warnHigh: 330, alarmHigh: 350, precision: 1, chart: true }, + { key: 'setpoint', label: 'Setpoint', unit: '°C', min: 200, max: 360, precision: 0, chart: false, volatile: true }, + { key: 'burnerDuty', label: 'Burner Duty', unit: '%', min: 0, max: 100, warnHigh: 92, precision: 1, chart: true }, + { key: 'tempDeviation', label: 'Temp Deviation', unit: '°C', min: -40, max: 40, warnLow: -8, alarmLow: -18, warnHigh: 8, alarmHigh: 18, precision: 1, chart: true }, + ], + }, + { + id: 'INS-04', + name: 'Vision Inspection', + kind: 'inspection', + baseCycleTime: 3.6, + power: 1.2, + signals: [ + { key: 'rejectRate', label: 'Reject Rate', unit: '%', min: 0, max: 20, warnHigh: 4, alarmHigh: 8, precision: 2, chart: true, primary: true }, + { key: 'cameraConfidence', label: 'Camera Confidence', unit: '%', min: 60, max: 100, warnLow: 90, alarmLow: 80, precision: 1, chart: true }, + { key: 'partsInspected', label: 'Parts Inspected', unit: 'pcs', min: 0, max: 100000, precision: 0, chart: false, cumulative: true }, + ], + }, + { + id: 'PKG-05', + name: 'Packer', + kind: 'packer', + baseCycleTime: 4.1, + power: 4.5, + signals: [ + { key: 'unitsPerMin', label: 'Output Rate', unit: 'u/min', min: 0, max: 20, warnLow: 8, alarmLow: 4, precision: 1, chart: true, primary: true, volatile: true }, + { key: 'filmTension', label: 'Film Tension', unit: 'N', min: 0, max: 80, warnHigh: 58, alarmHigh: 68, warnLow: 26, alarmLow: 16, precision: 1, chart: true }, + { key: 'downtime', label: 'Downtime', unit: 's', min: 0, max: 100000, precision: 0, chart: false, cumulative: true }, + ], + }, +]; + +/** Look up a signal spec, for thresholds and formatting. */ +export function signalSpec(stationId, key) { + const s = STATION_SPECS.find((x) => x.id === stationId); + return s ? s.signals.find((g) => g.key === key) : undefined; +} + +export function createStation(spec) { + const st = { + id: spec.id, + name: spec.name, + kind: spec.kind, + baseCycleTime: spec.baseCycleTime, + power: spec.power, + state: STATE.IDLE, + progress: 0, + completed: 0, + online: true, + signals: {}, + _integral: 0, + _downSec: 0, + /** Simulated time at which a micro-stop or unplanned stop ends. */ + _stopUntil: -1, + _stopKind: null, + }; + for (const g of spec.signals) st.signals[g.key] = 0; + return st; +} + +/** Nominal starting values, so the line does not have to warm up on camera. */ +export function seedStation(st) { + switch (st.kind) { + case 'conveyor': + st.signals.beltSpeed = 12; + st.signals.motorAmps = 8.2; + break; + case 'cnc': + st.signals.vibration = 1.62; + st.signals.spindleLoad = 62; + st.signals.spindleRpm = 8400; + st.signals.coolantTemp = 34; + st.signals.toolWear = 18; + break; + case 'oven': + st.signals.setpoint = 305; + st.signals.zone1Temp = 303; + st.signals.zone2Temp = 305; + st.signals.zone3Temp = 301; + st.signals.burnerDuty = 68; + st.signals.tempDeviation = 0; + break; + case 'inspection': + st.signals.rejectRate = 1.8; + st.signals.cameraConfidence = 98.4; + break; + case 'packer': + st.signals.unitsPerMin = 13.6; + st.signals.filmTension = 42; + break; + } +} + +/** + * Advance one station's continuous signals by dt simulated seconds. + * + * ctx carries the cross-station coupling: line speed factor, active fault + * modifiers, the rng, buffer levels, and read access to sibling stations - the + * oven deviation feeds the inspection reject rate, for example. + */ +export function updateSignals(st, ctx, dt) { + const { rng, speedFactor, faults } = ctx; + const running = st.state === STATE.RUNNING; + const s = st.signals; + + switch (st.kind) { + case 'conveyor': { + const jam = st.state === STATE.FAULT; + const target = jam ? 0 : running ? 12 * speedFactor : 0; + s.beltSpeed = clamp(lag(s.beltSpeed, target, 2.5, dt) + noise(rng, 0.05), 0, 20); + const loadAmps = 6.4 + s.beltSpeed * 0.16 + ctx.buffers[0] * 0.09; + s.motorAmps = clamp(lag(s.motorAmps, jam ? 19.5 : loadAmps, 3, dt) + noise(rng, 0.12), 0, 24); + s.infeedQueue = ctx.buffers[0]; + break; + } + + case 'cnc': { + const rpmTarget = running ? 8400 * speedFactor : 0; + s.spindleRpm = clamp(lag(s.spindleRpm, rpmTarget, 3.5, dt) + noise(rng, 12), 0, 10000); + + // Bearing degradation adds an exponential ramp on top of the wear-driven + // baseline. This is the headline signal of the demo. + const bearing = faults.bearingVibration || 0; + const vibTarget = 1.55 + s.toolWear * 0.006 + bearing + (running ? 0.06 : -0.55); + s.vibration = clamp(lag(s.vibration, vibTarget, 6, dt) + noise(rng, 0.035), 0, 6); + + // A degrading bearing loads the spindle harder for the same cut. + const loadTarget = running + ? 58 + s.toolWear * 0.18 + bearing * 5.5 + (speedFactor - 1) * 22 + : 4; + s.spindleLoad = clamp(lag(s.spindleLoad, loadTarget, 4, dt) + noise(rng, 0.5), 0, 100); + + const coolTarget = 22 + s.spindleLoad * 0.30 + bearing * 2.2; + s.coolantTemp = clamp(lag(s.coolantTemp, coolTarget, 45, dt) + noise(rng, 0.08), 15, 80); + break; + } + + case 'oven': { + // PID on zone 2, the control zone, driving burner duty. + const err = s.setpoint - s.zone2Temp; + st._integral = clamp(st._integral + err * dt, -900, 900); + const duty = clamp(0.85 * err + 0.02 * st._integral + 62, 0, 100); + s.burnerDuty = lag(s.burnerDuty, duty, 4, dt); + + // A burner fault cuts zone 2 heating capacity. The PID saturates trying to + // compensate, so zone 2 sags while zones 1 and 3 drift slightly hot. + const cap2 = faults.ovenZone2Capacity ?? 1; + const heat = (s.burnerDuty / 100) * 420; + s.zone1Temp = lag(s.zone1Temp, 20 + heat * 0.99, 55, dt) + noise(rng, 0.10); + s.zone2Temp = lag(s.zone2Temp, 20 + heat * cap2, 48, dt) + noise(rng, 0.10); + s.zone3Temp = lag(s.zone3Temp, 20 + heat * 0.97, 60, dt) + noise(rng, 0.10); + s.tempDeviation = s.zone2Temp - s.setpoint; + break; + } + + case 'inspection': { + // Sensor dropout: hold the last value rather than fabricating data. + if (!st.online) break; + + // Reject rate is driven, not random. Worn tooling and an out-of-spec cure + // both push parts out of tolerance. This is the causal chain the copilot + // gets to explain. + const wear = ctx.stationById['CNC-02'].signals.toolWear; + const wearTerm = Math.pow(wear / 100, 2) * 14; + const ovenDev = Math.abs(ctx.stationById['OVN-03'].signals.tempDeviation); + const ovenTerm = ovenDev > 6 ? (ovenDev - 6) * 0.42 : 0; + const vibTerm = Math.max(0, ctx.stationById['CNC-02'].signals.vibration - 2.6) * 1.1; + const target = 1.5 + wearTerm + ovenTerm + vibTerm; + s.rejectRate = clamp(lag(s.rejectRate, target, 20, dt) + noise(rng, 0.04), 0, 20); + s.cameraConfidence = clamp(lag(s.cameraConfidence, 98.5 - ovenTerm * 0.6, 15, dt) + noise(rng, 0.12), 60, 100); + break; + } + + case 'packer': { + if (DOWNTIME_STATES.has(st.state)) { + if (st.state === STATE.FAULT) { + // Film jam: tension spikes as the web binds, then collapses on tear. + s.filmTension = lag(s.filmTension, faults.packerJamPhase === 'tear' ? 4 : 74, 1.5, dt); + } else { + s.filmTension = lag(s.filmTension, 30, 4, dt); + } + s.unitsPerMin = lag(s.unitsPerMin, 0, 2, dt); + st._downSec += dt; + } else { + s.filmTension = clamp(lag(s.filmTension, 42 + (speedFactor - 1) * 9, 6, dt) + noise(rng, 0.35), 0, 80); + // Achieved rate, derived from real completions in line.js. + s.unitsPerMin = clamp(lag(s.unitsPerMin, ctx.achievedRate, 8, dt), 0, 20); + } + s.downtime = st._downSec; + break; + } + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..84a9318 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + LINE-1 Digital Twin + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..549d9cc --- /dev/null +++ b/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "digitaltwin-web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@react-three/drei": "^10.7.6", + "@react-three/fiber": "^9.3.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "recharts": "^3.2.1", + "three": "^0.180.0", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@vitejs/plugin-react": "^5.0.2", + "vite": "^7.1.5" + } +} diff --git a/web/src/App.jsx b/web/src/App.jsx new file mode 100644 index 0000000..f465573 --- /dev/null +++ b/web/src/App.jsx @@ -0,0 +1,71 @@ +/** + * Layout. + * + * The 3D hero sits beside the copilot, so the two things that make this different + * from a SCADA screen are on the same screen line. KPIs, the station strip and the + * detail charts run beneath, with alarms and the what-if controls to the right of + * them - roughly the order you talk through in the demo. + */ + +import { useTelemetry } from './hooks/useTelemetry.js'; +import { useStore } from './store.js'; +import { Scene } from './three/Scene.jsx'; +import { TimeControls } from './panels/TimeControls.jsx'; +import { KpiRow } from './panels/KpiRow.jsx'; +import { StationStrip } from './panels/StationStrip.jsx'; +import { StationDetail } from './panels/StationDetail.jsx'; +import { AlarmFeed } from './panels/AlarmFeed.jsx'; +import { WhatIfPanel } from './panels/WhatIfPanel.jsx'; +import { Copilot } from './panels/Copilot.jsx'; +import { SettingsPage } from './panels/SettingsPage.jsx'; +import { askCopilot, explainAlarmPrompt } from './lib/copilotClient.js'; + +export default function App() { + useTelemetry(); + + const connected = useStore((s) => s.connected); + const connError = useStore((s) => s.connError); + const frame = useStore((s) => s.frame); + const select = useStore((s) => s.select); + const settingsOpen = useStore((s) => s.settingsOpen); + + const explain = (alarm) => { + select(alarm.station); + askCopilot(explainAlarmPrompt(alarm)); + }; + + return ( +
+ + + {settingsOpen && } + + {!connected && connError &&
{connError}
} + + {!frame ? ( +
+ Connecting to the telemetry stream. If this persists, check that the server is + running on port 8787. +
+ ) : ( + <> +
+ + +
+ + + + +
+
+
+ + +
+
+ + )} +
+ ); +} diff --git a/web/src/hooks/useTelemetry.js b/web/src/hooks/useTelemetry.js new file mode 100644 index 0000000..b4e16ab --- /dev/null +++ b/web/src/hooks/useTelemetry.js @@ -0,0 +1,98 @@ +/** + * WebSocket telemetry client. + * + * Reconnects automatically with a bounded backoff. On stage the server is on the + * same laptop, so the realistic failure is "I restarted the server mid-demo" - + * the dashboard has to come back on its own rather than needing a page reload. + */ + +import { useEffect, useRef } from 'react'; +import { useStore } from '../store.js'; + +const RECONNECT_MIN_MS = 500; +const RECONNECT_MAX_MS = 5000; + +export function useTelemetry() { + const timerRef = useRef(null); + const socketRef = useRef(null); + const delayRef = useRef(RECONNECT_MIN_MS); + + useEffect(() => { + let disposed = false; + + // Static metadata (station specs, thresholds, fault catalogue) is fetched + // once instead of riding along on every frame. + const loadMeta = async () => { + try { + const res = await fetch('/api/meta'); + if (res.ok) useStore.getState().setMeta(await res.json()); + } catch { + /* the socket carries the important part; meta will retry on reconnect */ + } + }; + loadMeta(); + + const connect = () => { + if (disposed) return; + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const ws = new WebSocket(`${proto}://${window.location.host}/ws`); + socketRef.current = ws; + + ws.onopen = () => { + delayRef.current = RECONNECT_MIN_MS; + useStore.getState().setConnected(true); + if (!useStore.getState().meta) loadMeta(); + }; + + ws.onmessage = (ev) => { + let msg; + try { msg = JSON.parse(ev.data); } catch { return; } + + if (msg.type === 'history') { + useStore.getState().seedHistory(msg.frames); + return; + } + if (msg.type !== 'frame') return; + + const state = useStore.getState(); + const prev = state.frame; + // A reset rewinds the simulated clock. Client history from the previous + // run would otherwise render as a jump backwards on every chart. + if (prev && msg.frame.t < prev.t - 1) state.clearHistory(); + state.ingest(msg.frame); + }; + + ws.onclose = () => { + useStore.getState().setConnected(false, 'Telemetry stream disconnected. Reconnecting…'); + if (disposed) return; + timerRef.current = setTimeout(connect, delayRef.current); + delayRef.current = Math.min(RECONNECT_MAX_MS, delayRef.current * 2); + }; + + ws.onerror = () => ws.close(); + }; + + connect(); + + return () => { + disposed = true; + if (timerRef.current) clearTimeout(timerRef.current); + if (socketRef.current) { + socketRef.current.onclose = null; + socketRef.current.close(); + } + }; + }, []); +} + +/** POST a control action. Returns the parsed body, or throws with the server message. */ +export async function control(path, body) { + const res = await fetch(`/api/control/${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body || {}), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok || json.ok === false) throw new Error(json.error || `Request failed (${res.status})`); + return json; +} diff --git a/web/src/lib/copilotClient.js b/web/src/lib/copilotClient.js new file mode 100644 index 0000000..4870bdf --- /dev/null +++ b/web/src/lib/copilotClient.js @@ -0,0 +1,109 @@ +/** + * Copilot SSE client. + * + * Kept out of the component so the alarm feed's "Explain this" button and the + * copilot's own input box drive exactly the same code path. + */ + +import { useStore } from '../store.js'; + +let controller = null; +let ticker = null; + +export function abortAsk() { + if (controller) controller.abort(); + controller = null; + if (ticker) clearInterval(ticker); + ticker = null; + useStore.getState().setCopilot({ streaming: false }); +} + +/** + * Ask a question and stream the answer into the message log. + * + * providerOverride: 'auto' | 'openrouter' | 'ollama' | 'fallback' + */ +export async function askCopilot(question, providerOverride) { + const store = useStore.getState(); + const q = String(question || '').trim(); + if (!q || store.copilot.streaming) return; + + const provider = providerOverride || store.copilot.provider; + + // Only user/assistant turns go back as history; notes and metadata do not. + const history = store.copilot.messages + .filter((m) => m.role === 'user' || m.role === 'assistant') + .map((m) => ({ role: m.role, content: m.content })); + + store.pushMessage({ role: 'user', content: q }); + store.pushMessage({ role: 'assistant', content: '', note: null, label: null }); + store.setCopilot({ streaming: true, elapsedMs: 0 }); + + const started = Date.now(); + ticker = setInterval( + () => useStore.getState().setCopilot({ elapsedMs: Date.now() - started }), + 100, + ); + + controller = new AbortController(); + + try { + const res = await fetch('/api/copilot/chat', { + method: 'POST', + signal: controller.signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ question: q, history, provider }), + }); + if (!res.ok || !res.body) throw new Error(`Request failed (${res.status})`); + + const decoder = new TextDecoder(); + let buf = ''; + + for await (const chunk of res.body) { + buf += decoder.decode(chunk, { stream: true }); + let i; + while ((i = buf.indexOf('\n\n')) >= 0) { + const line = buf.slice(0, i).trim(); + buf = buf.slice(i + 2); + if (!line.startsWith('data:')) continue; + + let obj; + try { obj = JSON.parse(line.slice(5).trim()); } catch { continue; } + const s = useStore.getState(); + + if (obj.type === 'meta') { + s.patchLast({ label: obj.label }); + s.setCopilot({ activeLabel: obj.label }); + } else if (obj.type === 'token') { + s.appendToLast(obj.text); + } else if (obj.type === 'note') { + s.patchLast({ note: obj.text }); + } + } + } + } catch (err) { + if (err.name !== 'AbortError') { + useStore.getState().patchLast({ + note: `Could not reach the copilot: ${err.message}`, + }); + } + } finally { + if (ticker) clearInterval(ticker); + ticker = null; + controller = null; + const s = useStore.getState(); + s.setCopilot({ streaming: false, elapsedMs: Date.now() - started }); + // An empty bubble looks broken; say what happened instead. + const msgs = s.copilot.messages; + const last = msgs[msgs.length - 1]; + if (last && last.role === 'assistant' && !last.content.trim()) { + s.patchLast({ content: '(No answer was returned. Try the offline rule engine from the provider selector.)' }); + } + } +} + +/** Turn an alarm into a question the copilot can answer well. */ +export function explainAlarmPrompt(alarm) { + const where = alarm.signal ? `${alarm.station}.${alarm.signal}` : alarm.station; + return `Explain this alarm on ${where}: "${alarm.message}" — what is the likely root cause, what is the production impact, and what should the operator do?`; +} diff --git a/web/src/lib/format.js b/web/src/lib/format.js new file mode 100644 index 0000000..3834fde --- /dev/null +++ b/web/src/lib/format.js @@ -0,0 +1,94 @@ +/** + * Formatting and status helpers shared by every panel and the 3D scene, so a + * station reads the same colour and the same words everywhere it appears. + */ + +export function num(v, precision = 1) { + if (!Number.isFinite(v)) return '—'; + return v.toFixed(precision); +} + +export function pct(v, precision = 1) { + if (!Number.isFinite(v)) return '—'; + return `${(v * 100).toFixed(precision)}%`; +} + +/** Simulated seconds as a human duration. Mirrors the server-side formatter. */ +export function duration(sec) { + if (!Number.isFinite(sec)) return '—'; + if (sec < 90) return `${Math.round(sec)}s`; + if (sec < 5400) return `${(sec / 60).toFixed(1)} min`; + if (sec < 172800) return `${(sec / 3600).toFixed(1)} h`; + return `${(sec / 86400).toFixed(1)} d`; +} + +/** Wall-style clock for the simulated run time. */ +export function clock(sec) { + const s = Math.max(0, Math.floor(sec)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const ss = s % 60; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(ss).padStart(2, '0')}`; +} + +/** Where a reading sits against its declared thresholds. */ +export function signalStatus(spec, v) { + if (!spec || !Number.isFinite(v)) return 'ok'; + if (spec.alarmHigh !== undefined && v >= spec.alarmHigh) return 'alarm'; + if (spec.alarmLow !== undefined && v <= spec.alarmLow) return 'alarm'; + if (spec.warnHigh !== undefined && v >= spec.warnHigh) return 'warn'; + if (spec.warnLow !== undefined && v <= spec.warnLow) return 'warn'; + return 'ok'; +} + +export const STATUS_COLOR = { + ok: 'var(--text-primary)', + warn: 'var(--warning)', + alarm: 'var(--critical)', +}; + +/** + * Station state presentation. + * + * Every state carries a word as well as a colour - a status colour never conveys + * meaning on its own. + */ +export const STATE_META = { + running: { label: 'Running', color: '#0ca30c' }, + microstop: { label: 'Micro-stop', color: '#fab219' }, + blocked: { label: 'Blocked', color: '#ec835a' }, + starved: { label: 'Starved', color: '#898781' }, + down: { label: 'Down', color: '#d03b3b' }, + fault: { label: 'Fault', color: '#d03b3b' }, + idle: { label: 'Idle', color: '#898781' }, +}; + +export function stateMeta(state) { + return STATE_META[state] || { label: state || 'Unknown', color: '#898781' }; +} + +/** The signal a station card leads with. */ +export function primarySignal(spec) { + if (!spec) return null; + return spec.signals.find((g) => g.primary) || spec.signals[0]; +} + +/** + * Render a very small subset of markdown inline: **bold** only. + * + * Local models reliably emit bold for emphasis and little else. Rendering just + * that keeps the copilot output readable without pulling in a markdown parser. + */ +export function renderBold(text) { + const parts = []; + const re = /\*\*(.+?)\*\*/g; + let last = 0; + let m; + while ((m = re.exec(text)) !== null) { + if (m.index > last) parts.push(text.slice(last, m.index)); + parts.push({ bold: m[1] }); + last = m.index + m[0].length; + } + if (last < text.length) parts.push(text.slice(last)); + return parts; +} diff --git a/web/src/main.jsx b/web/src/main.jsx new file mode 100644 index 0000000..327d526 --- /dev/null +++ b/web/src/main.jsx @@ -0,0 +1,5 @@ +import { createRoot } from 'react-dom/client'; +import App from './App.jsx'; +import './styles.css'; + +createRoot(document.getElementById('root')).render(); diff --git a/web/src/panels/AlarmFeed.jsx b/web/src/panels/AlarmFeed.jsx new file mode 100644 index 0000000..c24c840 --- /dev/null +++ b/web/src/panels/AlarmFeed.jsx @@ -0,0 +1,70 @@ +/** + * Active alarms, most severe first. + * + * Every alarm carries its severity as a WORD as well as a colour, and "Explain" + * hands the alarm to the copilot - which is the moment the demo stops looking + * like a dashboard and starts looking like a twin that understands itself. + */ + +import { useStore } from '../store.js'; +import { duration } from '../lib/format.js'; + +const SEVERITY_ORDER = ['critical', 'major', 'warning', 'predictive', 'info']; + +export function AlarmFeed({ onExplain }) { + const frame = useStore((s) => s.frame); + const select = useStore((s) => s.select); + if (!frame) return null; + + const alarms = frame.analytics.alarms; + const counts = SEVERITY_ORDER.map((sev) => ({ + sev, + n: alarms.filter((a) => a.severity === sev).length, + })).filter((x) => x.n > 0); + + return ( +
+

+ Alarms + + {counts.length + ? counts.map((c) => `${c.n} ${c.sev}`).join(' · ') + : 'all clear'} + +

+ + {alarms.length === 0 ? ( +
+ No active alarms. All signals are within limits and no threshold crossings are projected. +
+ ) : ( +
+ {alarms.map((a) => ( +
+
+ + {a.severity === 'predictive' ? 'Trend' : a.severity} + + + + {duration(frame.t - a.raisedAt)} ago + +
+
{a.message}
+
+ +
+
+ ))} +
+ )} +
+ ); +} diff --git a/web/src/panels/Copilot.jsx b/web/src/panels/Copilot.jsx new file mode 100644 index 0000000..9872b47 --- /dev/null +++ b/web/src/panels/Copilot.jsx @@ -0,0 +1,136 @@ +/** + * The plant copilot panel. + * + * Two deliberate choices here. + * + * The provider selector is visible rather than buried in config. A strong + * reasoning model gives markedly better analysis but can take 15-20 s to first + * token, which is a long silence in front of a customer; the local model answers + * in a couple of seconds; the rule engine answers instantly and offline. Being + * able to switch mid-demo, and to SHOW that the reasoning is reproducible without + * any model at all, is worth the panel space. + * + * The suggestion chips exist so nobody has to type on stage. + */ + +import { useEffect, useRef, useState } from 'react'; +import { useStore } from '../store.js'; +import { askCopilot, abortAsk } from '../lib/copilotClient.js'; +import { renderBold } from '../lib/format.js'; + +const CHIPS = [ + 'Why is OEE down?', + "What's wrong with CNC-02?", + 'What happens if I raise line speed 10%?', + 'Draft a maintenance work order.', + 'Which station is the constraint right now?', +]; + +const PROVIDERS = [ + { id: 'auto', label: 'Auto' }, + { id: 'openrouter', label: 'Cloud' }, + { id: 'ollama', label: 'Local' }, + { id: 'fallback', label: 'Offline rules' }, +]; + +function Message({ msg }) { + if (msg.role === 'user') return
{msg.content}
; + return ( +
+
{msg.label || 'Copilot'}
+ {msg.note &&
{msg.note}
} + {renderBold(msg.content).map((part, i) => + typeof part === 'string' + ? {part} + : {part.bold}, + )} +
+ ); +} + +export function Copilot() { + const messages = useStore((s) => s.copilot.messages); + const streaming = useStore((s) => s.copilot.streaming); + const elapsedMs = useStore((s) => s.copilot.elapsedMs); + const provider = useStore((s) => s.copilot.provider); + const setCopilot = useStore((s) => s.setCopilot); + const configured = useStore((s) => (s.meta ? s.meta.copilot : null)); + + const [text, setText] = useState(''); + const logRef = useRef(null); + + useEffect(() => { + if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; + }, [messages]); + + const submit = (e) => { + e.preventDefault(); + const q = text.trim(); + if (!q) return; + setText(''); + askCopilot(q); + }; + + return ( +
+

+ Plant copilot + {configured ? configured.label : 'detecting…'} +

+ +
+ {PROVIDERS.map((p) => ( + + ))} +
+ +
+ {messages.length === 0 && ( +
+ Ask about the line and I will answer from live telemetry — station states, + readings against their limits, OEE attribution and measured trends. +

+ I am not told which fault was injected; the diagnosis comes from the data. +
+ )} + {messages.map((m, i) => )} + {streaming && ( +
+ + Thinking… {(elapsedMs / 1000).toFixed(1)}s + +
+ )} +
+ +
+ {CHIPS.map((c) => ( + + ))} +
+ +
+ setText(e.target.value)} + placeholder="Ask about the line…" + disabled={streaming} + /> + +
+
+ ); +} diff --git a/web/src/panels/KpiRow.jsx b/web/src/panels/KpiRow.jsx new file mode 100644 index 0000000..6bc85f7 --- /dev/null +++ b/web/src/panels/KpiRow.jsx @@ -0,0 +1,79 @@ +/** + * Line KPI tiles. + * + * OEE gets a hero figure; its three factors sit beside it as their own tiles with + * magnitude meters. Deliberately tiles rather than a chart: these are five + * single headline numbers, and a bar chart of five unrelated measures would be + * harder to read, not easier. + */ + +import { useStore } from '../store.js'; + +function Meter({ value, color }) { + return ( +
+ +
+ ); +} + +/** Colour the OEE factors by how bad the loss is, always beside a number. */ +function factorColor(v) { + if (v >= 0.95) return 'var(--good)'; + if (v >= 0.85) return 'var(--series-1)'; + if (v >= 0.70) return 'var(--warning)'; + return 'var(--critical)'; +} + +export function KpiRow() { + const kpi = useStore((s) => (s.frame ? s.frame.kpi : null)); + const windowSec = useStore((s) => (s.meta ? s.meta.kpiWindowSec : 1200)); + + if (!kpi) { + return
Waiting for telemetry…
; + } + + const factors = [ + { label: 'Availability', v: kpi.availability, foot: 'Stoppage losses' }, + { label: 'Performance', v: kpi.performance, foot: 'Micro-stops, slow cycles' }, + { label: 'Quality', v: kpi.quality, foot: 'Rejects at INS-04' }, + ]; + + return ( +
+
+ OEE · rolling {Math.round(windowSec / 60)} min + + {(kpi.oee * 100).toFixed(1)}% + + + Availability × Performance × Quality +
+ + {factors.map((f) => ( +
+ {f.label} + {(f.v * 100).toFixed(1)}% + + {f.foot} +
+ ))} + +
+ Output + {kpi.throughputPerHour.toFixed(0)}/h + + {kpi.good.toLocaleString()} good · {kpi.rejected.toLocaleString()} rejected + +
+ +
+ Energy + {kpi.energyKw.toFixed(0)}kW + + {kpi.energyPerUnit > 0 ? `${kpi.energyPerUnit.toFixed(3)} kWh/unit` : '—'} + +
+
+ ); +} diff --git a/web/src/panels/SettingsPage.jsx b/web/src/panels/SettingsPage.jsx new file mode 100644 index 0000000..b4c0533 --- /dev/null +++ b/web/src/panels/SettingsPage.jsx @@ -0,0 +1,432 @@ +/** + * Copilot settings. + * + * Rendered as a full-screen overlay rather than a route: the dashboard keeps + * streaming behind it, so changing a model never interrupts the demo, and there is + * no router dependency for a single page. + * + * The Test button is the point of this page as much as the model list is. A model + * that answers brilliantly in 30 seconds is unusable in front of a customer, and + * that is not something you want to discover mid-demo. Measure first. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useStore } from '../store.js'; + +const PROVIDERS = [ + { id: 'auto', title: 'Auto', desc: 'Cloud if a key is set, else local, else offline rules.' }, + { id: 'openrouter', title: 'Cloud', desc: 'OpenRouter. Best analysis, slowest to first token.' }, + { id: 'ollama', title: 'Local', desc: 'Ollama on this machine. Fast, needs it running.' }, + { id: 'fallback', title: 'Offline rules', desc: 'Deterministic rule engine. Instant, no network.' }, +]; + +const EFFORTS = ['none', 'low', 'medium', 'high']; + +/** Latency bands for the test result, in ms to first token. */ +function latencyClass(ms) { + if (ms === null || ms === undefined) return 'bad'; + if (ms < 4000) return 'good'; + if (ms < 12000) return 'slow'; + return 'bad'; +} + +function latencyVerdict(ms) { + if (ms < 4000) return 'Fast enough to present live.'; + if (ms < 12000) return 'Usable, but there will be a noticeable pause on stage.'; + return 'Too slow for a live beat — use this for follow-up questions only.'; +} + +function bytes(n) { + if (!n) return null; + return `${(n / 1e9).toFixed(1)} GB`; +} + +function ModelPanel({ + title, provider, models, error, loading, selected, active, + search, onSearch, onPick, onRefresh, onTest, testing, result, children, +}) { + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return models; + return models.filter((m) => m.id.toLowerCase().includes(q) || (m.name || '').toLowerCase().includes(q)); + }, [models, search]); + + const shown = filtered.slice(0, 80); + + return ( +
+
+ {title} + {active && Active} + + + +
+ + {children} + + {error ? ( +
{error}
+ ) : ( + <> + onSearch(e.target.value)} + /> +
+ {shown.length === 0 &&
No models match that filter.
} + {shown.map((m) => ( + + ))} +
+ {filtered.length > shown.length && ( + + Showing {shown.length} of {filtered.length} matches — narrow the filter to see more. + + )} + + )} + + {result && ( +
+ {result.ok ? ( + <> + {result.firstTokenMs} ms to first token + {result.totalMs !== result.firstTokenMs && <> · {result.totalMs} ms total} +
{latencyVerdict(result.firstTokenMs)} + {provider === 'ollama' && ( + <>
+ A first test after switching models includes loading it into memory. Run it twice. + + )} + + ) : ( + <>Test failed: {result.error} + )} +
+ )} +
+ ); +} + +export function SettingsPage() { + const close = useStore((s) => s.closeSettings); + const setMeta = useStore((s) => s.setMeta); + const setCopilot = useStore((s) => s.setCopilot); + + const [settings, setSettings] = useState(null); + const [status, setStatus] = useState(null); + const [errors, setErrors] = useState([]); + const [flash, setFlash] = useState(null); + + const [orModels, setOrModels] = useState({ models: [], error: null, loading: true }); + const [olModels, setOlModels] = useState({ models: [], error: null, loading: true }); + const [orSearch, setOrSearch] = useState(''); + const [olSearch, setOlSearch] = useState(''); + const [testing, setTesting] = useState(null); + const [results, setResults] = useState({}); + + // --- load --- + useEffect(() => { + fetch('/api/settings') + .then((r) => r.json()) + .then((j) => { setSettings(j.settings); setStatus(j.status); }) + .catch((e) => setErrors([`Could not load settings: ${e.message}`])); + }, []); + + const loadModels = useCallback((provider, force = false) => { + const set = provider === 'openrouter' ? setOrModels : setOlModels; + set((s) => ({ ...s, loading: true })); + fetch(`/api/copilot/models?provider=${provider}${force ? '&force=1' : ''}`) + .then((r) => r.json()) + .then((j) => set({ models: j.models || [], error: j.error || null, loading: false })) + .catch((e) => set({ models: [], error: e.message, loading: false })); + }, []); + + useEffect(() => { + loadModels('openrouter'); + loadModels('ollama'); + }, [loadModels]); + + // Escape closes, as an overlay should. + useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') close(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [close]); + + const save = async (patch) => { + setErrors([]); + try { + const res = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + const j = await res.json(); + if (!j.ok) { setErrors(j.errors || ['Rejected.']); return; } + setSettings(j.settings); + setStatus(j.status); + setFlash('Saved'); + setTimeout(() => setFlash(null), 1600); + + // Refresh the badge shown in the header and the copilot panel. + const meta = await fetch('/api/meta').then((r) => r.json()); + setMeta(meta); + // A provider chosen here becomes the default for new questions. + if (patch.provider) setCopilot({ provider: patch.provider }); + // The host may have moved, so the local model list could be stale. + if (patch.ollamaHost) loadModels('ollama', true); + } catch (e) { + setErrors([e.message]); + } + }; + + const resetAll = async () => { + const j = await fetch('/api/settings/reset', { method: 'POST' }).then((r) => r.json()); + setSettings(j.settings); + setStatus(j.status); + setFlash('Reset to environment defaults'); + setTimeout(() => setFlash(null), 2200); + const meta = await fetch('/api/meta').then((r) => r.json()); + setMeta(meta); + }; + + const runTest = async (provider, model) => { + setTesting(provider); + try { + const j = await fetch('/api/copilot/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider, model }), + }).then((r) => r.json()); + setResults((r) => ({ ...r, [provider]: j })); + } catch (e) { + setResults((r) => ({ ...r, [provider]: { ok: false, error: e.message } })); + } finally { + setTesting(null); + } + }; + + if (!settings) { + return ( +
+
e.stopPropagation()}> +
Loading settings…
+
+
+ ); + } + + const effective = status ? status.provider : 'fallback'; + + return ( +
+
e.stopPropagation()}> +
+

Copilot settings

+ {status && ( + {status.label} + )} + + {flash && {flash}} + +
+ +
+ {errors.length > 0 && ( +
{errors.join(' · ')}
+ )} + +
+

Provider

+

+ Which engine answers by default. You can still override per question from the + copilot panel. {status && <>Currently resolving to {effective} — {status.detail}.} +

+
+ {PROVIDERS.map((p) => ( + + ))} +
+ {!settings.openRouterKeyPresent && ( + + No OPENROUTER_API_KEY is set, so Cloud is unavailable and the model list cannot be + fetched. Add the key to .env and restart the server. + + )} +
+ +
+

Model

+

+ Pick a model per provider, or type an id directly. Test measures time to first + token — the number that decides whether a model is presentable live. +

+
+ save({ openRouterModel: id })} + onRefresh={() => loadModels('openrouter', true)} + onTest={() => runTest('openrouter', settings.openRouterModel)} + testing={testing === 'openrouter'} + result={results.openrouter} + > + setSettings((s) => ({ ...s, openRouterModel: e.target.value }))} + onBlur={(e) => e.target.value && save({ openRouterModel: e.target.value.trim() })} + spellCheck={false} + /> + + + save({ ollamaModel: id })} + onRefresh={() => loadModels('ollama', true)} + onTest={() => runTest('ollama', settings.ollamaModel)} + testing={testing === 'ollama'} + result={results.ollama} + > + setSettings((s) => ({ ...s, ollamaModel: e.target.value }))} + onBlur={(e) => e.target.value && save({ ollamaModel: e.target.value.trim() })} + spellCheck={false} + /> + +
+
+ +
+

Generation

+

+ Reasoning tokens count against the output budget on most providers. A reasoning + model on a tight budget spends it all thinking and returns nothing, so keep effort + low and the budget generous unless you have a reason not to. +

+
+
+
+ Temperature + {settings.temperature.toFixed(2)} +
+ setSettings((s) => ({ ...s, temperature: Number(e.target.value) }))} + onMouseUp={(e) => save({ temperature: Number(e.target.value) })} + onKeyUp={(e) => save({ temperature: Number(e.target.value) })} + /> + Low keeps diagnoses consistent between runs. +
+ +
+
Max output tokens
+ setSettings((s) => ({ ...s, maxTokens: Number(e.target.value) }))} + onBlur={(e) => save({ maxTokens: Number(e.target.value) })} + /> + Includes reasoning tokens on most providers. +
+ +
+
Reasoning effort
+
+ {EFFORTS.map((e) => ( + + ))} +
+ Local models map none/low to reasoning off. +
+
+
+ +
+

Ollama host

+

+ Accepts a bare host, host:port, or full URL. Ollama often sets OLLAMA_HOST to + 0.0.0.0 machine-wide, which is a bind address and not dialable — that is normalized + automatically. +

+ setSettings((s) => ({ ...s, ollamaHost: e.target.value }))} + onBlur={(e) => e.target.value && save({ ollamaHost: e.target.value.trim() })} + spellCheck={false} + /> + Dialing: {settings.resolvedOllamaHost} +
+
+ +
+ + {results.fallback && ( + + {results.fallback.firstTokenMs} ms · always available + + )} + + + +
+
+
+ ); +} diff --git a/web/src/panels/StationDetail.jsx b/web/src/panels/StationDetail.jsx new file mode 100644 index 0000000..a4a1f82 --- /dev/null +++ b/web/src/panels/StationDetail.jsx @@ -0,0 +1,112 @@ +/** + * Trend charts for the selected station. + * + * Most signals get their own single-series chart. The oven is the exception: its + * three zone temperatures belong on one axis because comparing them to each other + * (and to setpoint) is the whole diagnosis, and they share a unit. Series colours + * there are the first three validated categorical slots. + */ + +import { useStore } from '../store.js'; +import { TrendChart } from './TrendChart.jsx'; +import { duration } from '../lib/format.js'; + +/** + * Per-station chart grouping. Anything not named here becomes its own chart. + */ +const GROUPS = { + 'OVN-03': [ + { title: 'Zone temperatures', keys: ['zone1Temp', 'zone2Temp', 'zone3Temp'] }, + { keys: ['tempDeviation'] }, + { keys: ['burnerDuty'] }, + ], +}; + +/** Turn per-signal history buffers into rows Recharts can consume. */ +function buildRows(history, stationId, keys) { + const buffers = keys.map((k) => history[`${stationId}.${k}`] || []); + const base = buffers.reduce((a, b) => (b.length > a.length ? b : a), []); + if (!base.length) return []; + + return base.map((point, i) => { + const row = { t: point.t }; + keys.forEach((k, ki) => { + const buf = buffers[ki]; + // Buffers are appended in lockstep, so align from the end to stay correct + // if one signal started later than another. + const idx = buf.length - (base.length - i); + row[k] = idx >= 0 && idx < buf.length ? buf[idx].v : null; + }); + return row; + }); +} + +export function StationDetail() { + const selected = useStore((s) => s.selected); + const meta = useStore((s) => s.meta); + const history = useStore((s) => s.history); + const station = useStore((s) => (s.frame ? s.frame.stations.find((x) => x.id === selected) : null)); + const predictions = useStore((s) => (s.frame ? s.frame.analytics.predictions : [])); + + if (!meta || !station) return null; + const spec = meta.stations.find((x) => x.id === selected); + if (!spec) return null; + + const charted = spec.signals.filter((g) => g.chart); + const groups = GROUPS[selected] || charted.map((g) => ({ keys: [g.key] })); + const myPredictions = predictions.filter((p) => p.station === selected); + + return ( +
+

+ {station.id} · {station.name} + + {station.online ? `${charted.length} live signals` : 'Station is not reporting — traces paused'} + +

+ + {!station.online && ( +
+ No data from {station.id}. The readings shown elsewhere are the last known values, and + the charts stop rather than drawing a flat line that would look like a healthy reading. +
+ )} + + {myPredictions.length > 0 && ( +
+ {myPredictions.map((p) => ( +
+
+ Trend + {p.station}.{p.signal} +
+
+ {p.label} is rising {p.slopePerMin.toFixed(4)} {p.unit}/min. At this rate it reaches + the {p.threshold} {p.unit} limit in about {p.eta} of run time. + Least-squares fit over {duration(p.windowSec)}, r² {p.r2.toFixed(2)}. +
+
+ ))} +
+ )} + +
+ {groups.map((group) => { + const series = group.keys + .map((k) => spec.signals.find((g) => g.key === k)) + .filter(Boolean); + if (!series.length) return null; + const rows = buildRows(history, selected, group.keys); + return ( + + ); + })} +
+
+ ); +} diff --git a/web/src/panels/StationStrip.jsx b/web/src/panels/StationStrip.jsx new file mode 100644 index 0000000..e089287 --- /dev/null +++ b/web/src/panels/StationStrip.jsx @@ -0,0 +1,100 @@ +/** + * The five stations in process order, with WIP buffer levels between them. + * + * Clicking a card selects that station everywhere - here, in the detail charts, + * and in the 3D scene. The buffer pills between cards are what make blocking and + * starvation legible: when the packer stops, you watch these fill from the right. + */ + +import { useStore } from '../store.js'; +import { primarySignal, num, stateMeta, signalStatus, STATUS_COLOR } from '../lib/format.js'; + +function BufferPill({ level, capacity }) { + const frac = capacity > 0 ? level / capacity : 0; + const full = level >= capacity; + return ( +
+
+
+
+ + {level} + +
+ ); +} + +export function StationStrip() { + const frame = useStore((s) => s.frame); + const meta = useStore((s) => s.meta); + const selected = useStore((s) => s.selected); + const select = useStore((s) => s.select); + + if (!frame || !meta) return null; + + return ( +
+

+ Line 1 · stations + WIP between stations shown as vertical bars +

+
+ {frame.stations.map((st, i) => { + const spec = meta.stations.find((x) => x.id === st.id); + const g = primarySignal(spec); + const value = g ? st.signals[g.key] : null; + const sm = stateMeta(st.state); + const status = signalStatus(g, value); + + return ( +
+ + + {i < frame.buffers.length && ( + + )} +
+ ); + })} +
+
+ ); +} diff --git a/web/src/panels/TimeControls.jsx b/web/src/panels/TimeControls.jsx new file mode 100644 index 0000000..d37b502 --- /dev/null +++ b/web/src/panels/TimeControls.jsx @@ -0,0 +1,97 @@ +/** + * Header: identity, the simulated clock, and the clock multiplier. + * + * The speed control is not a convenience. A bearing trend that reads convincingly + * over twenty simulated minutes cannot be watched in real time in a meeting, and + * 60x turns that into twenty seconds. The multiplier is always shown next to the + * clock so nobody is misled about how fast time is passing. + */ + +import { useState } from 'react'; +import { useStore } from '../store.js'; +import { control } from '../hooks/useTelemetry.js'; +import { clock } from '../lib/format.js'; + +export function TimeControls() { + const frame = useStore((s) => s.frame); + const meta = useStore((s) => s.meta); + const connected = useStore((s) => s.connected); + const clearHistory = useStore((s) => s.clearHistory); + const openSettings = useStore((s) => s.openSettings); + const copilotLabel = useStore((s) => (s.meta ? s.meta.copilot.label : null)); + const [busy, setBusy] = useState(false); + + const speeds = meta ? meta.speeds : [0, 1, 5, 20, 60]; + const current = frame ? frame.sim.speed : 1; + const canTime = meta ? meta.source.capabilities.timeControl : false; + + const send = async (path, body, after) => { + setBusy(true); + try { + await control(path, body); + if (after) after(); + } catch { + /* the control panel surfaces errors; the header stays quiet */ + } finally { + setBusy(false); + } + }; + + return ( +
+
+

LINE-1 Digital Twin

+ Assembly & Cure · 5 stations +
+ + + + {connected ? 'Telemetry live' : 'Disconnected'} + + + {meta && ( + + + Source: {meta.source.kind} + + )} + +
+ + + {frame ? clock(frame.t) : '00:00:00'} + sim + + +
+ {speeds.map((s) => ( + + ))} +
+ + + + +
+ ); +} diff --git a/web/src/panels/TrendChart.jsx b/web/src/panels/TrendChart.jsx new file mode 100644 index 0000000..78257ea --- /dev/null +++ b/web/src/panels/TrendChart.jsx @@ -0,0 +1,189 @@ +/** + * Trend chart for one or more signals on a station. + * + * Mark specs follow the data-viz method: 2px lines, no per-point dots, a + * recessive horizontal-only grid, muted axes, and a crosshair tooltip. Threshold + * lines are drawn in reserved status colours and always carry a text label, so a + * limit is never indicated by colour alone. + * + * The y-domain auto-fits the data rather than using the signal's full physical + * range: a bearing drifting from 1.6 to 2.2 mm/s is invisible on a fixed 0-6 + * axis, and that drift is the entire point. A threshold is folded into the domain + * once the data gets within reach of it, so the line you are climbing toward + * appears exactly when it becomes relevant. + */ + +import { memo } from 'react'; +import { + LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine, ResponsiveContainer, +} from 'recharts'; +import { duration, num } from '../lib/format.js'; + +const SERIES_COLORS = ['var(--series-1)', 'var(--series-2)', 'var(--series-3)']; + +function CustomTooltip({ active, payload, label, series }) { + if (!active || !payload || !payload.length) return null; + return ( +
+
t = {duration(label)}
+ {payload.map((p) => { + const s = series.find((x) => x.key === p.dataKey); + return ( +
+ + {s ? s.label : p.dataKey} + {num(p.value, s ? s.precision : 2)} + {s ? s.unit : ''} +
+ ); + })} +
+ ); +} + +/** + * series: [{ key, label, unit, precision, warnHigh, alarmHigh, warnLow, alarmLow }] + * data: [{ t, : value, ... }] + */ +function TrendChartBase({ title, series, data, height = 150 }) { + if (!data || data.length < 2) { + return ( +
+ {title &&
{title}
} +
Collecting data…
+
+ ); + } + + // --- domain --- + let lo = Infinity; + let hi = -Infinity; + for (const row of data) { + for (const s of series) { + const v = row[s.key]; + if (Number.isFinite(v)) { if (v < lo) lo = v; if (v > hi) hi = v; } + } + } + if (!Number.isFinite(lo)) { lo = 0; hi = 1; } + + // Pull a threshold into view only once the data approaches it. + const marks = []; + for (const s of series) { + const span = Math.max(1e-6, hi - lo); + const consider = (value, kind) => { + if (value === undefined) return; + const near = value > hi ? value - hi < span * 2.5 : lo - value < span * 2.5; + if (!near) return; + marks.push({ value, kind, label: s.unit ? `${kind === 'alarm' ? 'Alarm' : 'Warn'} ${value}` : String(value) }); + if (value > hi) hi = value; + if (value < lo) lo = value; + }; + consider(s.alarmHigh, 'alarm'); + consider(s.warnHigh, 'warn'); + consider(s.alarmLow, 'alarm'); + consider(s.warnLow, 'warn'); + } + + // Clamp to the signal's declared physical range. Padding and threshold + // inclusion can otherwise push the axis outside what the quantity can be - a + // spindle load axis running to -8% and 106%, or negative rpm. An impossible + // number on an axis is the kind of detail a plant engineer spots immediately. + const specMin = Math.min(...series.map((s) => (s.min ?? -Infinity))); + const specMax = Math.max(...series.map((s) => (s.max ?? Infinity))); + + const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.002, 1e-4); + const domain = [ + Math.max(specMin, lo - pad), + Math.min(specMax, hi + pad), + ]; + const primary = series[0]; + + return ( +
+ {title && ( +
+ {title} + {series.length === 1 && ( + + {num(data[data.length - 1][primary.key], primary.precision)} + {primary.unit} + + )} +
+ )} + + + + + num(v, primary.precision > 1 ? 1 : primary.precision)} + /> + } + cursor={{ stroke: 'var(--text-muted)', strokeWidth: 1, strokeDasharray: '3 3' }} + isAnimationActive={false} + /> + {marks.map((m, i) => ( + + ))} + {series.map((s, i) => ( + + ))} + + + + {/* A legend is always present for two or more series, and each item carries + the name and its current value, so identity never rests on hue alone. */} + {series.length > 1 && ( +
+ {series.map((s, i) => ( + + + {s.label} + + {num(data[data.length - 1][s.key], s.precision)} + + {s.unit} + + ))} +
+ )} +
+ ); +} + +export const TrendChart = memo(TrendChartBase); diff --git a/web/src/panels/WhatIfPanel.jsx b/web/src/panels/WhatIfPanel.jsx new file mode 100644 index 0000000..7fa3bce --- /dev/null +++ b/web/src/panels/WhatIfPanel.jsx @@ -0,0 +1,132 @@ +/** + * The what-if panel: the controls that make this a twin rather than a dashboard. + * + * Setpoints are applied on release rather than on every slider tick, so dragging + * does not spam the model with intermediate values and pollute the event log. + * + * Controls the telemetry source cannot honour are disabled with a reason. A read + * -only historian or MQTT feed lets you observe the plant, not drive it, and the + * UI must say so rather than offering buttons that silently do nothing. + */ + +import { useEffect, useState } from 'react'; +import { useStore } from '../store.js'; +import { control } from '../hooks/useTelemetry.js'; + +export function WhatIfPanel() { + const frame = useStore((s) => s.frame); + const meta = useStore((s) => s.meta); + const [setpoint, setSetpoint] = useState(305); + const [lineSpeed, setLineSpeed] = useState(100); + const [error, setError] = useState(null); + + const serverSetpoint = frame ? frame.controls.setpoint : 305; + const serverLineSpeed = frame ? frame.controls.lineSpeedPct : 100; + + // Follow the server unless the user is mid-drag, so a reset or an external + // change is reflected here instead of the slider drifting out of sync. + const [dragging, setDragging] = useState(null); + useEffect(() => { if (dragging !== 'setpoint') setSetpoint(serverSetpoint); }, [serverSetpoint, dragging]); + useEffect(() => { if (dragging !== 'lineSpeed') setLineSpeed(serverLineSpeed); }, [serverLineSpeed, dragging]); + + if (!frame || !meta) return null; + + const caps = meta.source.capabilities; + const activeFaults = new Set(frame.faults.map((f) => f.id)); + + const send = async (path, body) => { + try { + setError(null); + await control(path, body); + } catch (err) { + setError(err.message); + } + }; + + return ( +
+

+ What-if controls + {caps.setpointControl ? 'live on the model' : 'read-only source'} +

+ + {!caps.setpointControl && ( +
+ This telemetry source is read-only, so setpoints and faults cannot be driven from here. +
+ )} + + {error &&
{error}
} + +
+
+ Oven setpoint + {setpoint} °C +
+ { setDragging('setpoint'); setSetpoint(Number(e.target.value)); }} + onMouseUp={(e) => { setDragging(null); send('setpoint', { value: Number(e.target.value) }); }} + onTouchEnd={(e) => { setDragging(null); send('setpoint', { value: Number(e.target.value) }); }} + onKeyUp={(e) => { setDragging(null); send('setpoint', { value: Number(e.target.value) }); }} + /> + + Zone 2 responds with a ~50 s lag. Reject rate climbs once deviation passes about 6 °C. + +
+ +
+
+ Line speed + {lineSpeed}% +
+ { setDragging('lineSpeed'); setLineSpeed(Number(e.target.value)); }} + onMouseUp={(e) => { setDragging(null); send('line-speed', { value: Number(e.target.value) }); }} + onTouchEnd={(e) => { setDragging(null); send('line-speed', { value: Number(e.target.value) }); }} + onKeyUp={(e) => { setDragging(null); send('line-speed', { value: Number(e.target.value) }); }} + /> + + CNC-02 is the bottleneck at 4.4 s. Faster cycles raise spindle load. + +
+ +
+ +

Inject a fault

+
+ {meta.faults.map((f) => { + const on = activeFaults.has(f.id); + return ( +
+ + {f.label} + {f.station} · {f.severity} + + +
+ ); + })} +
+ +
+ +
+ + Resets CNC-02 tool wear, as a maintenance intervention would. +
+
+ ); +} diff --git a/web/src/store.js b/web/src/store.js new file mode 100644 index 0000000..cead035 --- /dev/null +++ b/web/src/store.js @@ -0,0 +1,152 @@ +/** + * Client state. + * + * Frames arrive at 2 Hz. A naive context provider would re-render the whole tree + * on every frame, which stutters the 3D canvas; selector subscriptions keep the + * canvas, the charts and the copilot independent of each other. + * + * Signal history is kept here rather than derived, because the server sends + * point-in-time snapshots, not series. The buffers are the client's memory of the + * run and are what the trend charts draw. + */ + +import { create } from 'zustand'; + +/** Frames of history per signal. At 2 Hz this is about two real minutes. */ +const HISTORY = 240; + +export const useStore = create((set, get) => ({ + connected: false, + connError: null, + meta: null, + frame: null, + + /** "STATION.signal" -> [{ t, v }] */ + history: {}, + /** [{ t, oee, availability, performance, quality }] */ + kpiHistory: [], + + selected: 'CNC-02', + select: (id) => set({ selected: id }), + + settingsOpen: false, + openSettings: () => set({ settingsOpen: true }), + closeSettings: () => set({ settingsOpen: false }), + + setConnected: (connected, connError = null) => set({ connected, connError }), + setMeta: (meta) => set({ meta }), + + /** Fold one telemetry frame into state. */ + ingest: (frame) => { + const { history, kpiHistory, meta } = get(); + const nextHistory = { ...history }; + + for (const st of frame.stations) { + // A station that is not reporting must not extend its traces - a flat line + // drawn from stale data is indistinguishable from a real steady reading. + if (!st.online) continue; + for (const [key, value] of Object.entries(st.signals)) { + if (!Number.isFinite(value)) continue; + const k = `${st.id}.${key}`; + const prev = nextHistory[k]; + const next = prev ? (prev.length >= HISTORY ? prev.slice(1) : prev.slice()) : []; + next.push({ t: frame.t, v: value }); + nextHistory[k] = next; + } + } + + const kpi = frame.kpi; + const nextKpi = kpiHistory.length >= HISTORY ? kpiHistory.slice(1) : kpiHistory.slice(); + nextKpi.push({ + t: frame.t, + oee: kpi.oee * 100, + availability: kpi.availability * 100, + performance: kpi.performance * 100, + quality: kpi.quality * 100, + }); + + set({ frame, history: nextHistory, kpiHistory: nextKpi }); + + // Guard against a stale selection if the asset model ever changes. + if (meta && !frame.stations.some((s) => s.id === get().selected)) { + set({ selected: frame.stations[0].id }); + } + }, + + /** + * Seed history from the server's replay ring, so charts are populated the + * instant the page loads instead of filling over the next few minutes. + */ + seedHistory: (frames) => { + const history = {}; + const kpiHistory = []; + for (const f of frames) { + for (const st of f.stations) { + if (!st.online) continue; + for (const [key, value] of Object.entries(st.signals)) { + if (!Number.isFinite(value)) continue; + const k = `${st.id}.${key}`; + if (!history[k]) history[k] = []; + history[k].push({ t: f.t, v: value }); + } + } + kpiHistory.push({ + t: f.t, + oee: f.kpi.oee * 100, + availability: f.kpi.availability * 100, + performance: f.kpi.performance * 100, + quality: f.kpi.quality * 100, + }); + } + for (const k of Object.keys(history)) { + if (history[k].length > HISTORY) history[k] = history[k].slice(-HISTORY); + } + set({ history, kpiHistory: kpiHistory.slice(-HISTORY) }); + }, + + /** A reset on the server invalidates all client-side history. */ + clearHistory: () => set({ history: {}, kpiHistory: [] }), + + // --- copilot ------------------------------------------------------------ + + copilot: { + messages: [], + streaming: false, + provider: 'auto', + activeLabel: null, + elapsedMs: 0, + }, + + setCopilot: (patch) => set((s) => ({ copilot: { ...s.copilot, ...patch } })), + + pushMessage: (msg) => + set((s) => ({ copilot: { ...s.copilot, messages: [...s.copilot.messages, msg] } })), + + /** Append streamed text to the last assistant message. */ + appendToLast: (text) => + set((s) => { + const msgs = s.copilot.messages.slice(); + const last = msgs[msgs.length - 1]; + if (!last || last.role !== 'assistant') return {}; + msgs[msgs.length - 1] = { ...last, content: last.content + text }; + return { copilot: { ...s.copilot, messages: msgs } }; + }), + + patchLast: (patch) => + set((s) => { + const msgs = s.copilot.messages.slice(); + const last = msgs[msgs.length - 1]; + if (!last) return {}; + msgs[msgs.length - 1] = { ...last, ...patch }; + return { copilot: { ...s.copilot, messages: msgs } }; + }), +})); + +/** Read a signal's history without subscribing to unrelated signals. */ +export const selectSeries = (stationId, key) => (s) => s.history[`${stationId}.${key}`]; + +export const selectStation = (id) => (s) => + s.frame ? s.frame.stations.find((x) => x.id === id) : undefined; + +export const selectSpec = (id) => (s) => + s.meta ? s.meta.stations.find((x) => x.id === id) : undefined; diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..245461c --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,596 @@ +/** + * Design tokens and layout. + * + * This commits deliberately to a single dark theme. It is a plant operations + * dashboard shown on a projector in a meeting room, where dark is idiomatic and + * light washes out; a theme toggle would be a feature nobody uses in that + * setting. Colours are painted explicitly rather than inherited. + * + * Palette roles come from the validated reference palette. Status colours are + * reserved - they are never reused as chart series - and every status colour is + * always paired with a text label, never carrying meaning by hue alone. + */ + +:root { + color-scheme: dark; + + /* Surfaces and ink */ + --plane: #0d0d0d; + --surface-1: #1a1a19; + --surface-2: #201f1e; + --text-primary: #ffffff; + --text-secondary: #c3c2b7; + --text-muted: #898781; + --grid: #2c2c2a; + --axis: #383835; + --border: rgba(255, 255, 255, 0.1); + --border-strong: rgba(255, 255, 255, 0.18); + + /* Status - reserved, never used as a series colour */ + --good: #0ca30c; + --warning: #fab219; + --serious: #ec835a; + --critical: #d03b3b; + --predictive: #9085e9; + + /* Categorical series slots 1-3 (validated all-pairs on this surface) */ + --series-1: #3987e5; + --series-2: #d95926; + --series-3: #199e70; + + --radius: 10px; + --radius-sm: 6px; + --font: system-ui, -apple-system, "Segoe UI", sans-serif; + --mono: ui-monospace, "Cascadia Mono", "Consolas", monospace; +} + +* { box-sizing: border-box; } + +html, body, #root { height: 100%; } + +body { + margin: 0; + background: var(--plane); + color: var(--text-primary); + font-family: var(--font); + font-size: 14px; + -webkit-font-smoothing: antialiased; +} + +/* ---------------------------------------------------------------- layout --- */ + +.app { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + min-height: 100%; +} + +.row-hero { + display: grid; + grid-template-columns: minmax(0, 1.7fr) minmax(340px, 1fr); + gap: 12px; + min-height: 400px; +} + +.row-lower { + display: grid; + grid-template-columns: minmax(0, 1.7fr) minmax(340px, 1fr); + gap: 12px; + align-items: start; +} + +@media (max-width: 1280px) { + .row-hero, .row-lower { grid-template-columns: 1fr; } +} + +.col { display: flex; flex-direction: column; gap: 12px; min-width: 0; } + +/* ----------------------------------------------------------------- cards --- */ + +.card { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; + min-width: 0; +} + +.card.flush { padding: 0; overflow: hidden; } + +.card-title { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--text-muted); + margin: 0 0 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.card-title .hint { text-transform: none; letter-spacing: 0; font-weight: 400; } + +/* ---------------------------------------------------------------- header --- */ + +.header { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 16px; +} + +.brand { display: flex; align-items: baseline; gap: 10px; } +.brand h1 { font-size: 16px; margin: 0; font-weight: 650; letter-spacing: -0.01em; } +.brand .sub { font-size: 12px; color: var(--text-muted); } + +.header-spacer { flex: 1; } + +.clock { + font-family: var(--mono); + font-size: 13px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + padding: 4px 9px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text-secondary); + white-space: nowrap; +} + +.badge .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); flex: none; } +.badge.live .dot { background: var(--good); } +.badge.stale .dot { background: var(--critical); } + +/* --------------------------------------------------------------- buttons --- */ + +button { + font-family: inherit; + font-size: 12px; + color: var(--text-secondary); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 6px 11px; + cursor: pointer; + transition: background 120ms, border-color 120ms, color 120ms; +} + +button:hover:not(:disabled) { background: #2a2928; color: var(--text-primary); border-color: var(--border-strong); } +button:disabled { opacity: 0.45; cursor: not-allowed; } +button.active { background: #33322f; color: var(--text-primary); border-color: var(--border-strong); } +button.danger { color: #f0a3a3; } +button.danger:hover:not(:disabled) { background: #3a2020; border-color: #6b3535; color: #ffc9c9; } + +.btn-group { display: inline-flex; gap: 4px; flex-wrap: wrap; } + +/* ------------------------------------------------------------- KPI tiles --- */ + +.kpi-row { + display: grid; + grid-template-columns: 1.4fr repeat(5, 1fr); + gap: 12px; +} + +@media (max-width: 1100px) { .kpi-row { grid-template-columns: repeat(3, 1fr); } } + +.tile { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.tile .label { + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); +} + +.tile .value { + font-size: 26px; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.1; +} + +.tile.hero .value { font-size: 40px; } +.tile .unit { font-size: 14px; color: var(--text-secondary); font-weight: 400; margin-left: 2px; } +.tile .foot { font-size: 11px; color: var(--text-muted); } + +/* meter: a thin magnitude bar, 4px rounded end, anchored at zero */ +.meter { height: 5px; background: var(--grid); border-radius: 999px; overflow: hidden; margin-top: 2px; } +.meter > span { display: block; height: 100%; border-radius: 999px; background: var(--series-1); } + +/* --------------------------------------------------------- station strip --- */ + +.strip { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; } +@media (max-width: 1100px) { .strip { grid-template-columns: repeat(2, 1fr); } } + +.station-card { + text-align: left; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 7px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + border-left: 3px solid var(--text-muted); +} + +.station-card.sel { border-color: var(--border-strong); background: #2b2a28; } +.station-card .id { font-size: 12.5px; font-weight: 650; color: var(--text-primary); font-family: var(--mono); } +.station-card .nm { font-size: 10.5px; color: var(--text-muted); line-height: 1.25; } +.station-card .primary { font-size: 17px; font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums; } +.station-card .primary .u { font-size: 10.5px; color: var(--text-muted); font-weight: 400; margin-left: 3px; } + +/* status is always colour + text, never colour alone */ +.state { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.state .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; } + +.s-running { color: var(--good); } +.s-microstop { color: var(--warning); } +.s-blocked { color: var(--serious); } +.s-starved { color: var(--text-muted); } +.s-down, .s-fault { color: var(--critical); } +.s-idle { color: var(--text-muted); } + +/* ---------------------------------------------------------------- charts --- */ + +.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +@media (max-width: 860px) { .chart-grid { grid-template-columns: 1fr; } } + +.chart-box { min-width: 0; } +.chart-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 2px; } +.chart-name { font-size: 12px; color: var(--text-secondary); font-weight: 550; } +.chart-now { font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; } +.chart-now .u { font-size: 10.5px; color: var(--text-muted); font-weight: 400; margin-left: 3px; } + +.legend { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 4px; } +.legend-item { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text-secondary); } +.legend-swatch { width: 10px; height: 2px; border-radius: 1px; flex: none; } + +.tooltip { + background: #262523; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 7px 9px; + font-size: 11.5px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5); +} +.tooltip .t-time { color: var(--text-muted); margin-bottom: 3px; font-variant-numeric: tabular-nums; } +.tooltip .t-row { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); } +.tooltip .t-val { color: var(--text-primary); font-weight: 600; font-variant-numeric: tabular-nums; } + +/* ---------------------------------------------------------------- alarms --- */ + +.alarm-list { display: flex; flex-direction: column; gap: 7px; max-height: 340px; overflow-y: auto; } + +.alarm { + display: flex; + flex-direction: column; + gap: 4px; + padding: 9px 11px; + background: var(--surface-2); + border: 1px solid var(--border); + border-left: 3px solid var(--text-muted); + border-radius: var(--radius-sm); +} + +.alarm.critical { border-left-color: var(--critical); } +.alarm.major { border-left-color: var(--serious); } +.alarm.warning { border-left-color: var(--warning); } +.alarm.predictive { border-left-color: var(--predictive); } + +.alarm-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.sev { + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 2px 6px; + border-radius: 3px; + background: var(--grid); + color: var(--text-secondary); +} +.sev.critical { color: #ffb3b3; background: #3a1f1f; } +.sev.major { color: #f7c3a8; background: #3a2a1f; } +.sev.warning { color: #f8dd9a; background: #3a341f; } +.sev.predictive { color: #cfc8f6; background: #272341; } + +.alarm .who { font-family: var(--mono); font-size: 11px; color: var(--text-muted); } +.alarm .msg { font-size: 12px; color: var(--text-secondary); line-height: 1.45; } +.alarm .actions { display: flex; gap: 6px; } +.alarm button { padding: 3px 8px; font-size: 11px; } + +.empty { font-size: 12px; color: var(--text-muted); padding: 14px 0; text-align: center; } + +/* ---------------------------------------------------------------- whatif --- */ + +.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; } +.field-head { display: flex; align-items: baseline; justify-content: space-between; font-size: 12px; color: var(--text-secondary); } +.field-head .v { font-family: var(--mono); font-size: 12.5px; color: var(--text-primary); font-variant-numeric: tabular-nums; } + +input[type="range"] { width: 100%; accent-color: var(--series-1); background: transparent; } + +.fault-list { display: flex; flex-direction: column; gap: 6px; } +.fault-row { display: flex; align-items: center; gap: 8px; } +.fault-row .nm { flex: 1; font-size: 12px; color: var(--text-secondary); min-width: 0; } +.fault-row .nm small { display: block; color: var(--text-muted); font-size: 10.5px; } +.fault-row.on .nm { color: var(--text-primary); } + +.divider { height: 1px; background: var(--border); margin: 14px 0; } + +/* --------------------------------------------------------------- copilot --- */ + +.copilot { display: flex; flex-direction: column; height: 100%; min-height: 0; } +.copilot-log { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; padding-right: 4px; min-height: 120px; } + +.msg { font-size: 12.5px; line-height: 1.55; } +.msg.user { color: var(--text-primary); background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; } +.msg.assistant { color: var(--text-secondary); white-space: pre-wrap; } +.msg.assistant strong { color: var(--text-primary); font-weight: 650; } +.msg .who { font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; } +.msg .note { font-size: 11px; color: var(--warning); margin-bottom: 5px; } + +.chips { display: flex; flex-wrap: wrap; gap: 5px; margin: 10px 0 8px; } +.chips button { font-size: 11px; padding: 5px 9px; } + +.ask { display: flex; gap: 6px; } +.ask input { + flex: 1; + min-width: 0; + font-family: inherit; + font-size: 12.5px; + color: var(--text-primary); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 10px; +} +.ask input:focus { outline: none; border-color: var(--border-strong); } + +.thinking { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--text-muted); } +.spinner { + width: 11px; height: 11px; flex: none; + border: 2px solid var(--grid); + border-top-color: var(--series-1); + border-radius: 50%; + animation: spin 700ms linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* -------------------------------------------------------------------- 3D --- */ + +.scene { position: relative; width: 100%; height: 100%; min-height: 380px; border-radius: var(--radius); overflow: hidden; background: #121211; } +.scene canvas { display: block; } + +.scene-overlay { + position: absolute; + left: 14px; top: 12px; + display: flex; flex-direction: column; gap: 4px; + pointer-events: none; +} +.scene-overlay .ttl { font-size: 12px; font-weight: 650; letter-spacing: 0.02em; } +.scene-overlay .sub { font-size: 11px; color: var(--text-muted); } + +.scene-hint { + position: absolute; + right: 14px; bottom: 12px; + font-size: 10.5px; + color: var(--text-muted); + pointer-events: none; +} + +.scene-legend { + position: absolute; + left: 14px; bottom: 12px; + display: flex; gap: 10px; flex-wrap: wrap; + pointer-events: none; +} + +/* --------------------------------------------------------------- utility --- */ + +.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } +.muted { color: var(--text-muted); } +.small { font-size: 11px; } +.stack { display: flex; flex-direction: column; gap: 8px; } +.inline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + +.banner { + background: #3a2020; + border: 1px solid #6b3535; + color: #ffc9c9; + border-radius: var(--radius-sm); + padding: 9px 12px; + font-size: 12px; +} + +::-webkit-scrollbar { width: 9px; height: 9px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #35342f; border-radius: 999px; } +::-webkit-scrollbar-thumb:hover { background: #45443e; } + +/* ------------------------------------------------------------- settings --- */ + +.settings-scrim { + position: fixed; + inset: 0; + background: rgba(6, 6, 6, 0.78); + z-index: 100; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 24px; + overflow-y: auto; +} + +.settings { + width: 100%; + max-width: 1080px; + background: var(--surface-1); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + padding: 0; + overflow: hidden; +} + +.settings-head { + display: flex; + align-items: center; + gap: 14px; + padding: 16px 20px; + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + background: var(--surface-1); + z-index: 2; +} +.settings-head h2 { margin: 0; font-size: 16px; font-weight: 650; } +.settings-head .spacer { flex: 1; } + +.settings-body { padding: 18px 20px 22px; display: flex; flex-direction: column; gap: 22px; } + +.settings-section { display: flex; flex-direction: column; gap: 10px; } +.settings-section > h3 { + margin: 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--text-muted); +} +.settings-section .desc { font-size: 12px; color: var(--text-muted); line-height: 1.5; margin: -2px 0 4px; } + +.provider-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } +@media (max-width: 820px) { .provider-grid { grid-template-columns: repeat(2, 1fr); } } + +.provider-opt { + text-align: left; + padding: 11px 13px; + display: flex; + flex-direction: column; + gap: 4px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--surface-2); +} +.provider-opt.sel { border-color: var(--series-1); background: #21293a; } +.provider-opt .t { font-size: 13px; font-weight: 600; color: var(--text-primary); } +.provider-opt .d { font-size: 11px; color: var(--text-muted); line-height: 1.4; } + +.model-cols { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +@media (max-width: 900px) { .model-cols { grid-template-columns: 1fr; } } + +.model-panel { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + padding: 12px; + display: flex; + flex-direction: column; + gap: 9px; + min-width: 0; +} +.model-panel.active { border-color: var(--series-1); } +.model-panel-head { display: flex; align-items: center; gap: 8px; } +.model-panel-head .t { font-size: 13px; font-weight: 600; } +.model-panel-head .spacer { flex: 1; } + +.model-list { + max-height: 230px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-1); +} + +.model-row { + width: 100%; + text-align: left; + border: none; + border-bottom: 1px solid var(--border); + border-radius: 0; + background: transparent; + padding: 8px 10px; + display: flex; + align-items: baseline; + gap: 8px; +} +.model-row:last-child { border-bottom: none; } +.model-row.sel { background: #21293a; } +.model-row .mid { font-family: var(--mono); font-size: 11.5px; color: var(--text-primary); word-break: break-all; } +.model-row .meta { margin-left: auto; font-size: 10.5px; color: var(--text-muted); white-space: nowrap; flex: none; } + +.settings input[type="text"], .settings input[type="number"], .settings input[type="search"] { + width: 100%; + min-width: 0; + font-family: inherit; + font-size: 12.5px; + color: var(--text-primary); + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 7px 9px; +} +.settings input:focus { outline: none; border-color: var(--border-strong); } +.settings input[type="text"].mono { font-family: var(--mono); } + +.gen-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } +@media (max-width: 820px) { .gen-grid { grid-template-columns: 1fr; } } + +.test-result { + font-size: 11.5px; + border-radius: var(--radius-sm); + padding: 7px 9px; + border: 1px solid var(--border); + background: var(--surface-1); + line-height: 1.5; +} +.test-result.good { border-color: #1d5c1d; color: #b6e6b6; } +.test-result.slow { border-color: #6b5a1f; color: #f3dd9f; } +.test-result.bad { border-color: #6b3535; color: #ffc9c9; } +.test-result .lat { font-family: var(--mono); font-weight: 600; } + +.settings-foot { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 20px; + border-top: 1px solid var(--border); + background: var(--surface-2); +} +.settings-foot .spacer { flex: 1; } + +.saved-flash { font-size: 11.5px; color: var(--good); } +.err-flash { font-size: 11.5px; color: #ffc9c9; } diff --git a/web/src/three/Conveyor.jsx b/web/src/three/Conveyor.jsx new file mode 100644 index 0000000..735759c --- /dev/null +++ b/web/src/three/Conveyor.jsx @@ -0,0 +1,99 @@ +/** + * Conveyor segments and the product on them. + * + * The queued boxes are not decoration: there is exactly one box per unit of real + * WIP in that buffer, packed toward the downstream end. So when the packer jams, + * you watch the boxes physically pile up and the buffers fill from the right - + * the same propagation the 2D buffer pills show, in a form a plant manager reads + * instantly. + * + * The gliding boxes on top are the only decorative element, indicating flow while + * the upstream station is producing. + */ + +import { useRef } from 'react'; +import { useFrame } from '@react-three/fiber'; + +const BOX = 0.34; + +export function Belt({ x, width }) { + return ( + + {/* belt deck */} + + + + + {/* side rails */} + {[-0.58, 0.58].map((z) => ( + + + + + ))} + {/* legs */} + {[-width / 2 + 0.25, width / 2 - 0.25].map((lx) => ( + + + + + ))} + + ); +} + +/** One box per unit of WIP, queued from the downstream end. */ +export function QueueBoxes({ x, width, count, full }) { + const spacing = BOX + 0.06; + const start = x + width / 2 - 0.28; + const boxes = []; + for (let i = 0; i < count; i++) { + const px = start - i * spacing; + // Never let the queue render past its own segment. + if (px < x - width / 2) break; + boxes.push(px); + } + + return ( + + {boxes.map((px, i) => ( + + + + + ))} + + ); +} + +/** Decorative in-transit product, moving only while the feeding station runs. */ +export function FlowBoxes({ x, width, active, rate }) { + const group = useRef(null); + const phase = useRef(0); + + useFrame((_, dt) => { + if (!group.current) return; + if (active) phase.current = (phase.current + dt * rate * 0.55) % 1; + const n = group.current.children.length; + group.current.children.forEach((child, i) => { + const p = (phase.current + i / n) % 1; + child.position.x = x - width / 2 + p * width; + child.visible = active; + }); + }); + + return ( + + {[0, 1].map((i) => ( + + + + + ))} + + ); +} diff --git a/web/src/three/Scene.jsx b/web/src/three/Scene.jsx new file mode 100644 index 0000000..ca79c08 --- /dev/null +++ b/web/src/three/Scene.jsx @@ -0,0 +1,171 @@ +/** + * The 3D hero view of the line. + * + * No external assets are loaded anywhere in this scene - no HDRI environment, no + * CDN font, no texture. Everything is primitives and analytic lights, because the + * demo has to survive a room with no working wifi. + */ + +import { Suspense } from 'react'; +import { Canvas } from '@react-three/fiber'; +import { OrbitControls, ContactShadows } from '@react-three/drei'; +import { useStore } from '../store.js'; +import { Station } from './Station.jsx'; +import { Belt, QueueBoxes, FlowBoxes } from './Conveyor.jsx'; +import { stateMeta } from '../lib/format.js'; + +/** Station centres, evenly spaced along the line. */ +const SPACING = 4.3; +const stationX = (i, n) => (i - (n - 1) / 2) * SPACING; + +function Floor() { + return ( + <> + + + + + {/* aisle markings, purely to give the eye a ground plane to read depth from */} + {[-3.2, 3.2].map((z) => ( + + + + + ))} + + ); +} + +function LineContent() { + const frame = useStore((s) => s.frame); + const meta = useStore((s) => s.meta); + const selected = useStore((s) => s.selected); + const select = useStore((s) => s.select); + + if (!frame || !meta) return null; + const n = frame.stations.length; + + return ( + + {frame.stations.map((st, i) => ( + x.id === st.id)} + x={stationX(i, n)} + selected={selected === st.id} + onSelect={select} + /> + ))} + + {frame.buffers.map((level, i) => { + const x = (stationX(i, n) + stationX(i + 1, n)) / 2; + const width = SPACING - 2.15; + const upstream = frame.stations[i]; + return ( + + + = frame.bufferCapacity} + /> + + + ); + })} + + ); +} + +export function Scene() { + const frame = useStore((s) => s.frame); + const connected = useStore((s) => s.connected); + + const worstState = frame + ? (frame.stations.find((s) => s.state === 'fault' || s.state === 'down') + || frame.stations.find((s) => s.state === 'blocked')) + : null; + + return ( +
+ + + + + {/* Analytic lights only - an HDRI would be a network fetch. */} + + + + + + + + + + + + + +
+ LINE-1 · Assembly & Cure + + {connected ? 'Live twin' : 'Disconnected'} + {worstState ? ` · ${worstState.id} ${stateMeta(worstState.state).label.toLowerCase()}` : ' · all stations nominal'} + +
+ + {/* Legend pairs every status colour with its word. */} +
+ {['running', 'microstop', 'blocked', 'fault'].map((s) => { + const sm = stateMeta(s); + return ( + + + {sm.label} + + ); + })} +
+ +
Drag to orbit · scroll to zoom · click a machine to inspect
+
+ ); +} diff --git a/web/src/three/Station.jsx b/web/src/three/Station.jsx new file mode 100644 index 0000000..9e4173f --- /dev/null +++ b/web/src/three/Station.jsx @@ -0,0 +1,226 @@ +/** + * One machine, built from primitives. + * + * Geometry is boxes and cylinders on purpose. Sourcing or modelling CAD-accurate + * machines is a day of work that adds nothing to the argument being made, and it + * is the classic way a two-day demo becomes a four-day demo. + * + * What the geometry DOES carry is state, three ways: a status-coloured status bar + * on the machine, a pulse when it is stopped on a fault, and - the detail that + * lands with maintenance engineers - a physical shake on CNC-02 that scales with + * measured bearing vibration. + */ + +import { useMemo, useRef } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { Html } from '@react-three/drei'; +import { stateMeta } from '../lib/format.js'; + +function Body({ w, h, d, y, color, roughness = 0.6, metalness = 0.35 }) { + return ( + + + + + ); +} + +/** Per-kind machine shape. */ +function Machine({ kind, station, spinRef }) { + const shell = '#4a4941'; + const dark = '#2f2e28'; + + switch (kind) { + case 'conveyor': + return ( + + + {[-0.5, 0, 0.5].map((x) => ( + + + + + ))} + + ); + + case 'cnc': + return ( + + + {/* enclosure window */} + + + + + {/* spindle, rotates while cutting */} + + + + + + + + + + + + ); + + case 'oven': { + // Zone glow follows burner duty, so a saturated burner visibly runs hot. + const duty = (station.signals.burnerDuty || 0) / 100; + return ( + + + {/* tunnel mouth */} + + + + + {/* three zone strips on the roof */} + {[-0.75, 0, 0.75].map((x, i) => ( + + + + + ))} + + ); + } + + case 'inspection': + return ( + + + {/* gantry */} + {[-0.6, 0.6].map((x) => ( + + + + + ))} + + + + + {/* camera + light cone */} + + + + + + + + + + ); + + case 'packer': + return ( + + + {/* film roll */} + + + + + + + + + + ); + + default: + return ; + } +} + +export function Station({ station, spec, x, selected, onSelect }) { + const group = useRef(null); + const spinRef = useRef(null); + const barRef = useRef(null); + + const sm = stateMeta(station.state); + const stopped = station.state === 'fault' || station.state === 'down'; + + // Shake amplitude from measured vibration, above the healthy baseline. + const shake = useMemo(() => { + if (station.kind !== 'cnc') return 0; + const v = station.signals.vibration || 0; + return Math.max(0, v - 2.0) * 0.011; + }, [station.kind, station.signals.vibration]); + + const running = station.state === 'running'; + + useFrame((state, dt) => { + const t = state.clock.elapsedTime; + + if (group.current) { + group.current.position.x = x + (shake ? Math.sin(t * 47) * shake : 0); + group.current.position.z = shake ? Math.cos(t * 61) * shake : 0; + } + if (spinRef.current && running) { + spinRef.current.rotation.y += dt * 14; + } + // A stopped station pulses, so it draws the eye from across the room. + if (barRef.current) { + const mat = barRef.current.material; + mat.emissiveIntensity = stopped ? 0.7 + Math.sin(t * 5) * 0.6 : 0.65; + } + }); + + return ( + + { e.stopPropagation(); onSelect(station.id); }}> + + + {/* status bar: the machine's own state light */} + + + + + + {/* selection ring */} + {selected && ( + + + + + )} + + + {/* Labels are DOM, not 3D text: drei's fetches a font from a CDN, + which would fail in an offline demo room. */} + +
+
{station.id}
+
{station.online ? sm.label : 'No data'}
+
+ +
+ ); +} diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..14fa846 --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + // The API and the telemetry socket live on the server process. Proxying them + // keeps the browser on one origin, so there is no CORS setup to go wrong on + // stage and the copilot API key never leaves the server. + proxy: { + '/api': { target: 'http://localhost:8787', changeOrigin: true }, + '/ws': { target: 'ws://localhost:8787', ws: true }, + }, + }, +});