Spaces:
Sleeping
Sleeping
minhnghiem32131024429 commited on
Commit ·
5e8b911
1
Parent(s): 1e9a5a9
Deploy delivery robot app
Browse files- .env.example +3 -0
- .gitignore +30 -0
- ARCHITECTURE.md +57 -0
- Dockerfile +12 -0
- PLAN.md +125 -0
- app/__init__.py +0 -0
- app/main.py +83 -0
- app/models.py +60 -0
- app/routers/__init__.py +0 -0
- app/routers/elevator.py +90 -0
- app/routers/locker.py +112 -0
- app/routers/orders.py +398 -0
- app/routers/robot.py +162 -0
- app/security.py +28 -0
- app/simulator.py +136 -0
- app/state.py +49 -0
- app/supabase_client.py +14 -0
- app/telegram.py +45 -0
- cloudflare_worker.js +27 -0
- customer_order.html +672 -0
- elevator_panel.html +1831 -0
- map_editor.html +1352 -0
- requirements.txt +7 -0
- static/OrbitControls.js +1506 -0
- static/three-global.js +2 -0
- static/three.module.min.js +0 -0
.env.example
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
SUPABASE_URL=https://pecfsoviamtjirugasmh.supabase.co
|
| 2 |
+
SUPABASE_KEY=your_anon_key_here
|
| 3 |
+
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
.gitignore
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment
|
| 2 |
+
.env
|
| 3 |
+
.env.local
|
| 4 |
+
.env.*.local
|
| 5 |
+
config.h
|
| 6 |
+
|
| 7 |
+
# Python
|
| 8 |
+
__pycache__/
|
| 9 |
+
*.py[cod]
|
| 10 |
+
*.pyo
|
| 11 |
+
.venv/
|
| 12 |
+
venv/
|
| 13 |
+
*.egg-info/
|
| 14 |
+
dist/
|
| 15 |
+
build/
|
| 16 |
+
|
| 17 |
+
# Arduino
|
| 18 |
+
ESP_Code/build/
|
| 19 |
+
*.hex
|
| 20 |
+
|
| 21 |
+
# OS
|
| 22 |
+
.DS_Store
|
| 23 |
+
Thumbs.db
|
| 24 |
+
|
| 25 |
+
# IDE
|
| 26 |
+
.vscode/
|
| 27 |
+
.idea/
|
| 28 |
+
.claude/
|
| 29 |
+
*.pgm
|
| 30 |
+
*.yaml
|
ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Architecture Notes
|
| 2 |
+
|
| 3 |
+
## Long-Poll Pattern (ESP32 ↔ Server)
|
| 4 |
+
|
| 5 |
+
The ESP32 polls `GET /api/elevator/command` when IDLE. Instead of returning immediately with "no command", the server *holds* the HTTP connection open for up to 10 seconds using `asyncio.wait_for`:
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
ESP32 Server
|
| 9 |
+
|---GET /api/elevator/command-->|
|
| 10 |
+
| | await event.wait() (up to 10s)
|
| 11 |
+
| |
|
| 12 |
+
| (dashboard sends POST) |
|
| 13 |
+
| | event.set() → command available
|
| 14 |
+
|<------{"cmd":"CALL_3"}---------| returns immediately
|
| 15 |
+
| |
|
| 16 |
+
|---GET /api/elevator/command-->| (next cycle after move completes)
|
| 17 |
+
| | no command → timeout after 10s
|
| 18 |
+
|<------{"cmd":"NONE"}-----------| returns after 10s
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
**Why this matters:** The naive approach polls every 500 ms — 120 requests/minute. Long-poll reduces this to ~6 requests/minute (one per timeout cycle), and delivers commands with near-zero latency when they do arrive.
|
| 22 |
+
|
| 23 |
+
**Implementation detail:** A single `asyncio.Event` guards a single command slot. After the ESP32 reads the command, both the slot and the Event are cleared so the next poll cycle starts clean. The ESP32 sets `http.setTimeout(15000)` to ensure the socket stays open past the 10-second server hold.
|
| 24 |
+
|
| 25 |
+
## SSE Pattern (Server → Dashboard)
|
| 26 |
+
|
| 27 |
+
The dashboard opens one persistent HTTP connection to `GET /api/events`. The server keeps this connection alive and pushes JSON-encoded events whenever elevator or robot state changes:
|
| 28 |
+
|
| 29 |
+
```
|
| 30 |
+
Browser Server
|
| 31 |
+
|---GET /api/events------------>|
|
| 32 |
+
| | registers asyncio.Queue
|
| 33 |
+
|<--data:{"type":"elevator"...}-| on ESP32 POST /status
|
| 34 |
+
|<--data:{"type":"robot"...}----| on Jetson POST /status
|
| 35 |
+
| |
|
| 36 |
+
| (client disconnects) |
|
| 37 |
+
| | queue removed from set (finally block)
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
**Why this matters:** Polling every 500 ms from the browser creates 120 requests/minute regardless of whether anything changed. SSE pushes updates only when state changes, with zero polling overhead and no perceptible latency.
|
| 41 |
+
|
| 42 |
+
**Implementation detail:** Each connected browser gets its own `asyncio.Queue`. The `broadcast()` function in `state.py` pushes a copy of every state change to every registered queue. When the browser disconnects (tab closed, network drop), the `finally` block in the async generator removes the queue from the set, preventing memory leaks.
|
| 43 |
+
|
| 44 |
+
## Component Interaction Summary
|
| 45 |
+
|
| 46 |
+
```
|
| 47 |
+
[ESP32] [Jetson / ROS2] [Browser]
|
| 48 |
+
| | |
|
| 49 |
+
|--POST /api/elevator/status--> SSE <--|
|
| 50 |
+
|<--GET /api/elevator/command-- (long-poll) |
|
| 51 |
+
| |--POST /api/robot/status-->
|
| 52 |
+
| |<--GET /api/robot/goal-- (long-poll)
|
| 53 |
+
| POST /api/robot/goal <--|
|
| 54 |
+
|<-- (elevator_cmd_event set by robot/goal handler) |
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
`POST /api/robot/goal` is the single mission-dispatch entry point. It sets both the robot nav goal and the elevator call command in one atomic operation, ensuring the elevator is already summoned when the robot arrives.
|
Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /code
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
EXPOSE 7860
|
| 11 |
+
|
| 12 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
PLAN.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Delivery Robot System — Implementation Plan
|
| 2 |
+
|
| 3 |
+
## Architecture Overview
|
| 4 |
+
ESP32 (elevator) + Jetson/ROS2 (robot) + FastAPI server (HF Spaces) + Supabase (orders DB) + Telegram Bot (notifications) + Browser dashboards
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## Phase 1 — Supabase: Order schema & connection [ ]
|
| 9 |
+
- [ ] Create Supabase project
|
| 10 |
+
- [ ] Create `orders` table
|
| 11 |
+
- [ ] Add `supabase-py` to requirements.txt
|
| 12 |
+
- [ ] Add Supabase client singleton to `app/supabase_client.py`
|
| 13 |
+
- [ ] Add env vars: `SUPABASE_URL`, `SUPABASE_KEY`
|
| 14 |
+
|
| 15 |
+
### orders table schema
|
| 16 |
+
```sql
|
| 17 |
+
create table orders (
|
| 18 |
+
id uuid primary key default gen_random_uuid(),
|
| 19 |
+
floor int not null check (floor between 1 and 5),
|
| 20 |
+
pin text not null, -- 4-digit, stored as bcrypt hash
|
| 21 |
+
telegram_id text, -- optional, for notification
|
| 22 |
+
status text not null default 'pending'
|
| 23 |
+
check (status in ('pending','assigned','delivering','delivered','cancelled')),
|
| 24 |
+
created_at timestamptz default now(),
|
| 25 |
+
delivered_at timestamptz
|
| 26 |
+
);
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## Phase 2 — Order API endpoints [ ]
|
| 32 |
+
- [ ] `POST /api/orders` — create order, generate PIN, send Telegram noti, return order_id + PIN
|
| 33 |
+
- [ ] `GET /api/orders` — list all orders (admin dashboard)
|
| 34 |
+
- [ ] `GET /api/orders/next` — return oldest pending order (used by queue logic)
|
| 35 |
+
- [ ] `POST /api/orders/{id}/confirm` — verify PIN, mark delivered, send Telegram noti
|
| 36 |
+
- [ ] `POST /api/orders/{id}/cancel` — admin cancel
|
| 37 |
+
|
| 38 |
+
Add `app/routers/orders.py` + update `app/main.py`.
|
| 39 |
+
|
| 40 |
+
---
|
| 41 |
+
|
| 42 |
+
## Phase 3 — Telegram Bot [ ]
|
| 43 |
+
- [ ] Create bot via @BotFather, get token
|
| 44 |
+
- [ ] Add `app/telegram.py` — thin wrapper around Telegram sendMessage API (httpx, no heavy lib)
|
| 45 |
+
- [ ] Trigger on order created: send PIN to customer
|
| 46 |
+
- [ ] Trigger on delivering: "Robot đang đến tầng X"
|
| 47 |
+
- [ ] Trigger on delivered: "Đã giao thành công. Cảm ơn!"
|
| 48 |
+
- [ ] Env var: `TELEGRAM_BOT_TOKEN`
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
## Phase 4 — Customer interface (`/order` page) [ ]
|
| 53 |
+
- [ ] Serve `customer_order.html` at `GET /order`
|
| 54 |
+
- [ ] Form: chọn tầng (1-5), nhập Telegram username
|
| 55 |
+
- [ ] Submit → POST /api/orders → hiển thị "Đơn hàng đã tạo, kiểm tra Telegram để nhận PIN"
|
| 56 |
+
- [ ] PIN entry page: nhập PIN khi robot đến → POST /api/orders/{id}/confirm
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
## Phase 5 — Queue logic & auto-dispatch [ ]
|
| 61 |
+
- [ ] `app/queue.py` — background task (asyncio loop) runs every 5s
|
| 62 |
+
- [ ] When robot state == IDLE and orders queue non-empty:
|
| 63 |
+
- Pick oldest pending order
|
| 64 |
+
- Update order status → assigned
|
| 65 |
+
- Dispatch robot goal (POST /api/robot/goal) + elevator command
|
| 66 |
+
- Update Telegram "Robot đang đến"
|
| 67 |
+
- [ ] On robot ARRIVED + PIN confirmed → mark delivered, pick next order
|
| 68 |
+
|
| 69 |
+
---
|
| 70 |
+
|
| 71 |
+
## Phase 6 — Floor map & Robot position [ ]
|
| 72 |
+
Map file: `my_khu_vuc_2.pgm` + `my_khu_vuc_2.yaml`
|
| 73 |
+
- resolution: 0.05 m/px, origin: [-33.9, -22.3, 0]
|
| 74 |
+
- 10 floors share this map (floors 11-15 not scanned yet, demo with 10)
|
| 75 |
+
- Convert formula: `px = (world_x - (-33.9)) / 0.05`, `py = img_height - (world_y - (-22.3)) / 0.05`
|
| 76 |
+
|
| 77 |
+
Tasks:
|
| 78 |
+
- [ ] Convert PGM → PNG on startup using Pillow, serve via StaticFiles
|
| 79 |
+
- [ ] Add map panel to dashboard: `<img>` of PNG + `<canvas>` overlay for robot dot
|
| 80 |
+
- [ ] On SSE robot event: update canvas dot position using pose x/y → pixel conversion
|
| 81 |
+
- [ ] Add Pillow to requirements.txt
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## Phase 7 — Admin dashboard polish [ ]
|
| 86 |
+
- [ ] Order queue panel: list pending/assigned orders, cancel button
|
| 87 |
+
- [ ] Order history table: completed orders with timestamp
|
| 88 |
+
- [ ] System status bar: elevator state + robot state at a glance
|
| 89 |
+
|
| 90 |
+
---
|
| 91 |
+
|
| 92 |
+
## Env vars needed (add to HF Spaces Secrets)
|
| 93 |
+
| Key | Description | Status |
|
| 94 |
+
|-----|-------------|--------|
|
| 95 |
+
| `SUPABASE_URL` | `https://pecfsoviamtjirugasmh.supabase.co` | set |
|
| 96 |
+
| `SUPABASE_KEY` | anon key from Supabase dashboard | set |
|
| 97 |
+
| `TELEGRAM_BOT_TOKEN` | From @BotFather | set |
|
| 98 |
+
| `TELEGRAM_PROXY_URL` | Cloudflare Worker URL proxying api.telegram.org | set |
|
| 99 |
+
|
| 100 |
+
## Pages
|
| 101 |
+
| URL | Purpose |
|
| 102 |
+
|-----|---------|
|
| 103 |
+
| `/` | Admin dashboard — create orders, start delivery run, monitor elevator + robot |
|
| 104 |
+
| `/order` | Customer self-service order page |
|
| 105 |
+
| `/robot` | Robot screen — PIN keypad for compartment unlock |
|
| 106 |
+
| `/docs` | Auto-generated FastAPI OpenAPI docs |
|
| 107 |
+
|
| 108 |
+
---
|
| 109 |
+
|
| 110 |
+
## Current status
|
| 111 |
+
- [x] ESP32 FSM + long-poll working
|
| 112 |
+
- [x] FastAPI server deployed on HF Spaces
|
| 113 |
+
- [x] Dashboard with SSE + elevator animation
|
| 114 |
+
- [x] Floor buttons dispatch elevator commands
|
| 115 |
+
- [x] Phase 1 (Supabase) — table created on pecfsoviamtjirugasmh
|
| 116 |
+
- [x] Phase 2 (Order API) — app/routers/orders.py with full CRUD + dispatch + confirm
|
| 117 |
+
- [x] Phase 3 (Telegram) — app/telegram.py, Cloudflare Worker proxy (cloudflare_worker.js)
|
| 118 |
+
- [x] Phase 4 (Customer UI) — customer_order.html at /order (English)
|
| 119 |
+
- [x] Phase 5 (Queue logic) — batch delivery run via POST /api/orders/start_run
|
| 120 |
+
- All pending orders picked at once, dispatched FIFO
|
| 121 |
+
- Auto-advance to next order after each PIN confirm
|
| 122 |
+
- delivery_queue + current_order tracked in state.py
|
| 123 |
+
- [x] Phase 7 (Admin polish) — full English, order queue with status badges,
|
| 124 |
+
Start Delivery Run button (disabled during active run), live SSE updates
|
| 125 |
+
- [ ] Phase 6 (Floor map) — pending, needs map PNG + coordinate overlay
|
app/__init__.py
ADDED
|
File without changes
|
app/main.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
from contextlib import asynccontextmanager
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from fastapi import FastAPI
|
| 7 |
+
from fastapi.staticfiles import StaticFiles
|
| 8 |
+
from fastapi.responses import HTMLResponse, Response
|
| 9 |
+
from sse_starlette.sse import EventSourceResponse
|
| 10 |
+
|
| 11 |
+
import app.state as state
|
| 12 |
+
from app.routers import elevator, orders, robot, locker
|
| 13 |
+
from app.simulator import run_elevator_simulator
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@asynccontextmanager
|
| 17 |
+
async def lifespan(app: FastAPI):
|
| 18 |
+
# Start the virtual elevator simulator in the background
|
| 19 |
+
simulator_task = asyncio.create_task(run_elevator_simulator())
|
| 20 |
+
yield
|
| 21 |
+
# Cleanup: Cancel the task on shutdown
|
| 22 |
+
simulator_task.cancel()
|
| 23 |
+
try:
|
| 24 |
+
await simulator_task
|
| 25 |
+
except asyncio.CancelledError:
|
| 26 |
+
pass
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
app = FastAPI(
|
| 30 |
+
title="Elevator & Robot Mission Coordinator",
|
| 31 |
+
version="1.0.0",
|
| 32 |
+
lifespan=lifespan,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
app.include_router(elevator.router)
|
| 36 |
+
app.include_router(robot.router)
|
| 37 |
+
app.include_router(orders.router)
|
| 38 |
+
app.include_router(locker.router)
|
| 39 |
+
|
| 40 |
+
_HTML_PATH = Path(__file__).parent.parent / "elevator_panel.html"
|
| 41 |
+
_ORDER_HTML_PATH = Path(__file__).parent.parent / "customer_order.html"
|
| 42 |
+
_STATIC_DIR = Path(__file__).parent.parent / "static"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
_NO_CACHE = {"Cache-Control": "no-store"}
|
| 46 |
+
|
| 47 |
+
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
| 51 |
+
async def serve_dashboard() -> Response:
|
| 52 |
+
return HTMLResponse(_HTML_PATH.read_text(encoding="utf-8"), headers=_NO_CACHE)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@app.get("/order", response_class=HTMLResponse, include_in_schema=False)
|
| 56 |
+
async def serve_order_page() -> Response:
|
| 57 |
+
return HTMLResponse(_ORDER_HTML_PATH.read_text(encoding="utf-8"), headers=_NO_CACHE)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@app.get("/robot", response_class=HTMLResponse, include_in_schema=False)
|
| 61 |
+
async def serve_robot_panel() -> Response:
|
| 62 |
+
return HTMLResponse(_ORDER_HTML_PATH.read_text(encoding="utf-8"), headers=_NO_CACHE)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@app.get("/api/events", include_in_schema=False)
|
| 66 |
+
async def sse_events():
|
| 67 |
+
queue: asyncio.Queue = asyncio.Queue()
|
| 68 |
+
state.sse_queues.add(queue)
|
| 69 |
+
|
| 70 |
+
async def event_generator():
|
| 71 |
+
try:
|
| 72 |
+
while True:
|
| 73 |
+
payload = await queue.get()
|
| 74 |
+
yield {"data": json.dumps(payload)}
|
| 75 |
+
finally:
|
| 76 |
+
state.sse_queues.discard(queue)
|
| 77 |
+
|
| 78 |
+
return EventSourceResponse(event_generator())
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
if __name__ == "__main__":
|
| 82 |
+
import uvicorn
|
| 83 |
+
uvicorn.run("app.main:app", host="0.0.0.0", port=7860, reload=False)
|
app/models.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class ElevatorStatusUpdate(BaseModel):
|
| 5 |
+
Floor: int = Field(..., description="Current floor (1-10)", ge=1, le=10)
|
| 6 |
+
Dir: str = Field(..., description="Direction: UP, DOWN, or IDLE")
|
| 7 |
+
Doors: str = Field(..., description="Door state: CLOSED, OPENING, OPEN, CLOSING")
|
| 8 |
+
State: str = Field(..., description="FSM state: IDLE, MOVING, DOORS_OPENING, DOORS_OPEN, DOORS_CLOSING")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ElevatorCommandRequest(BaseModel):
|
| 12 |
+
floor: int = Field(..., description="Target floor (1-10)", ge=1, le=10)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ElevatorCommandResponse(BaseModel):
|
| 16 |
+
cmd: str = Field(..., description="Command string e.g. CALL_3, or NONE")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ElevatorStateResponse(BaseModel):
|
| 20 |
+
Floor: int = Field(..., description="Current floor")
|
| 21 |
+
Dir: str = Field(..., description="Direction")
|
| 22 |
+
Doors: str = Field(..., description="Door state")
|
| 23 |
+
State: str = Field(..., description="FSM state")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class RobotPose(BaseModel):
|
| 27 |
+
x: float = Field(..., description="X position in meters")
|
| 28 |
+
y: float = Field(..., description="Y position in meters")
|
| 29 |
+
theta: float = Field(..., description="Heading in radians")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class RobotStatusUpdate(BaseModel):
|
| 33 |
+
state: str = Field(..., description="Nav state: IDLE, NAVIGATING, ARRIVED, ERROR")
|
| 34 |
+
floor: int = Field(..., description="Robot's current floor", ge=1, le=10)
|
| 35 |
+
pose: RobotPose = Field(..., description="Current robot pose")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class RobotGoalRequest(BaseModel):
|
| 39 |
+
floor: int = Field(..., description="Target floor (1-10)", ge=1, le=10)
|
| 40 |
+
pose: RobotPose = Field(..., description="Target pose on the floor")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class RobotGoalResponse(BaseModel):
|
| 44 |
+
goal: RobotGoalRequest | None = Field(..., description="Nav goal or null on timeout")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class RobotStateResponse(BaseModel):
|
| 48 |
+
state: str = Field(..., description="Nav state")
|
| 49 |
+
floor: int = Field(..., description="Robot's current floor")
|
| 50 |
+
pose: RobotPose = Field(..., description="Current robot pose")
|
| 51 |
+
|
| 52 |
+
class LockerCommandRequest(BaseModel):
|
| 53 |
+
locker_id: int = Field(..., description="Locker number", ge=1)
|
| 54 |
+
action: str = Field(..., description="open, close, or pulse")
|
| 55 |
+
duration_ms: int = Field(3000, description="Pulse duration in milliseconds", ge=100, le=60000)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class LockerCommandResponse(BaseModel):
|
| 59 |
+
ok: bool = Field(..., description="Whether command was accepted")
|
| 60 |
+
command: dict | None = Field(None, description="Command payload for Jetson, or null on timeout")
|
app/routers/__init__.py
ADDED
|
File without changes
|
app/routers/elevator.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
+
|
| 5 |
+
import app.state as state
|
| 6 |
+
from app.models import (
|
| 7 |
+
ElevatorCommandRequest,
|
| 8 |
+
ElevatorCommandResponse,
|
| 9 |
+
ElevatorStateResponse,
|
| 10 |
+
ElevatorStatusUpdate,
|
| 11 |
+
)
|
| 12 |
+
from app.security import require_api_key
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/api/elevator", tags=["elevator"])
|
| 15 |
+
|
| 16 |
+
LONG_POLL_TIMEOUT = 10.0
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.post("/status", status_code=200, dependencies=[Depends(require_api_key)])
|
| 20 |
+
async def receive_elevator_status(body: ElevatorStatusUpdate) -> dict:
|
| 21 |
+
"""
|
| 22 |
+
ESP32/hardware elevator reports state.
|
| 23 |
+
|
| 24 |
+
Protected because a fake client could otherwise spoof elevator state.
|
| 25 |
+
"""
|
| 26 |
+
state.elevator_state.Floor = body.Floor
|
| 27 |
+
state.elevator_state.Dir = body.Dir
|
| 28 |
+
state.elevator_state.Doors = body.Doors
|
| 29 |
+
state.elevator_state.State = body.State
|
| 30 |
+
|
| 31 |
+
await state.broadcast("elevator", {
|
| 32 |
+
"Floor": body.Floor,
|
| 33 |
+
"Dir": body.Dir,
|
| 34 |
+
"Doors": body.Doors,
|
| 35 |
+
"State": body.State,
|
| 36 |
+
})
|
| 37 |
+
return {"ok": True}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.get("/command", response_model=ElevatorCommandResponse, dependencies=[Depends(require_api_key)])
|
| 41 |
+
async def poll_elevator_command() -> ElevatorCommandResponse:
|
| 42 |
+
"""
|
| 43 |
+
ESP32/hardware elevator long-polls for elevator command.
|
| 44 |
+
|
| 45 |
+
Protected so random internet clients cannot consume elevator commands.
|
| 46 |
+
"""
|
| 47 |
+
if state.elevator_cmd is not None:
|
| 48 |
+
cmd = state.elevator_cmd
|
| 49 |
+
state.elevator_cmd = None
|
| 50 |
+
state.elevator_cmd_event.clear()
|
| 51 |
+
return ElevatorCommandResponse(cmd=cmd)
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
await asyncio.wait_for(state.elevator_cmd_event.wait(), timeout=LONG_POLL_TIMEOUT)
|
| 55 |
+
except asyncio.TimeoutError:
|
| 56 |
+
return ElevatorCommandResponse(cmd="NONE")
|
| 57 |
+
|
| 58 |
+
cmd = state.elevator_cmd
|
| 59 |
+
state.elevator_cmd = None
|
| 60 |
+
state.elevator_cmd_event.clear()
|
| 61 |
+
|
| 62 |
+
if cmd is None:
|
| 63 |
+
return ElevatorCommandResponse(cmd="NONE")
|
| 64 |
+
return ElevatorCommandResponse(cmd=cmd)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.post("/command", status_code=200, dependencies=[Depends(require_api_key)])
|
| 68 |
+
async def dispatch_elevator_command(body: ElevatorCommandRequest) -> dict:
|
| 69 |
+
"""
|
| 70 |
+
Dashboard/admin sends an elevator command.
|
| 71 |
+
|
| 72 |
+
Protected because this can command real elevator hardware later.
|
| 73 |
+
"""
|
| 74 |
+
if not 1 <= body.floor <= 10:
|
| 75 |
+
raise HTTPException(status_code=422, detail="Floor must be between 1 and 10")
|
| 76 |
+
|
| 77 |
+
state.elevator_cmd = f"CALL_{body.floor}"
|
| 78 |
+
state.elevator_cmd_event.set()
|
| 79 |
+
return {"ok": True, "cmd": state.elevator_cmd}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@router.get("/state", response_model=ElevatorStateResponse)
|
| 83 |
+
async def get_elevator_state() -> ElevatorStateResponse:
|
| 84 |
+
s = state.elevator_state
|
| 85 |
+
return ElevatorStateResponse(
|
| 86 |
+
Floor=s.Floor,
|
| 87 |
+
Dir=s.Dir,
|
| 88 |
+
Doors=s.Doors,
|
| 89 |
+
State=s.State,
|
| 90 |
+
)
|
app/routers/locker.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 5 |
+
|
| 6 |
+
import app.state as state
|
| 7 |
+
from app.models import LockerCommandRequest, LockerCommandResponse
|
| 8 |
+
from app.security import require_api_key
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/api/locker", tags=["locker"])
|
| 12 |
+
|
| 13 |
+
# 5-lock system:
|
| 14 |
+
# locker_id 1 = BASE lock
|
| 15 |
+
# locker_id 2-5 = delivery lockers 1-4
|
| 16 |
+
LOCKER_COUNT = int(os.environ.get("LOCKER_COUNT", "5"))
|
| 17 |
+
DEFAULT_LOCKER_PULSE_MS = int(os.environ.get("DEFAULT_LOCKER_PULSE_MS", "3000"))
|
| 18 |
+
MAX_LOCKER_PULSE_MS = int(os.environ.get("MAX_LOCKER_PULSE_MS", "10000"))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.post("/command", response_model=LockerCommandResponse, dependencies=[Depends(require_api_key)])
|
| 22 |
+
async def post_locker_command(req: LockerCommandRequest):
|
| 23 |
+
"""
|
| 24 |
+
Dashboard/admin creates a locker command for Jetson.
|
| 25 |
+
|
| 26 |
+
Protected because this can unlock physical locks.
|
| 27 |
+
"""
|
| 28 |
+
action = req.action.strip().lower()
|
| 29 |
+
|
| 30 |
+
if req.locker_id < 1 or req.locker_id > LOCKER_COUNT:
|
| 31 |
+
raise HTTPException(
|
| 32 |
+
status_code=400,
|
| 33 |
+
detail=f"locker_id must be between 1 and {LOCKER_COUNT}"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
if action not in ["open", "close", "pulse"]:
|
| 37 |
+
raise HTTPException(
|
| 38 |
+
status_code=400,
|
| 39 |
+
detail="action must be one of: open, close, pulse"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
duration_ms = req.duration_ms
|
| 43 |
+
|
| 44 |
+
if action == "open":
|
| 45 |
+
# Safer web meaning: open = timed pulse, not permanent energize.
|
| 46 |
+
duration_ms = DEFAULT_LOCKER_PULSE_MS
|
| 47 |
+
action = "pulse"
|
| 48 |
+
|
| 49 |
+
if action == "pulse":
|
| 50 |
+
duration_ms = max(100, min(duration_ms, MAX_LOCKER_PULSE_MS))
|
| 51 |
+
|
| 52 |
+
cmd = {
|
| 53 |
+
"locker_id": req.locker_id,
|
| 54 |
+
"action": action,
|
| 55 |
+
"duration_ms": duration_ms,
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
state.locker_cmd = cmd
|
| 59 |
+
state.locker_cmd_event.set()
|
| 60 |
+
|
| 61 |
+
await state.broadcast("locker", {
|
| 62 |
+
"event": "command_created",
|
| 63 |
+
"locker_id": req.locker_id,
|
| 64 |
+
"action": action,
|
| 65 |
+
"duration_ms": duration_ms,
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
return LockerCommandResponse(ok=True, command=cmd)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@router.get("/command", response_model=LockerCommandResponse, dependencies=[Depends(require_api_key)])
|
| 72 |
+
async def get_locker_command():
|
| 73 |
+
"""
|
| 74 |
+
Jetson long-polls this endpoint.
|
| 75 |
+
If no command exists, wait up to 10 seconds.
|
| 76 |
+
|
| 77 |
+
Protected so random clients cannot consume locker commands.
|
| 78 |
+
"""
|
| 79 |
+
try:
|
| 80 |
+
await asyncio.wait_for(state.locker_cmd_event.wait(), timeout=10.0)
|
| 81 |
+
except asyncio.TimeoutError:
|
| 82 |
+
return LockerCommandResponse(ok=True, command=None)
|
| 83 |
+
|
| 84 |
+
cmd = state.locker_cmd
|
| 85 |
+
|
| 86 |
+
# Consume command after Jetson receives it.
|
| 87 |
+
state.locker_cmd = None
|
| 88 |
+
state.locker_cmd_event.clear()
|
| 89 |
+
|
| 90 |
+
return LockerCommandResponse(ok=True, command=cmd)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@router.get("/state")
|
| 94 |
+
async def get_locker_state():
|
| 95 |
+
return {
|
| 96 |
+
"locker_count": LOCKER_COUNT,
|
| 97 |
+
"mapping": {
|
| 98 |
+
"1": "BASE",
|
| 99 |
+
"2": "LOCKER 1",
|
| 100 |
+
"3": "LOCKER 2",
|
| 101 |
+
"4": "LOCKER 3",
|
| 102 |
+
"5": "LOCKER 4",
|
| 103 |
+
},
|
| 104 |
+
"lockers": [
|
| 105 |
+
{
|
| 106 |
+
"locker_id": i,
|
| 107 |
+
"label": "BASE" if i == 1 else f"LOCKER {i - 1}",
|
| 108 |
+
"state": "unknown"
|
| 109 |
+
}
|
| 110 |
+
for i in range(1, LOCKER_COUNT + 1)
|
| 111 |
+
]
|
| 112 |
+
}
|
app/routers/orders.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import os
|
| 3 |
+
import random
|
| 4 |
+
import string
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 8 |
+
from pydantic import BaseModel, Field
|
| 9 |
+
|
| 10 |
+
import app.state as state
|
| 11 |
+
from app.models import RobotGoalRequest, RobotPose
|
| 12 |
+
from app.security import require_api_key
|
| 13 |
+
from app.supabase_client import get_supabase
|
| 14 |
+
from app.telegram import send_message
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
router = APIRouter(prefix="/api/orders", tags=["orders"])
|
| 18 |
+
|
| 19 |
+
ACTIVE_STATUSES = ["pending", "assigned", "delivering", "arrived"]
|
| 20 |
+
|
| 21 |
+
# Waypoints per floor — tune x/y/theta to match actual Nav2 map.
|
| 22 |
+
# IMPORTANT: These are placeholders until you map real delivery points per floor.
|
| 23 |
+
FLOOR_WAYPOINTS: dict[int, tuple[float, float, float]] = {
|
| 24 |
+
1: (0.0, 0.0, 0.0),
|
| 25 |
+
2: (0.0, 5.0, 0.0),
|
| 26 |
+
3: (0.0, 10.0, 0.0),
|
| 27 |
+
4: (0.0, 15.0, 0.0),
|
| 28 |
+
5: (0.0, 20.0, 0.0),
|
| 29 |
+
6: (0.0, 25.0, 0.0),
|
| 30 |
+
7: (0.0, 30.0, 0.0),
|
| 31 |
+
8: (0.0, 35.0, 0.0),
|
| 32 |
+
9: (0.0, 40.0, 0.0),
|
| 33 |
+
10: (0.0, 45.0, 0.0),
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _use_supabase() -> bool:
|
| 38 |
+
return bool(os.getenv("SUPABASE_URL") and os.getenv("SUPABASE_KEY"))
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _gen_pin() -> str:
|
| 42 |
+
return "".join(random.choices(string.digits, k=6))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _gen_short_id(existing: set[str]) -> str:
|
| 46 |
+
"""4-digit numeric ID unique among currently active orders."""
|
| 47 |
+
for _ in range(100):
|
| 48 |
+
candidate = f"{random.randint(1000, 9999)}"
|
| 49 |
+
if candidate not in existing:
|
| 50 |
+
return candidate
|
| 51 |
+
return f"{random.randint(1000, 9999)}"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _now_iso() -> str:
|
| 55 |
+
return datetime.now(timezone.utc).isoformat()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _get_active_short_ids_from_supabase() -> set[str]:
|
| 59 |
+
db = get_supabase()
|
| 60 |
+
result = (
|
| 61 |
+
db.table("orders")
|
| 62 |
+
.select("short_id")
|
| 63 |
+
.in_("status", ACTIVE_STATUSES)
|
| 64 |
+
.execute()
|
| 65 |
+
)
|
| 66 |
+
return {r["short_id"] for r in (result.data or []) if r.get("short_id")}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _order_to_waypoint(order: dict) -> dict:
|
| 70 |
+
x, y, theta = FLOOR_WAYPOINTS.get(order["floor"], (0.0, 0.0, 0.0))
|
| 71 |
+
return {
|
| 72 |
+
"order_id": order["id"],
|
| 73 |
+
"floor": order["floor"],
|
| 74 |
+
"x": x,
|
| 75 |
+
"y": y,
|
| 76 |
+
"theta": theta,
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
async def _dispatch_order(order: dict) -> None:
|
| 81 |
+
state.current_order = order
|
| 82 |
+
floor = int(order["floor"])
|
| 83 |
+
x, y, theta = FLOOR_WAYPOINTS.get(floor, (0.0, 0.0, 0.0))
|
| 84 |
+
|
| 85 |
+
order["status"] = "delivering"
|
| 86 |
+
order["dispatched_at"] = _now_iso()
|
| 87 |
+
|
| 88 |
+
if _use_supabase() and order.get("id"):
|
| 89 |
+
try:
|
| 90 |
+
db = get_supabase()
|
| 91 |
+
db.table("orders").update({
|
| 92 |
+
"status": "delivering",
|
| 93 |
+
"dispatched_at": order["dispatched_at"],
|
| 94 |
+
}).eq("id", order["id"]).execute()
|
| 95 |
+
except Exception as exc:
|
| 96 |
+
logger.warning("Supabase dispatch update failed: %s", exc)
|
| 97 |
+
|
| 98 |
+
state.robot_goal = RobotGoalRequest(
|
| 99 |
+
floor=floor,
|
| 100 |
+
pose=RobotPose(x=x, y=y, theta=theta),
|
| 101 |
+
)
|
| 102 |
+
state.robot_goal_event.set()
|
| 103 |
+
|
| 104 |
+
state.elevator_cmd = f"CALL_{floor}"
|
| 105 |
+
state.elevator_cmd_event.set()
|
| 106 |
+
|
| 107 |
+
if order.get("telegram_id"):
|
| 108 |
+
short = order.get("short_id", "")
|
| 109 |
+
pin = order.get("pin", "")
|
| 110 |
+
text = (
|
| 111 |
+
f"🤖 Your delivery is on the way to floor {floor}\!\n\n"
|
| 112 |
+
f"Order ID: `{short}`\n"
|
| 113 |
+
f"PIN \(tap to reveal\): ||{pin}||"
|
| 114 |
+
)
|
| 115 |
+
try:
|
| 116 |
+
await send_message(order["telegram_id"], text, parse_mode="MarkdownV2")
|
| 117 |
+
except Exception as exc:
|
| 118 |
+
logger.warning("Telegram send failed: %s", exc)
|
| 119 |
+
|
| 120 |
+
await state.broadcast("order_dispatched", {
|
| 121 |
+
"id": order["id"],
|
| 122 |
+
"short_id": order.get("short_id"),
|
| 123 |
+
"floor": floor,
|
| 124 |
+
"status": "delivering",
|
| 125 |
+
"queue_remaining": len(state.delivery_queue),
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
logger.info(
|
| 129 |
+
"Dispatched order %s to floor %s (%d remaining)",
|
| 130 |
+
order["id"], floor, len(state.delivery_queue)
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class OrderCreate(BaseModel):
|
| 135 |
+
floor: int = Field(..., ge=1, le=10)
|
| 136 |
+
room: str | None = Field(None, description="Room ID, e.g. R01")
|
| 137 |
+
telegram_id: str | None = Field(None, description="Telegram chat_id")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class OrderConfirm(BaseModel):
|
| 141 |
+
order_short_id: str = Field(..., description="4-digit numeric order ID")
|
| 142 |
+
pin: str = Field(..., description="6-digit PIN")
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@router.post("", status_code=201)
|
| 146 |
+
async def create_order(body: OrderCreate) -> dict:
|
| 147 |
+
"""Public customer endpoint."""
|
| 148 |
+
pin = _gen_pin()
|
| 149 |
+
|
| 150 |
+
if not _use_supabase():
|
| 151 |
+
existing = {o.get("short_id") for o in state.dev_orders if o.get("short_id")}
|
| 152 |
+
short = _gen_short_id(existing)
|
| 153 |
+
state.dev_order_seq += 1
|
| 154 |
+
|
| 155 |
+
order = {
|
| 156 |
+
"id": f"dev-{state.dev_order_seq}",
|
| 157 |
+
"floor": body.floor,
|
| 158 |
+
"room": body.room,
|
| 159 |
+
"pin": pin,
|
| 160 |
+
"short_id": short,
|
| 161 |
+
"telegram_id": body.telegram_id,
|
| 162 |
+
"status": "pending",
|
| 163 |
+
"created_at": _now_iso(),
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
state.dev_orders.insert(0, order)
|
| 167 |
+
logger.info("Dev order created: %s short_id=%s floor=%s", order["id"], short, body.floor)
|
| 168 |
+
|
| 169 |
+
else:
|
| 170 |
+
existing = _get_active_short_ids_from_supabase()
|
| 171 |
+
short = _gen_short_id(existing)
|
| 172 |
+
|
| 173 |
+
db = get_supabase()
|
| 174 |
+
result = (
|
| 175 |
+
db.table("orders")
|
| 176 |
+
.insert({
|
| 177 |
+
"floor": body.floor,
|
| 178 |
+
"room": body.room,
|
| 179 |
+
"pin": pin,
|
| 180 |
+
"short_id": short,
|
| 181 |
+
"telegram_id": body.telegram_id,
|
| 182 |
+
"status": "pending",
|
| 183 |
+
})
|
| 184 |
+
.execute()
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
if not result.data:
|
| 188 |
+
raise HTTPException(status_code=500, detail="Supabase insert returned no data")
|
| 189 |
+
|
| 190 |
+
order = result.data[0]
|
| 191 |
+
logger.info("Order created: %s short_id=%s floor=%s", order["id"], short, body.floor)
|
| 192 |
+
|
| 193 |
+
if body.telegram_id:
|
| 194 |
+
text = (
|
| 195 |
+
f"✅ Order placed\! Floor: {body.floor}\n\n"
|
| 196 |
+
f"Order ID: `{short}`\n"
|
| 197 |
+
f"PIN \(tap to reveal\): ||{pin}||"
|
| 198 |
+
)
|
| 199 |
+
try:
|
| 200 |
+
await send_message(body.telegram_id, text, parse_mode="MarkdownV2")
|
| 201 |
+
except Exception as exc:
|
| 202 |
+
logger.warning("Telegram send failed: %s", exc)
|
| 203 |
+
|
| 204 |
+
await state.broadcast("order_created", {
|
| 205 |
+
"id": order["id"],
|
| 206 |
+
"short_id": short,
|
| 207 |
+
"floor": body.floor,
|
| 208 |
+
"room": body.room,
|
| 209 |
+
"status": "pending",
|
| 210 |
+
})
|
| 211 |
+
|
| 212 |
+
return {
|
| 213 |
+
"id": order["id"],
|
| 214 |
+
"short_id": short,
|
| 215 |
+
"floor": body.floor,
|
| 216 |
+
"room": body.room,
|
| 217 |
+
"pin": pin,
|
| 218 |
+
"status": "pending",
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
@router.get("", dependencies=[Depends(require_api_key)])
|
| 223 |
+
async def list_orders() -> dict:
|
| 224 |
+
"""Admin/dashboard endpoint."""
|
| 225 |
+
if not _use_supabase():
|
| 226 |
+
return {"orders": list(state.dev_orders)}
|
| 227 |
+
|
| 228 |
+
db = get_supabase()
|
| 229 |
+
result = (
|
| 230 |
+
db.table("orders")
|
| 231 |
+
.select("*")
|
| 232 |
+
.order("created_at", desc=True)
|
| 233 |
+
.limit(100)
|
| 234 |
+
.execute()
|
| 235 |
+
)
|
| 236 |
+
return {"orders": result.data or []}
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
@router.get("/active", dependencies=[Depends(require_api_key)])
|
| 240 |
+
async def get_active_order() -> dict:
|
| 241 |
+
return {
|
| 242 |
+
"order": state.current_order,
|
| 243 |
+
"queue_remaining": len(state.delivery_queue),
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
@router.get("/waypoints", dependencies=[Depends(require_api_key)])
|
| 248 |
+
async def get_waypoints() -> dict:
|
| 249 |
+
waypoints = []
|
| 250 |
+
if state.current_order:
|
| 251 |
+
waypoints.append(_order_to_waypoint(state.current_order))
|
| 252 |
+
for o in state.delivery_queue:
|
| 253 |
+
waypoints.append(_order_to_waypoint(o))
|
| 254 |
+
return {"waypoints": waypoints}
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
@router.post("/start_run", dependencies=[Depends(require_api_key)])
|
| 258 |
+
async def start_delivery_run() -> dict:
|
| 259 |
+
"""Admin endpoint. Protected because it dispatches robot/elevator commands."""
|
| 260 |
+
if state.current_order:
|
| 261 |
+
raise HTTPException(status_code=400, detail="A delivery run is already in progress")
|
| 262 |
+
|
| 263 |
+
if not _use_supabase():
|
| 264 |
+
pending = [o for o in state.dev_orders if o.get("status") == "pending"]
|
| 265 |
+
else:
|
| 266 |
+
db = get_supabase()
|
| 267 |
+
result = (
|
| 268 |
+
db.table("orders")
|
| 269 |
+
.select("*")
|
| 270 |
+
.eq("status", "pending")
|
| 271 |
+
.order("created_at")
|
| 272 |
+
.execute()
|
| 273 |
+
)
|
| 274 |
+
pending = result.data or []
|
| 275 |
+
|
| 276 |
+
if not pending:
|
| 277 |
+
raise HTTPException(status_code=400, detail="No pending orders")
|
| 278 |
+
|
| 279 |
+
if _use_supabase():
|
| 280 |
+
ids = [o["id"] for o in pending]
|
| 281 |
+
db = get_supabase()
|
| 282 |
+
db.table("orders").update({"status": "assigned"}).in_("id", ids).execute()
|
| 283 |
+
|
| 284 |
+
for o in pending:
|
| 285 |
+
o["status"] = "assigned"
|
| 286 |
+
|
| 287 |
+
state.delivery_queue = pending[1:]
|
| 288 |
+
await _dispatch_order(pending[0])
|
| 289 |
+
|
| 290 |
+
await state.broadcast("run_started", {
|
| 291 |
+
"total": len(pending),
|
| 292 |
+
"queue_remaining": len(state.delivery_queue),
|
| 293 |
+
})
|
| 294 |
+
|
| 295 |
+
logger.info("Delivery run started: %d orders", len(pending))
|
| 296 |
+
return {"ok": True, "total": len(pending), "first_floor": pending[0]["floor"]}
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
@router.post("/{order_id}/confirm")
|
| 300 |
+
async def confirm_order(order_id: str, body: OrderConfirm) -> dict:
|
| 301 |
+
"""Customer confirmation endpoint."""
|
| 302 |
+
if not _use_supabase():
|
| 303 |
+
order = next((o for o in state.dev_orders if o.get("id") == order_id), None)
|
| 304 |
+
if not order:
|
| 305 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 306 |
+
else:
|
| 307 |
+
db = get_supabase()
|
| 308 |
+
result = db.table("orders").select("*").eq("id", order_id).execute()
|
| 309 |
+
if not result.data:
|
| 310 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 311 |
+
order = result.data[0]
|
| 312 |
+
|
| 313 |
+
if order["status"] == "delivered":
|
| 314 |
+
raise HTTPException(status_code=400, detail="Order already delivered")
|
| 315 |
+
|
| 316 |
+
if order.get("short_id") != body.order_short_id:
|
| 317 |
+
raise HTTPException(status_code=400, detail="Invalid order ID")
|
| 318 |
+
if order["pin"] != body.pin:
|
| 319 |
+
raise HTTPException(status_code=400, detail="Invalid PIN")
|
| 320 |
+
|
| 321 |
+
delivered_at = _now_iso()
|
| 322 |
+
|
| 323 |
+
if _use_supabase():
|
| 324 |
+
db = get_supabase()
|
| 325 |
+
db.table("orders").update({
|
| 326 |
+
"status": "delivered",
|
| 327 |
+
"delivered_at": delivered_at,
|
| 328 |
+
}).eq("id", order_id).execute()
|
| 329 |
+
else:
|
| 330 |
+
order["status"] = "delivered"
|
| 331 |
+
order["delivered_at"] = delivered_at
|
| 332 |
+
|
| 333 |
+
if order.get("telegram_id"):
|
| 334 |
+
try:
|
| 335 |
+
await send_message(
|
| 336 |
+
order["telegram_id"],
|
| 337 |
+
f"✅ Delivery confirmed\! Floor {order['floor']}\. Thank you\!",
|
| 338 |
+
parse_mode="MarkdownV2",
|
| 339 |
+
)
|
| 340 |
+
except Exception as exc:
|
| 341 |
+
logger.warning("Telegram send failed: %s", exc)
|
| 342 |
+
|
| 343 |
+
await state.broadcast("unlock", {"order_id": order_id, "floor": order["floor"]})
|
| 344 |
+
|
| 345 |
+
if state.delivery_queue:
|
| 346 |
+
next_order = state.delivery_queue.pop(0)
|
| 347 |
+
await _dispatch_order(next_order)
|
| 348 |
+
else:
|
| 349 |
+
state.current_order = None
|
| 350 |
+
await state.broadcast("run_complete", {})
|
| 351 |
+
|
| 352 |
+
return {"ok": True}
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
@router.post("/{order_id}/cancel", dependencies=[Depends(require_api_key)])
|
| 356 |
+
async def cancel_order(order_id: str) -> dict:
|
| 357 |
+
"""Admin endpoint."""
|
| 358 |
+
if not _use_supabase():
|
| 359 |
+
order = next((o for o in state.dev_orders if o.get("id") == order_id), None)
|
| 360 |
+
if not order:
|
| 361 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 362 |
+
if order.get("status") in ("delivered", "cancelled"):
|
| 363 |
+
raise HTTPException(status_code=400, detail="Cannot cancel this order")
|
| 364 |
+
order["status"] = "cancelled"
|
| 365 |
+
order["cancelled_at"] = _now_iso()
|
| 366 |
+
cancelled_order = order
|
| 367 |
+
else:
|
| 368 |
+
db = get_supabase()
|
| 369 |
+
result = db.table("orders").select("id,status,short_id,telegram_id").eq("id", order_id).execute()
|
| 370 |
+
if not result.data:
|
| 371 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 372 |
+
if result.data[0]["status"] in ("delivered", "cancelled"):
|
| 373 |
+
raise HTTPException(status_code=400, detail="Cannot cancel this order")
|
| 374 |
+
db.table("orders").update({
|
| 375 |
+
"status": "cancelled",
|
| 376 |
+
"cancelled_at": _now_iso(),
|
| 377 |
+
}).eq("id", order_id).execute()
|
| 378 |
+
cancelled_order = result.data[0]
|
| 379 |
+
|
| 380 |
+
if state.current_order and state.current_order.get("id") == order_id:
|
| 381 |
+
state.current_order = None
|
| 382 |
+
state.delivery_queue.clear()
|
| 383 |
+
|
| 384 |
+
state.delivery_queue = [o for o in state.delivery_queue if o["id"] != order_id]
|
| 385 |
+
|
| 386 |
+
if cancelled_order.get("telegram_id"):
|
| 387 |
+
short = cancelled_order.get("short_id", "")
|
| 388 |
+
try:
|
| 389 |
+
await send_message(
|
| 390 |
+
cancelled_order["telegram_id"],
|
| 391 |
+
f"❌ Order `{short}` has been cancelled\. Please contact support if this was unexpected\.",
|
| 392 |
+
parse_mode="MarkdownV2",
|
| 393 |
+
)
|
| 394 |
+
except Exception as exc:
|
| 395 |
+
logger.warning("Telegram send failed: %s", exc)
|
| 396 |
+
|
| 397 |
+
await state.broadcast("order_cancelled", {"id": order_id})
|
| 398 |
+
return {"ok": True}
|
app/routers/robot.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
|
| 4 |
+
from app.supabase_client import get_supabase
|
| 5 |
+
from app.telegram import send_message
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 11 |
+
|
| 12 |
+
import app.state as state
|
| 13 |
+
from app.models import (
|
| 14 |
+
RobotGoalRequest,
|
| 15 |
+
RobotGoalResponse,
|
| 16 |
+
RobotStateResponse,
|
| 17 |
+
RobotStatusUpdate,
|
| 18 |
+
)
|
| 19 |
+
from app.security import require_api_key
|
| 20 |
+
|
| 21 |
+
router = APIRouter(prefix="/api/robot", tags=["robot"])
|
| 22 |
+
|
| 23 |
+
LONG_POLL_TIMEOUT = 10.0
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _use_supabase() -> bool:
|
| 27 |
+
return bool(os.getenv("SUPABASE_URL") and os.getenv("SUPABASE_KEY"))
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
async def notify_current_order_arrived() -> None:
|
| 31 |
+
order = state.current_order
|
| 32 |
+
if not order:
|
| 33 |
+
return
|
| 34 |
+
|
| 35 |
+
if order.get("_arrival_notified"):
|
| 36 |
+
return
|
| 37 |
+
|
| 38 |
+
order["_arrival_notified"] = True
|
| 39 |
+
order["status"] = "arrived"
|
| 40 |
+
order["arrived_at"] = datetime.now(timezone.utc).isoformat()
|
| 41 |
+
|
| 42 |
+
if _use_supabase() and order.get("id"):
|
| 43 |
+
try:
|
| 44 |
+
db = get_supabase()
|
| 45 |
+
db.table("orders").update({
|
| 46 |
+
"status": "arrived",
|
| 47 |
+
"arrived_at": order["arrived_at"],
|
| 48 |
+
}).eq("id", order["id"]).execute()
|
| 49 |
+
except Exception as exc:
|
| 50 |
+
# Do not break robot status reporting because DB update failed.
|
| 51 |
+
print("Supabase arrival update failed:", exc)
|
| 52 |
+
|
| 53 |
+
chat_id = order.get("telegram_id")
|
| 54 |
+
floor = order.get("floor")
|
| 55 |
+
room = order.get("room") or "your room"
|
| 56 |
+
short_id = order.get("short_id")
|
| 57 |
+
pin = order.get("pin")
|
| 58 |
+
|
| 59 |
+
if chat_id:
|
| 60 |
+
text = (
|
| 61 |
+
f"🤖 Your delivery robot has arrived at floor {floor}, {room}.\n\n"
|
| 62 |
+
f"Order ID: `{short_id}`\n"
|
| 63 |
+
f"PIN \(tap to reveal\): ||{pin}||"
|
| 64 |
+
)
|
| 65 |
+
await send_message(chat_id, text, parse_mode="MarkdownV2")
|
| 66 |
+
|
| 67 |
+
await state.broadcast("order_arrived", {
|
| 68 |
+
"order_id": order.get("id"),
|
| 69 |
+
"floor": floor,
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
@router.post("/status", status_code=200, dependencies=[Depends(require_api_key)])
|
| 73 |
+
async def receive_robot_status(body: RobotStatusUpdate) -> dict:
|
| 74 |
+
"""
|
| 75 |
+
Jetson reports robot state.
|
| 76 |
+
|
| 77 |
+
Protected because a fake client could otherwise spoof robot state.
|
| 78 |
+
"""
|
| 79 |
+
state.robot_state.state = body.state
|
| 80 |
+
state.robot_state.floor = body.floor
|
| 81 |
+
state.robot_state.pose = body.pose
|
| 82 |
+
|
| 83 |
+
await state.broadcast("robot", {
|
| 84 |
+
"state": body.state,
|
| 85 |
+
"floor": body.floor,
|
| 86 |
+
"pose": body.pose.model_dump(),
|
| 87 |
+
})
|
| 88 |
+
|
| 89 |
+
if body.state.upper() == "ARRIVED":
|
| 90 |
+
await notify_current_order_arrived()
|
| 91 |
+
|
| 92 |
+
return {"ok": True}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@router.post("/simulate_arrival", status_code=200, dependencies=[Depends(require_api_key)])
|
| 96 |
+
async def simulate_arrival() -> dict:
|
| 97 |
+
"""Simulate robot arriving at destination — for demo/testing without a physical robot."""
|
| 98 |
+
if not state.current_order:
|
| 99 |
+
raise HTTPException(status_code=400, detail="No active delivery")
|
| 100 |
+
await notify_current_order_arrived()
|
| 101 |
+
return {"ok": True}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@router.get("/goal", response_model=RobotGoalResponse, dependencies=[Depends(require_api_key)])
|
| 105 |
+
async def poll_robot_goal() -> RobotGoalResponse:
|
| 106 |
+
"""
|
| 107 |
+
Jetson long-polls for the next robot goal.
|
| 108 |
+
|
| 109 |
+
Protected so random internet clients cannot consume/steal pending robot goals.
|
| 110 |
+
"""
|
| 111 |
+
if state.robot_goal is not None:
|
| 112 |
+
goal = state.robot_goal
|
| 113 |
+
state.robot_goal = None
|
| 114 |
+
state.robot_goal_event.clear()
|
| 115 |
+
return RobotGoalResponse(goal=goal)
|
| 116 |
+
|
| 117 |
+
try:
|
| 118 |
+
await asyncio.wait_for(state.robot_goal_event.wait(), timeout=LONG_POLL_TIMEOUT)
|
| 119 |
+
except asyncio.TimeoutError:
|
| 120 |
+
return RobotGoalResponse(goal=None)
|
| 121 |
+
|
| 122 |
+
goal = state.robot_goal
|
| 123 |
+
state.robot_goal = None
|
| 124 |
+
state.robot_goal_event.clear()
|
| 125 |
+
|
| 126 |
+
return RobotGoalResponse(goal=goal)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@router.post("/goal", status_code=200, dependencies=[Depends(require_api_key)])
|
| 130 |
+
async def dispatch_robot_goal(body: RobotGoalRequest) -> dict:
|
| 131 |
+
"""
|
| 132 |
+
Dashboard/admin sends a Nav2 goal.
|
| 133 |
+
|
| 134 |
+
Protected because this directly commands robot navigation.
|
| 135 |
+
"""
|
| 136 |
+
state.robot_goal = body
|
| 137 |
+
state.robot_goal_event.set()
|
| 138 |
+
|
| 139 |
+
state.elevator_cmd = f"CALL_{body.floor}"
|
| 140 |
+
state.elevator_cmd_event.set()
|
| 141 |
+
|
| 142 |
+
await state.broadcast("robot", {
|
| 143 |
+
"state": "DISPATCHED",
|
| 144 |
+
"floor": body.floor,
|
| 145 |
+
"pose": body.pose.model_dump(),
|
| 146 |
+
})
|
| 147 |
+
return {"ok": True}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@router.get("/state", response_model=RobotStateResponse)
|
| 151 |
+
async def get_robot_state() -> RobotStateResponse:
|
| 152 |
+
"""
|
| 153 |
+
Public read-only state endpoint.
|
| 154 |
+
|
| 155 |
+
Keep open so the dashboard can render basic state and you can test with curl.
|
| 156 |
+
"""
|
| 157 |
+
s = state.robot_state
|
| 158 |
+
return RobotStateResponse(
|
| 159 |
+
state=s.state,
|
| 160 |
+
floor=s.floor,
|
| 161 |
+
pose=s.pose,
|
| 162 |
+
)
|
app/security.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import secrets
|
| 3 |
+
from fastapi import Header, HTTPException, status
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
ROBOT_API_KEY = os.getenv("ROBOT_API_KEY", "")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def require_api_key(x_api_key: str | None = Header(default=None)):
|
| 10 |
+
"""
|
| 11 |
+
Simple API-key protection for dangerous robot endpoints.
|
| 12 |
+
|
| 13 |
+
Client must send:
|
| 14 |
+
X-API-Key: your_secret_key
|
| 15 |
+
"""
|
| 16 |
+
if not ROBOT_API_KEY:
|
| 17 |
+
raise HTTPException(
|
| 18 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 19 |
+
detail="Server ROBOT_API_KEY is not configured",
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
if not x_api_key or not secrets.compare_digest(x_api_key, ROBOT_API_KEY):
|
| 23 |
+
raise HTTPException(
|
| 24 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 25 |
+
detail="Invalid or missing API key",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
return True
|
app/simulator.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
import app.state as state
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
# Constants matching ESP_Code.ino FSM timings (in seconds)
|
| 8 |
+
TIME_PER_FLOOR = 2.0
|
| 9 |
+
TIME_DOOR_OPENING = 1.5
|
| 10 |
+
TIME_DOORS_OPEN = 4.0
|
| 11 |
+
TIME_DOOR_CLOSING = 1.5
|
| 12 |
+
|
| 13 |
+
async def run_elevator_simulator():
|
| 14 |
+
"""
|
| 15 |
+
Virtual Elevator Simulator that runs as a background task.
|
| 16 |
+
Monitors state.elevator_cmd_event, updates state.elevator_state,
|
| 17 |
+
and broadcasts state transitions via Server-Sent Events (SSE).
|
| 18 |
+
"""
|
| 19 |
+
logger.info("Virtual Elevator Simulator started")
|
| 20 |
+
|
| 21 |
+
# Initialize state
|
| 22 |
+
state.elevator_state.Floor = 1
|
| 23 |
+
state.elevator_state.Dir = "IDLE"
|
| 24 |
+
state.elevator_state.Doors = "CLOSED"
|
| 25 |
+
state.elevator_state.State = "IDLE"
|
| 26 |
+
|
| 27 |
+
while True:
|
| 28 |
+
try:
|
| 29 |
+
# Wait until a command is set
|
| 30 |
+
await state.elevator_cmd_event.wait()
|
| 31 |
+
|
| 32 |
+
cmd = state.elevator_cmd
|
| 33 |
+
if not cmd or not cmd.startswith("CALL_"):
|
| 34 |
+
state.elevator_cmd_event.clear()
|
| 35 |
+
await asyncio.sleep(0.1)
|
| 36 |
+
continue
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
target_floor = int(cmd.split("_")[1])
|
| 40 |
+
except (IndexError, ValueError):
|
| 41 |
+
logger.error("Invalid elevator command format: %s", cmd)
|
| 42 |
+
state.elevator_cmd = None
|
| 43 |
+
state.elevator_cmd_event.clear()
|
| 44 |
+
continue
|
| 45 |
+
|
| 46 |
+
current_floor = state.elevator_state.Floor
|
| 47 |
+
if target_floor == current_floor:
|
| 48 |
+
logger.info("Elevator is already at target floor %d", target_floor)
|
| 49 |
+
state.elevator_cmd = None
|
| 50 |
+
state.elevator_cmd_event.clear()
|
| 51 |
+
continue
|
| 52 |
+
|
| 53 |
+
# Consume the command
|
| 54 |
+
state.elevator_cmd = None
|
| 55 |
+
state.elevator_cmd_event.clear()
|
| 56 |
+
|
| 57 |
+
# 1. State: MOVING
|
| 58 |
+
state.elevator_state.State = "MOVING"
|
| 59 |
+
state.elevator_state.Dir = "UP" if target_floor > current_floor else "DOWN"
|
| 60 |
+
state.elevator_state.Doors = "CLOSED"
|
| 61 |
+
|
| 62 |
+
logger.info("Elevator moving from floor %d to %d (%s)", current_floor, target_floor, state.elevator_state.Dir)
|
| 63 |
+
await state.broadcast("elevator", {
|
| 64 |
+
"Floor": state.elevator_state.Floor,
|
| 65 |
+
"Dir": state.elevator_state.Dir,
|
| 66 |
+
"Doors": state.elevator_state.Doors,
|
| 67 |
+
"State": state.elevator_state.State,
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
# Move floor-by-floor
|
| 71 |
+
step = 1 if target_floor > current_floor else -1
|
| 72 |
+
while state.elevator_state.Floor != target_floor:
|
| 73 |
+
await asyncio.sleep(TIME_PER_FLOOR)
|
| 74 |
+
state.elevator_state.Floor += step
|
| 75 |
+
logger.info("Elevator reached floor %d", state.elevator_state.Floor)
|
| 76 |
+
await state.broadcast("elevator", {
|
| 77 |
+
"Floor": state.elevator_state.Floor,
|
| 78 |
+
"Dir": state.elevator_state.Dir,
|
| 79 |
+
"Doors": state.elevator_state.Doors,
|
| 80 |
+
"State": state.elevator_state.State,
|
| 81 |
+
})
|
| 82 |
+
|
| 83 |
+
# 2. State: DOORS_OPENING
|
| 84 |
+
state.elevator_state.Dir = "IDLE"
|
| 85 |
+
state.elevator_state.State = "DOORS_OPENING"
|
| 86 |
+
state.elevator_state.Doors = "OPENING"
|
| 87 |
+
logger.info("Elevator arrived at floor %d. Opening doors...", state.elevator_state.Floor)
|
| 88 |
+
await state.broadcast("elevator", {
|
| 89 |
+
"Floor": state.elevator_state.Floor,
|
| 90 |
+
"Dir": state.elevator_state.Dir,
|
| 91 |
+
"Doors": state.elevator_state.Doors,
|
| 92 |
+
"State": state.elevator_state.State,
|
| 93 |
+
})
|
| 94 |
+
await asyncio.sleep(TIME_DOOR_OPENING)
|
| 95 |
+
|
| 96 |
+
# 3. State: DOORS_OPEN
|
| 97 |
+
state.elevator_state.State = "DOORS_OPEN"
|
| 98 |
+
state.elevator_state.Doors = "OPEN"
|
| 99 |
+
logger.info("Elevator doors are OPEN at floor %d", state.elevator_state.Floor)
|
| 100 |
+
await state.broadcast("elevator", {
|
| 101 |
+
"Floor": state.elevator_state.Floor,
|
| 102 |
+
"Dir": state.elevator_state.Dir,
|
| 103 |
+
"Doors": state.elevator_state.Doors,
|
| 104 |
+
"State": state.elevator_state.State,
|
| 105 |
+
})
|
| 106 |
+
await asyncio.sleep(TIME_DOORS_OPEN)
|
| 107 |
+
|
| 108 |
+
# 4. State: DOORS_CLOSING
|
| 109 |
+
state.elevator_state.State = "DOORS_CLOSING"
|
| 110 |
+
state.elevator_state.Doors = "CLOSING"
|
| 111 |
+
logger.info("Elevator doors are CLOSING at floor %d", state.elevator_state.Floor)
|
| 112 |
+
await state.broadcast("elevator", {
|
| 113 |
+
"Floor": state.elevator_state.Floor,
|
| 114 |
+
"Dir": state.elevator_state.Dir,
|
| 115 |
+
"Doors": state.elevator_state.Doors,
|
| 116 |
+
"State": state.elevator_state.State,
|
| 117 |
+
})
|
| 118 |
+
await asyncio.sleep(TIME_DOOR_CLOSING)
|
| 119 |
+
|
| 120 |
+
# 5. State: IDLE / CLOSED
|
| 121 |
+
state.elevator_state.State = "IDLE"
|
| 122 |
+
state.elevator_state.Doors = "CLOSED"
|
| 123 |
+
logger.info("Elevator is now IDLE at floor %d", state.elevator_state.Floor)
|
| 124 |
+
await state.broadcast("elevator", {
|
| 125 |
+
"Floor": state.elevator_state.Floor,
|
| 126 |
+
"Dir": state.elevator_state.Dir,
|
| 127 |
+
"Doors": state.elevator_state.Doors,
|
| 128 |
+
"State": state.elevator_state.State,
|
| 129 |
+
})
|
| 130 |
+
|
| 131 |
+
except asyncio.CancelledError:
|
| 132 |
+
logger.info("Elevator simulator task cancelled")
|
| 133 |
+
break
|
| 134 |
+
except Exception as e:
|
| 135 |
+
logger.error("Exception in elevator simulator: %s", e, exc_info=True)
|
| 136 |
+
await asyncio.sleep(1.0)
|
app/state.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from dataclasses import dataclass, field
|
| 3 |
+
|
| 4 |
+
from app.models import RobotGoalRequest, RobotPose
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class ElevatorState:
|
| 9 |
+
Floor: int = 1
|
| 10 |
+
Dir: str = "IDLE"
|
| 11 |
+
Doors: str = "CLOSED"
|
| 12 |
+
State: str = "IDLE"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class RobotState:
|
| 17 |
+
state: str = "IDLE"
|
| 18 |
+
floor: int = 1
|
| 19 |
+
pose: RobotPose = field(default_factory=lambda: RobotPose(x=0.0, y=0.0, theta=0.0))
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
elevator_state = ElevatorState()
|
| 23 |
+
robot_state = RobotState()
|
| 24 |
+
|
| 25 |
+
elevator_cmd: str | None = None
|
| 26 |
+
robot_goal: RobotGoalRequest | None = None
|
| 27 |
+
locker_cmd: dict | None = None
|
| 28 |
+
|
| 29 |
+
elevator_cmd_event = asyncio.Event()
|
| 30 |
+
robot_goal_event = asyncio.Event()
|
| 31 |
+
locker_cmd_event = asyncio.Event()
|
| 32 |
+
|
| 33 |
+
sse_queues: set[asyncio.Queue] = set()
|
| 34 |
+
|
| 35 |
+
# Active order being delivered right now
|
| 36 |
+
current_order: dict | None = None
|
| 37 |
+
|
| 38 |
+
# Remaining orders queued for this delivery run (FIFO)
|
| 39 |
+
delivery_queue: list[dict] = []
|
| 40 |
+
|
| 41 |
+
# Dev-mode in-memory orders (used when Supabase env is missing)
|
| 42 |
+
dev_orders: list[dict] = []
|
| 43 |
+
dev_order_seq: int = 0
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def broadcast(event_type: str, data: dict) -> None:
|
| 47 |
+
payload = {"type": event_type, **data}
|
| 48 |
+
for q in list(sse_queues):
|
| 49 |
+
await q.put(payload)
|
app/supabase_client.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
from supabase import Client, create_client
|
| 4 |
+
|
| 5 |
+
_client: Client | None = None
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def get_supabase() -> Client:
|
| 9 |
+
global _client
|
| 10 |
+
if _client is None:
|
| 11 |
+
url = os.environ["SUPABASE_URL"]
|
| 12 |
+
key = os.environ["SUPABASE_KEY"]
|
| 13 |
+
_client = create_client(url, key)
|
| 14 |
+
return _client
|
app/telegram.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
import httpx
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
_TOKEN: str | None = None
|
| 9 |
+
_BASE_URL: str | None = None
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _setup() -> tuple[str | None, str | None]:
|
| 13 |
+
global _TOKEN, _BASE_URL
|
| 14 |
+
if _TOKEN is None:
|
| 15 |
+
_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")
|
| 16 |
+
if _BASE_URL is None:
|
| 17 |
+
proxy = os.environ.get("TELEGRAM_PROXY_URL", "").rstrip("/")
|
| 18 |
+
_BASE_URL = proxy if proxy else "https://api.telegram.org"
|
| 19 |
+
return _TOKEN, _BASE_URL
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def send_message(chat_id: str, text: str, parse_mode: str | None = None) -> None:
|
| 23 |
+
token, base_url = _setup()
|
| 24 |
+
if not token:
|
| 25 |
+
logger.warning("TELEGRAM_BOT_TOKEN not set — skipping")
|
| 26 |
+
return
|
| 27 |
+
if not chat_id:
|
| 28 |
+
logger.warning("No chat_id — skipping")
|
| 29 |
+
return
|
| 30 |
+
|
| 31 |
+
url = f"{base_url}/bot{token}/sendMessage"
|
| 32 |
+
payload: dict = {"chat_id": chat_id, "text": text}
|
| 33 |
+
if parse_mode:
|
| 34 |
+
payload["parse_mode"] = parse_mode
|
| 35 |
+
|
| 36 |
+
logger.info("Sending Telegram via %s to %s", base_url, chat_id)
|
| 37 |
+
async with httpx.AsyncClient() as client:
|
| 38 |
+
try:
|
| 39 |
+
resp = await client.post(url, json=payload, timeout=10)
|
| 40 |
+
if resp.is_success:
|
| 41 |
+
logger.info("Telegram OK → %s", chat_id)
|
| 42 |
+
else:
|
| 43 |
+
logger.error("Telegram API error %s: %s", resp.status_code, resp.text)
|
| 44 |
+
except Exception as e:
|
| 45 |
+
logger.error("Telegram send failed: %s — %s", type(e).__name__, e)
|
cloudflare_worker.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Deploy this as a Cloudflare Worker.
|
| 2 |
+
// It proxies all requests to api.telegram.org.
|
| 3 |
+
//
|
| 4 |
+
// Steps:
|
| 5 |
+
// 1. Go to https://dash.cloudflare.com → Workers & Pages → Create Worker
|
| 6 |
+
// 2. Paste this code, click Deploy
|
| 7 |
+
// 3. Copy the worker URL (e.g. https://tg-proxy.yourname.workers.dev)
|
| 8 |
+
// 4. Add TELEGRAM_PROXY_URL=https://tg-proxy.yourname.workers.dev
|
| 9 |
+
// to HuggingFace Spaces secrets
|
| 10 |
+
|
| 11 |
+
export default {
|
| 12 |
+
async fetch(request) {
|
| 13 |
+
const url = new URL(request.url);
|
| 14 |
+
const target = new URL("https://api.telegram.org");
|
| 15 |
+
target.pathname = url.pathname;
|
| 16 |
+
target.search = url.search;
|
| 17 |
+
|
| 18 |
+
const proxied = new Request(target.toString(), {
|
| 19 |
+
method: request.method,
|
| 20 |
+
headers: request.headers,
|
| 21 |
+
body: request.method !== "GET" && request.method !== "HEAD"
|
| 22 |
+
? request.body : undefined,
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
return fetch(proxied);
|
| 26 |
+
},
|
| 27 |
+
};
|
customer_order.html
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="vi">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=1920">
|
| 6 |
+
<title>UEH B1 — Delivery Robot</title>
|
| 7 |
+
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700;900&family=Space+Grotesk:wght@300;400;500;600&display=swap" rel="stylesheet">
|
| 8 |
+
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
| 9 |
+
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
| 10 |
+
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
| 11 |
+
<style>
|
| 12 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 13 |
+
html, body {
|
| 14 |
+
width: 100%; height: 100%; overflow: hidden;
|
| 15 |
+
background: #000;
|
| 16 |
+
margin: 0; padding: 0;
|
| 17 |
+
}
|
| 18 |
+
#root {
|
| 19 |
+
width: 1920px; height: 1080px; overflow: hidden;
|
| 20 |
+
background: #020810;
|
| 21 |
+
font-family: 'Space Grotesk', sans-serif;
|
| 22 |
+
color: #c8dce8;
|
| 23 |
+
transform-origin: top left;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
@keyframes scanline {
|
| 27 |
+
0% { transform: translateY(-100%); }
|
| 28 |
+
100% { transform: translateY(100vh); }
|
| 29 |
+
}
|
| 30 |
+
@keyframes pulse-ring {
|
| 31 |
+
0% { transform: scale(0.8); opacity: 0.8; }
|
| 32 |
+
50% { transform: scale(1.15); opacity: 0.3; }
|
| 33 |
+
100% { transform: scale(0.8); opacity: 0.8; }
|
| 34 |
+
}
|
| 35 |
+
@keyframes blink-cursor {
|
| 36 |
+
0%, 100% { opacity: 1; }
|
| 37 |
+
50% { opacity: 0; }
|
| 38 |
+
}
|
| 39 |
+
@keyframes fadeInUp {
|
| 40 |
+
from { opacity: 0; transform: translateY(30px); }
|
| 41 |
+
to { opacity: 1; transform: translateY(0); }
|
| 42 |
+
}
|
| 43 |
+
@keyframes glitch {
|
| 44 |
+
0%,100% { clip-path: none; transform: none; }
|
| 45 |
+
5% { clip-path: inset(20% 0 60% 0); transform: translateX(-4px); }
|
| 46 |
+
10% { clip-path: inset(50% 0 20% 0); transform: translateX(4px); }
|
| 47 |
+
15% { clip-path: none; transform: none; }
|
| 48 |
+
}
|
| 49 |
+
@keyframes rotateRing {
|
| 50 |
+
from { transform: rotate(0deg); }
|
| 51 |
+
to { transform: rotate(360deg); }
|
| 52 |
+
}
|
| 53 |
+
@keyframes rotateRingRev {
|
| 54 |
+
from { transform: rotate(0deg); }
|
| 55 |
+
to { transform: rotate(-360deg); }
|
| 56 |
+
}
|
| 57 |
+
@keyframes float {
|
| 58 |
+
0%,100% { transform: translateY(0px); }
|
| 59 |
+
50% { transform: translateY(-12px); }
|
| 60 |
+
}
|
| 61 |
+
@keyframes successBurst {
|
| 62 |
+
0% { transform: scale(0); opacity: 0; }
|
| 63 |
+
60% { transform: scale(1.2); opacity: 1; }
|
| 64 |
+
100% { transform: scale(1); opacity: 1; }
|
| 65 |
+
}
|
| 66 |
+
@keyframes countdownShrink {
|
| 67 |
+
from { width: 100%; }
|
| 68 |
+
to { width: 0%; }
|
| 69 |
+
}
|
| 70 |
+
@keyframes gridFade {
|
| 71 |
+
0%,100% { opacity: 0.04; }
|
| 72 |
+
50% { opacity: 0.08; }
|
| 73 |
+
}
|
| 74 |
+
@keyframes shake {
|
| 75 |
+
0%,100% { transform: translateX(0); }
|
| 76 |
+
20% { transform: translateX(-12px); }
|
| 77 |
+
40% { transform: translateX(12px); }
|
| 78 |
+
60% { transform: translateX(-8px); }
|
| 79 |
+
80% { transform: translateX(8px); }
|
| 80 |
+
}
|
| 81 |
+
@keyframes neonFlicker {
|
| 82 |
+
0%,100% { text-shadow: 0 0 20px #00c8e8, 0 0 40px #00c8e880; }
|
| 83 |
+
50% { text-shadow: 0 0 30px #00c8e8, 0 0 60px #00c8e8, 0 0 80px #00c8e840; }
|
| 84 |
+
}
|
| 85 |
+
@keyframes compBlink {
|
| 86 |
+
0%,100% { opacity: 0.25; }
|
| 87 |
+
50% { opacity: 0.6; }
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.screen { width: 1920px; height: 1080px; position: relative; overflow: hidden; }
|
| 91 |
+
|
| 92 |
+
/* Grid background */
|
| 93 |
+
.grid-bg {
|
| 94 |
+
position: absolute; inset: 0;
|
| 95 |
+
background-image:
|
| 96 |
+
linear-gradient(rgba(0,200,232,0.05) 1px, transparent 1px),
|
| 97 |
+
linear-gradient(90deg, rgba(0,200,232,0.05) 1px, transparent 1px);
|
| 98 |
+
background-size: 80px 80px;
|
| 99 |
+
animation: gridFade 4s ease-in-out infinite;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/* Corner decorations */
|
| 103 |
+
.corner {
|
| 104 |
+
position: absolute; width: 80px; height: 80px;
|
| 105 |
+
border-color: #00c8e8; border-style: solid; opacity: 0.5;
|
| 106 |
+
}
|
| 107 |
+
.corner-tl { top: 32px; left: 32px; border-width: 3px 0 0 3px; }
|
| 108 |
+
.corner-tr { top: 32px; right: 32px; border-width: 3px 3px 0 0; }
|
| 109 |
+
.corner-bl { bottom: 32px; left: 32px; border-width: 0 0 3px 3px; }
|
| 110 |
+
.corner-br { bottom: 32px; right: 32px; border-width: 0 3px 3px 0; }
|
| 111 |
+
|
| 112 |
+
/* Scan line effect */
|
| 113 |
+
.scanline {
|
| 114 |
+
position: absolute; left: 0; right: 0; height: 2px;
|
| 115 |
+
background: linear-gradient(90deg, transparent, rgba(0,200,232,0.4), transparent);
|
| 116 |
+
animation: scanline 6s linear infinite;
|
| 117 |
+
pointer-events: none;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/* Status bar top */
|
| 121 |
+
.status-bar {
|
| 122 |
+
position: absolute; top: 0; left: 0; right: 0; height: 64px;
|
| 123 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 124 |
+
padding: 0 60px;
|
| 125 |
+
background: linear-gradient(180deg, rgba(0,200,232,0.08) 0%, transparent 100%);
|
| 126 |
+
border-bottom: 1px solid rgba(0,200,232,0.15);
|
| 127 |
+
}
|
| 128 |
+
.status-bar .brand {
|
| 129 |
+
font-family: 'Orbitron', monospace; font-size: 18px; font-weight: 700;
|
| 130 |
+
letter-spacing: 4px; color: #00c8e8;
|
| 131 |
+
}
|
| 132 |
+
.status-indicators { display: flex; gap: 32px; align-items: center; }
|
| 133 |
+
.status-item {
|
| 134 |
+
display: flex; align-items: center; gap: 8px;
|
| 135 |
+
font-size: 13px; font-weight: 500; letter-spacing: 1px; color: #7ab0c8;
|
| 136 |
+
}
|
| 137 |
+
.status-dot {
|
| 138 |
+
width: 8px; height: 8px; border-radius: 50%; background: #00e676;
|
| 139 |
+
box-shadow: 0 0 8px #00e676;
|
| 140 |
+
}
|
| 141 |
+
.status-dot.warn { background: #f59e0b; box-shadow: 0 0 8px #f59e0b; }
|
| 142 |
+
|
| 143 |
+
/* Clock */
|
| 144 |
+
.clock {
|
| 145 |
+
font-family: 'Orbitron', monospace; font-size: 20px;
|
| 146 |
+
color: #00c8e8; letter-spacing: 3px;
|
| 147 |
+
}
|
| 148 |
+
</style>
|
| 149 |
+
</head>
|
| 150 |
+
<body>
|
| 151 |
+
<div id="root"></div>
|
| 152 |
+
<script type="text/babel">
|
| 153 |
+
const { useState, useEffect, useRef, useCallback } = React;
|
| 154 |
+
|
| 155 |
+
function useTime() {
|
| 156 |
+
const [t, setT] = useState(new Date());
|
| 157 |
+
useEffect(() => {
|
| 158 |
+
const id = setInterval(() => setT(new Date()), 1000);
|
| 159 |
+
return () => clearInterval(id);
|
| 160 |
+
}, []);
|
| 161 |
+
return t;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/* ─── Idle Screen ─── */
|
| 165 |
+
function IdleScreen({ onEnter }) {
|
| 166 |
+
const time = useTime();
|
| 167 |
+
const [glitch, setGlitch] = useState(false);
|
| 168 |
+
|
| 169 |
+
useEffect(() => {
|
| 170 |
+
const id = setInterval(() => {
|
| 171 |
+
setGlitch(true);
|
| 172 |
+
setTimeout(() => setGlitch(false), 300);
|
| 173 |
+
}, 8000);
|
| 174 |
+
return () => clearInterval(id);
|
| 175 |
+
}, []);
|
| 176 |
+
|
| 177 |
+
useEffect(() => {
|
| 178 |
+
const handler = (e) => { if (e.key === 'Enter') onEnter(); };
|
| 179 |
+
window.addEventListener('keydown', handler);
|
| 180 |
+
return () => window.removeEventListener('keydown', handler);
|
| 181 |
+
}, [onEnter]);
|
| 182 |
+
|
| 183 |
+
const hh = String(time.getHours()).padStart(2,'0');
|
| 184 |
+
const mm = String(time.getMinutes()).padStart(2,'0');
|
| 185 |
+
const ss = String(time.getSeconds()).padStart(2,'0');
|
| 186 |
+
const dateStr = time.toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
| 187 |
+
|
| 188 |
+
return (
|
| 189 |
+
<div className="screen" style={{ background: 'linear-gradient(135deg, #020810 0%, #040d18 50%, #020810 100%)' }}>
|
| 190 |
+
<div className="grid-bg" />
|
| 191 |
+
<div className="scanline" />
|
| 192 |
+
|
| 193 |
+
{/* Corners */}
|
| 194 |
+
<div className="corner corner-tl" />
|
| 195 |
+
<div className="corner corner-tr" />
|
| 196 |
+
<div className="corner corner-bl" />
|
| 197 |
+
<div className="corner corner-br" />
|
| 198 |
+
|
| 199 |
+
{/* Status bar */}
|
| 200 |
+
<div className="status-bar">
|
| 201 |
+
<div className="brand">UEH B1 · DELIVERY ROBOT</div>
|
| 202 |
+
<div className="status-indicators">
|
| 203 |
+
<div className="status-item"><div className="status-dot" /><span>ONLINE</span></div>
|
| 204 |
+
<div className="status-item"><div className="status-dot" /><span>ROS2 CONNECTED</span></div>
|
| 205 |
+
<div className="status-item"><div className="status-dot warn" /><span>FLOOR G</span></div>
|
| 206 |
+
<div className="status-item">
|
| 207 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#00e676" strokeWidth="2">
|
| 208 |
+
<rect x="2" y="7" width="18" height="11" rx="2"/><path d="M22 11v3"/>
|
| 209 |
+
<rect x="4" y="9" width="12" height="7" rx="1" fill="#00e676"/>
|
| 210 |
+
</svg>
|
| 211 |
+
<span style={{color:'#00e676'}}>87%</span>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
<div className="clock">{hh}:{mm}:{ss}</div>
|
| 215 |
+
</div>
|
| 216 |
+
|
| 217 |
+
{/* Main center */}
|
| 218 |
+
<div style={{ position:'absolute', inset:0, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:0 }}>
|
| 219 |
+
|
| 220 |
+
{/* Robot Icon — animated rings */}
|
| 221 |
+
<div style={{ position:'relative', width:320, height:320, display:'flex', alignItems:'center', justifyContent:'center', animation:'float 4s ease-in-out infinite' }}>
|
| 222 |
+
{/* Outer ring */}
|
| 223 |
+
<svg style={{ position:'absolute', inset:0, animation:'rotateRing 8s linear infinite' }} width="320" height="320" viewBox="0 0 320 320">
|
| 224 |
+
<circle cx="160" cy="160" r="148" fill="none" stroke="#00c8e820" strokeWidth="1" strokeDasharray="8 16" />
|
| 225 |
+
<circle cx="160" cy="160" r="148" fill="none" stroke="#00c8e8" strokeWidth="2" strokeDasharray="40 250" />
|
| 226 |
+
</svg>
|
| 227 |
+
{/* Inner ring */}
|
| 228 |
+
<svg style={{ position:'absolute', inset:0, animation:'rotateRingRev 5s linear infinite' }} width="320" height="320" viewBox="0 0 320 320">
|
| 229 |
+
<circle cx="160" cy="160" r="120" fill="none" stroke="#00c8e840" strokeWidth="1" strokeDasharray="4 20" />
|
| 230 |
+
<circle cx="160" cy="160" r="120" fill="none" stroke="#00c8e8" strokeWidth="1.5" strokeDasharray="20 80" />
|
| 231 |
+
</svg>
|
| 232 |
+
{/* Pulse ring */}
|
| 233 |
+
<div style={{ position:'absolute', width:200, height:200, borderRadius:'50%', border:'2px solid #00c8e840', animation:'pulse-ring 3s ease-in-out infinite' }} />
|
| 234 |
+
{/* Robot body */}
|
| 235 |
+
<div style={{ position:'relative', zIndex:2 }}>
|
| 236 |
+
<svg width="160" height="190" viewBox="0 0 160 190" fill="none">
|
| 237 |
+
{/* Antenna */}
|
| 238 |
+
<line x1="80" y1="18" x2="80" y2="6" stroke="#00c8e8" strokeWidth="2"/>
|
| 239 |
+
<circle cx="80" cy="5" r="4" fill="#00c8e8" style={{filter:'drop-shadow(0 0 8px #00c8e8)'}}/>
|
| 240 |
+
{/* Head */}
|
| 241 |
+
<rect x="45" y="18" width="70" height="38" rx="10" fill="#0d1e2d" stroke="#00c8e8" strokeWidth="2"/>
|
| 242 |
+
{/* Eyes */}
|
| 243 |
+
<rect x="56" y="28" width="18" height="11" rx="4" fill="#00c8e8" style={{filter:'drop-shadow(0 0 6px #00c8e8)'}}/>
|
| 244 |
+
<rect x="86" y="28" width="18" height="11" rx="4" fill="#00c8e8" style={{filter:'drop-shadow(0 0 6px #00c8e8)'}}/>
|
| 245 |
+
{/* Neck */}
|
| 246 |
+
<line x1="80" y1="56" x2="80" y2="62" stroke="#1a3a55" strokeWidth="3"/>
|
| 247 |
+
{/* Body frame */}
|
| 248 |
+
<rect x="26" y="62" width="108" height="100" rx="12" fill="#0d1e2d" stroke="#00c8e8" strokeWidth="2"/>
|
| 249 |
+
{/* 4 Compartments stacked */}
|
| 250 |
+
{[0,1,2,3].map(i => (
|
| 251 |
+
<g key={i}>
|
| 252 |
+
<rect x="34" y={67+i*23} width="92" height="19" rx="4"
|
| 253 |
+
fill="#050e1a" stroke="#1a3a55" strokeWidth="1.5"
|
| 254 |
+
style={{animation:`compBlink 2.4s ease-in-out infinite`, animationDelay:`${i*0.5}s`}}
|
| 255 |
+
/>
|
| 256 |
+
{/* Compartment number */}
|
| 257 |
+
<text x="42" y={67+i*23+13} fill="#1a3a55" fontSize="9" fontFamily="Orbitron" fontWeight="600">{i+1}</text>
|
| 258 |
+
{/* Lock icon */}
|
| 259 |
+
<rect x="112" y={67+i*23+4} width="8" height="7" rx="1" fill="none" stroke="#1a3a55" strokeWidth="1"/>
|
| 260 |
+
<path d={`M${113} ${67+i*23+4} Q${116} ${67+i*23} ${119} ${67+i*23+4}`} fill="none" stroke="#1a3a55" strokeWidth="1"/>
|
| 261 |
+
{/* Divider line */}
|
| 262 |
+
<line x1="52" y1={67+i*23+10} x2="108" y2={67+i*23+10} stroke="#1a3a5520" strokeWidth="1"/>
|
| 263 |
+
</g>
|
| 264 |
+
))}
|
| 265 |
+
{/* Side struts */}
|
| 266 |
+
<rect x="8" y="72" width="18" height="82" rx="5" fill="#0a1828" stroke="#1a3a55" strokeWidth="1.5"/>
|
| 267 |
+
<rect x="134" y="72" width="18" height="82" rx="5" fill="#0a1828" stroke="#1a3a55" strokeWidth="1.5"/>
|
| 268 |
+
{/* Wheels */}
|
| 269 |
+
<circle cx="42" cy="172" r="13" fill="#0d1e2d" stroke="#00c8e8" strokeWidth="2"/>
|
| 270 |
+
<circle cx="118" cy="172" r="13" fill="#0d1e2d" stroke="#00c8e8" strokeWidth="2"/>
|
| 271 |
+
<circle cx="42" cy="172" r="5" fill="#00c8e830"/>
|
| 272 |
+
<circle cx="118" cy="172" r="5" fill="#00c8e830"/>
|
| 273 |
+
</svg>
|
| 274 |
+
</div>
|
| 275 |
+
</div>
|
| 276 |
+
|
| 277 |
+
{/* Title */}
|
| 278 |
+
<div style={{ marginTop: 40, textAlign:'center' }}>
|
| 279 |
+
<div style={{
|
| 280 |
+
fontFamily:'Orbitron', fontSize: 56, fontWeight: 900, letterSpacing: 8,
|
| 281 |
+
color: '#00c8e8', lineHeight: 1,
|
| 282 |
+
textShadow: '0 0 30px #00c8e880, 0 0 60px #00c8e840',
|
| 283 |
+
animation: glitch ? 'glitch 0.3s steps(1) forwards' : 'neonFlicker 3s ease-in-out infinite',
|
| 284 |
+
}}>
|
| 285 |
+
UEH B1
|
| 286 |
+
</div>
|
| 287 |
+
<div style={{ fontFamily:'Space Grotesk', fontSize: 18, fontWeight: 500, letterSpacing: 6, color:'#7ab0c8', marginTop: 12, textTransform:'uppercase' }}>
|
| 288 |
+
Autonomous Delivery System
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
|
| 292 |
+
{/* Press Enter prompt */}
|
| 293 |
+
<div style={{ marginTop: 64, display:'flex', flexDirection:'column', alignItems:'center', gap: 16 }}>
|
| 294 |
+
<div style={{
|
| 295 |
+
fontFamily:'Orbitron', fontSize: 22, fontWeight: 600, letterSpacing: 5,
|
| 296 |
+
color: '#ffffff', animation: 'blink-cursor 1.8s ease-in-out infinite',
|
| 297 |
+
}}>
|
| 298 |
+
PRESS ENTER TO RECEIVE PACKAGE
|
| 299 |
+
</div>
|
| 300 |
+
<div style={{ display:'flex', gap: 8, alignItems:'center' }}>
|
| 301 |
+
{[...Array(5)].map((_,i) => (
|
| 302 |
+
<div key={i} style={{ width: i===2?24:8, height: 3, borderRadius: 2, background: i===2?'#00c8e8':'#1a3a55' }} />
|
| 303 |
+
))}
|
| 304 |
+
</div>
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
|
| 308 |
+
{/* Bottom info bar */}
|
| 309 |
+
<div style={{
|
| 310 |
+
position:'absolute', bottom:0, left:0, right:0, height:52,
|
| 311 |
+
display:'flex', alignItems:'center', justifyContent:'space-between',
|
| 312 |
+
padding:'0 60px',
|
| 313 |
+
background: 'linear-gradient(0deg, rgba(0,200,232,0.06) 0%, transparent 100%)',
|
| 314 |
+
borderTop: '1px solid rgba(0,200,232,0.12)',
|
| 315 |
+
fontSize: 13, color: '#3a6070', letterSpacing: 2, fontFamily:'Orbitron',
|
| 316 |
+
}}>
|
| 317 |
+
<span>SYS v2.4.1 · ROS2 HUMBLE</span>
|
| 318 |
+
<span>{dateStr.toUpperCase()}</span>
|
| 319 |
+
<span>BASE STATION · FLOOR G</span>
|
| 320 |
+
</div>
|
| 321 |
+
</div>
|
| 322 |
+
);
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
/* ─── Digit Input Box ─── */
|
| 326 |
+
function DigitInput({ value, active, error }) {
|
| 327 |
+
return (
|
| 328 |
+
<div style={{
|
| 329 |
+
width: 100, height: 120,
|
| 330 |
+
border: `3px solid ${error ? '#ff4444' : active ? '#00c8e8' : value ? '#00c8e840' : '#1a3a55'}`,
|
| 331 |
+
borderRadius: 12, display:'flex', alignItems:'center', justifyContent:'center',
|
| 332 |
+
background: error ? 'rgba(255,68,68,0.08)' : active ? 'rgba(0,200,232,0.06)' : '#040d18',
|
| 333 |
+
boxShadow: active ? '0 0 24px rgba(0,200,232,0.3), inset 0 0 20px rgba(0,200,232,0.05)' : error ? '0 0 24px rgba(255,68,68,0.3)' : 'none',
|
| 334 |
+
transition: 'all 0.2s',
|
| 335 |
+
position: 'relative', overflow: 'hidden',
|
| 336 |
+
}}>
|
| 337 |
+
{active && !value && (
|
| 338 |
+
<div style={{ width: 3, height: 60, background: '#00c8e8', borderRadius: 2, animation:'blink-cursor 1s ease-in-out infinite' }} />
|
| 339 |
+
)}
|
| 340 |
+
{value && (
|
| 341 |
+
<span style={{
|
| 342 |
+
fontFamily:'Orbitron', fontSize: 52, fontWeight: 700,
|
| 343 |
+
color: error ? '#ff6666' : '#00c8e8',
|
| 344 |
+
textShadow: error ? '0 0 20px #ff444480' : '0 0 20px #00c8e880',
|
| 345 |
+
animation: 'fadeInUp 0.15s ease-out',
|
| 346 |
+
}}>{value}</span>
|
| 347 |
+
)}
|
| 348 |
+
</div>
|
| 349 |
+
);
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
/* ─── Input Screen (shared for ID and Passcode) ─── */
|
| 353 |
+
function InputScreen({ title, subtitle, length, onComplete, onBack, hint }) {
|
| 354 |
+
const [digits, setDigits] = useState([]);
|
| 355 |
+
const [error, setError] = useState(false);
|
| 356 |
+
const [shake, setShake] = useState(false);
|
| 357 |
+
|
| 358 |
+
const triggerError = useCallback(() => {
|
| 359 |
+
setError(true); setShake(true);
|
| 360 |
+
setTimeout(() => { setError(false); setShake(false); setDigits([]); }, 900);
|
| 361 |
+
}, []);
|
| 362 |
+
|
| 363 |
+
useEffect(() => {
|
| 364 |
+
const handler = (e) => {
|
| 365 |
+
if (e.key === 'Escape') { onBack(); return; }
|
| 366 |
+
if (e.key === 'Backspace') { setDigits(d => d.slice(0,-1)); setError(false); return; }
|
| 367 |
+
if (/^[0-9]$/.test(e.key) && digits.length < length) {
|
| 368 |
+
const next = [...digits, e.key];
|
| 369 |
+
setDigits(next);
|
| 370 |
+
if (next.length === length) {
|
| 371 |
+
setTimeout(() => onComplete(next.join('')), 150);
|
| 372 |
+
}
|
| 373 |
+
}
|
| 374 |
+
};
|
| 375 |
+
window.addEventListener('keydown', handler);
|
| 376 |
+
return () => window.removeEventListener('keydown', handler);
|
| 377 |
+
}, [digits, length, onComplete, onBack]);
|
| 378 |
+
|
| 379 |
+
// expose triggerError
|
| 380 |
+
useEffect(() => { window.__triggerInputError = triggerError; }, [triggerError]);
|
| 381 |
+
|
| 382 |
+
return (
|
| 383 |
+
<div className="screen" style={{ background: 'linear-gradient(135deg, #020810 0%, #04101e 50%, #020810 100%)' }}>
|
| 384 |
+
<div className="grid-bg" />
|
| 385 |
+
<div className="scanline" />
|
| 386 |
+
<div className="corner corner-tl" /><div className="corner corner-tr" />
|
| 387 |
+
<div className="corner corner-bl" /><div className="corner corner-br" />
|
| 388 |
+
|
| 389 |
+
<div style={{
|
| 390 |
+
position:'absolute', inset:0, display:'flex', flexDirection:'column',
|
| 391 |
+
alignItems:'center', justifyContent:'center', gap:0,
|
| 392 |
+
animation: 'fadeInUp 0.3s ease-out',
|
| 393 |
+
}}>
|
| 394 |
+
{/* Header */}
|
| 395 |
+
<div style={{ textAlign:'center', marginBottom: 60 }}>
|
| 396 |
+
<div style={{ fontFamily:'Orbitron', fontSize: 13, letterSpacing: 6, color:'#3a6070', marginBottom: 16 }}>
|
| 397 |
+
UEH B1 · DELIVERY SYSTEM
|
| 398 |
+
</div>
|
| 399 |
+
<div style={{ fontFamily:'Orbitron', fontSize: 48, fontWeight: 700, color:'#fff', letterSpacing: 4 }}>
|
| 400 |
+
{title}
|
| 401 |
+
</div>
|
| 402 |
+
<div style={{ fontFamily:'Space Grotesk', fontSize: 18, color:'#7ab0c8', marginTop: 12, letterSpacing: 2 }}>
|
| 403 |
+
{subtitle}
|
| 404 |
+
</div>
|
| 405 |
+
</div>
|
| 406 |
+
|
| 407 |
+
{/* Digit boxes */}
|
| 408 |
+
<div style={{
|
| 409 |
+
display:'flex', gap: 24,
|
| 410 |
+
animation: shake ? 'shake 0.5s ease-in-out' : 'none',
|
| 411 |
+
}}>
|
| 412 |
+
{Array.from({length}).map((_,i) => (
|
| 413 |
+
<DigitInput key={i} value={digits[i] || ''} active={i === digits.length} error={error} />
|
| 414 |
+
))}
|
| 415 |
+
</div>
|
| 416 |
+
|
| 417 |
+
{hint && (
|
| 418 |
+
<div style={{ marginTop: 32, fontFamily:'Space Grotesk', fontSize: 15, color:'#3a6070', letterSpacing: 2 }}>
|
| 419 |
+
{hint}
|
| 420 |
+
</div>
|
| 421 |
+
)}
|
| 422 |
+
|
| 423 |
+
{/* Keyboard hint */}
|
| 424 |
+
<div style={{ marginTop: 60, display:'flex', gap: 16, alignItems:'center' }}>
|
| 425 |
+
<kbd style={{ padding:'6px 14px', border:'1px solid #1a3a55', borderRadius:6, fontFamily:'Orbitron', fontSize:12, color:'#3a6070', background:'#040d18' }}>0-9</kbd>
|
| 426 |
+
<span style={{ color:'#1a3a55', fontSize:13 }}>type digits</span>
|
| 427 |
+
<kbd style={{ padding:'6px 14px', border:'1px solid #1a3a55', borderRadius:6, fontFamily:'Orbitron', fontSize:12, color:'#3a6070', background:'#040d18' }}>⌫</kbd>
|
| 428 |
+
<span style={{ color:'#1a3a55', fontSize:13 }}>delete</span>
|
| 429 |
+
<kbd style={{ padding:'6px 14px', border:'1px solid #1a3a55', borderRadius:6, fontFamily:'Orbitron', fontSize:12, color:'#3a6070', background:'#040d18' }}>ESC</kbd>
|
| 430 |
+
<span style={{ color:'#1a3a55', fontSize:13 }}>cancel</span>
|
| 431 |
+
</div>
|
| 432 |
+
</div>
|
| 433 |
+
</div>
|
| 434 |
+
);
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
/* ─── Success Screen ─── */
|
| 438 |
+
function SuccessScreen({ orderId, onDone }) {
|
| 439 |
+
const [countdown, setCountdown] = useState(8);
|
| 440 |
+
const compIdx = orderId ? (parseInt(orderId, 10) % 4) : 0;
|
| 441 |
+
const compColors = ['#00c8e8','#f59e0b','#ff6b2b','#a855f7'];
|
| 442 |
+
const compColor = compColors[compIdx];
|
| 443 |
+
|
| 444 |
+
useEffect(() => {
|
| 445 |
+
const id = setInterval(() => setCountdown(c => { if(c<=1){ onDone(); return 0; } return c-1; }), 1000);
|
| 446 |
+
return () => clearInterval(id);
|
| 447 |
+
}, [onDone]);
|
| 448 |
+
|
| 449 |
+
return (
|
| 450 |
+
<div className="screen" style={{ background: 'linear-gradient(135deg, #020e08 0%, #041208 50%, #020e08 100%)' }}>
|
| 451 |
+
<div style={{
|
| 452 |
+
position:'absolute', inset:0,
|
| 453 |
+
background: 'radial-gradient(ellipse at center, rgba(0,230,118,0.08) 0%, transparent 70%)',
|
| 454 |
+
}} />
|
| 455 |
+
<div className="corner corner-tl" style={{borderColor:'#00e676'}} />
|
| 456 |
+
<div className="corner corner-tr" style={{borderColor:'#00e676'}} />
|
| 457 |
+
<div className="corner corner-bl" style={{borderColor:'#00e676'}} />
|
| 458 |
+
<div className="corner corner-br" style={{borderColor:'#00e676'}} />
|
| 459 |
+
|
| 460 |
+
<div style={{ position:'absolute', inset:0, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap: 0 }}>
|
| 461 |
+
{/* Success icon */}
|
| 462 |
+
<div style={{ position:'relative', animation:'successBurst 0.6s cubic-bezier(0.34,1.56,0.64,1) forwards' }}>
|
| 463 |
+
<div style={{ position:'absolute', inset:-40, borderRadius:'50%', border:'2px solid #00e67630', animation:'pulse-ring 2s ease-in-out infinite' }} />
|
| 464 |
+
<div style={{ position:'absolute', inset:-20, borderRadius:'50%', border:'1px solid #00e67620', animation:'pulse-ring 2s ease-in-out infinite 0.5s' }} />
|
| 465 |
+
<div style={{
|
| 466 |
+
width: 160, height: 160, borderRadius:'50%',
|
| 467 |
+
background: 'radial-gradient(circle, rgba(0,230,118,0.15) 0%, rgba(0,230,118,0.05) 100%)',
|
| 468 |
+
border: '3px solid #00e676',
|
| 469 |
+
display:'flex', alignItems:'center', justifyContent:'center',
|
| 470 |
+
boxShadow: '0 0 40px rgba(0,230,118,0.4), inset 0 0 30px rgba(0,230,118,0.1)',
|
| 471 |
+
}}>
|
| 472 |
+
<svg width="80" height="80" viewBox="0 0 80 80" fill="none">
|
| 473 |
+
<polyline points="16,42 32,58 64,22" stroke="#00e676" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round" style={{filter:'drop-shadow(0 0 8px #00e676)'}}/>
|
| 474 |
+
</svg>
|
| 475 |
+
</div>
|
| 476 |
+
</div>
|
| 477 |
+
|
| 478 |
+
<div style={{ marginTop: 48, textAlign:'center' }}>
|
| 479 |
+
<div style={{ fontFamily:'Orbitron', fontSize: 52, fontWeight: 900, color:'#00e676', letterSpacing: 6, textShadow:'0 0 30px #00e67680' }}>
|
| 480 |
+
UNLOCKED
|
| 481 |
+
</div>
|
| 482 |
+
<div style={{ fontFamily:'Space Grotesk', fontSize: 22, color:'#7ab0c8', marginTop: 16, letterSpacing: 2 }}>
|
| 483 |
+
Ngăn hàng đã mở · Vui lòng lấy kiện hàng
|
| 484 |
+
</div>
|
| 485 |
+
<div style={{ fontFamily:'Orbitron', fontSize: 16, color:'#3a6070', marginTop: 12, letterSpacing: 3 }}>
|
| 486 |
+
ORDER #{orderId}
|
| 487 |
+
</div>
|
| 488 |
+
</div>
|
| 489 |
+
|
| 490 |
+
{/* Compartment visualization */}
|
| 491 |
+
<div style={{ marginTop: 48, display:'flex', alignItems:'center', gap: 32 }}>
|
| 492 |
+
<div style={{ textAlign:'center' }}>
|
| 493 |
+
<div style={{ fontSize:12, color:'#3a6070', letterSpacing:2, marginBottom:12, fontFamily:'Orbitron' }}>OPEN COMPARTMENT</div>
|
| 494 |
+
<svg width="120" height="200" viewBox="0 0 120 200" fill="none">
|
| 495 |
+
{/* Body */}
|
| 496 |
+
<rect x="10" y="10" width="100" height="160" rx="8" fill="#0a1828" stroke="#1c3a55" strokeWidth="2"/>
|
| 497 |
+
{[0,1,2,3].map(i => {
|
| 498 |
+
const active = i === compIdx;
|
| 499 |
+
return (
|
| 500 |
+
<g key={i}>
|
| 501 |
+
<rect x="16" y={18+i*38} width="88" height="32" rx="4"
|
| 502 |
+
fill={active ? `${compColor}25` : '#050e1a'}
|
| 503 |
+
stroke={active ? compColor : '#0d2238'}
|
| 504 |
+
strokeWidth={active ? 2 : 1}
|
| 505 |
+
style={{filter: active ? `drop-shadow(0 0 6px ${compColor}60)` : 'none'}}
|
| 506 |
+
/>
|
| 507 |
+
<text x="26" y={18+i*38+19} fill={active ? compColor : '#1a3050'} fontSize="11" fontFamily="Orbitron" fontWeight="700">
|
| 508 |
+
{i+1}
|
| 509 |
+
</text>
|
| 510 |
+
{active ? (
|
| 511 |
+
<>
|
| 512 |
+
<text x="55" y={18+i*38+14} fill={compColor} fontSize="9" fontFamily="Space Grotesk">UNLOCKED</text>
|
| 513 |
+
<text x="55" y={18+i*38+26} fill={compColor} fontSize="16">🔓</text>
|
| 514 |
+
</>
|
| 515 |
+
) : (
|
| 516 |
+
<text x="88" y={18+i*38+20} fill="#0d2238" fontSize="14">🔒</text>
|
| 517 |
+
)}
|
| 518 |
+
</g>
|
| 519 |
+
);
|
| 520 |
+
})}
|
| 521 |
+
{/* Wheels */}
|
| 522 |
+
<circle cx="28" cy="178" r="10" fill="#0a1828" stroke="#1c3a55" strokeWidth="1.5"/>
|
| 523 |
+
<circle cx="92" cy="178" r="10" fill="#0a1828" stroke="#1c3a55" strokeWidth="1.5"/>
|
| 524 |
+
</svg>
|
| 525 |
+
</div>
|
| 526 |
+
<div style={{ display:'flex', flexDirection:'column', gap:12 }}>
|
| 527 |
+
<div style={{ padding:'16px 24px', borderRadius:8, border:`1px solid ${compColor}40`, background:`${compColor}0d` }}>
|
| 528 |
+
<div style={{ fontFamily:'Orbitron', fontSize:13, color:compColor, letterSpacing:2, marginBottom:4 }}>COMPARTMENT {compIdx+1}</div>
|
| 529 |
+
<div style={{ fontFamily:'Space Grotesk', fontSize:14, color:'#7ab0c8' }}>Nhấc hàng và đóng ngăn lại</div>
|
| 530 |
+
</div>
|
| 531 |
+
<div style={{ padding:'12px 24px', borderRadius:8, border:'1px solid #00e67630', background:'rgba(0,230,118,0.05)' }}>
|
| 532 |
+
<div style={{ fontFamily:'Space Grotesk', fontSize:13, color:'#00e676' }}>✓ Passcode xác nhận thành công</div>
|
| 533 |
+
<div style={{ fontFamily:'Space Grotesk', fontSize:12, color:'#3a6070', marginTop:4 }}>Order #{orderId}</div>
|
| 534 |
+
</div>
|
| 535 |
+
</div>
|
| 536 |
+
</div>
|
| 537 |
+
|
| 538 |
+
{/* Countdown bar */}
|
| 539 |
+
<div style={{ marginTop: 56, width: 400, textAlign:'center' }}>
|
| 540 |
+
<div style={{ fontFamily:'Space Grotesk', fontSize: 14, color:'#3a6070', letterSpacing: 2, marginBottom: 12 }}>
|
| 541 |
+
TỰ ĐỘNG KHÓA SAU {countdown} GIÂY
|
| 542 |
+
</div>
|
| 543 |
+
<div style={{ height: 4, borderRadius: 2, background:'#0d1e2d', overflow:'hidden' }}>
|
| 544 |
+
<div style={{
|
| 545 |
+
height:'100%', background:'#00e676', borderRadius: 2,
|
| 546 |
+
animation: `countdownShrink ${countdown}s linear forwards`,
|
| 547 |
+
boxShadow: '0 0 8px #00e676',
|
| 548 |
+
}} />
|
| 549 |
+
</div>
|
| 550 |
+
</div>
|
| 551 |
+
</div>
|
| 552 |
+
</div>
|
| 553 |
+
);
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
/* ─── Error Screen ─── */
|
| 557 |
+
function ErrorScreen({ onBack }) {
|
| 558 |
+
useEffect(() => {
|
| 559 |
+
const t = setTimeout(onBack, 3000);
|
| 560 |
+
const h = (e) => { if(e.key==='Enter'||e.key==='Escape') onBack(); };
|
| 561 |
+
window.addEventListener('keydown', h);
|
| 562 |
+
return () => { clearTimeout(t); window.removeEventListener('keydown', h); };
|
| 563 |
+
}, [onBack]);
|
| 564 |
+
return (
|
| 565 |
+
<div className="screen" style={{ background: 'linear-gradient(135deg, #0e0202 0%, #140404 50%, #0e0202 100%)' }}>
|
| 566 |
+
<div style={{ position:'absolute', inset:0, background:'radial-gradient(ellipse at center, rgba(255,68,68,0.1) 0%, transparent 70%)' }} />
|
| 567 |
+
<div className="corner corner-tl" style={{borderColor:'#ff4444'}} />
|
| 568 |
+
<div className="corner corner-tr" style={{borderColor:'#ff4444'}} />
|
| 569 |
+
<div className="corner corner-bl" style={{borderColor:'#ff4444'}} />
|
| 570 |
+
<div className="corner corner-br" style={{borderColor:'#ff4444'}} />
|
| 571 |
+
<div style={{ position:'absolute', inset:0, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap: 24 }}>
|
| 572 |
+
<div style={{ width:120, height:120, borderRadius:'50%', border:'3px solid #ff4444', display:'flex', alignItems:'center', justifyContent:'center', boxShadow:'0 0 40px rgba(255,68,68,0.4)', animation:'successBurst 0.4s ease-out' }}>
|
| 573 |
+
<svg width="60" height="60" viewBox="0 0 60 60" fill="none">
|
| 574 |
+
<line x1="16" y1="16" x2="44" y2="44" stroke="#ff4444" strokeWidth="6" strokeLinecap="round" style={{filter:'drop-shadow(0 0 6px #ff4444)'}}/>
|
| 575 |
+
<line x1="44" y1="16" x2="16" y2="44" stroke="#ff4444" strokeWidth="6" strokeLinecap="round" style={{filter:'drop-shadow(0 0 6px #ff4444)'}}/>
|
| 576 |
+
</svg>
|
| 577 |
+
</div>
|
| 578 |
+
<div style={{ fontFamily:'Orbitron', fontSize:48, fontWeight:900, color:'#ff4444', letterSpacing:6, textShadow:'0 0 30px #ff444480' }}>SAI MÃ</div>
|
| 579 |
+
<div style={{ fontFamily:'Space Grotesk', fontSize:20, color:'#7a4040', letterSpacing:2 }}>Passcode không đúng · Thử lại sau</div>
|
| 580 |
+
<div style={{ fontFamily:'Orbitron', fontSize:14, color:'#3a2020', letterSpacing:3, marginTop:8 }}>TỰ ĐỘNG QUAY LẠI SAU 3 GIÂY</div>
|
| 581 |
+
</div>
|
| 582 |
+
</div>
|
| 583 |
+
);
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
/* ─── App ─── */
|
| 587 |
+
function App() {
|
| 588 |
+
const [screen, setScreen] = useState('idle');
|
| 589 |
+
const [orderId, setOrderId] = useState('');
|
| 590 |
+
const [currentOrder, setCurrentOrder] = useState(null);
|
| 591 |
+
|
| 592 |
+
useEffect(() => {
|
| 593 |
+
const poll = async () => {
|
| 594 |
+
try {
|
| 595 |
+
const d = await fetch('/api/orders/active').then(r => r.json());
|
| 596 |
+
setCurrentOrder(d.order || null);
|
| 597 |
+
} catch {}
|
| 598 |
+
};
|
| 599 |
+
poll();
|
| 600 |
+
const id = setInterval(poll, 5000);
|
| 601 |
+
return () => clearInterval(id);
|
| 602 |
+
}, []);
|
| 603 |
+
|
| 604 |
+
const handleIdComplete = (id) => {
|
| 605 |
+
if (!currentOrder) { window.__triggerInputError?.(); return; }
|
| 606 |
+
if (id !== currentOrder.short_id) { window.__triggerInputError?.(); return; }
|
| 607 |
+
setOrderId(id);
|
| 608 |
+
setScreen('enter_pass');
|
| 609 |
+
};
|
| 610 |
+
|
| 611 |
+
const handlePassComplete = async (pass) => {
|
| 612 |
+
if (!currentOrder) { window.__triggerInputError?.(); return; }
|
| 613 |
+
try {
|
| 614 |
+
const res = await fetch(`/api/orders/${currentOrder.id}/confirm`, {
|
| 615 |
+
method: 'POST', headers: {'Content-Type':'application/json'},
|
| 616 |
+
body: JSON.stringify({ order_short_id: orderId, pin: pass }),
|
| 617 |
+
});
|
| 618 |
+
if (res.ok) { setScreen('success'); }
|
| 619 |
+
else { window.__triggerInputError?.(); }
|
| 620 |
+
} catch { setScreen('error'); }
|
| 621 |
+
};
|
| 622 |
+
|
| 623 |
+
return (
|
| 624 |
+
<div>
|
| 625 |
+
{screen === 'idle' && (
|
| 626 |
+
<IdleScreen onEnter={() => setScreen('enter_id')} />
|
| 627 |
+
)}
|
| 628 |
+
{screen === 'enter_id' && (
|
| 629 |
+
<InputScreen
|
| 630 |
+
title="NHẬP MÃ ĐƠN"
|
| 631 |
+
subtitle="Enter your 4-digit order ID"
|
| 632 |
+
length={4}
|
| 633 |
+
hint="Mã đơn hàng trên phiếu giao hàng của bạn"
|
| 634 |
+
onComplete={handleIdComplete}
|
| 635 |
+
onBack={() => setScreen('idle')}
|
| 636 |
+
/>
|
| 637 |
+
)}
|
| 638 |
+
{screen === 'enter_pass' && (
|
| 639 |
+
<InputScreen
|
| 640 |
+
title="NHẬP PASSCODE"
|
| 641 |
+
subtitle="Enter your 6-digit passcode"
|
| 642 |
+
length={6}
|
| 643 |
+
hint="Mã xác nhận đã gửi qua Telegram"
|
| 644 |
+
onComplete={handlePassComplete}
|
| 645 |
+
onBack={() => setScreen('enter_id')}
|
| 646 |
+
/>
|
| 647 |
+
)}
|
| 648 |
+
{screen === 'success' && (
|
| 649 |
+
<SuccessScreen orderId={orderId} onDone={() => setScreen('idle')} />
|
| 650 |
+
)}
|
| 651 |
+
{screen === 'error' && (
|
| 652 |
+
<ErrorScreen onBack={() => setScreen('enter_pass')} />
|
| 653 |
+
)}
|
| 654 |
+
</div>
|
| 655 |
+
);
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
| 659 |
+
</script>
|
| 660 |
+
<script>
|
| 661 |
+
function scaleRoot() {
|
| 662 |
+
const root = document.getElementById('root');
|
| 663 |
+
const scale = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
|
| 664 |
+
root.style.transform = `scale(${scale})`;
|
| 665 |
+
root.style.marginLeft = `${(window.innerWidth - 1920 * scale) / 2}px`;
|
| 666 |
+
root.style.marginTop = `${(window.innerHeight - 1080 * scale) / 2}px`;
|
| 667 |
+
}
|
| 668 |
+
scaleRoot();
|
| 669 |
+
window.addEventListener('resize', scaleRoot);
|
| 670 |
+
</script>
|
| 671 |
+
</body>
|
| 672 |
+
</html>
|
elevator_panel.html
ADDED
|
@@ -0,0 +1,1831 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="vi">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=1920">
|
| 6 |
+
<title>UEH B1 — Admin Dashboard</title>
|
| 7 |
+
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700;900&family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 8 |
+
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js"></script>
|
| 9 |
+
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"></script>
|
| 10 |
+
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"></script>
|
| 11 |
+
<style>
|
| 12 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 13 |
+
html, body {
|
| 14 |
+
width: 100%; height: 100%; overflow: hidden;
|
| 15 |
+
background: #000;
|
| 16 |
+
margin: 0; padding: 0;
|
| 17 |
+
}
|
| 18 |
+
#root {
|
| 19 |
+
width: 1920px; height: 1080px; overflow: hidden;
|
| 20 |
+
background: #050c14;
|
| 21 |
+
font-family: 'Space Grotesk', sans-serif;
|
| 22 |
+
color: #c8dce8;
|
| 23 |
+
transform-origin: top left;
|
| 24 |
+
}
|
| 25 |
+
::-webkit-scrollbar { width: 4px; }
|
| 26 |
+
::-webkit-scrollbar-track { background: #050c14; }
|
| 27 |
+
::-webkit-scrollbar-thumb { background: #1a3a55; border-radius: 2px; }
|
| 28 |
+
|
| 29 |
+
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
|
| 30 |
+
@keyframes pulse-dot { 0%,100%{transform:scale(1);opacity:1} 50%{transform:scale(1.4);opacity:0.6} }
|
| 31 |
+
@keyframes fadeIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} }
|
| 32 |
+
@keyframes pathDraw {
|
| 33 |
+
from { stroke-dashoffset: 2000; }
|
| 34 |
+
to { stroke-dashoffset: 0; }
|
| 35 |
+
}
|
| 36 |
+
@keyframes robotMove { 0%{opacity:1} 100%{opacity:1} }
|
| 37 |
+
@keyframes elevatorMove {
|
| 38 |
+
0% { top: var(--from-top); }
|
| 39 |
+
100% { top: var(--to-top); }
|
| 40 |
+
}
|
| 41 |
+
@keyframes scanH {
|
| 42 |
+
0% { transform: translateX(-100%); }
|
| 43 |
+
100% { transform: translateX(200%); }
|
| 44 |
+
}
|
| 45 |
+
@keyframes toast {
|
| 46 |
+
0% { opacity:0; transform:translateY(20px); }
|
| 47 |
+
15% { opacity:1; transform:translateY(0); }
|
| 48 |
+
80% { opacity:1; }
|
| 49 |
+
100% { opacity:0; }
|
| 50 |
+
}
|
| 51 |
+
@keyframes compBlink {
|
| 52 |
+
0%,100% { opacity: 0.28; }
|
| 53 |
+
50% { opacity: 0.7; }
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.grid-bg {
|
| 57 |
+
position: absolute; inset: 0; pointer-events: none;
|
| 58 |
+
background-image:
|
| 59 |
+
linear-gradient(rgba(0,200,232,0.03) 1px, transparent 1px),
|
| 60 |
+
linear-gradient(90deg, rgba(0,200,232,0.03) 1px, transparent 1px);
|
| 61 |
+
background-size: 60px 60px;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
button { cursor: pointer; border: none; background: none; font-family: inherit; }
|
| 65 |
+
|
| 66 |
+
.tag {
|
| 67 |
+
display: inline-flex; align-items: center; gap: 5px;
|
| 68 |
+
padding: 3px 10px; border-radius: 4px;
|
| 69 |
+
font-size: 11px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase;
|
| 70 |
+
}
|
| 71 |
+
</style>
|
| 72 |
+
</head>
|
| 73 |
+
<body>
|
| 74 |
+
<div id="root"></div>
|
| 75 |
+
<button id="admin-key-button" onclick="window.setAdminApiKey && window.setAdminApiKey()" style="
|
| 76 |
+
position:fixed;
|
| 77 |
+
top:12px;
|
| 78 |
+
right:12px;
|
| 79 |
+
z-index:99999;
|
| 80 |
+
padding:8px 12px;
|
| 81 |
+
border:1px solid #00c8e8;
|
| 82 |
+
border-radius:6px;
|
| 83 |
+
background:#071827;
|
| 84 |
+
color:#00c8e8;
|
| 85 |
+
font-family:Orbitron, monospace;
|
| 86 |
+
font-size:11px;
|
| 87 |
+
letter-spacing:1px;
|
| 88 |
+
box-shadow:0 0 12px rgba(0,200,232,0.25);
|
| 89 |
+
">ADMIN KEY</button>
|
| 90 |
+
<script type="text/babel">
|
| 91 |
+
const { useState, useEffect, useRef, useCallback, useMemo } = React;
|
| 92 |
+
|
| 93 |
+
const numToFloor = n => String(Math.max(1, Math.min(10, Number(n) || 1)));
|
| 94 |
+
const floorToNum = f => Math.max(1, Math.min(10, parseInt(f, 10) || 1));
|
| 95 |
+
|
| 96 |
+
const ADMIN_API_KEY_STORAGE = 'ROBOT_API_KEY';
|
| 97 |
+
|
| 98 |
+
function getAdminApiKey(forcePrompt = false) {
|
| 99 |
+
let key = window.localStorage.getItem(ADMIN_API_KEY_STORAGE) || '';
|
| 100 |
+
|
| 101 |
+
if (forcePrompt || !key) {
|
| 102 |
+
key = window.prompt('Enter robot admin API key:') || '';
|
| 103 |
+
key = key.trim();
|
| 104 |
+
|
| 105 |
+
if (key) {
|
| 106 |
+
window.localStorage.setItem(ADMIN_API_KEY_STORAGE, key);
|
| 107 |
+
const btn = document.getElementById('admin-key-button');
|
| 108 |
+
if (btn) {
|
| 109 |
+
btn.textContent = 'KEY OK';
|
| 110 |
+
btn.style.borderColor = '#00e676';
|
| 111 |
+
btn.style.color = '#00e676';
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
return key;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function setAdminApiKey() {
|
| 120 |
+
const oldKey = window.localStorage.getItem(ADMIN_API_KEY_STORAGE) || '';
|
| 121 |
+
const key = window.prompt('Enter robot admin API key:', oldKey) || '';
|
| 122 |
+
const clean = key.trim();
|
| 123 |
+
|
| 124 |
+
if (clean) {
|
| 125 |
+
window.localStorage.setItem(ADMIN_API_KEY_STORAGE, clean);
|
| 126 |
+
const btn = document.getElementById('admin-key-button');
|
| 127 |
+
if (btn) {
|
| 128 |
+
btn.textContent = 'KEY OK';
|
| 129 |
+
btn.style.borderColor = '#00e676';
|
| 130 |
+
btn.style.color = '#00e676';
|
| 131 |
+
}
|
| 132 |
+
alert('Admin API key saved in this browser.');
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function forgetAdminApiKey() {
|
| 137 |
+
window.localStorage.removeItem(ADMIN_API_KEY_STORAGE);
|
| 138 |
+
const btn = document.getElementById('admin-key-button');
|
| 139 |
+
if (btn) {
|
| 140 |
+
btn.textContent = 'ADMIN KEY';
|
| 141 |
+
btn.style.borderColor = '#00c8e8';
|
| 142 |
+
btn.style.color = '#00c8e8';
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
window.setAdminApiKey = setAdminApiKey;
|
| 147 |
+
window.forgetAdminApiKey = forgetAdminApiKey;
|
| 148 |
+
|
| 149 |
+
function authHeaders(extra = {}) {
|
| 150 |
+
const key = getAdminApiKey(false);
|
| 151 |
+
return {
|
| 152 |
+
...extra,
|
| 153 |
+
...(key ? { 'X-API-Key': key } : {}),
|
| 154 |
+
};
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
async function authFetch(url, options = {}) {
|
| 158 |
+
const opts = {
|
| 159 |
+
...options,
|
| 160 |
+
headers: authHeaders(options.headers || {}),
|
| 161 |
+
};
|
| 162 |
+
|
| 163 |
+
let res = await fetch(url, opts);
|
| 164 |
+
|
| 165 |
+
if (res.status === 401) {
|
| 166 |
+
forgetAdminApiKey();
|
| 167 |
+
const retryKey = getAdminApiKey(true);
|
| 168 |
+
|
| 169 |
+
if (retryKey) {
|
| 170 |
+
const retryOpts = {
|
| 171 |
+
...options,
|
| 172 |
+
headers: authHeaders(options.headers || {}),
|
| 173 |
+
};
|
| 174 |
+
res = await fetch(url, retryOpts);
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
return res;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
/* ══════════════════════════════════════════
|
| 183 |
+
FLOOR MAP GEOMETRY
|
| 184 |
+
viewBox: 0 0 700 490
|
| 185 |
+
══════════════════════════════════════════ */
|
| 186 |
+
const CY = 110; // corridor center Y (horizontal wing)
|
| 187 |
+
const CX = 96; // corridor center X (vertical wing)
|
| 188 |
+
|
| 189 |
+
const FLOOR_MAPS = {
|
| 190 |
+
'1': {
|
| 191 |
+
// Floor: 1 — generated by Map Editor
|
| 192 |
+
walls: [
|
| 193 |
+
{ x:0, y:0, w:700, h:220 },
|
| 194 |
+
{ x:0, y:220, w:400, h:270 },
|
| 195 |
+
{ x:495, y:220, w:205, h:270 },
|
| 196 |
+
{ x:400, y:235, w:5, h:255 },
|
| 197 |
+
{ x:490, y:235, w:5, h:255 },
|
| 198 |
+
{ x:405, y:290, w:85, h:200 },
|
| 199 |
+
],
|
| 200 |
+
corridors: [
|
| 201 |
+
{ x:400, y:220, w:95, h:15 },
|
| 202 |
+
{ x:415, y:235, w:75, h:55 },
|
| 203 |
+
{ x:405, y:245, w:10, h:40 },
|
| 204 |
+
],
|
| 205 |
+
rooms: [
|
| 206 |
+
{ id:'R02', x:405, y:235, w:10, h:10, group:'top' },
|
| 207 |
+
{ id:'R03', x:405, y:285, w:15, h:5, group:'top' },
|
| 208 |
+
],
|
| 209 |
+
elevators: [],
|
| 210 |
+
},
|
| 211 |
+
'2': {
|
| 212 |
+
walls: [{ x:0, y:0, w:700, h:80 }, { x:0, y:80, w:120, h:70 }, { x:625, y:80, w:75, h:410 }, { x:0, y:150, w:10, h:340 }, { x:10, y:190, w:25, h:300 }, { x:65, y:190, w:5, h:300 }, { x:100, y:190, w:20, h:300 }, { x:210, y:210, w:415, h:280 }, { x:35, y:215, w:30, h:275 }, { x:70, y:215, w:30, h:275 }, { x:120, y:400, w:90, h:90 }],
|
| 213 |
+
corridors: [{ x:125, y:155, w:500, h:55 }, { x:120, y:80, w:90, h:320 }, { x:10, y:150, w:110, h:40 }],
|
| 214 |
+
rooms: [{ id:'R01', x:210, y:80, w:415, h:75, group:'vert-w' }],
|
| 215 |
+
elevators: [{ id:'E1', x:70, y:190, w:30, h:25 }, { id:'E2', x:35, y:190, w:30, h:25 }],
|
| 216 |
+
},
|
| 217 |
+
'3': {
|
| 218 |
+
walls: [{ x:0, y:0, w:700, h:80 }, { x:0, y:80, w:120, h:70 }, { x:625, y:80, w:75, h:410 }, { x:0, y:150, w:10, h:340 }, { x:10, y:190, w:25, h:300 }, { x:65, y:190, w:5, h:300 }, { x:100, y:190, w:20, h:300 }, { x:210, y:210, w:415, h:280 }, { x:35, y:215, w:30, h:275 }, { x:70, y:215, w:30, h:275 }, { x:120, y:400, w:90, h:90 }],
|
| 219 |
+
corridors: [{ x:10, y:150, w:110, h:40 }, { x:125, y:155, w:500, h:55 }, { x:120, y:80, w:90, h:320 }],
|
| 220 |
+
rooms: [{ id:'R01', x:210, y:80, w:415, h:75, group:'vert-w' }],
|
| 221 |
+
elevators: [{ id:'E1', x:70, y:190, w:30, h:25 }, { id:'E2', x:35, y:190, w:30, h:25 }],
|
| 222 |
+
},
|
| 223 |
+
'4': {
|
| 224 |
+
walls: [{ x:0, y:0, w:700, h:80 }, { x:0, y:80, w:120, h:70 }, { x:210, y:80, w:10, h:75 }, { x:655, y:80, w:45, h:75 }, { x:0, y:150, w:10, h:340 }, { x:695, y:155, w:5, h:335 }, { x:10, y:190, w:25, h:30 }, { x:65, y:190, w:5, h:30 }, { x:100, y:190, w:20, h:30 }, { x:600, y:210, w:95, h:280 }, { x:35, y:215, w:30, h:5 }, { x:70, y:215, w:30, h:5 }, { x:10, y:325, w:110, h:50 }, { x:210, y:350, w:290, h:140 }, { x:500, y:400, w:100, h:90 }, { x:10, y:450, w:200, h:40 }],
|
| 225 |
+
corridors: [{ x:120, y:80, w:90, h:320 }, { x:10, y:150, w:110, h:40 }, { x:210, y:155, w:485, h:55 }, { x:385, y:210, w:140, h:140 }, { x:500, y:350, w:25, h:50 }, { x:10, y:375, w:110, h:25 }],
|
| 226 |
+
rooms: [{ id:'R01', x:10, y:220, w:110, h:35, group:'vert' }, { id:'R02', x:10, y:255, w:110, h:35, group:'vert' }, { id:'R03', x:10, y:290, w:110, h:35, group:'vert' }, { id:'R04', x:525, y:210, w:75, h:90, group:'vert-w' }, { id:'R05', x:525, y:300, w:75, h:100, group:'vert-w' }, { id:'R06', x:210, y:210, w:175, h:140, group:'vert-w' }, { id:'R07', x:220, y:80, w:145, h:75, group:'top' }, { id:'R08', x:365, y:80, w:145, h:75, group:'top' }, { id:'R09', x:510, y:80, w:145, h:75, group:'top' }, { id:'R10', x:10, y:400, w:200, h:50, group:'bot' }],
|
| 227 |
+
elevators: [{ id:'E1', x:70, y:190, w:30, h:25 }, { id:'E2', x:35, y:190, w:30, h:25 }],
|
| 228 |
+
},
|
| 229 |
+
'5': {
|
| 230 |
+
walls: [{ x:0, y:0, w:700, h:80 }, { x:0, y:80, w:120, h:70 }, { x:210, y:80, w:10, h:75 }, { x:655, y:80, w:45, h:75 }, { x:0, y:150, w:10, h:340 }, { x:695, y:155, w:5, h:335 }, { x:10, y:190, w:25, h:30 }, { x:65, y:190, w:5, h:30 }, { x:100, y:190, w:20, h:30 }, { x:600, y:210, w:95, h:280 }, { x:35, y:215, w:30, h:5 }, { x:70, y:215, w:30, h:5 }, { x:10, y:325, w:110, h:50 }, { x:210, y:350, w:290, h:140 }, { x:500, y:400, w:100, h:90 }, { x:10, y:450, w:200, h:40 }],
|
| 231 |
+
corridors: [{ x:120, y:80, w:90, h:320 }, { x:10, y:150, w:110, h:40 }, { x:210, y:155, w:485, h:55 }, { x:385, y:210, w:140, h:140 }, { x:500, y:350, w:25, h:50 }, { x:10, y:375, w:110, h:25 }],
|
| 232 |
+
rooms: [{ id:'R01', x:10, y:220, w:110, h:35, group:'vert' }, { id:'R02', x:10, y:255, w:110, h:35, group:'vert' }, { id:'R03', x:10, y:290, w:110, h:35, group:'vert' }, { id:'R04', x:525, y:210, w:75, h:90, group:'vert-w' }, { id:'R05', x:525, y:300, w:75, h:100, group:'vert-w' }, { id:'R06', x:210, y:210, w:175, h:140, group:'vert-w' }, { id:'R07', x:220, y:80, w:145, h:75, group:'top' }, { id:'R08', x:365, y:80, w:145, h:75, group:'top' }, { id:'R09', x:510, y:80, w:145, h:75, group:'top' }, { id:'R10', x:10, y:400, w:200, h:50, group:'bot' }],
|
| 233 |
+
elevators: [{ id:'E1', x:70, y:190, w:30, h:25 }, { id:'E2', x:35, y:190, w:30, h:25 }],
|
| 234 |
+
},
|
| 235 |
+
'6': {
|
| 236 |
+
walls: [{ x:0, y:0, w:700, h:80 }, { x:0, y:80, w:120, h:70 }, { x:625, y:80, w:75, h:410 }, { x:0, y:150, w:10, h:340 }, { x:10, y:190, w:25, h:300 }, { x:65, y:190, w:5, h:300 }, { x:100, y:190, w:20, h:300 }, { x:35, y:215, w:30, h:275 }, { x:70, y:215, w:30, h:275 }, { x:120, y:400, w:505, h:90 }],
|
| 237 |
+
corridors: [{ x:120, y:80, w:90, h:320 }, { x:10, y:150, w:110, h:40 }],
|
| 238 |
+
rooms: [{ id:'R01', x:210, y:80, w:415, h:320, group:'vert-w' }],
|
| 239 |
+
elevators: [{ id:'E1', x:70, y:190, w:30, h:25 }, { id:'E2', x:35, y:190, w:30, h:25 }],
|
| 240 |
+
},
|
| 241 |
+
'7': {
|
| 242 |
+
walls: [{ x:0, y:0, w:700, h:80 }, { x:0, y:80, w:120, h:70 }, { x:210, y:80, w:10, h:75 }, { x:655, y:80, w:45, h:75 }, { x:0, y:150, w:10, h:340 }, { x:695, y:155, w:5, h:335 }, { x:10, y:190, w:25, h:30 }, { x:65, y:190, w:5, h:30 }, { x:100, y:190, w:20, h:30 }, { x:600, y:210, w:95, h:280 }, { x:35, y:215, w:30, h:5 }, { x:70, y:215, w:30, h:5 }, { x:10, y:325, w:110, h:50 }, { x:210, y:350, w:290, h:140 }, { x:500, y:400, w:100, h:90 }, { x:10, y:450, w:200, h:40 }],
|
| 243 |
+
corridors: [{ x:120, y:80, w:90, h:320 }, { x:10, y:150, w:110, h:40 }, { x:210, y:155, w:485, h:55 }, { x:10, y:375, w:110, h:25 }, { x:385, y:210, w:140, h:140 }, { x:500, y:350, w:25, h:50 }],
|
| 244 |
+
rooms: [{ id:'R01', x:10, y:220, w:110, h:35, group:'vert' }, { id:'R02', x:10, y:255, w:110, h:35, group:'vert' }, { id:'R03', x:10, y:290, w:110, h:35, group:'vert' }, { id:'R04', x:525, y:210, w:75, h:90, group:'vert-w' }, { id:'R05', x:525, y:300, w:75, h:100, group:'vert-w' }, { id:'R06', x:210, y:210, w:175, h:140, group:'vert-w' }, { id:'R07', x:220, y:80, w:145, h:75, group:'top' }, { id:'R08', x:365, y:80, w:145, h:75, group:'top' }, { id:'R09', x:510, y:80, w:145, h:75, group:'top' }, { id:'R10', x:10, y:400, w:200, h:50, group:'bot' }],
|
| 245 |
+
elevators: [{ id:'E1', x:70, y:190, w:30, h:25 }, { id:'E2', x:35, y:190, w:30, h:25 }],
|
| 246 |
+
},
|
| 247 |
+
'8': {
|
| 248 |
+
walls: [{ x:0, y:0, w:700, h:80 }, { x:0, y:80, w:120, h:70 }, { x:210, y:80, w:10, h:75 }, { x:655, y:80, w:45, h:75 }, { x:0, y:150, w:10, h:340 }, { x:695, y:155, w:5, h:335 }, { x:10, y:190, w:25, h:30 }, { x:65, y:190, w:5, h:30 }, { x:100, y:190, w:20, h:30 }, { x:600, y:210, w:95, h:280 }, { x:35, y:215, w:30, h:5 }, { x:70, y:215, w:30, h:5 }, { x:10, y:325, w:110, h:50 }, { x:210, y:350, w:290, h:140 }, { x:500, y:400, w:100, h:90 }, { x:10, y:450, w:200, h:40 }],
|
| 249 |
+
corridors: [{ x:120, y:80, w:90, h:320 }, { x:10, y:150, w:110, h:40 }, { x:210, y:155, w:485, h:55 }, { x:385, y:210, w:140, h:140 }, { x:500, y:350, w:25, h:50 }, { x:10, y:375, w:110, h:25 }],
|
| 250 |
+
rooms: [{ id:'R01', x:10, y:220, w:110, h:35, group:'vert' }, { id:'R02', x:10, y:255, w:110, h:35, group:'vert' }, { id:'R03', x:10, y:290, w:110, h:35, group:'vert' }, { id:'R04', x:525, y:210, w:75, h:90, group:'vert-w' }, { id:'R05', x:525, y:300, w:75, h:100, group:'vert-w' }, { id:'R06', x:210, y:210, w:175, h:140, group:'vert-w' }, { id:'R07', x:220, y:80, w:145, h:75, group:'top' }, { id:'R08', x:365, y:80, w:145, h:75, group:'top' }, { id:'R09', x:510, y:80, w:145, h:75, group:'top' }, { id:'R10', x:10, y:400, w:200, h:50, group:'bot' }],
|
| 251 |
+
elevators: [{ id:'E1', x:70, y:190, w:30, h:25 }, { id:'E2', x:35, y:190, w:30, h:25 }],
|
| 252 |
+
},
|
| 253 |
+
'9': {
|
| 254 |
+
walls: [{ x:0, y:0, w:700, h:80 }, { x:0, y:80, w:120, h:70 }, { x:210, y:80, w:10, h:75 }, { x:655, y:80, w:45, h:75 }, { x:0, y:150, w:10, h:340 }, { x:695, y:155, w:5, h:335 }, { x:10, y:190, w:25, h:30 }, { x:65, y:190, w:5, h:30 }, { x:100, y:190, w:20, h:30 }, { x:600, y:210, w:95, h:280 }, { x:35, y:215, w:30, h:5 }, { x:70, y:215, w:30, h:5 }, { x:10, y:325, w:110, h:50 }, { x:210, y:350, w:290, h:140 }, { x:500, y:400, w:100, h:90 }, { x:10, y:450, w:200, h:40 }],
|
| 255 |
+
corridors: [{ x:120, y:80, w:90, h:320 }, { x:10, y:150, w:110, h:40 }, { x:210, y:155, w:485, h:55 }, { x:385, y:210, w:140, h:140 }, { x:500, y:350, w:25, h:50 }, { x:10, y:375, w:110, h:25 }],
|
| 256 |
+
rooms: [{ id:'R01', x:10, y:220, w:110, h:35, group:'vert' }, { id:'R02', x:10, y:255, w:110, h:35, group:'vert' }, { id:'R03', x:10, y:290, w:110, h:35, group:'vert' }, { id:'R04', x:525, y:210, w:75, h:90, group:'vert-w' }, { id:'R05', x:525, y:300, w:75, h:100, group:'vert-w' }, { id:'R06', x:210, y:210, w:175, h:140, group:'vert-w' }, { id:'R07', x:220, y:80, w:145, h:75, group:'top' }, { id:'R08', x:365, y:80, w:145, h:75, group:'top' }, { id:'R09', x:510, y:80, w:145, h:75, group:'top' }, { id:'R10', x:10, y:400, w:200, h:50, group:'bot' }],
|
| 257 |
+
elevators: [{ id:'E1', x:70, y:190, w:30, h:25 }, { id:'E2', x:35, y:190, w:30, h:25 }],
|
| 258 |
+
},
|
| 259 |
+
'10': {
|
| 260 |
+
walls: [{ x:0, y:0, w:700, h:80 }, { x:0, y:80, w:120, h:70 }, { x:210, y:80, w:10, h:75 }, { x:655, y:80, w:45, h:75 }, { x:0, y:150, w:10, h:340 }, { x:695, y:155, w:5, h:335 }, { x:10, y:190, w:25, h:30 }, { x:65, y:190, w:5, h:30 }, { x:100, y:190, w:20, h:30 }, { x:600, y:210, w:95, h:280 }, { x:35, y:215, w:30, h:5 }, { x:70, y:215, w:30, h:5 }, { x:10, y:325, w:110, h:50 }, { x:210, y:350, w:290, h:140 }, { x:500, y:400, w:100, h:90 }, { x:10, y:450, w:200, h:40 }],
|
| 261 |
+
corridors: [{ x:120, y:80, w:90, h:320 }, { x:10, y:150, w:110, h:40 }, { x:210, y:155, w:485, h:55 }, { x:385, y:210, w:140, h:140 }, { x:500, y:350, w:25, h:50 }, { x:10, y:375, w:110, h:25 }],
|
| 262 |
+
rooms: [{ id:'R11', x:10, y:220, w:110, h:35, group:'vert' }, { id:'R12', x:10, y:255, w:110, h:35, group:'vert' }, { id:'R13', x:10, y:290, w:110, h:35, group:'vert' }, { id:'R14', x:525, y:210, w:75, h:90, group:'vert-w' }, { id:'R15', x:525, y:300, w:75, h:100, group:'vert-w' }, { id:'R16', x:210, y:210, w:175, h:140, group:'vert-w' }, { id:'R17', x:220, y:80, w:145, h:75, group:'top' }, { id:'R18', x:365, y:80, w:145, h:75, group:'top' }, { id:'R19', x:510, y:80, w:145, h:75, group:'top' }, { id:'R20', x:10, y:400, w:200, h:50, group:'bot' }],
|
| 263 |
+
elevators: [{ id:'E1', x:70, y:190, w:30, h:25 }, { id:'E2', x:35, y:190, w:30, h:25 }],
|
| 264 |
+
},
|
| 265 |
+
};
|
| 266 |
+
|
| 267 |
+
const DEFAULT_MAP = FLOOR_MAPS['3'];
|
| 268 |
+
const mapForFloor = floor => FLOOR_MAPS[floor] || DEFAULT_MAP;
|
| 269 |
+
|
| 270 |
+
const ELV_CORRIDOR = { x: 310, y: CY };
|
| 271 |
+
|
| 272 |
+
const getDoor = (r) => {
|
| 273 |
+
if (r.group==='top') return { x: r.x + r.w/2, y: r.y + r.h + 2 };
|
| 274 |
+
if (r.group==='bot') return { x: r.x + r.w/2, y: r.y - 2 };
|
| 275 |
+
return { x: r.x + r.w + 2, y: r.y + r.h/2 };
|
| 276 |
+
};
|
| 277 |
+
|
| 278 |
+
const buildPath = (from, roomId, floor = '1') => {
|
| 279 |
+
const room = mapForFloor(floor).rooms.find(r => r.id === roomId);
|
| 280 |
+
if (!room) return '';
|
| 281 |
+
|
| 282 |
+
if (floor === '1') {
|
| 283 |
+
const to = { x: room.x + room.w / 2, y: room.y + room.h / 2 };
|
| 284 |
+
const corridorY = 75;
|
| 285 |
+
const pts = [];
|
| 286 |
+
pts.push([from.x, from.y]);
|
| 287 |
+
if (Math.abs(from.x - to.x) > 2) {
|
| 288 |
+
pts.push([from.x, corridorY]);
|
| 289 |
+
pts.push([to.x, corridorY]);
|
| 290 |
+
}
|
| 291 |
+
pts.push([to.x, to.y]);
|
| 292 |
+
return pts.map((p,i) => (i===0?'M':'L')+p[0]+','+p[1]).join(' ');
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
const door = getDoor(room);
|
| 296 |
+
const pts = [];
|
| 297 |
+
// From position
|
| 298 |
+
pts.push([from.x, from.y]);
|
| 299 |
+
// If starting from vertical wing or below y=200, go up corridor
|
| 300 |
+
if (from.y > 200) {
|
| 301 |
+
pts.push([CX, from.y]);
|
| 302 |
+
pts.push([CX, CY]);
|
| 303 |
+
} else if (from.x < 150 && from.y > CY) {
|
| 304 |
+
pts.push([from.x, CY]);
|
| 305 |
+
} else {
|
| 306 |
+
pts.push([from.x, CY]);
|
| 307 |
+
}
|
| 308 |
+
// For vertical wing rooms, go differently
|
| 309 |
+
if (room.group === 'vert') {
|
| 310 |
+
pts.push([CX, CY]);
|
| 311 |
+
pts.push([CX, door.y]);
|
| 312 |
+
pts.push([door.x, door.y]);
|
| 313 |
+
} else {
|
| 314 |
+
// Navigate along corridor to door X
|
| 315 |
+
pts.push([door.x, CY]);
|
| 316 |
+
pts.push([door.x, door.y]);
|
| 317 |
+
}
|
| 318 |
+
return pts.map((p,i) => (i===0?'M':'L')+p[0]+','+p[1]).join(' ');
|
| 319 |
+
};
|
| 320 |
+
|
| 321 |
+
const pathLength = (d) => {
|
| 322 |
+
// Approximate length by summing segments
|
| 323 |
+
const parts = d.match(/[ML][\d.]+,[\d.]+/g) || [];
|
| 324 |
+
let len = 0, prev = null;
|
| 325 |
+
parts.forEach(p => {
|
| 326 |
+
const [x,y] = p.slice(1).split(',').map(Number);
|
| 327 |
+
if (prev) len += Math.hypot(x-prev[0], y-prev[1]);
|
| 328 |
+
prev = [x,y];
|
| 329 |
+
});
|
| 330 |
+
return len;
|
| 331 |
+
};
|
| 332 |
+
|
| 333 |
+
const FLOORS = ['1','2','3','4','5','6','7','8','9','10'];
|
| 334 |
+
|
| 335 |
+
const roomLabel = (floor, room) => {
|
| 336 |
+
const n = room.id.slice(1);
|
| 337 |
+
return `${floor}${n}`;
|
| 338 |
+
};
|
| 339 |
+
|
| 340 |
+
const MAP_W = 700;
|
| 341 |
+
const MAP_H = 490;
|
| 342 |
+
|
| 343 |
+
const getBaseStationForFloor = (floor, map) => {
|
| 344 |
+
if (floor !== '1' || !map) return null;
|
| 345 |
+
if (map.corridors?.length) {
|
| 346 |
+
const c = map.corridors[0];
|
| 347 |
+
const w = Math.min(120, Math.max(80, c.w));
|
| 348 |
+
const h = 24;
|
| 349 |
+
const x = c.x + Math.max(0, (c.w - w) / 2);
|
| 350 |
+
const y = c.y + Math.max(0, (c.h - h) / 2);
|
| 351 |
+
return { x, y, w, h };
|
| 352 |
+
}
|
| 353 |
+
if (!map.elevators?.length) return null;
|
| 354 |
+
const elvs = map.elevators;
|
| 355 |
+
const maxX = Math.max(...elvs.map(e => e.x + e.w));
|
| 356 |
+
const centerY = Math.round(elvs.reduce((s, e) => s + e.y + e.h / 2, 0) / elvs.length);
|
| 357 |
+
const w = 110;
|
| 358 |
+
const h = 24;
|
| 359 |
+
const gap = 12;
|
| 360 |
+
const x = Math.min(MAP_W - w - 8, maxX + gap);
|
| 361 |
+
const y = Math.max(8, Math.min(MAP_H - h - 8, centerY - h / 2));
|
| 362 |
+
return { x, y, w, h };
|
| 363 |
+
};
|
| 364 |
+
|
| 365 |
+
const getRobotHomeForFloor = (floor, map) => {
|
| 366 |
+
const base = getBaseStationForFloor(floor, map);
|
| 367 |
+
if (base) {
|
| 368 |
+
return {
|
| 369 |
+
x: base.x + base.w / 2,
|
| 370 |
+
y: Math.min(MAP_H - 18, base.y + base.h / 2 + 8),
|
| 371 |
+
};
|
| 372 |
+
}
|
| 373 |
+
return { x: CX, y: 468 };
|
| 374 |
+
};
|
| 375 |
+
|
| 376 |
+
// Visual robot home on the SVG test map only. This is NOT the ROS/Nav2 pose.
|
| 377 |
+
// It is placed near R02 so the web animation starts close to your current test area.
|
| 378 |
+
const ROBOT_HOME = { x: 410, y: 240 };
|
| 379 |
+
|
| 380 |
+
// Temporary real-robot test goals for Jetson/Nav2 through FastAPI /api/robot/goal.
|
| 381 |
+
// ROS map-frame poses recorded on 2026-05-27 test map.
|
| 382 |
+
const QUICK_ROBOT_GOALS = {
|
| 383 |
+
home: {
|
| 384 |
+
label: 'Home / Initial',
|
| 385 |
+
floor: 1,
|
| 386 |
+
pose: {
|
| 387 |
+
x: 0.870163506,
|
| 388 |
+
y: 0.729029868,
|
| 389 |
+
theta: 3.140028138,
|
| 390 |
+
},
|
| 391 |
+
},
|
| 392 |
+
dropoff_1: {
|
| 393 |
+
label: 'Spot 1 / Near Home',
|
| 394 |
+
floor: 1,
|
| 395 |
+
pose: {
|
| 396 |
+
x: 0.190865630,
|
| 397 |
+
y: 0.809709194,
|
| 398 |
+
theta: 3.037726853,
|
| 399 |
+
},
|
| 400 |
+
},
|
| 401 |
+
dropoff_2: {
|
| 402 |
+
label: 'Spot 2',
|
| 403 |
+
floor: 1,
|
| 404 |
+
pose: {
|
| 405 |
+
x: -0.352,
|
| 406 |
+
y: -0.965,
|
| 407 |
+
theta: -1.564,
|
| 408 |
+
},
|
| 409 |
+
},
|
| 410 |
+
};
|
| 411 |
+
|
| 412 |
+
// Locker test configuration.
|
| 413 |
+
// Arduino command numbering:
|
| 414 |
+
// id 1 = BASE LOCK -> old original lock
|
| 415 |
+
// id 2 = LOCKER 1 -> new delivery locker 1
|
| 416 |
+
// id 3 = LOCKER 2 -> new delivery locker 2
|
| 417 |
+
// id 4 = LOCKER 3 -> new delivery locker 3
|
| 418 |
+
// id 5 = LOCKER 4 -> new delivery locker 4
|
| 419 |
+
const LOCKER_CONTROLS = [
|
| 420 |
+
{ id: 1, label: 'BASE' },
|
| 421 |
+
{ id: 2, label: 'LOCKER 1' },
|
| 422 |
+
{ id: 3, label: 'LOCKER 2' },
|
| 423 |
+
{ id: 4, label: 'LOCKER 3' },
|
| 424 |
+
{ id: 5, label: 'LOCKER 4' },
|
| 425 |
+
];
|
| 426 |
+
const LOCKER_IDS = LOCKER_CONTROLS.map(l => l.id);
|
| 427 |
+
const DEFAULT_LOCKER_OPEN_MS = 3000;
|
| 428 |
+
|
| 429 |
+
/* ══════════════════════════════════════════
|
| 430 |
+
FLOOR MAP SVG
|
| 431 |
+
══════════════════════════════════════════ */
|
| 432 |
+
function FloorMap({ floor, orders, robotPos, activePath, highlightRoom, onRoomClick, elv1Floor, elv2Floor }) {
|
| 433 |
+
const map = mapForFloor(floor);
|
| 434 |
+
const rooms = map.rooms;
|
| 435 |
+
const elevators = map.elevators;
|
| 436 |
+
const baseStation = getBaseStationForFloor(floor, map);
|
| 437 |
+
return (
|
| 438 |
+
<svg viewBox="0 0 700 490" style={{ width:'100%', height:'100%' }} shapeRendering="crispEdges">
|
| 439 |
+
<defs>
|
| 440 |
+
<filter id="glow-cyan">
|
| 441 |
+
<feGaussianBlur stdDeviation="3" result="blur"/>
|
| 442 |
+
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
| 443 |
+
</filter>
|
| 444 |
+
<filter id="glow-green">
|
| 445 |
+
<feGaussianBlur stdDeviation="4" result="blur"/>
|
| 446 |
+
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
| 447 |
+
</filter>
|
| 448 |
+
<marker id="arrow" markerWidth="6" markerHeight="6" refX="3" refY="3" orient="auto">
|
| 449 |
+
<path d="M0,0 L6,3 L0,6 Z" fill="#00c8e8" opacity="0.8"/>
|
| 450 |
+
</marker>
|
| 451 |
+
</defs>
|
| 452 |
+
|
| 453 |
+
{map.walls.map((w, i) => (
|
| 454 |
+
<rect key={`w-${i}`} x={w.x} y={w.y} width={w.w} height={w.h} fill="#0a1828"/>
|
| 455 |
+
))}
|
| 456 |
+
{map.corridors.map((c, i) => (
|
| 457 |
+
<rect key={`c-${i}`} x={c.x} y={c.y} width={c.w} height={c.h} fill="#0d2238"/>
|
| 458 |
+
))}
|
| 459 |
+
|
| 460 |
+
{/* ── Building outer walls ── */}
|
| 461 |
+
{/* Horizontal wing background */}
|
| 462 |
+
{/* Vertical wing background */}
|
| 463 |
+
|
| 464 |
+
{/* ── Corridor fills ── */}
|
| 465 |
+
{/* Junction */}
|
| 466 |
+
|
| 467 |
+
{/* Corridor center line */}
|
| 468 |
+
|
| 469 |
+
{/* ── Rooms ── */}
|
| 470 |
+
{rooms.map(room => {
|
| 471 |
+
const isHighlighted = highlightRoom === room.id;
|
| 472 |
+
const orderIdx = orders.findIndex(o => o.room === room.id && o.floor === floor);
|
| 473 |
+
const isInQueue = orderIdx >= 0;
|
| 474 |
+
const label = roomLabel(floor, room);
|
| 475 |
+
|
| 476 |
+
return (
|
| 477 |
+
<g key={room.id} onClick={() => onRoomClick(room.id)} style={{cursor:'pointer'}}>
|
| 478 |
+
<rect
|
| 479 |
+
x={room.x} y={room.y} width={room.w} height={room.h}
|
| 480 |
+
rx="3"
|
| 481 |
+
fill={isHighlighted ? 'rgba(0,200,232,0.18)' : isInQueue ? 'rgba(255,107,43,0.12)' : '#061220'}
|
| 482 |
+
stroke={isHighlighted ? '#00c8e8' : isInQueue ? '#ff6b2b' : '#1c3a55'}
|
| 483 |
+
strokeWidth={isHighlighted || isInQueue ? 2 : 1.5}
|
| 484 |
+
style={{ filter: (isHighlighted||isInQueue) ? 'url(#glow-cyan)' : 'none', transition:'all 0.2s' }}
|
| 485 |
+
/>
|
| 486 |
+
{/* Room number */}
|
| 487 |
+
<text
|
| 488 |
+
x={room.x + room.w/2} y={room.y + room.h/2 - 6}
|
| 489 |
+
textAnchor="middle" dominantBaseline="middle"
|
| 490 |
+
fill={isHighlighted ? '#00c8e8' : isInQueue ? '#ff6b2b' : '#3a6070'}
|
| 491 |
+
fontSize="12" fontFamily="Orbitron" fontWeight="600"
|
| 492 |
+
>{label}</text>
|
| 493 |
+
{isInQueue && (
|
| 494 |
+
<text x={room.x+room.w/2} y={room.y+room.h/2+10} textAnchor="middle" fill="#ff6b2b" fontSize="9" fontFamily="Space Grotesk">ORDER {orderIdx+1}</text>
|
| 495 |
+
)}
|
| 496 |
+
{/* Door mark */}
|
| 497 |
+
{(() => {
|
| 498 |
+
const d = getDoor(room);
|
| 499 |
+
return <circle cx={d.x} cy={d.y} r="3" fill={isInQueue?'#ff6b2b':'#1c3a55'} />;
|
| 500 |
+
})()}
|
| 501 |
+
</g>
|
| 502 |
+
);
|
| 503 |
+
})}
|
| 504 |
+
|
| 505 |
+
{/* ── Elevators ── */}
|
| 506 |
+
{elevators.map((elv, i) => {
|
| 507 |
+
const currentF = i===0 ? elv1Floor : elv2Floor;
|
| 508 |
+
const isHere = currentF === floor;
|
| 509 |
+
return (
|
| 510 |
+
<g key={elv.id}>
|
| 511 |
+
<rect x={elv.x} y={elv.y} width={elv.w} height={elv.h} rx="4"
|
| 512 |
+
fill={isHere ? 'rgba(245,158,11,0.15)' : '#06111e'}
|
| 513 |
+
stroke={isHere ? '#f59e0b' : '#2a4a60'}
|
| 514 |
+
strokeWidth={isHere ? 2 : 1.5}
|
| 515 |
+
/>
|
| 516 |
+
<text x={elv.x+elv.w/2} y={elv.y+elv.h/2-1} textAnchor="middle" fill={isHere?'#f59e0b':'#3a6070'} fontSize="9" fontFamily="Orbitron" fontWeight="700">{elv.id}</text>
|
| 517 |
+
<text x={elv.x+elv.w/2} y={elv.y+elv.h/2+9} textAnchor="middle" fill={isHere?'#f59e0b':'#2a4060'} fontSize="8" fontFamily="Space Grotesk">F{currentF}</text>
|
| 518 |
+
</g>
|
| 519 |
+
);
|
| 520 |
+
})}
|
| 521 |
+
|
| 522 |
+
{/* ── Active delivery path ── */}
|
| 523 |
+
{activePath && activePath.map((seg, i) => (
|
| 524 |
+
<path key={i} d={seg.d} fill="none"
|
| 525 |
+
stroke={i===0?'#00c8e8':'#ff6b2b'}
|
| 526 |
+
strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"
|
| 527 |
+
strokeDasharray={seg.len + ' ' + seg.len}
|
| 528 |
+
style={{
|
| 529 |
+
strokeDashoffset: seg.len,
|
| 530 |
+
animation: `pathDraw ${Math.max(0.8, seg.len/200)}s ease-out ${seg.delay}s forwards`,
|
| 531 |
+
}}
|
| 532 |
+
markerEnd="url(#arrow)"
|
| 533 |
+
opacity="0.9"
|
| 534 |
+
filter="url(#glow-cyan)"
|
| 535 |
+
/>
|
| 536 |
+
))}
|
| 537 |
+
|
| 538 |
+
{/* ── Robot position ── */}
|
| 539 |
+
{robotPos && (() => {
|
| 540 |
+
const rx = robotPos.x ?? ROBOT_HOME.x;
|
| 541 |
+
const ry = robotPos.y ?? ROBOT_HOME.y;
|
| 542 |
+
return (
|
| 543 |
+
<g filter="url(#glow-green)" transform={`translate(${rx}, ${ry})`}>
|
| 544 |
+
<circle cx="0" cy="0" r="10" fill="#00e67640" stroke="#00e676" strokeWidth="2"/>
|
| 545 |
+
<text x="0" y="1" textAnchor="middle" dominantBaseline="middle" fontSize="8" fill="#00e676" fontWeight="700">R</text>
|
| 546 |
+
<text x="0" y="22" textAnchor="middle" fill="#00e67690" fontSize="9" fontFamily="Orbitron">ROBOT</text>
|
| 547 |
+
</g>
|
| 548 |
+
);
|
| 549 |
+
})()}
|
| 550 |
+
|
| 551 |
+
{/* ── Base station label (Floor 1 only) ── */}
|
| 552 |
+
|
| 553 |
+
{/* ── Compass ── */}
|
| 554 |
+
<g transform="translate(668,460)">
|
| 555 |
+
<circle cx="0" cy="0" r="18" fill="#030c18" stroke="#1a3a55" strokeWidth="1"/>
|
| 556 |
+
<text x="0" y="-8" textAnchor="middle" fill="#3a6070" fontSize="8" fontFamily="Orbitron">N</text>
|
| 557 |
+
<text x="0" y="12" textAnchor="middle" fill="#3a6070" fontSize="8" fontFamily="Orbitron">S</text>
|
| 558 |
+
<text x="-12" y="3" textAnchor="middle" fill="#3a6070" fontSize="8" fontFamily="Orbitron">W</text>
|
| 559 |
+
<text x="12" y="3" textAnchor="middle" fill="#3a6070" fontSize="8" fontFamily="Orbitron">E</text>
|
| 560 |
+
<line x1="0" y1="-12" x2="0" y2="12" stroke="#2a4a60" strokeWidth="1"/>
|
| 561 |
+
<line x1="-12" y1="0" x2="12" y2="0" stroke="#2a4a60" strokeWidth="1"/>
|
| 562 |
+
<polygon points="0,-12 -3,-2 3,-2" fill="#00c8e8" opacity="0.8"/>
|
| 563 |
+
</g>
|
| 564 |
+
|
| 565 |
+
{/* ── Scale bar ── */}
|
| 566 |
+
<g transform="translate(540,472)">
|
| 567 |
+
<line x1="0" y1="8" x2="80" y2="8" stroke="#2a4a60" strokeWidth="1"/>
|
| 568 |
+
<line x1="0" y1="4" x2="0" y2="12" stroke="#2a4a60" strokeWidth="1"/>
|
| 569 |
+
<line x1="80" y1="4" x2="80" y2="12" stroke="#2a4a60" strokeWidth="1"/>
|
| 570 |
+
<text x="40" y="4" textAnchor="middle" fill="#3a6070" fontSize="8" fontFamily="Space Grotesk">4 m</text>
|
| 571 |
+
</g>
|
| 572 |
+
</svg>
|
| 573 |
+
);
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
/* ══════════════════════════════════════════
|
| 579 |
+
COMPARTMENT PANEL
|
| 580 |
+
══════════════════════════════════════════ */
|
| 581 |
+
const COMP_COLORS = ['#00c8e8','#f59e0b','#ff6b2b','#a855f7'];
|
| 582 |
+
|
| 583 |
+
function CompartmentPanel({ compartments }) {
|
| 584 |
+
return (
|
| 585 |
+
<div style={{ background:'#070f1a', border:'1px solid #1a3a55', borderRadius:8, padding:'12px', marginBottom:12 }}>
|
| 586 |
+
<div style={{ fontFamily:'Orbitron', fontSize:11, color:'#7ab0c8', letterSpacing:2, marginBottom:10 }}>
|
| 587 |
+
CARGO COMPARTMENTS
|
| 588 |
+
</div>
|
| 589 |
+
|
| 590 |
+
{/* Robot side-view with 4 stacked compartments */}
|
| 591 |
+
<div style={{ display:'flex', gap:12, alignItems:'flex-start' }}>
|
| 592 |
+
{/* Mini robot schematic */}
|
| 593 |
+
<svg width="52" height="120" viewBox="0 0 52 120" fill="none" style={{flexShrink:0}}>
|
| 594 |
+
{/* Head */}
|
| 595 |
+
<rect x="11" y="2" width="30" height="20" rx="4" fill="#0d1e2d" stroke="#00c8e8" strokeWidth="1.5"/>
|
| 596 |
+
<rect x="16" y="7" width="7" height="5" rx="2" fill="#00c8e8"/>
|
| 597 |
+
<rect x="29" y="7" width="7" height="5" rx="2" fill="#00c8e8"/>
|
| 598 |
+
{/* Neck */}
|
| 599 |
+
<line x1="26" y1="22" x2="26" y2="26" stroke="#1a3a55" strokeWidth="2"/>
|
| 600 |
+
{/* Body frame */}
|
| 601 |
+
<rect x="6" y="26" width="40" height="80" rx="5" fill="#0a1828" stroke="#1c3a55" strokeWidth="1.5"/>
|
| 602 |
+
{/* 4 Compartments */}
|
| 603 |
+
{[0,1,2,3].map(i => {
|
| 604 |
+
const c = compartments[i];
|
| 605 |
+
const color = c ? COMP_COLORS[i] : null;
|
| 606 |
+
return (
|
| 607 |
+
<g key={i}>
|
| 608 |
+
<rect
|
| 609 |
+
x="9" y={29 + i*19} width="34" height="16" rx="2"
|
| 610 |
+
fill={c ? `${color}22` : '#1a3a55'}
|
| 611 |
+
stroke={c ? color : '#1a3050'}
|
| 612 |
+
strokeWidth={c ? 1.5 : 1}
|
| 613 |
+
style={!c ? { animation:`compBlink 2s ease-in-out infinite`, animationDelay:`${i*0.4}s` } : {}}
|
| 614 |
+
/>
|
| 615 |
+
{c && <circle cx="38" cy={29 + i*19 + 8} r="3" fill={color} style={{filter:`drop-shadow(0 0 3px ${color})`}}/>}
|
| 616 |
+
<text x="13" y={29 + i*19 + 10} fill={c ? color : '#1a3050'} fontSize="7" fontFamily="Orbitron">{i+1}</text>
|
| 617 |
+
</g>
|
| 618 |
+
);
|
| 619 |
+
})}
|
| 620 |
+
{/* Wheels */}
|
| 621 |
+
<circle cx="14" cy="112" r="6" fill="#0d1e2d" stroke="#1c3a55" strokeWidth="1.5"/>
|
| 622 |
+
<circle cx="38" cy="112" r="6" fill="#0d1e2d" stroke="#1c3a55" strokeWidth="1.5"/>
|
| 623 |
+
<circle cx="14" cy="112" r="2.5" fill="#1a3a55"/>
|
| 624 |
+
<circle cx="38" cy="112" r="2.5" fill="#1a3a55"/>
|
| 625 |
+
</svg>
|
| 626 |
+
|
| 627 |
+
{/* Compartment status list */}
|
| 628 |
+
<div style={{ flex:1, display:'flex', flexDirection:'column', gap:5 }}>
|
| 629 |
+
{[0,1,2,3].map(i => {
|
| 630 |
+
const c = compartments[i];
|
| 631 |
+
const color = COMP_COLORS[i];
|
| 632 |
+
return (
|
| 633 |
+
<div key={i} style={{
|
| 634 |
+
padding:'5px 8px', borderRadius:4,
|
| 635 |
+
border: `1px solid ${c ? color+'50' : '#0d2238'}`,
|
| 636 |
+
background: c ? `${color}0d` : '#040d18',
|
| 637 |
+
display:'flex', alignItems:'center', gap:7,
|
| 638 |
+
animation: !c ? `compBlink 2s ease-in-out infinite` : 'none',
|
| 639 |
+
animationDelay: `${i*0.4}s`,
|
| 640 |
+
}}>
|
| 641 |
+
<div style={{
|
| 642 |
+
width:18, height:18, borderRadius:3, flexShrink:0,
|
| 643 |
+
background: c ? `${color}25` : '#040d18',
|
| 644 |
+
border: `1px solid ${c ? color : '#1a3050'}`,
|
| 645 |
+
display:'flex', alignItems:'center', justifyContent:'center',
|
| 646 |
+
}}>
|
| 647 |
+
<span style={{ fontFamily:'Orbitron', fontSize:9, color: c ? color : '#1a3050', fontWeight:700 }}>{i+1}</span>
|
| 648 |
+
</div>
|
| 649 |
+
<div style={{ flex:1, minWidth:0 }}>
|
| 650 |
+
{c ? (
|
| 651 |
+
<>
|
| 652 |
+
<div style={{ fontSize:10, color, fontFamily:'Orbitron', fontWeight:600, letterSpacing:1 }}>
|
| 653 |
+
F{c.floor} · R{c.roomDef.id.slice(1)}
|
| 654 |
+
</div>
|
| 655 |
+
<div style={{ fontSize:9, color:'#3a6070', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
| 656 |
+
@{c.telegram}
|
| 657 |
+
</div>
|
| 658 |
+
</>
|
| 659 |
+
) : (
|
| 660 |
+
<span style={{ fontSize:10, color:'#1a3050', fontFamily:'Space Grotesk', letterSpacing:1 }}>EMPTY</span>
|
| 661 |
+
)}
|
| 662 |
+
</div>
|
| 663 |
+
{c && <div style={{ fontSize:9, color:'#3a6070', fontFamily:'Orbitron', letterSpacing:1 }}>IN USE</div>}
|
| 664 |
+
</div>
|
| 665 |
+
);
|
| 666 |
+
})}
|
| 667 |
+
</div>
|
| 668 |
+
</div>
|
| 669 |
+
</div>
|
| 670 |
+
);
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
/* ══════════════════════════════════════════
|
| 674 |
+
ELEVATOR BUILDING PANEL
|
| 675 |
+
══════════════════════════════════════════ */
|
| 676 |
+
function ElevatorPanel({ elv1Floor, elv2Floor, robotFloor, onSimulate }) {
|
| 677 |
+
const floorH = 28;
|
| 678 |
+
const panelH = FLOORS.length * floorH + 20;
|
| 679 |
+
|
| 680 |
+
return (
|
| 681 |
+
<div style={{
|
| 682 |
+
background: '#070f1a', border: '1px solid #1a3a55', borderRadius: 8, padding: '12px',
|
| 683 |
+
marginBottom: 12,
|
| 684 |
+
}}>
|
| 685 |
+
<div style={{ marginBottom:10 }}>
|
| 686 |
+
<span style={{ fontFamily:'Orbitron', fontSize:11, color:'#7ab0c8', letterSpacing:2 }}>ELEVATORS</span>
|
| 687 |
+
</div>
|
| 688 |
+
|
| 689 |
+
<div style={{ display:'flex', gap:8 }}>
|
| 690 |
+
{/* Building cross-section */}
|
| 691 |
+
<div style={{ flex:1, position:'relative', height: panelH }}>
|
| 692 |
+
{/* Floor rows */}
|
| 693 |
+
{[...FLOORS].reverse().map((f, i) => (
|
| 694 |
+
<div key={f} style={{
|
| 695 |
+
position:'absolute', top: i*floorH+10, left:0, right:0, height:floorH-2,
|
| 696 |
+
display:'flex', alignItems:'center', gap:6,
|
| 697 |
+
}}>
|
| 698 |
+
<div style={{
|
| 699 |
+
width:24, fontFamily:'Orbitron', fontSize:9, color: f===robotFloor?'#00e676':'#2a4a60',
|
| 700 |
+
textAlign:'right', flexShrink:0,
|
| 701 |
+
}}>F{f}</div>
|
| 702 |
+
<div style={{
|
| 703 |
+
flex:1, height:floorH-6, background: '#061018',
|
| 704 |
+
border: `1px solid ${f===robotFloor?'#00e67630':'#0d2238'}`,
|
| 705 |
+
borderRadius:2, position:'relative', overflow:'visible',
|
| 706 |
+
}}>
|
| 707 |
+
{/* Shaft 1 */}
|
| 708 |
+
<div style={{ position:'absolute', left:6, top:'50%', transform:'translateY(-50%)' }}>
|
| 709 |
+
<div style={{
|
| 710 |
+
width:20, height:18, borderRadius:2,
|
| 711 |
+
background: elv1Floor===f?'rgba(245,158,11,0.3)':'#030c18',
|
| 712 |
+
border: `1px solid ${elv1Floor===f?'#f59e0b':'#1a3050'}`,
|
| 713 |
+
display:'flex', alignItems:'center', justifyContent:'center',
|
| 714 |
+
}}>
|
| 715 |
+
{elv1Floor===f && <div style={{ width:6, height:6, borderRadius:'50%', background:'#f59e0b', boxShadow:'0 0 6px #f59e0b' }}/>}
|
| 716 |
+
</div>
|
| 717 |
+
</div>
|
| 718 |
+
{/* Shaft 2 */}
|
| 719 |
+
<div style={{ position:'absolute', left:34, top:'50%', transform:'translateY(-50%)' }}>
|
| 720 |
+
<div style={{
|
| 721 |
+
width:20, height:18, borderRadius:2,
|
| 722 |
+
background: elv2Floor===f?'rgba(245,158,11,0.3)':'#030c18',
|
| 723 |
+
border: `1px solid ${elv2Floor===f?'#f59e0b':'#1a3050'}`,
|
| 724 |
+
display:'flex', alignItems:'center', justifyContent:'center',
|
| 725 |
+
}}>
|
| 726 |
+
{elv2Floor===f && <div style={{ width:6, height:6, borderRadius:'50%', background:'#f59e0b', boxShadow:'0 0 6px #f59e0b' }}/>}
|
| 727 |
+
</div>
|
| 728 |
+
</div>
|
| 729 |
+
{/* Robot indicator */}
|
| 730 |
+
{f===robotFloor && (
|
| 731 |
+
<div style={{ position:'absolute', right:4, top:'50%', transform:'translateY(-50%)', width:12, height:12, borderRadius:'50%', background:'#00e676', boxShadow:'0 0 8px #00e676' }}/>
|
| 732 |
+
)}
|
| 733 |
+
</div>
|
| 734 |
+
</div>
|
| 735 |
+
))}
|
| 736 |
+
</div>
|
| 737 |
+
|
| 738 |
+
{/* Labels */}
|
| 739 |
+
<div style={{ display:'flex', flexDirection:'column', gap:2, justifyContent:'flex-end', paddingBottom:10 }}>
|
| 740 |
+
<div style={{ display:'flex', gap:6, alignItems:'center' }}>
|
| 741 |
+
<div style={{ width:8, height:8, borderRadius:1, background:'#f59e0b' }}/>
|
| 742 |
+
<span style={{ fontSize:9, color:'#3a6070', fontFamily:'Space Grotesk' }}>E1 E2</span>
|
| 743 |
+
</div>
|
| 744 |
+
<div style={{ display:'flex', gap:6, alignItems:'center' }}>
|
| 745 |
+
<div style={{ width:8, height:8, borderRadius:'50%', background:'#00e676' }}/>
|
| 746 |
+
<span style={{ fontSize:9, color:'#3a6070', fontFamily:'Space Grotesk' }}>Robot</span>
|
| 747 |
+
</div>
|
| 748 |
+
</div>
|
| 749 |
+
</div>
|
| 750 |
+
</div>
|
| 751 |
+
);
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
/* ══════════════════════════════════════════
|
| 755 |
+
ORDER CARD (draggable)
|
| 756 |
+
══════════════════════════════════════════ */
|
| 757 |
+
function OrderCard({ order, index, total, onRemove, onDragStart, onDragOver, onDrop }) {
|
| 758 |
+
const colors = ['#00c8e8','#f59e0b','#ff6b2b','#a855f7'];
|
| 759 |
+
const c = colors[index % colors.length];
|
| 760 |
+
|
| 761 |
+
return (
|
| 762 |
+
<div
|
| 763 |
+
draggable
|
| 764 |
+
onDragStart={() => onDragStart(index)}
|
| 765 |
+
onDragOver={(e) => { e.preventDefault(); onDragOver(index); }}
|
| 766 |
+
onDrop={() => onDrop(index)}
|
| 767 |
+
style={{
|
| 768 |
+
background: '#070f1a', border: `1px solid ${c}40`,
|
| 769 |
+
borderLeft: `3px solid ${c}`,
|
| 770 |
+
borderRadius: 6, padding: '10px 12px',
|
| 771 |
+
display: 'flex', alignItems: 'center', gap: 10,
|
| 772 |
+
cursor: 'grab', animation: 'fadeIn 0.2s ease-out',
|
| 773 |
+
position: 'relative', overflow: 'hidden',
|
| 774 |
+
}}
|
| 775 |
+
>
|
| 776 |
+
{/* Scan shimmer */}
|
| 777 |
+
<div style={{
|
| 778 |
+
position:'absolute', inset:0, pointerEvents:'none',
|
| 779 |
+
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.03), transparent)',
|
| 780 |
+
animation: 'scanH 3s linear infinite',
|
| 781 |
+
}}/>
|
| 782 |
+
|
| 783 |
+
<div style={{
|
| 784 |
+
width:28, height:28, borderRadius:4, background:`${c}20`,
|
| 785 |
+
border:`1px solid ${c}60`, display:'flex', alignItems:'center', justifyContent:'center',
|
| 786 |
+
flexShrink:0,
|
| 787 |
+
}}>
|
| 788 |
+
<span style={{ fontFamily:'Orbitron', fontSize:12, fontWeight:700, color:c }}>{index+1}</span>
|
| 789 |
+
</div>
|
| 790 |
+
|
| 791 |
+
<div style={{ flex:1, minWidth:0 }}>
|
| 792 |
+
<div style={{ display:'flex', gap:8, alignItems:'center' }}>
|
| 793 |
+
<span style={{ fontFamily:'Orbitron', fontSize:14, fontWeight:700, color:'#e2e8f0' }}>
|
| 794 |
+
{order.floorLabel}{order.roomDef.id.slice(1)}
|
| 795 |
+
</span>
|
| 796 |
+
<span style={{ fontSize:10, color:'#3a6070', fontFamily:'Space Grotesk' }}>Floor {order.floor}</span>
|
| 797 |
+
</div>
|
| 798 |
+
<div style={{ fontSize:12, color:'#7ab0c8', marginTop:2, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
| 799 |
+
@{order.telegram || 'not set'}
|
| 800 |
+
</div>
|
| 801 |
+
</div>
|
| 802 |
+
|
| 803 |
+
<div style={{ display:'flex', flexDirection:'column', alignItems:'flex-end', gap:4, flexShrink:0 }}>
|
| 804 |
+
<div style={{ fontSize:9, color:'#2a4a60', fontFamily:'Orbitron', letterSpacing:1 }}>
|
| 805 |
+
{index===0?'PRIORITY':'QUEUED'}
|
| 806 |
+
</div>
|
| 807 |
+
<button onClick={() => onRemove(index)} style={{
|
| 808 |
+
width:20, height:20, borderRadius:3, border:'1px solid #ff444440',
|
| 809 |
+
background:'#ff444410', color:'#ff6666', fontSize:12,
|
| 810 |
+
display:'flex', alignItems:'center', justifyContent:'center',
|
| 811 |
+
lineHeight:1,
|
| 812 |
+
}}>×</button>
|
| 813 |
+
</div>
|
| 814 |
+
</div>
|
| 815 |
+
);
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
/* ══════════════════════════════════════════
|
| 819 |
+
ADD ORDER FORM
|
| 820 |
+
══════════════════════════════════════════ */
|
| 821 |
+
function AddOrderForm({ selectedRoom, selectedFloor, allFloors, onAdd, onClose, compartments }) {
|
| 822 |
+
const [floor, setFloor] = useState(selectedFloor || '1');
|
| 823 |
+
const [room, setRoom] = useState(selectedRoom || 'R01');
|
| 824 |
+
const [telegram, setTelegram] = useState('');
|
| 825 |
+
const [compartment, setCompartment] = useState(() => compartments.findIndex(c => c === null));
|
| 826 |
+
const [telegramError, setTelegramError] = useState(false);
|
| 827 |
+
const floorRooms = mapForFloor(floor).rooms;
|
| 828 |
+
|
| 829 |
+
// Sync floor when user changes map floor while form is open
|
| 830 |
+
useEffect(() => { if (selectedFloor) setFloor(selectedFloor); }, [selectedFloor]);
|
| 831 |
+
// Sync room when user clicks map while form is open
|
| 832 |
+
useEffect(() => { if (selectedRoom) setRoom(selectedRoom); }, [selectedRoom]);
|
| 833 |
+
useEffect(() => {
|
| 834 |
+
if (!floorRooms.some(r => r.id === room)) setRoom(floorRooms[0]?.id || 'R01');
|
| 835 |
+
}, [floor, floorRooms, room]);
|
| 836 |
+
|
| 837 |
+
const inputStyle = {
|
| 838 |
+
background:'#040d18', border:'1px solid #1a3a55', borderRadius:5,
|
| 839 |
+
color:'#c8dce8', fontFamily:'Space Grotesk', fontSize:13,
|
| 840 |
+
padding:'8px 10px', width:'100%', outline:'none',
|
| 841 |
+
};
|
| 842 |
+
|
| 843 |
+
return (
|
| 844 |
+
<div style={{
|
| 845 |
+
background:'#070f1a', border:'1px solid #00c8e840', borderRadius:8,
|
| 846 |
+
padding:'16px', marginBottom:12, animation:'fadeIn 0.2s ease-out',
|
| 847 |
+
}}>
|
| 848 |
+
<div style={{ fontFamily:'Orbitron', fontSize:11, color:'#7ab0c8', letterSpacing:2, marginBottom:12 }}>
|
| 849 |
+
ADD DELIVERY ORDER
|
| 850 |
+
</div>
|
| 851 |
+
|
| 852 |
+
<div style={{ display:'flex', flexDirection:'column', gap:8 }}>
|
| 853 |
+
<div style={{ display:'flex', gap:8 }}>
|
| 854 |
+
<div style={{ flex:1 }}>
|
| 855 |
+
<div style={{ fontSize:10, color:'#3a6070', marginBottom:4, letterSpacing:1 }}>FLOOR</div>
|
| 856 |
+
<select value={floor} onChange={e=>setFloor(e.target.value)} style={{...inputStyle, background:'#040d18'}}>
|
| 857 |
+
{allFloors.map(f => <option key={f} value={f}>{f}F</option>)}
|
| 858 |
+
</select>
|
| 859 |
+
</div>
|
| 860 |
+
<div style={{ flex:1 }}>
|
| 861 |
+
<div style={{ fontSize:10, color:'#3a6070', marginBottom:4, letterSpacing:1 }}>ROOM</div>
|
| 862 |
+
<select value={room} onChange={e=>setRoom(e.target.value)} style={{...inputStyle, background:'#040d18'}}>
|
| 863 |
+
{floorRooms.map(r => <option key={r.id} value={r.id}>Room {r.id.slice(1).padStart(2,'0')}</option>)}
|
| 864 |
+
</select>
|
| 865 |
+
</div>
|
| 866 |
+
</div>
|
| 867 |
+
|
| 868 |
+
<div>
|
| 869 |
+
<div style={{ fontSize:10, color:'#3a6070', marginBottom:4, letterSpacing:1 }}>COMPARTMENT</div>
|
| 870 |
+
<div style={{ display:'flex', gap:6 }}>
|
| 871 |
+
{[0,1,2,3].map(i => {
|
| 872 |
+
const taken = compartments[i] !== null;
|
| 873 |
+
const active = compartment === i;
|
| 874 |
+
const color = COMP_COLORS[i];
|
| 875 |
+
return (
|
| 876 |
+
<button key={i} onClick={() => !taken && setCompartment(i)} disabled={taken} style={{
|
| 877 |
+
flex:1, padding:'8px 0', borderRadius:5,
|
| 878 |
+
border: `1px solid ${taken ? '#0d2238' : active ? color : color+'40'}`,
|
| 879 |
+
background: taken ? '#030c18' : active ? `${color}20` : 'transparent',
|
| 880 |
+
color: taken ? '#1a3050' : active ? color : color+'80',
|
| 881 |
+
fontFamily:'Orbitron', fontSize:12, fontWeight:700,
|
| 882 |
+
cursor: taken ? 'not-allowed' : 'pointer',
|
| 883 |
+
position:'relative',
|
| 884 |
+
}}>
|
| 885 |
+
{i+1}
|
| 886 |
+
{taken && <div style={{ position:'absolute', top:1, right:4, fontSize:7, fontFamily:'Orbitron', color:'#3a6070', letterSpacing:0 }}>USED</div>}
|
| 887 |
+
</button>
|
| 888 |
+
);
|
| 889 |
+
})}
|
| 890 |
+
</div>
|
| 891 |
+
</div>
|
| 892 |
+
|
| 893 |
+
<div>
|
| 894 |
+
<div style={{ fontSize:10, color: telegramError?'#ff6b2b':'#3a6070', marginBottom:4, letterSpacing:1, transition:'color 0.2s' }}>
|
| 895 |
+
TELEGRAM USER ID {telegramError && '— required!'}
|
| 896 |
+
</div>
|
| 897 |
+
<div style={{ position:'relative' }}>
|
| 898 |
+
<input
|
| 899 |
+
value={telegram}
|
| 900 |
+
onChange={e=>{ setTelegram(e.target.value.replace(/\D/g, '')); setTelegramError(false); }}
|
| 901 |
+
placeholder="user id"
|
| 902 |
+
inputMode="numeric"
|
| 903 |
+
style={{...inputStyle, borderColor: telegramError?'#ff6b2b':'#1a3a55', boxShadow: telegramError?'0 0 8px rgba(255,107,43,0.3)':'none', transition:'all 0.2s'}}
|
| 904 |
+
/>
|
| 905 |
+
</div>
|
| 906 |
+
</div>
|
| 907 |
+
|
| 908 |
+
<div style={{ display:'flex', gap:8, marginTop:4 }}>
|
| 909 |
+
<button onClick={onClose} style={{
|
| 910 |
+
flex:1, padding:'8px', borderRadius:5, border:'1px solid #1a3a55',
|
| 911 |
+
background:'transparent', color:'#7ab0c8', fontFamily:'Space Grotesk', fontSize:13,
|
| 912 |
+
}}>Cancel</button>
|
| 913 |
+
<button onClick={() => {
|
| 914 |
+
if (!telegram.trim()) { setTelegramError(true); setTimeout(()=>setTelegramError(false),1200); return; }
|
| 915 |
+
if (compartment === -1) return;
|
| 916 |
+
onAdd({ floor, room, telegram: telegram.trim(), compartment });
|
| 917 |
+
}} style={{
|
| 918 |
+
flex:2, padding:'8px', borderRadius:5, border:'1px solid #00c8e840',
|
| 919 |
+
background:'rgba(0,200,232,0.1)', color:'#00c8e8',
|
| 920 |
+
fontFamily:'Orbitron', fontSize:11, letterSpacing:2,
|
| 921 |
+
}}>+ ADD ORDER</button>
|
| 922 |
+
</div>
|
| 923 |
+
</div>
|
| 924 |
+
</div>
|
| 925 |
+
);
|
| 926 |
+
}
|
| 927 |
+
|
| 928 |
+
/* ══════════════════════════════════════════
|
| 929 |
+
DISPATCH MODAL
|
| 930 |
+
════════════════════���═════════════════════ */
|
| 931 |
+
function DispatchModal({ orders, onConfirm, onClose }) {
|
| 932 |
+
const total = orders.length;
|
| 933 |
+
return (
|
| 934 |
+
<div style={{
|
| 935 |
+
position:'fixed', inset:0, background:'rgba(0,0,0,0.7)', backdropFilter:'blur(4px)',
|
| 936 |
+
display:'flex', alignItems:'center', justifyContent:'center', zIndex:100,
|
| 937 |
+
}}>
|
| 938 |
+
<div style={{
|
| 939 |
+
background:'#070f1a', border:'1px solid #00c8e840', borderRadius:12,
|
| 940 |
+
padding:'32px', width:480, animation:'fadeIn 0.2s ease-out',
|
| 941 |
+
}}>
|
| 942 |
+
<div style={{ fontFamily:'Orbitron', fontSize:18, fontWeight:700, color:'#00c8e8', marginBottom:8, letterSpacing:3 }}>
|
| 943 |
+
DISPATCH PLAN
|
| 944 |
+
</div>
|
| 945 |
+
<div style={{ fontSize:13, color:'#3a6070', marginBottom:24, letterSpacing:1 }}>
|
| 946 |
+
{total} delivery {total>1?'stops':'stop'} — confirm to deploy robot
|
| 947 |
+
</div>
|
| 948 |
+
|
| 949 |
+
{/* Route visualization */}
|
| 950 |
+
<div style={{ display:'flex', flexDirection:'column', gap:6, marginBottom:24 }}>
|
| 951 |
+
{/* Base */}
|
| 952 |
+
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
| 953 |
+
<div style={{ width:8, height:8, borderRadius:'50%', background:'#00e676', boxShadow:'0 0 6px #00e676' }}/>
|
| 954 |
+
<span style={{ fontSize:12, color:'#7ab0c8', fontFamily:'Space Grotesk' }}>Base Station — Floor 1</span>
|
| 955 |
+
</div>
|
| 956 |
+
{orders.map((o, i) => (
|
| 957 |
+
<React.Fragment key={i}>
|
| 958 |
+
<div style={{ marginLeft:3, width:2, height:16, background:'#1a3a55' }}/>
|
| 959 |
+
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
| 960 |
+
<div style={{ width:8, height:8, borderRadius:2, background:'#f59e0b', boxShadow:'0 0 6px #f59e0b60' }}/>
|
| 961 |
+
<span style={{ fontSize:11, color:'#3a6070', fontFamily:'Space Grotesk' }}>Elevator → Floor {o.floor}</span>
|
| 962 |
+
</div>
|
| 963 |
+
<div style={{ marginLeft:3, width:2, height:16, background:'#1a3a55' }}/>
|
| 964 |
+
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
| 965 |
+
<div style={{ width:8, height:8, borderRadius:2, background:'#ff6b2b' }}/>
|
| 966 |
+
<span style={{ fontSize:12, color:'#c8dce8', fontFamily:'Space Grotesk' }}>
|
| 967 |
+
Room {o.floorLabel}{o.roomDef.id.slice(1)} — @{o.telegram}
|
| 968 |
+
</span>
|
| 969 |
+
</div>
|
| 970 |
+
</React.Fragment>
|
| 971 |
+
))}
|
| 972 |
+
<div style={{ marginLeft:3, width:2, height:16, background:'#1a3a55' }}/>
|
| 973 |
+
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
| 974 |
+
<div style={{ width:8, height:8, borderRadius:'50%', background:'#00e676', opacity:0.5 }}/>
|
| 975 |
+
<span style={{ fontSize:11, color:'#3a6070', fontFamily:'Space Grotesk' }}>Return to Base</span>
|
| 976 |
+
</div>
|
| 977 |
+
</div>
|
| 978 |
+
|
| 979 |
+
<div style={{ display:'flex', gap:10 }}>
|
| 980 |
+
<button onClick={onClose} style={{
|
| 981 |
+
flex:1, padding:'12px', borderRadius:6, border:'1px solid #1a3a55',
|
| 982 |
+
background:'transparent', color:'#7ab0c8', fontFamily:'Space Grotesk', fontSize:13,
|
| 983 |
+
}}>Cancel</button>
|
| 984 |
+
<button onClick={onConfirm} style={{
|
| 985 |
+
flex:2, padding:'12px', borderRadius:6, border:'1px solid #00c8e840',
|
| 986 |
+
background:'linear-gradient(135deg, rgba(0,200,232,0.15) 0%, rgba(0,200,232,0.05) 100%)',
|
| 987 |
+
color:'#00c8e8', fontFamily:'Orbitron', fontSize:12, fontWeight:700, letterSpacing:3,
|
| 988 |
+
boxShadow:'0 0 20px rgba(0,200,232,0.15)',
|
| 989 |
+
}}>▶ DEPLOY ROBOT</button>
|
| 990 |
+
</div>
|
| 991 |
+
</div>
|
| 992 |
+
</div>
|
| 993 |
+
);
|
| 994 |
+
}
|
| 995 |
+
|
| 996 |
+
/* ══════════════════════════════════════════
|
| 997 |
+
MAIN APP
|
| 998 |
+
══════════════════════════════════════════ */
|
| 999 |
+
function App() {
|
| 1000 |
+
const time = useTime();
|
| 1001 |
+
const [viewFloor, setViewFloor] = useState('1');
|
| 1002 |
+
const [robotFloor, setRobotFloor] = useState('1');
|
| 1003 |
+
const [robotPos, setRobotPos] = useState(ROBOT_HOME);
|
| 1004 |
+
const [elv1Floor, setElv1Floor] = useState('1');
|
| 1005 |
+
const [elv2Floor, setElv2Floor] = useState('3');
|
| 1006 |
+
const [orders, setOrders] = useState([]);
|
| 1007 |
+
const [compartments, setCompartments] = useState([null, null, null, null]);
|
| 1008 |
+
const [showAddForm, setShowAddForm] = useState(false);
|
| 1009 |
+
const [showDispatch, setShowDispatch] = useState(false);
|
| 1010 |
+
const [activePath, setActivePath] = useState(null);
|
| 1011 |
+
const [highlightRoom, setHighlightRoom] = useState(null);
|
| 1012 |
+
const [dispatching, setDispatching] = useState(false);
|
| 1013 |
+
const [dispatchStatus, setDispatchStatus] = useState(null); // null | 'running' | 'done'
|
| 1014 |
+
const [arrived, setArrived] = useState(false);
|
| 1015 |
+
const [currentStop, setCurrentStop] = useState(0);
|
| 1016 |
+
const [toast, setToast] = useState(null);
|
| 1017 |
+
const [dragFrom, setDragFrom] = useState(null);
|
| 1018 |
+
const [selectedRoomForAdd, setSelectedRoomForAdd] = useState(null);
|
| 1019 |
+
const [sseConnected, setSseConnected] = useState(false);
|
| 1020 |
+
const [selectedQuickGoal, setSelectedQuickGoal] = useState(null);
|
| 1021 |
+
|
| 1022 |
+
const showToast = useCallback((msg, type='info') => {
|
| 1023 |
+
setToast({ msg, type });
|
| 1024 |
+
setTimeout(() => setToast(null), 3200);
|
| 1025 |
+
}, []);
|
| 1026 |
+
|
| 1027 |
+
const sendQuickRobotGoal = useCallback(async (goalKey) => {
|
| 1028 |
+
const goal = QUICK_ROBOT_GOALS[goalKey];
|
| 1029 |
+
if (!goal) {
|
| 1030 |
+
showToast('Unknown robot goal', 'warn');
|
| 1031 |
+
return;
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
try {
|
| 1035 |
+
const res = await authFetch('/api/robot/goal', {
|
| 1036 |
+
method: 'POST',
|
| 1037 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1038 |
+
body: JSON.stringify({
|
| 1039 |
+
floor: goal.floor,
|
| 1040 |
+
pose: goal.pose,
|
| 1041 |
+
}),
|
| 1042 |
+
});
|
| 1043 |
+
|
| 1044 |
+
if (!res.ok) {
|
| 1045 |
+
const text = await res.text();
|
| 1046 |
+
throw new Error(text || `HTTP ${res.status}`);
|
| 1047 |
+
}
|
| 1048 |
+
|
| 1049 |
+
setRobotFloor(numToFloor(goal.floor));
|
| 1050 |
+
setViewFloor(numToFloor(goal.floor));
|
| 1051 |
+
showToast(`Sent robot to ${goal.label}`, 'success');
|
| 1052 |
+
} catch (err) {
|
| 1053 |
+
console.error(err);
|
| 1054 |
+
showToast(`Failed to send ${goal.label}`, 'warn');
|
| 1055 |
+
}
|
| 1056 |
+
}, [showToast]);
|
| 1057 |
+
|
| 1058 |
+
const sendLockerCommand = useCallback(async (lockerId, action, durationMs = DEFAULT_LOCKER_OPEN_MS) => {
|
| 1059 |
+
try {
|
| 1060 |
+
const res = await authFetch('/api/locker/command', {
|
| 1061 |
+
method: 'POST',
|
| 1062 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1063 |
+
body: JSON.stringify({
|
| 1064 |
+
locker_id: lockerId,
|
| 1065 |
+
action: action,
|
| 1066 |
+
duration_ms: durationMs,
|
| 1067 |
+
}),
|
| 1068 |
+
});
|
| 1069 |
+
|
| 1070 |
+
if (!res.ok) {
|
| 1071 |
+
const text = await res.text();
|
| 1072 |
+
throw new Error(text || `HTTP ${res.status}`);
|
| 1073 |
+
}
|
| 1074 |
+
|
| 1075 |
+
showToast(`Locker ${lockerId}: ${action.toUpperCase()}`, 'success');
|
| 1076 |
+
} catch (err) {
|
| 1077 |
+
console.error(err);
|
| 1078 |
+
showToast(`Locker ${lockerId} command failed`, 'warn');
|
| 1079 |
+
}
|
| 1080 |
+
}, [showToast]);
|
| 1081 |
+
|
| 1082 |
+
const loadOrders = useCallback(async () => {
|
| 1083 |
+
try {
|
| 1084 |
+
const [ordersRes, activeRes] = await Promise.all([
|
| 1085 |
+
authFetch('/api/orders').then(r => r.json()),
|
| 1086 |
+
authFetch('/api/orders/active').then(r => r.json()),
|
| 1087 |
+
]);
|
| 1088 |
+
const active = (ordersRes.orders || [])
|
| 1089 |
+
.filter(o => !['delivered','cancelled'].includes(o.status))
|
| 1090 |
+
.slice(0, 4);
|
| 1091 |
+
const uiOrders = active.map((o, i) => {
|
| 1092 |
+
const floorStr = numToFloor(o.floor);
|
| 1093 |
+
const rooms = mapForFloor(floorStr).rooms;
|
| 1094 |
+
const roomId = o.room || rooms[0]?.id || null;
|
| 1095 |
+
const roomDef = rooms.find(r => r.id === roomId) || rooms[0] || null;
|
| 1096 |
+
return {
|
| 1097 |
+
floor: floorStr,
|
| 1098 |
+
room: roomId,
|
| 1099 |
+
telegram: o.telegram_id || '',
|
| 1100 |
+
roomDef,
|
| 1101 |
+
floorLabel: floorStr,
|
| 1102 |
+
id: o.id,
|
| 1103 |
+
short_id: o.short_id,
|
| 1104 |
+
pin: o.pin || '',
|
| 1105 |
+
compartment: i % 4,
|
| 1106 |
+
status: o.status,
|
| 1107 |
+
};
|
| 1108 |
+
});
|
| 1109 |
+
setOrders(uiOrders);
|
| 1110 |
+
const comps = [null, null, null, null];
|
| 1111 |
+
uiOrders.forEach((o, i) => {
|
| 1112 |
+
comps[i] = { floor: o.floor, room: o.room, telegram: o.telegram, roomDef: o.roomDef, floorLabel: o.floorLabel };
|
| 1113 |
+
});
|
| 1114 |
+
setCompartments(comps);
|
| 1115 |
+
if (activeRes.order) {
|
| 1116 |
+
setDispatching(true);
|
| 1117 |
+
setDispatchStatus('running');
|
| 1118 |
+
setRobotFloor(numToFloor(activeRes.order.floor));
|
| 1119 |
+
setViewFloor(numToFloor(activeRes.order.floor));
|
| 1120 |
+
setElv1Floor(numToFloor(activeRes.order.floor));
|
| 1121 |
+
}
|
| 1122 |
+
} catch {}
|
| 1123 |
+
}, []);
|
| 1124 |
+
|
| 1125 |
+
useEffect(() => {
|
| 1126 |
+
(async () => {
|
| 1127 |
+
try {
|
| 1128 |
+
const [elev, robot] = await Promise.all([
|
| 1129 |
+
fetch('/api/elevator/state').then(r => r.json()),
|
| 1130 |
+
fetch('/api/robot/state').then(r => r.json()),
|
| 1131 |
+
]);
|
| 1132 |
+
setElv1Floor(numToFloor(elev.Floor));
|
| 1133 |
+
setRobotFloor(numToFloor(robot.floor));
|
| 1134 |
+
} catch {}
|
| 1135 |
+
})();
|
| 1136 |
+
loadOrders();
|
| 1137 |
+
}, [loadOrders]);
|
| 1138 |
+
|
| 1139 |
+
useEffect(() => {
|
| 1140 |
+
const connect = () => {
|
| 1141 |
+
const es = new EventSource('/api/events');
|
| 1142 |
+
es.onopen = () => setSseConnected(true);
|
| 1143 |
+
es.onmessage = evt => {
|
| 1144 |
+
const d = JSON.parse(evt.data);
|
| 1145 |
+
if (d.type === 'elevator') setElv1Floor(numToFloor(d.Floor));
|
| 1146 |
+
if (d.type === 'robot') setRobotFloor(numToFloor(d.floor));
|
| 1147 |
+
if (d.type === 'order_dispatched') {
|
| 1148 |
+
const f = numToFloor(d.floor);
|
| 1149 |
+
setRobotFloor(f); setViewFloor(f); setElv1Floor(f);
|
| 1150 |
+
showToast(`Delivering to Floor ${f}`, 'info');
|
| 1151 |
+
}
|
| 1152 |
+
if (d.type === 'order_arrived') {
|
| 1153 |
+
setArrived(true);
|
| 1154 |
+
showToast(`Robot arrived at floor ${d.floor} — waiting for customer`, 'info');
|
| 1155 |
+
}
|
| 1156 |
+
if (d.type === 'run_complete') {
|
| 1157 |
+
setDispatching(false); setDispatchStatus('done'); setArrived(false);
|
| 1158 |
+
setRobotFloor('1'); setElv1Floor('1'); setViewFloor('1');
|
| 1159 |
+
setOrders([]); setCompartments([null,null,null,null]); setActivePath(null);
|
| 1160 |
+
showToast('All deliveries complete — robot returned to base', 'success');
|
| 1161 |
+
setTimeout(() => setDispatchStatus(null), 3000);
|
| 1162 |
+
}
|
| 1163 |
+
if (d.type === 'unlock') showToast('Compartment unlocked — package collected', 'success');
|
| 1164 |
+
if (['order_cancelled','run_started'].includes(d.type)) loadOrders();
|
| 1165 |
+
};
|
| 1166 |
+
es.onerror = () => { setSseConnected(false); es.close(); setTimeout(connect, 3000); };
|
| 1167 |
+
return es;
|
| 1168 |
+
};
|
| 1169 |
+
const es = connect();
|
| 1170 |
+
return () => es.close();
|
| 1171 |
+
}, [loadOrders, showToast]);
|
| 1172 |
+
|
| 1173 |
+
// Handle room click on map
|
| 1174 |
+
const handleRoomClick = (roomId) => {
|
| 1175 |
+
if (dispatching) return;
|
| 1176 |
+
|
| 1177 |
+
// Quick Robot Test on Floor 1
|
| 1178 |
+
const quickGoalMap = { 'R02': 'dropoff_1', 'R03': 'dropoff_2' };
|
| 1179 |
+
if (viewFloor === '1' && quickGoalMap[roomId]) {
|
| 1180 |
+
setSelectedQuickGoal(quickGoalMap[roomId]);
|
| 1181 |
+
setHighlightRoom(roomId);
|
| 1182 |
+
setShowAddForm(false);
|
| 1183 |
+
|
| 1184 |
+
const p = buildPath(robotPos || ROBOT_HOME, roomId, '1');
|
| 1185 |
+
if (p) {
|
| 1186 |
+
setActivePath([{ d: p, len: pathLength(p), delay: 0, floor: '1' }]);
|
| 1187 |
+
}
|
| 1188 |
+
return;
|
| 1189 |
+
}
|
| 1190 |
+
|
| 1191 |
+
if (showAddForm) {
|
| 1192 |
+
setSelectedRoomForAdd(roomId);
|
| 1193 |
+
setHighlightRoom(roomId);
|
| 1194 |
+
return;
|
| 1195 |
+
}
|
| 1196 |
+
if (orders.length >= 4) { showToast('Maximum 4 orders reached!', 'warn'); return; }
|
| 1197 |
+
setSelectedRoomForAdd(roomId);
|
| 1198 |
+
setHighlightRoom(roomId);
|
| 1199 |
+
setShowAddForm(true);
|
| 1200 |
+
};
|
| 1201 |
+
|
| 1202 |
+
// Add order
|
| 1203 |
+
const handleAddOrder = async ({ floor, room, telegram, compartment }) => {
|
| 1204 |
+
if (orders.length >= 4) { showToast('Queue full!', 'warn'); return; }
|
| 1205 |
+
const roomDef = mapForFloor(floor).rooms.find(r => r.id === room);
|
| 1206 |
+
const floorLabel = floor;
|
| 1207 |
+
try {
|
| 1208 |
+
const res = await fetch('/api/orders', {
|
| 1209 |
+
method: 'POST', headers: {'Content-Type':'application/json'},
|
| 1210 |
+
body: JSON.stringify({ floor: floorToNum(floor), room, telegram_id: telegram || null }),
|
| 1211 |
+
});
|
| 1212 |
+
if (!res.ok) throw new Error();
|
| 1213 |
+
const data = await res.json();
|
| 1214 |
+
const newOrder = { floor, room, telegram, roomDef, floorLabel, id: data.id, short_id: data.short_id, pin: data.pin, compartment, status: 'pending' };
|
| 1215 |
+
setCompartments(prev => { const n=[...prev]; n[compartment]={floor,room,telegram,roomDef,floorLabel}; return n; });
|
| 1216 |
+
setOrders(prev => [...prev, newOrder]);
|
| 1217 |
+
setShowAddForm(false);
|
| 1218 |
+
setSelectedRoomForAdd(null);
|
| 1219 |
+
setHighlightRoom(null);
|
| 1220 |
+
showToast(`Order #${data.short_id} added · PIN: ${data.pin}`, 'success');
|
| 1221 |
+
} catch {
|
| 1222 |
+
showToast('Failed to create order', 'warn');
|
| 1223 |
+
}
|
| 1224 |
+
};
|
| 1225 |
+
|
| 1226 |
+
// Remove order
|
| 1227 |
+
const removeOrder = async (idx) => {
|
| 1228 |
+
const o = orders[idx];
|
| 1229 |
+
if (o?.id) {
|
| 1230 |
+
try { await authFetch(`/api/orders/${o.id}/cancel`, {method:'POST'}); } catch {}
|
| 1231 |
+
}
|
| 1232 |
+
if (o?.compartment != null) setCompartments(prev => { const n=[...prev]; n[o.compartment]=null; return n; });
|
| 1233 |
+
setOrders(prev => prev.filter((_,i) => i!==idx));
|
| 1234 |
+
};
|
| 1235 |
+
|
| 1236 |
+
// Drag & drop
|
| 1237 |
+
const handleDragStart = (idx) => setDragFrom(idx);
|
| 1238 |
+
const handleDragOver = (idx) => {};
|
| 1239 |
+
const handleDrop = (idx) => {
|
| 1240 |
+
if (dragFrom === null || dragFrom === idx) return;
|
| 1241 |
+
const arr = [...orders];
|
| 1242 |
+
const [item] = arr.splice(dragFrom, 1);
|
| 1243 |
+
arr.splice(idx, 0, item);
|
| 1244 |
+
setOrders(arr);
|
| 1245 |
+
setDragFrom(null);
|
| 1246 |
+
};
|
| 1247 |
+
|
| 1248 |
+
// Dispatch
|
| 1249 |
+
const handleDispatchConfirm = async () => {
|
| 1250 |
+
setShowDispatch(false);
|
| 1251 |
+
try {
|
| 1252 |
+
const res = await authFetch('/api/orders/start_run', {method:'POST'});
|
| 1253 |
+
if (!res.ok) {
|
| 1254 |
+
const e = await res.json();
|
| 1255 |
+
showToast(e.detail || 'Failed to start delivery', 'warn');
|
| 1256 |
+
return;
|
| 1257 |
+
}
|
| 1258 |
+
setDispatching(true);
|
| 1259 |
+
setDispatchStatus('running');
|
| 1260 |
+
setArrived(false);
|
| 1261 |
+
if (orders.length > 0) {
|
| 1262 |
+
const first = orders[0];
|
| 1263 |
+
const elvPath = `M${ROBOT_HOME.x},${ROBOT_HOME.y} L${CX},${ROBOT_HOME.y} L${CX},${CY} L${ELV_CORRIDOR.x},${CY}`;
|
| 1264 |
+
const len = pathLength(elvPath);
|
| 1265 |
+
const delivPath = buildPath(ELV_CORRIDOR, first.room, first.floor);
|
| 1266 |
+
const dLen = pathLength(delivPath);
|
| 1267 |
+
setActivePath([
|
| 1268 |
+
{ d: elvPath, len, delay: 0, floor: '1' },
|
| 1269 |
+
{ d: delivPath, len: dLen, delay: 1.0, floor: first.floor },
|
| 1270 |
+
]);
|
| 1271 |
+
}
|
| 1272 |
+
showToast(`Delivery run started — ${orders.length} stop${orders.length>1?'s':''}`, 'info');
|
| 1273 |
+
} catch {
|
| 1274 |
+
showToast('Network error — could not start delivery', 'warn');
|
| 1275 |
+
}
|
| 1276 |
+
};
|
| 1277 |
+
|
| 1278 |
+
// Elevator command
|
| 1279 |
+
const handleSimElev = async () => {
|
| 1280 |
+
const next = floorToNum(elv1Floor) < 10 ? floorToNum(elv1Floor) + 1 : 1;
|
| 1281 |
+
try {
|
| 1282 |
+
await authFetch('/api/elevator/command', {
|
| 1283 |
+
method:'POST', headers:{'Content-Type':'application/json'},
|
| 1284 |
+
body: JSON.stringify({floor: next}),
|
| 1285 |
+
});
|
| 1286 |
+
} catch {}
|
| 1287 |
+
};
|
| 1288 |
+
|
| 1289 |
+
const simulateArrival = useCallback(async () => {
|
| 1290 |
+
try {
|
| 1291 |
+
const res = await authFetch('/api/robot/simulate_arrival', { method: 'POST' });
|
| 1292 |
+
if (!res.ok) {
|
| 1293 |
+
const e = await res.json();
|
| 1294 |
+
showToast(e.detail || 'Simulate arrival failed', 'warn');
|
| 1295 |
+
return;
|
| 1296 |
+
}
|
| 1297 |
+
setArrived(true);
|
| 1298 |
+
showToast('Simulated robot arrival — Telegram sent to customer', 'success');
|
| 1299 |
+
} catch {
|
| 1300 |
+
showToast('Network error', 'warn');
|
| 1301 |
+
}
|
| 1302 |
+
}, [showToast]);
|
| 1303 |
+
|
| 1304 |
+
const hh = String(time.getHours()).padStart(2,'0');
|
| 1305 |
+
const mm = String(time.getMinutes()).padStart(2,'0');
|
| 1306 |
+
const ss = String(time.getSeconds()).padStart(2,'0');
|
| 1307 |
+
|
| 1308 |
+
return (
|
| 1309 |
+
<div style={{
|
| 1310 |
+
width:1920, height:1080, display:'grid',
|
| 1311 |
+
gridTemplateColumns:'280px 1fr 380px',
|
| 1312 |
+
gridTemplateRows:'56px 1fr',
|
| 1313 |
+
background:'#050c14', position:'relative',
|
| 1314 |
+
}}>
|
| 1315 |
+
<div className="grid-bg" />
|
| 1316 |
+
|
| 1317 |
+
{/* ── TOP BAR ── */}
|
| 1318 |
+
<div style={{
|
| 1319 |
+
gridColumn:'1/-1',
|
| 1320 |
+
display:'flex', alignItems:'center', justifyContent:'space-between',
|
| 1321 |
+
padding:'0 24px', height:56,
|
| 1322 |
+
background:'linear-gradient(180deg, #06101e 0%, rgba(6,16,30,0.8) 100%)',
|
| 1323 |
+
borderBottom:'1px solid #1a3a55',
|
| 1324 |
+
position:'relative', zIndex:10,
|
| 1325 |
+
}}>
|
| 1326 |
+
<div style={{ display:'flex', alignItems:'center', gap:20 }}>
|
| 1327 |
+
{/* Logo */}
|
| 1328 |
+
<div style={{ fontFamily:'Orbitron', fontSize:20, fontWeight:900, color:'#00c8e8', letterSpacing:4, textShadow:'0 0 20px #00c8e840' }}>
|
| 1329 |
+
UEH B1
|
| 1330 |
+
</div>
|
| 1331 |
+
<div style={{ width:1, height:28, background:'#1a3a55' }}/>
|
| 1332 |
+
<div style={{ fontFamily:'Space Grotesk', fontSize:13, color:'#3a6070', letterSpacing:2 }}>
|
| 1333 |
+
DELIVERY MANAGEMENT SYSTEM
|
| 1334 |
+
</div>
|
| 1335 |
+
</div>
|
| 1336 |
+
|
| 1337 |
+
<div style={{ display:'flex', gap:24, alignItems:'center' }}>
|
| 1338 |
+
{/* Status chips */}
|
| 1339 |
+
{[
|
| 1340 |
+
{ label: sseConnected ? 'LIVE' : 'CONNECTING', color: sseConnected ? '#00e676' : '#f59e0b' },
|
| 1341 |
+
{ label:`FLOOR ${robotFloor}`, color:'#f59e0b' },
|
| 1342 |
+
{ label:`${orders.length}/4 ORDERS`, color: orders.length>=4?'#ff6b2b':'#7ab0c8' },
|
| 1343 |
+
].map(s => (
|
| 1344 |
+
<div key={s.label} style={{ display:'flex', alignItems:'center', gap:6 }}>
|
| 1345 |
+
<div style={{ width:6, height:6, borderRadius:'50%', background:s.color, boxShadow:`0 0 6px ${s.color}` }}/>
|
| 1346 |
+
<span style={{ fontFamily:'Orbitron', fontSize:10, color:s.color, letterSpacing:2 }}>{s.label}</span>
|
| 1347 |
+
</div>
|
| 1348 |
+
))}
|
| 1349 |
+
<div style={{ fontFamily:'Orbitron', fontSize:16, color:'#00c8e8', letterSpacing:3 }}>{hh}:{mm}:{ss}</div>
|
| 1350 |
+
</div>
|
| 1351 |
+
</div>
|
| 1352 |
+
|
| 1353 |
+
{/* ── LEFT SIDEBAR ── */}
|
| 1354 |
+
<div style={{
|
| 1355 |
+
background:'#06101e', borderRight:'1px solid #1a3a55',
|
| 1356 |
+
padding:'16px 14px', display:'flex', flexDirection:'column', gap:10,
|
| 1357 |
+
overflow:'auto', position:'relative', zIndex:1,
|
| 1358 |
+
}}>
|
| 1359 |
+
{/* Robot status */}
|
| 1360 |
+
<div style={{ background:'#070f1a', border:'1px solid #1a3a55', borderRadius:8, padding:'12px' }}>
|
| 1361 |
+
<div style={{ fontFamily:'Orbitron', fontSize:11, color:'#7ab0c8', letterSpacing:2, marginBottom:10 }}>ROBOT STATUS</div>
|
| 1362 |
+
<div style={{ display:'flex', gap:10, alignItems:'center', marginBottom:10 }}>
|
| 1363 |
+
<div style={{ position:'relative' }}>
|
| 1364 |
+
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
| 1365 |
+
<rect x="10" y="14" width="28" height="26" rx="4" fill="#0d1e2d" stroke="#00c8e8" strokeWidth="1.5"/>
|
| 1366 |
+
<rect x="15" y="6" width="18" height="12" rx="3" fill="#0d1e2d" stroke="#00c8e8" strokeWidth="1.5"/>
|
| 1367 |
+
<rect x="18" y="9" width="5" height="4" rx="1" fill="#00c8e8"/>
|
| 1368 |
+
<rect x="25" y="9" width="5" height="4" rx="1" fill="#00c8e8"/>
|
| 1369 |
+
<circle cx="14" cy="42" r="4" fill="#0d1e2d" stroke="#00c8e8" strokeWidth="1.5"/>
|
| 1370 |
+
<circle cx="34" cy="42" r="4" fill="#0d1e2d" stroke="#00c8e8" strokeWidth="1.5"/>
|
| 1371 |
+
</svg>
|
| 1372 |
+
<div style={{ position:'absolute', bottom:-2, right:-2, width:10, height:10, borderRadius:'50%', background:dispatching?'#f59e0b':'#00e676', border:'1px solid #050c14', boxShadow:`0 0 6px ${dispatching?'#f59e0b':'#00e676'}`, animation:'pulse-dot 2s infinite' }}/>
|
| 1373 |
+
</div>
|
| 1374 |
+
<div>
|
| 1375 |
+
<div style={{ fontFamily:'Orbitron', fontSize:13, color:'#e2e8f0', fontWeight:700 }}>B1-BOT-01</div>
|
| 1376 |
+
<div style={{ fontSize:11, color: dispatching?'#f59e0b':'#00e676', marginTop:2 }}>
|
| 1377 |
+
{dispatchStatus==='running'?'◉ DELIVERING':'◉ STANDBY'}
|
| 1378 |
+
</div>
|
| 1379 |
+
</div>
|
| 1380 |
+
</div>
|
| 1381 |
+
|
| 1382 |
+
{[
|
| 1383 |
+
{ label:'BATTERY', value:'--', bar:null, color:'#3a6070' },
|
| 1384 |
+
{ label:'FLOOR', value:`F${robotFloor}`, bar:null, color:'#f59e0b' },
|
| 1385 |
+
{ label:'SPEED', value:'--', bar:null, color:'#3a6070' },
|
| 1386 |
+
].map(s => (
|
| 1387 |
+
<div key={s.label} style={{ marginBottom:7 }}>
|
| 1388 |
+
<div style={{ display:'flex', justifyContent:'space-between', marginBottom:3 }}>
|
| 1389 |
+
<span style={{ fontSize:10, color:'#2a4a60', letterSpacing:1, fontFamily:'Orbitron' }}>{s.label}</span>
|
| 1390 |
+
<span style={{ fontSize:10, color:s.color, fontFamily:'Orbitron' }}>{s.value}</span>
|
| 1391 |
+
</div>
|
| 1392 |
+
{s.bar !== null && (
|
| 1393 |
+
<div style={{ height:3, background:'#0d2238', borderRadius:2 }}>
|
| 1394 |
+
<div style={{ width:`${s.bar*100}%`, height:'100%', background:s.color, borderRadius:2, boxShadow:`0 0 4px ${s.color}60` }}/>
|
| 1395 |
+
</div>
|
| 1396 |
+
)}
|
| 1397 |
+
</div>
|
| 1398 |
+
))}
|
| 1399 |
+
</div>
|
| 1400 |
+
|
| 1401 |
+
{/* Compartment panel */}
|
| 1402 |
+
<CompartmentPanel compartments={compartments} />
|
| 1403 |
+
|
| 1404 |
+
{/* Locker manual control */}
|
| 1405 |
+
<div style={{
|
| 1406 |
+
background:'#070f1a',
|
| 1407 |
+
border:'1px solid rgba(245,158,11,0.35)',
|
| 1408 |
+
borderRadius:8,
|
| 1409 |
+
padding:'12px',
|
| 1410 |
+
boxShadow:'0 0 18px rgba(245,158,11,0.05)',
|
| 1411 |
+
}}>
|
| 1412 |
+
<div style={{
|
| 1413 |
+
display:'flex',
|
| 1414 |
+
alignItems:'center',
|
| 1415 |
+
justifyContent:'space-between',
|
| 1416 |
+
marginBottom:10,
|
| 1417 |
+
}}>
|
| 1418 |
+
<div style={{
|
| 1419 |
+
fontFamily:'Orbitron',
|
| 1420 |
+
fontSize:11,
|
| 1421 |
+
color:'#f59e0b',
|
| 1422 |
+
letterSpacing:2,
|
| 1423 |
+
}}>
|
| 1424 |
+
LOCKER MANUAL CONTROL
|
| 1425 |
+
</div>
|
| 1426 |
+
<div style={{
|
| 1427 |
+
fontSize:9,
|
| 1428 |
+
color:'#7a5b22',
|
| 1429 |
+
fontFamily:'Orbitron',
|
| 1430 |
+
letterSpacing:1,
|
| 1431 |
+
}}>
|
| 1432 |
+
{DEFAULT_LOCKER_OPEN_MS} ms
|
| 1433 |
+
</div>
|
| 1434 |
+
</div>
|
| 1435 |
+
|
| 1436 |
+
<div style={{ display:'flex', flexDirection:'column', gap:7 }}>
|
| 1437 |
+
{LOCKER_CONTROLS.map(locker => (
|
| 1438 |
+
<div key={`locker-${locker.id}`} style={{
|
| 1439 |
+
display:'grid',
|
| 1440 |
+
gridTemplateColumns:'92px 1fr 1fr',
|
| 1441 |
+
gap:6,
|
| 1442 |
+
alignItems:'center',
|
| 1443 |
+
}}>
|
| 1444 |
+
<div style={{
|
| 1445 |
+
color: locker.id === 1 ? '#f59e0b' : '#c8dce8',
|
| 1446 |
+
fontSize:11,
|
| 1447 |
+
fontFamily:'Orbitron',
|
| 1448 |
+
letterSpacing:1,
|
| 1449 |
+
}}>
|
| 1450 |
+
{locker.label}
|
| 1451 |
+
</div>
|
| 1452 |
+
|
| 1453 |
+
<button
|
| 1454 |
+
onClick={() => sendLockerCommand(locker.id, 'open', DEFAULT_LOCKER_OPEN_MS)}
|
| 1455 |
+
style={{
|
| 1456 |
+
padding:'8px',
|
| 1457 |
+
borderRadius:5,
|
| 1458 |
+
background:'rgba(22,163,74,0.16)',
|
| 1459 |
+
border:'1px solid #16a34a80',
|
| 1460 |
+
color:'#86efac',
|
| 1461 |
+
fontFamily:'Orbitron',
|
| 1462 |
+
fontSize:10,
|
| 1463 |
+
letterSpacing:1,
|
| 1464 |
+
}}>
|
| 1465 |
+
PULSE
|
| 1466 |
+
</button>
|
| 1467 |
+
|
| 1468 |
+
<button
|
| 1469 |
+
onClick={() => sendLockerCommand(locker.id, 'close', 3000)}
|
| 1470 |
+
style={{
|
| 1471 |
+
padding:'8px',
|
| 1472 |
+
borderRadius:5,
|
| 1473 |
+
background:'rgba(220,38,38,0.16)',
|
| 1474 |
+
border:'1px solid #dc262680',
|
| 1475 |
+
color:'#fca5a5',
|
| 1476 |
+
fontFamily:'Orbitron',
|
| 1477 |
+
fontSize:10,
|
| 1478 |
+
letterSpacing:1,
|
| 1479 |
+
}}>
|
| 1480 |
+
LOCK
|
| 1481 |
+
</button>
|
| 1482 |
+
</div>
|
| 1483 |
+
))}
|
| 1484 |
+
</div>
|
| 1485 |
+
|
| 1486 |
+
<div style={{
|
| 1487 |
+
marginTop:8,
|
| 1488 |
+
fontSize:10,
|
| 1489 |
+
color:'#3a6070',
|
| 1490 |
+
lineHeight:1.4,
|
| 1491 |
+
}}>
|
| 1492 |
+
PULSE sends a timed unlock pulse. LOCK forces the relay off. BASE uses Arduino locker id 1; delivery lockers use ids 2–5.
|
| 1493 |
+
</div>
|
| 1494 |
+
</div>
|
| 1495 |
+
|
| 1496 |
+
{/* Elevator panel */}
|
| 1497 |
+
<ElevatorPanel elv1Floor={elv1Floor} elv2Floor={elv2Floor} robotFloor={robotFloor} onSimulate={handleSimElev}/>
|
| 1498 |
+
|
| 1499 |
+
{/* Floor selector */}
|
| 1500 |
+
<div style={{ background:'#070f1a', border:'1px solid #1a3a55', borderRadius:8, padding:'12px' }}>
|
| 1501 |
+
<div style={{ fontFamily:'Orbitron', fontSize:11, color:'#7ab0c8', letterSpacing:2, marginBottom:10 }}>VIEW FLOOR</div>
|
| 1502 |
+
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:5 }}>
|
| 1503 |
+
{FLOORS.map(f => (
|
| 1504 |
+
<button key={f} onClick={() => setViewFloor(f)} style={{
|
| 1505 |
+
padding:'6px 0', borderRadius:4,
|
| 1506 |
+
background: viewFloor===f?'rgba(0,200,232,0.15)':robotFloor===f?'rgba(0,230,118,0.08)':'#040d18',
|
| 1507 |
+
border: `1px solid ${viewFloor===f?'#00c8e8':robotFloor===f?'#00e67630':'#0d2238'}`,
|
| 1508 |
+
color: viewFloor===f?'#00c8e8':robotFloor===f?'#00e676':'#3a6070',
|
| 1509 |
+
fontFamily:'Orbitron', fontSize:11, letterSpacing:2,
|
| 1510 |
+
}}>
|
| 1511 |
+
{f}F
|
| 1512 |
+
</button>
|
| 1513 |
+
))}
|
| 1514 |
+
</div>
|
| 1515 |
+
</div>
|
| 1516 |
+
</div>
|
| 1517 |
+
|
| 1518 |
+
{/* ── CENTER MAP ── */}
|
| 1519 |
+
<div style={{ position:'relative', background:'#050c14', display:'flex', flexDirection:'column', overflow:'hidden' }}>
|
| 1520 |
+
{/* Header */}
|
| 1521 |
+
<div style={{
|
| 1522 |
+
display:'flex', alignItems:'center', justifyContent:'space-between',
|
| 1523 |
+
padding:'10px 20px', borderBottom:'1px solid #0d2238', flexShrink:0,
|
| 1524 |
+
}}>
|
| 1525 |
+
<div style={{ display:'flex', alignItems:'center', gap:12 }}>
|
| 1526 |
+
<span style={{ fontFamily:'Orbitron', fontSize:12, color:'#7ab0c8', letterSpacing:3 }}>FLOOR MAP</span>
|
| 1527 |
+
<div style={{
|
| 1528 |
+
padding:'2px 10px', borderRadius:4,
|
| 1529 |
+
background:'rgba(0,200,232,0.1)', border:'1px solid #00c8e840',
|
| 1530 |
+
fontFamily:'Orbitron', fontSize:13, fontWeight:700, color:'#00c8e8',
|
| 1531 |
+
}}>{viewFloor}F</div>
|
| 1532 |
+
</div>
|
| 1533 |
+
<div style={{ display:'flex', gap:14, fontSize:11, color:'#2a4a60' }}>
|
| 1534 |
+
{[
|
| 1535 |
+
{ color:'#1c3a55', fill:'#061220', label:'Room' },
|
| 1536 |
+
{ color:'#f59e0b', fill:'rgba(245,158,11,0.15)', label:'Elevator' },
|
| 1537 |
+
{ color:'#ff6b2b', fill:'rgba(255,107,43,0.12)', label:'Queued' },
|
| 1538 |
+
{ color:'#00e676', label:'Robot', dot:true },
|
| 1539 |
+
].map(l => (
|
| 1540 |
+
<div key={l.label} style={{ display:'flex', alignItems:'center', gap:5 }}>
|
| 1541 |
+
{l.dot
|
| 1542 |
+
? <div style={{ width:8, height:8, borderRadius:'50%', background:'#00e676', boxShadow:'0 0 5px #00e676' }}/>
|
| 1543 |
+
: <div style={{ width:12, height:8, borderRadius:2, background:l.fill, border:`1px solid ${l.color}` }}/>}
|
| 1544 |
+
<span style={{ fontFamily:'Space Grotesk' }}>{l.label}</span>
|
| 1545 |
+
</div>
|
| 1546 |
+
))}
|
| 1547 |
+
</div>
|
| 1548 |
+
</div>
|
| 1549 |
+
|
| 1550 |
+
{/* 2D map — full width */}
|
| 1551 |
+
<div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center',
|
| 1552 |
+
padding:'16px', minHeight:0, position:'relative' }}>
|
| 1553 |
+
<div style={{
|
| 1554 |
+
width:'100%', maxHeight:'100%', aspectRatio:'700/490',
|
| 1555 |
+
background:'#050c14', border:'1px solid #0d2238', borderRadius:8,
|
| 1556 |
+
overflow:'hidden', position:'relative',
|
| 1557 |
+
}}>
|
| 1558 |
+
<FloorMap
|
| 1559 |
+
floor={viewFloor}
|
| 1560 |
+
orders={orders}
|
| 1561 |
+
robotPos={viewFloor===robotFloor?robotPos:null}
|
| 1562 |
+
activePath={activePath?.filter(p=>p.floor===viewFloor)}
|
| 1563 |
+
highlightRoom={highlightRoom}
|
| 1564 |
+
onRoomClick={handleRoomClick}
|
| 1565 |
+
elv1Floor={elv1Floor}
|
| 1566 |
+
elv2Floor={elv2Floor}
|
| 1567 |
+
/>
|
| 1568 |
+
{dispatching && (
|
| 1569 |
+
<div style={{
|
| 1570 |
+
position:'absolute', top:8, left:8, padding:'3px 10px', borderRadius:4,
|
| 1571 |
+
background:'rgba(245,158,11,0.15)', border:'1px solid #f59e0b40',
|
| 1572 |
+
fontFamily:'Orbitron', fontSize:10, color:'#f59e0b', letterSpacing:2,
|
| 1573 |
+
animation:'blink 1s ease-in-out infinite',
|
| 1574 |
+
}}>◉ DISPATCHING</div>
|
| 1575 |
+
)}
|
| 1576 |
+
{selectedQuickGoal && !dispatching && (
|
| 1577 |
+
<div
|
| 1578 |
+
onClick={() => {
|
| 1579 |
+
sendQuickRobotGoal(selectedQuickGoal);
|
| 1580 |
+
setSelectedQuickGoal(null);
|
| 1581 |
+
|
| 1582 |
+
if (activePath && activePath[0] && activePath[0].d) {
|
| 1583 |
+
const pathStr = activePath[0].d;
|
| 1584 |
+
const pts = pathStr.split(/M|L/).map(s => s.trim()).filter(Boolean).map(s => {
|
| 1585 |
+
const [x,y] = s.split(',');
|
| 1586 |
+
return { x: parseFloat(x), y: parseFloat(y) };
|
| 1587 |
+
});
|
| 1588 |
+
if (pts.length > 1) {
|
| 1589 |
+
const startTime = performance.now();
|
| 1590 |
+
const duration = 3000; // 3 seconds
|
| 1591 |
+
let totalLen = 0;
|
| 1592 |
+
const segs = [];
|
| 1593 |
+
for (let i = 0; i < pts.length - 1; i++) {
|
| 1594 |
+
const dx = pts[i+1].x - pts[i].x;
|
| 1595 |
+
const dy = pts[i+1].y - pts[i].y;
|
| 1596 |
+
const len = Math.hypot(dx, dy);
|
| 1597 |
+
totalLen += len;
|
| 1598 |
+
segs.push({ len, dx, dy, start: pts[i] });
|
| 1599 |
+
}
|
| 1600 |
+
|
| 1601 |
+
const animate = (time) => {
|
| 1602 |
+
let progress = (time - startTime) / duration;
|
| 1603 |
+
if (progress >= 1) {
|
| 1604 |
+
setRobotPos(pts[pts.length - 1]);
|
| 1605 |
+
return;
|
| 1606 |
+
}
|
| 1607 |
+
const targetLen = progress * totalLen;
|
| 1608 |
+
let currentLen = 0;
|
| 1609 |
+
for (let i = 0; i < segs.length; i++) {
|
| 1610 |
+
if (currentLen + segs[i].len >= targetLen || i === segs.length - 1) {
|
| 1611 |
+
const segProg = segs[i].len === 0 ? 1 : (targetLen - currentLen) / segs[i].len;
|
| 1612 |
+
setRobotPos({
|
| 1613 |
+
x: segs[i].start.x + segs[i].dx * segProg,
|
| 1614 |
+
y: segs[i].start.y + segs[i].dy * segProg,
|
| 1615 |
+
});
|
| 1616 |
+
break;
|
| 1617 |
+
}
|
| 1618 |
+
currentLen += segs[i].len;
|
| 1619 |
+
}
|
| 1620 |
+
requestAnimationFrame(animate);
|
| 1621 |
+
};
|
| 1622 |
+
requestAnimationFrame(animate);
|
| 1623 |
+
}
|
| 1624 |
+
}
|
| 1625 |
+
}}
|
| 1626 |
+
style={{
|
| 1627 |
+
position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)',
|
| 1628 |
+
background: 'rgba(0, 230, 118, 0.9)', border: '2px solid #00e676',
|
| 1629 |
+
padding: '12px 30px', borderRadius: 30, cursor: 'pointer',
|
| 1630 |
+
color: '#000', fontFamily: 'Orbitron', fontSize: 16, fontWeight: 800, letterSpacing: 2,
|
| 1631 |
+
boxShadow: '0 4px 15px rgba(0, 230, 118, 0.4)',
|
| 1632 |
+
transition: 'transform 0.1s', zIndex: 10
|
| 1633 |
+
}}
|
| 1634 |
+
onMouseOver={(e) => e.currentTarget.style.transform = 'translateX(-50%) scale(1.05)'}
|
| 1635 |
+
onMouseOut={(e) => e.currentTarget.style.transform = 'translateX(-50%) scale(1)'}
|
| 1636 |
+
>
|
| 1637 |
+
▶ START TO {QUICK_ROBOT_GOALS[selectedQuickGoal]?.label.toUpperCase()}
|
| 1638 |
+
</div>
|
| 1639 |
+
)}
|
| 1640 |
+
</div>
|
| 1641 |
+
</div>
|
| 1642 |
+
</div>
|
| 1643 |
+
|
| 1644 |
+
{/* ── RIGHT PANEL ── */}
|
| 1645 |
+
<div style={{
|
| 1646 |
+
background:'#06101e', borderLeft:'1px solid #1a3a55',
|
| 1647 |
+
padding:'16px', display:'flex', flexDirection:'column', gap:12,
|
| 1648 |
+
overflow:'auto', position:'relative', zIndex:1,
|
| 1649 |
+
}}>
|
| 1650 |
+
{/* Header */}
|
| 1651 |
+
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between' }}>
|
| 1652 |
+
<span style={{ fontFamily:'Orbitron', fontSize:13, color:'#7ab0c8', letterSpacing:3 }}>ORDER QUEUE</span>
|
| 1653 |
+
<div style={{ display:'flex', gap:6, alignItems:'center' }}>
|
| 1654 |
+
<span style={{ fontFamily:'Orbitron', fontSize:12, color: orders.length>=4?'#ff6b2b':'#3a6070' }}>
|
| 1655 |
+
{orders.length}/4
|
| 1656 |
+
</span>
|
| 1657 |
+
<div style={{
|
| 1658 |
+
display:'flex', gap:4,
|
| 1659 |
+
}}>
|
| 1660 |
+
{[...Array(4)].map((_,i) => (
|
| 1661 |
+
<div key={i} style={{ width:16, height:6, borderRadius:2, background: i<orders.length?'#00c8e8':'#0d2238' }}/>
|
| 1662 |
+
))}
|
| 1663 |
+
</div>
|
| 1664 |
+
</div>
|
| 1665 |
+
</div>
|
| 1666 |
+
|
| 1667 |
+
{/* Orders */}
|
| 1668 |
+
<div style={{ display:'flex', flexDirection:'column', gap:6, minHeight:40 }}>
|
| 1669 |
+
{orders.length === 0 && (
|
| 1670 |
+
<div style={{
|
| 1671 |
+
padding:'24px', textAlign:'center',
|
| 1672 |
+
border:'1px dashed #0d2238', borderRadius:8,
|
| 1673 |
+
color:'#2a4a60', fontFamily:'Space Grotesk', fontSize:13,
|
| 1674 |
+
}}>
|
| 1675 |
+
No orders yet<br/>
|
| 1676 |
+
<span style={{ fontSize:11, color:'#1a3050' }}>Click rooms on map or press + to add</span>
|
| 1677 |
+
</div>
|
| 1678 |
+
)}
|
| 1679 |
+
{orders.map((o, i) => (
|
| 1680 |
+
<OrderCard
|
| 1681 |
+
key={o.id} order={o} index={i} total={orders.length}
|
| 1682 |
+
onRemove={removeOrder}
|
| 1683 |
+
onDragStart={handleDragStart}
|
| 1684 |
+
onDragOver={handleDragOver}
|
| 1685 |
+
onDrop={handleDrop}
|
| 1686 |
+
/>
|
| 1687 |
+
))}
|
| 1688 |
+
</div>
|
| 1689 |
+
|
| 1690 |
+
{/* Add form or button */}
|
| 1691 |
+
{showAddForm ? (
|
| 1692 |
+
<AddOrderForm
|
| 1693 |
+
selectedRoom={selectedRoomForAdd}
|
| 1694 |
+
selectedFloor={viewFloor}
|
| 1695 |
+
allFloors={FLOORS}
|
| 1696 |
+
compartments={compartments}
|
| 1697 |
+
onAdd={handleAddOrder}
|
| 1698 |
+
onClose={() => { setShowAddForm(false); setHighlightRoom(null); }}
|
| 1699 |
+
/>
|
| 1700 |
+
) : (
|
| 1701 |
+
<button onClick={() => { if(orders.length<4){ setShowAddForm(true); setSelectedRoomForAdd(mapForFloor(viewFloor).rooms[0]?.id || 'R01'); } else showToast('Queue full!','warn'); }}
|
| 1702 |
+
style={{
|
| 1703 |
+
padding:'10px', borderRadius:6,
|
| 1704 |
+
border: orders.length>=4?'1px dashed #1a3a55':'1px dashed #00c8e840',
|
| 1705 |
+
background:'transparent',
|
| 1706 |
+
color: orders.length>=4?'#2a4a60':'#00c8e8',
|
| 1707 |
+
fontFamily:'Orbitron', fontSize:11, letterSpacing:3,
|
| 1708 |
+
opacity: orders.length>=4?0.5:1,
|
| 1709 |
+
}}>
|
| 1710 |
+
+ ADD ORDER
|
| 1711 |
+
</button>
|
| 1712 |
+
)}
|
| 1713 |
+
|
| 1714 |
+
|
| 1715 |
+
{/* Dispatch */}
|
| 1716 |
+
<div style={{ marginTop:'auto', display:'flex', flexDirection:'column', gap:8 }}>
|
| 1717 |
+
{orders.length > 0 && !dispatching && (
|
| 1718 |
+
<div style={{ fontSize:11, color:'#3a6070', fontFamily:'Space Grotesk', letterSpacing:1, textAlign:'center' }}>
|
| 1719 |
+
Drag cards to reorder delivery priority
|
| 1720 |
+
</div>
|
| 1721 |
+
)}
|
| 1722 |
+
<button
|
| 1723 |
+
onClick={() => { if(orders.length>0 && !dispatching) setShowDispatch(true); }}
|
| 1724 |
+
disabled={orders.length===0 || dispatching}
|
| 1725 |
+
style={{
|
| 1726 |
+
padding:'16px', borderRadius:8,
|
| 1727 |
+
background: orders.length===0||dispatching
|
| 1728 |
+
? '#040d18'
|
| 1729 |
+
: 'linear-gradient(135deg, rgba(0,200,232,0.2) 0%, rgba(0,200,232,0.08) 100%)',
|
| 1730 |
+
border: `2px solid ${orders.length===0||dispatching?'#0d2238':'#00c8e860'}`,
|
| 1731 |
+
color: orders.length===0||dispatching?'#1a3a55':'#00c8e8',
|
| 1732 |
+
fontFamily:'Orbitron', fontSize:15, fontWeight:700, letterSpacing:4,
|
| 1733 |
+
boxShadow: orders.length>0&&!dispatching?'0 0 30px rgba(0,200,232,0.15), inset 0 0 20px rgba(0,200,232,0.05)':'none',
|
| 1734 |
+
transition:'all 0.2s',
|
| 1735 |
+
}}
|
| 1736 |
+
>
|
| 1737 |
+
{dispatching ? '◉ DISPATCHING...' : `▶ DISPATCH (${orders.length})`}
|
| 1738 |
+
</button>
|
| 1739 |
+
|
| 1740 |
+
{dispatching && (
|
| 1741 |
+
<button
|
| 1742 |
+
onClick={simulateArrival}
|
| 1743 |
+
disabled={arrived}
|
| 1744 |
+
style={{
|
| 1745 |
+
padding:'12px', borderRadius:8,
|
| 1746 |
+
background: arrived ? '#040d18' : 'linear-gradient(135deg, rgba(245,158,11,0.2) 0%, rgba(245,158,11,0.08) 100%)',
|
| 1747 |
+
border: `2px solid ${arrived ? '#0d2238' : '#f59e0b60'}`,
|
| 1748 |
+
color: arrived ? '#1a3a55' : '#f59e0b',
|
| 1749 |
+
fontFamily:'Orbitron', fontSize:12, fontWeight:700, letterSpacing:3,
|
| 1750 |
+
boxShadow: arrived ? 'none' : '0 0 20px rgba(245,158,11,0.1)',
|
| 1751 |
+
transition:'all 0.2s',
|
| 1752 |
+
}}
|
| 1753 |
+
>
|
| 1754 |
+
{arrived ? '✓ ARRIVED' : '⬡ SIMULATE ARRIVAL'}
|
| 1755 |
+
</button>
|
| 1756 |
+
)}
|
| 1757 |
+
|
| 1758 |
+
{dispatchStatus === 'done' && (
|
| 1759 |
+
<div style={{
|
| 1760 |
+
padding:'10px', borderRadius:6,
|
| 1761 |
+
background:'rgba(0,230,118,0.08)', border:'1px solid #00e67640',
|
| 1762 |
+
textAlign:'center', fontFamily:'Space Grotesk', fontSize:12, color:'#00e676',
|
| 1763 |
+
}}>
|
| 1764 |
+
✓ All deliveries complete
|
| 1765 |
+
</div>
|
| 1766 |
+
)}
|
| 1767 |
+
</div>
|
| 1768 |
+
</div>
|
| 1769 |
+
|
| 1770 |
+
{/* ── DISPATCH MODAL ── */}
|
| 1771 |
+
{showDispatch && (
|
| 1772 |
+
<DispatchModal orders={orders} onConfirm={handleDispatchConfirm} onClose={() => setShowDispatch(false)}/>
|
| 1773 |
+
)}
|
| 1774 |
+
|
| 1775 |
+
{/* ── TOAST ── */}
|
| 1776 |
+
{toast && (
|
| 1777 |
+
<div style={{
|
| 1778 |
+
position:'fixed', bottom:24, left:'50%', transform:'translateX(-50%)',
|
| 1779 |
+
padding:'10px 24px', borderRadius:8, zIndex:200,
|
| 1780 |
+
background: toast.type==='success'?'rgba(0,230,118,0.15)':toast.type==='warn'?'rgba(255,107,43,0.15)':'rgba(0,200,232,0.12)',
|
| 1781 |
+
border: `1px solid ${toast.type==='success'?'#00e67660':toast.type==='warn'?'#ff6b2b60':'#00c8e840'}`,
|
| 1782 |
+
color: toast.type==='success'?'#00e676':toast.type==='warn'?'#ff6b2b':'#00c8e8',
|
| 1783 |
+
fontFamily:'Space Grotesk', fontSize:13, whiteSpace:'nowrap',
|
| 1784 |
+
backdropFilter:'blur(8px)',
|
| 1785 |
+
animation:'toast 3.2s ease-out forwards',
|
| 1786 |
+
boxShadow:'0 4px 24px rgba(0,0,0,0.4)',
|
| 1787 |
+
}}>
|
| 1788 |
+
{toast.msg}
|
| 1789 |
+
</div>
|
| 1790 |
+
)}
|
| 1791 |
+
</div>
|
| 1792 |
+
);
|
| 1793 |
+
}
|
| 1794 |
+
|
| 1795 |
+
function useTime() {
|
| 1796 |
+
const [t, setT] = useState(new Date());
|
| 1797 |
+
useEffect(() => {
|
| 1798 |
+
const id = setInterval(() => setT(new Date()), 1000);
|
| 1799 |
+
return () => clearInterval(id);
|
| 1800 |
+
}, []);
|
| 1801 |
+
return t;
|
| 1802 |
+
}
|
| 1803 |
+
|
| 1804 |
+
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
| 1805 |
+
</script>
|
| 1806 |
+
<script>
|
| 1807 |
+
function scaleRoot() {
|
| 1808 |
+
const root = document.getElementById('root');
|
| 1809 |
+
const scaleX = window.innerWidth / 1920;
|
| 1810 |
+
const scaleY = window.innerHeight / 1080;
|
| 1811 |
+
const scale = Math.min(scaleX, scaleY);
|
| 1812 |
+
root.style.transform = `scale(${scale})`;
|
| 1813 |
+
root.style.marginLeft = `${(window.innerWidth - 1920 * scale) / 2}px`;
|
| 1814 |
+
root.style.marginTop = `${(window.innerHeight - 1080 * scale) / 2}px`;
|
| 1815 |
+
}
|
| 1816 |
+
scaleRoot();
|
| 1817 |
+
window.addEventListener('resize', scaleRoot);
|
| 1818 |
+
</script>
|
| 1819 |
+
<script>
|
| 1820 |
+
setTimeout(() => {
|
| 1821 |
+
const key = window.localStorage.getItem('ROBOT_API_KEY');
|
| 1822 |
+
const btn = document.getElementById('admin-key-button');
|
| 1823 |
+
if (key && btn) {
|
| 1824 |
+
btn.textContent = 'KEY OK';
|
| 1825 |
+
btn.style.borderColor = '#00e676';
|
| 1826 |
+
btn.style.color = '#00e676';
|
| 1827 |
+
}
|
| 1828 |
+
}, 500);
|
| 1829 |
+
</script>
|
| 1830 |
+
</body>
|
| 1831 |
+
</html>
|
map_editor.html
ADDED
|
@@ -0,0 +1,1352 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<title>Floor Map Editor — UEH B1</title>
|
| 6 |
+
<style>
|
| 7 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 8 |
+
body {
|
| 9 |
+
background: #040c14; color: #c8dce8;
|
| 10 |
+
font-family: 'Segoe UI', sans-serif;
|
| 11 |
+
height: 100vh; display: flex; flex-direction: column; overflow: hidden;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/* ── Top bar ── */
|
| 15 |
+
.topbar {
|
| 16 |
+
height: 48px; background: #06101e;
|
| 17 |
+
border-bottom: 1px solid #1a3a55;
|
| 18 |
+
display: flex; align-items: center; gap: 10px;
|
| 19 |
+
padding: 0 14px; flex-shrink: 0; user-select: none;
|
| 20 |
+
}
|
| 21 |
+
.logo { font-family: monospace; font-size: 13px; font-weight: bold; color: #00c8e8; letter-spacing: 3px; }
|
| 22 |
+
.sep { width: 1px; height: 24px; background: #1a3a55; margin: 0 2px; }
|
| 23 |
+
|
| 24 |
+
.floor-tabs { display: flex; gap: 3px; }
|
| 25 |
+
.ftab {
|
| 26 |
+
padding: 4px 10px; border-radius: 4px;
|
| 27 |
+
border: 1px solid #1a3a55; background: transparent;
|
| 28 |
+
color: #7ab0c8; font-size: 11px; font-family: monospace; cursor: pointer;
|
| 29 |
+
transition: all 0.15s;
|
| 30 |
+
}
|
| 31 |
+
.ftab:hover { border-color: #2a5a75; color: #c8dce8; }
|
| 32 |
+
.ftab.active { background: rgba(0,200,232,0.12); border-color: #00c8e8; color: #00c8e8; }
|
| 33 |
+
.ftab-add { color: #3a6070; border-style: dashed; }
|
| 34 |
+
|
| 35 |
+
.tools { display: flex; gap: 3px; }
|
| 36 |
+
.tbtn {
|
| 37 |
+
padding: 5px 11px; border-radius: 4px;
|
| 38 |
+
border: 1px solid #1a3a55; background: transparent;
|
| 39 |
+
color: #7ab0c8; font-size: 11px; font-family: monospace;
|
| 40 |
+
cursor: pointer; display: flex; align-items: center; gap: 5px;
|
| 41 |
+
transition: all 0.15s;
|
| 42 |
+
}
|
| 43 |
+
.tbtn:hover { border-color: #2a5a75; background: #061018; }
|
| 44 |
+
.tbtn.active { background: rgba(0,200,232,0.12); border-color: #00c8e8; color: #00c8e8; }
|
| 45 |
+
.tbtn.del { color: #ff6b6b; border-color: #ff6b6b30; }
|
| 46 |
+
.tbtn.del:hover { background: rgba(255,107,107,0.1); }
|
| 47 |
+
.tbtn.green { color: #00e676; border-color: #00e67630; }
|
| 48 |
+
.tbtn.green:hover { background: rgba(0,230,118,0.1); }
|
| 49 |
+
|
| 50 |
+
.snap-ctrl {
|
| 51 |
+
display: flex; align-items: center; gap: 6px;
|
| 52 |
+
font-size: 11px; color: #3a6070; font-family: monospace;
|
| 53 |
+
margin-left: auto;
|
| 54 |
+
}
|
| 55 |
+
.snap-ctrl input {
|
| 56 |
+
width: 40px; background: #040d18; border: 1px solid #1a3a55;
|
| 57 |
+
color: #c8dce8; padding: 3px 6px; border-radius: 3px;
|
| 58 |
+
font-size: 11px; font-family: monospace; text-align: center;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/* ── Main layout ── */
|
| 62 |
+
.content { flex: 1; display: flex; overflow: hidden; }
|
| 63 |
+
|
| 64 |
+
.canvas-wrap {
|
| 65 |
+
flex: 1; overflow: auto; background: #020810;
|
| 66 |
+
display: flex; align-items: flex-start;
|
| 67 |
+
justify-content: flex-start; padding: 24px;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
#canvas { display: block; user-select: none; }
|
| 71 |
+
|
| 72 |
+
/* ── Right panel ── */
|
| 73 |
+
.panel {
|
| 74 |
+
width: 232px; background: #06101e;
|
| 75 |
+
border-left: 1px solid #1a3a55;
|
| 76 |
+
padding: 12px; overflow-y: auto;
|
| 77 |
+
display: flex; flex-direction: column; gap: 12px;
|
| 78 |
+
flex-shrink: 0;
|
| 79 |
+
}
|
| 80 |
+
.panel h3 {
|
| 81 |
+
font-size: 10px; color: #3a6070;
|
| 82 |
+
letter-spacing: 2px; text-transform: uppercase;
|
| 83 |
+
margin-bottom: 7px; font-family: monospace;
|
| 84 |
+
}
|
| 85 |
+
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
|
| 86 |
+
.stat-box {
|
| 87 |
+
background: #040d18; border: 1px solid #0d2238;
|
| 88 |
+
border-radius: 4px; padding: 6px; text-align: center;
|
| 89 |
+
}
|
| 90 |
+
.stat-box .val { font-size: 20px; font-weight: bold; color: #00c8e8; font-family: monospace; }
|
| 91 |
+
.stat-box .lbl { font-size: 9px; color: #3a6070; letter-spacing: 1px; }
|
| 92 |
+
|
| 93 |
+
.prop-row { margin-bottom: 7px; }
|
| 94 |
+
.prop-row label { display: block; font-size: 10px; color: #3a6070; margin-bottom: 3px; letter-spacing: 1px; font-family: monospace; }
|
| 95 |
+
.prop-row input, .prop-row select {
|
| 96 |
+
width: 100%; background: #040d18; border: 1px solid #1a3a55;
|
| 97 |
+
color: #c8dce8; padding: 5px 8px; border-radius: 4px;
|
| 98 |
+
font-size: 12px; font-family: monospace;
|
| 99 |
+
}
|
| 100 |
+
.prop-row input:focus, .prop-row select:focus { outline: none; border-color: #00c8e8; }
|
| 101 |
+
.prop-row .row2 { display: flex; gap: 5px; }
|
| 102 |
+
.prop-row .row2 input { flex: 1; }
|
| 103 |
+
|
| 104 |
+
/* ── Door picker ── */
|
| 105 |
+
.door-picker {
|
| 106 |
+
display: grid; grid-template-columns: repeat(3, 1fr);
|
| 107 |
+
gap: 3px; margin: 4px 0;
|
| 108 |
+
}
|
| 109 |
+
.dpick-btn {
|
| 110 |
+
height: 26px; border-radius: 3px;
|
| 111 |
+
border: 1px solid #1a3a55; background: #040d18;
|
| 112 |
+
color: #7ab0c8; font-size: 15px; cursor: pointer;
|
| 113 |
+
transition: all 0.12s; display: flex; align-items: center; justify-content: center;
|
| 114 |
+
}
|
| 115 |
+
.dpick-btn:hover { border-color: #2a5a75; background: #061018; }
|
| 116 |
+
.dpick-room {
|
| 117 |
+
background: rgba(0,200,232,0.05); border: 1px dashed #1a3a55;
|
| 118 |
+
border-radius: 2px; height: 26px;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.legend { display: flex; flex-direction: column; gap: 5px; }
|
| 122 |
+
.legend-item { display: flex; align-items: center; gap: 7px; font-size: 11px; color: #3a6070; }
|
| 123 |
+
.legend-dot { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
|
| 124 |
+
|
| 125 |
+
.xbtn {
|
| 126 |
+
width: 100%; padding: 8px; border-radius: 5px; cursor: pointer;
|
| 127 |
+
font-family: monospace; font-size: 11px; letter-spacing: 1px;
|
| 128 |
+
transition: all 0.15s;
|
| 129 |
+
}
|
| 130 |
+
.xbtn-primary {
|
| 131 |
+
border: 1px solid #00c8e840; background: rgba(0,200,232,0.08); color: #00c8e8;
|
| 132 |
+
}
|
| 133 |
+
.xbtn-primary:hover { background: rgba(0,200,232,0.16); }
|
| 134 |
+
.xbtn-secondary {
|
| 135 |
+
border: 1px solid #1a3a55; background: transparent; color: #7ab0c8;
|
| 136 |
+
}
|
| 137 |
+
.xbtn-secondary:hover { background: #061018; }
|
| 138 |
+
|
| 139 |
+
/* ── Status bar ── */
|
| 140 |
+
.statusbar {
|
| 141 |
+
height: 26px; background: #030c14;
|
| 142 |
+
border-top: 1px solid #0d2238;
|
| 143 |
+
display: flex; align-items: center;
|
| 144 |
+
padding: 0 14px; font-size: 11px; color: #3a6070;
|
| 145 |
+
font-family: monospace; gap: 20px; flex-shrink: 0;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
/* ── Modal ── */
|
| 149 |
+
.overlay {
|
| 150 |
+
position: fixed; inset: 0;
|
| 151 |
+
background: rgba(0,0,0,0.72); backdrop-filter: blur(3px);
|
| 152 |
+
display: flex; align-items: center; justify-content: center;
|
| 153 |
+
z-index: 100;
|
| 154 |
+
}
|
| 155 |
+
.modal {
|
| 156 |
+
background: #070f1a; border: 1px solid #1a3a55;
|
| 157 |
+
border-radius: 10px; padding: 22px; width: 600px;
|
| 158 |
+
max-height: 80vh; overflow: auto;
|
| 159 |
+
}
|
| 160 |
+
.modal h2 { font-size: 13px; color: #00c8e8; font-family: monospace; letter-spacing: 2px; margin-bottom: 12px; }
|
| 161 |
+
.modal p { font-size: 11px; color: #3a6070; margin-bottom: 8px; }
|
| 162 |
+
.modal textarea {
|
| 163 |
+
width: 100%; height: 320px;
|
| 164 |
+
background: #040d18; border: 1px solid #1a3a55;
|
| 165 |
+
color: #a0c8d8; font-family: monospace; font-size: 12px;
|
| 166 |
+
padding: 10px; border-radius: 5px; resize: vertical; outline: none;
|
| 167 |
+
}
|
| 168 |
+
.modal textarea:focus { border-color: #00c8e8; }
|
| 169 |
+
.modal-actions { display: flex; gap: 8px; margin-top: 10px; }
|
| 170 |
+
</style>
|
| 171 |
+
</head>
|
| 172 |
+
<body>
|
| 173 |
+
|
| 174 |
+
<!-- ── TOP BAR ── -->
|
| 175 |
+
<div class="topbar">
|
| 176 |
+
<span class="logo">MAP EDITOR</span>
|
| 177 |
+
<div class="sep"></div>
|
| 178 |
+
|
| 179 |
+
<div class="floor-tabs" id="floorTabs"></div>
|
| 180 |
+
<button class="ftab ftab-add" onclick="addFloor()">+ floor</button>
|
| 181 |
+
|
| 182 |
+
<div class="sep"></div>
|
| 183 |
+
|
| 184 |
+
<div class="tools">
|
| 185 |
+
<button class="tbtn active" id="tool-select" onclick="setTool('select')" title="S">◎ Select</button>
|
| 186 |
+
<button class="tbtn" id="tool-room" onclick="setTool('room')" title="R">▬ Room</button>
|
| 187 |
+
<button class="tbtn" id="tool-wall" onclick="setTool('wall')" title="W">■ Wall</button>
|
| 188 |
+
<button class="tbtn" id="tool-corridor" onclick="setTool('corridor')" title="C">░ Corridor</button>
|
| 189 |
+
<button class="tbtn" id="tool-elevator" onclick="setTool('elevator')" title="E">⬆ Elevator</button>
|
| 190 |
+
<button class="tbtn del" onclick="deleteSelected()" title="Del">✕ Delete</button>
|
| 191 |
+
<div class="sep"></div>
|
| 192 |
+
<button class="tbtn" onclick="autoBuildWalls()" title="Fill empty space with walls">Auto walls</button>
|
| 193 |
+
<button class="tbtn" onclick="undo()" title="Ctrl+Z">↩ Undo</button>
|
| 194 |
+
<button class="tbtn green" onclick="openExport()">↓ Export JS</button>
|
| 195 |
+
<div class="sep"></div>
|
| 196 |
+
<button class="tbtn" onclick="document.getElementById('pgmInput').click()" title="Load PGM as background">🗺 Load PGM</button>
|
| 197 |
+
<input type="file" id="pgmInput" accept=".pgm" style="display:none" onchange="loadPGM(this)">
|
| 198 |
+
</div>
|
| 199 |
+
|
| 200 |
+
<div class="snap-ctrl">
|
| 201 |
+
SNAP<input id="snapInput" type="number" value="5" min="1" max="20" onchange="state.snap=+this.value">
|
| 202 |
+
GRID<input id="gridInput" type="number" value="10" min="5" max="50" onchange="state.gridSize=+this.value;render()">
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
|
| 206 |
+
<!-- ── MAIN ── -->
|
| 207 |
+
<div class="content">
|
| 208 |
+
<div class="canvas-wrap" id="canvasWrap">
|
| 209 |
+
<svg id="canvas" xmlns="http://www.w3.org/2000/svg"></svg>
|
| 210 |
+
</div>
|
| 211 |
+
|
| 212 |
+
<div class="panel">
|
| 213 |
+
<div>
|
| 214 |
+
<h3>Stats</h3>
|
| 215 |
+
<div class="stat-grid">
|
| 216 |
+
<div class="stat-box"><div class="val" id="sRooms">0</div><div class="lbl">ROOMS</div></div>
|
| 217 |
+
<div class="stat-box"><div class="val" id="sElvs">0</div><div class="lbl">ELEV</div></div>
|
| 218 |
+
<div class="stat-box"><div class="val" id="sWalls">0</div><div class="lbl">WALLS</div></div>
|
| 219 |
+
<div class="stat-box"><div class="val" id="sCorr">0</div><div class="lbl">CORR</div></div>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
|
| 223 |
+
<div id="propsSection" style="display:none">
|
| 224 |
+
<h3>Properties</h3>
|
| 225 |
+
<div id="propsForm"></div>
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
+
<div>
|
| 229 |
+
<h3>Room defaults</h3>
|
| 230 |
+
<div class="prop-row">
|
| 231 |
+
<label>DOOR POSITION</label>
|
| 232 |
+
<div id="defaultDoorPicker"></div>
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
|
| 236 |
+
<div>
|
| 237 |
+
<h3>Legend</h3>
|
| 238 |
+
<div class="legend">
|
| 239 |
+
<div class="legend-item"><div class="legend-dot" style="background:#0a1828;border:1.5px solid #1c3a55"></div>Wall</div>
|
| 240 |
+
<div class="legend-item"><div class="legend-dot" style="background:#0d2238;border:1px dashed #1a3a55"></div>Corridor</div>
|
| 241 |
+
<div class="legend-item"><div class="legend-dot" style="background:rgba(0,200,232,0.12);border:1.5px solid #00c8e8"></div>Room — top</div>
|
| 242 |
+
<div class="legend-item"><div class="legend-dot" style="background:rgba(0,230,118,0.12);border:1.5px solid #00e676"></div>Room — bot</div>
|
| 243 |
+
<div class="legend-item"><div class="legend-dot" style="background:rgba(255,107,43,0.12);border:1.5px solid #ff6b2b"></div>Room — East</div>
|
| 244 |
+
<div class="legend-item"><div class="legend-dot" style="background:rgba(232,58,140,0.12);border:1.5px solid #e83a8c"></div>Room — West</div>
|
| 245 |
+
<div class="legend-item"><div class="legend-dot" style="background:rgba(245,158,11,0.15);border:1.5px solid #f59e0b"></div>Elevator</div>
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
|
| 249 |
+
<div id="overlaySection" style="display:none">
|
| 250 |
+
<h3>Map Overlay</h3>
|
| 251 |
+
<div class="prop-row">
|
| 252 |
+
<label>OPACITY — <span id="opacityVal">0.35</span></label>
|
| 253 |
+
<input type="range" min="0" max="1" step="0.05" value="0.35"
|
| 254 |
+
style="width:100%;accent-color:#00c8e8;margin-top:4px"
|
| 255 |
+
oninput="state.overlay.opacity=+this.value;document.getElementById('opacityVal').textContent=(+this.value).toFixed(2);render()">
|
| 256 |
+
</div>
|
| 257 |
+
<div class="prop-row">
|
| 258 |
+
<label>OFFSET X / Y</label>
|
| 259 |
+
<div class="row2">
|
| 260 |
+
<input type="number" id="ovOX" value="0" placeholder="x" onchange="state.overlay.ox=+this.value;render()">
|
| 261 |
+
<input type="number" id="ovOY" value="0" placeholder="y" onchange="state.overlay.oy=+this.value;render()">
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
<div class="prop-row">
|
| 265 |
+
<label>SCALE X / Y</label>
|
| 266 |
+
<div class="row2">
|
| 267 |
+
<input type="number" id="ovSX" value="1" step="0.01" placeholder="sx" onchange="state.overlay.sx=+this.value;render()">
|
| 268 |
+
<input type="number" id="ovSY" value="1" step="0.01" placeholder="sy" onchange="state.overlay.sy=+this.value;render()">
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
+
<button class="xbtn xbtn-secondary" onclick="clearOverlay()" style="font-size:10px">✕ Remove overlay</button>
|
| 272 |
+
</div>
|
| 273 |
+
|
| 274 |
+
<div style="margin-top:auto;display:flex;flex-direction:column;gap:6px;">
|
| 275 |
+
<button class="xbtn xbtn-secondary" onclick="openImport()">↑ Import ROOMS_DEF</button>
|
| 276 |
+
<button class="xbtn xbtn-secondary" onclick="exportAllFloors()">↓ Export all floors</button>
|
| 277 |
+
<button class="xbtn xbtn-primary" onclick="openExport()">↓ Export current floor</button>
|
| 278 |
+
</div>
|
| 279 |
+
</div>
|
| 280 |
+
</div>
|
| 281 |
+
|
| 282 |
+
<!-- ── STATUS BAR ── -->
|
| 283 |
+
<div class="statusbar">
|
| 284 |
+
<span id="coordDisp">x: — y: —</span>
|
| 285 |
+
<span id="sizeDisp"></span>
|
| 286 |
+
<span style="margin-left:auto" id="hintDisp">S/R/W/C/E=tools · drag/Shift=multi-select · Auto walls fills empty space · Ctrl+Z=undo</span>
|
| 287 |
+
</div>
|
| 288 |
+
|
| 289 |
+
<!-- ── EXPORT MODAL ── -->
|
| 290 |
+
<div class="overlay" id="exportModal" style="display:none">
|
| 291 |
+
<div class="modal">
|
| 292 |
+
<h2>EXPORT — JS CONSTANTS</h2>
|
| 293 |
+
<textarea id="exportText" readonly></textarea>
|
| 294 |
+
<div class="modal-actions">
|
| 295 |
+
<button class="xbtn xbtn-primary" onclick="copyExport()" id="copyBtn">Copy to clipboard</button>
|
| 296 |
+
<button class="xbtn xbtn-secondary" onclick="closeModal('exportModal')">Close</button>
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
|
| 301 |
+
<!-- ── IMPORT MODAL ── -->
|
| 302 |
+
<div class="overlay" id="importModal" style="display:none">
|
| 303 |
+
<div class="modal">
|
| 304 |
+
<h2>IMPORT — ROOMS_DEF</h2>
|
| 305 |
+
<p>Paste an existing ROOMS_DEF array (JS literal) to load into current floor:</p>
|
| 306 |
+
<textarea id="importText" placeholder="[ { id:'R01', x:2, y:2, w:90, h:76, group:'top' }, ... ]"></textarea>
|
| 307 |
+
<div class="modal-actions">
|
| 308 |
+
<button class="xbtn xbtn-primary" onclick="doImport()">Import</button>
|
| 309 |
+
<button class="xbtn xbtn-secondary" onclick="closeModal('importModal')">Cancel</button>
|
| 310 |
+
</div>
|
| 311 |
+
</div>
|
| 312 |
+
</div>
|
| 313 |
+
|
| 314 |
+
<script>
|
| 315 |
+
// ──────────────────────────────────────────────────────────
|
| 316 |
+
// CONSTANTS
|
| 317 |
+
// ──────────────────────────────────────────────────────────
|
| 318 |
+
const MAP_W = 700, MAP_H = 490;
|
| 319 |
+
let SCALE = 1.5; // display scale (auto-fit on load)
|
| 320 |
+
const FLOORS = ['1','2','3','4','5','6','7','8','9','10'];
|
| 321 |
+
|
| 322 |
+
// ──────────────────────────────────────────────────────────
|
| 323 |
+
// STATE
|
| 324 |
+
// ──────────────────────────────────────────────────────────
|
| 325 |
+
const state = {
|
| 326 |
+
tool: 'select',
|
| 327 |
+
floor: '1',
|
| 328 |
+
floors: {}, // floorId → { rooms, walls, corridors, elevators }
|
| 329 |
+
selected: [], // array of { type, floorId, idx }
|
| 330 |
+
drawing: null, // { type, x0,y0,x1,y1 }
|
| 331 |
+
rubberBand: null, // { x0,y0,x1,y1, add } drag-select box
|
| 332 |
+
snap: 5,
|
| 333 |
+
gridSize: 10,
|
| 334 |
+
history: [],
|
| 335 |
+
roomCtr: {}, // floorId → next number
|
| 336 |
+
elvCtr: {},
|
| 337 |
+
clipboard: null, // [{type, obj}] for cross-floor copy/paste
|
| 338 |
+
defaultGroup: 'top',
|
| 339 |
+
// drag state
|
| 340 |
+
_dragStart: null, // { mx, my }
|
| 341 |
+
_dragOrigs: null, // [{x,y}] original positions for multi-move
|
| 342 |
+
_dragging: false,
|
| 343 |
+
overlay: { dataURL: null, opacity: 0.35, ox: 0, oy: 0, sx: 1, sy: 1 },
|
| 344 |
+
};
|
| 345 |
+
|
| 346 |
+
function fd(fid = state.floor) {
|
| 347 |
+
if (!state.floors[fid]) {
|
| 348 |
+
state.floors[fid] = { rooms:[], walls:[], corridors:[], elevators:[] };
|
| 349 |
+
state.roomCtr[fid] = 1;
|
| 350 |
+
state.elvCtr[fid] = 1;
|
| 351 |
+
}
|
| 352 |
+
return state.floors[fid];
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
const snap = v => Math.round(v / state.snap) * state.snap;
|
| 356 |
+
|
| 357 |
+
function isSelected(type, idx) {
|
| 358 |
+
return state.selected.some(s => s.type === type && s.idx === idx && s.floorId === state.floor);
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
function selectedObject(sel = state.selected[0]) {
|
| 362 |
+
if (!sel) return null;
|
| 363 |
+
return fd(sel.floorId)[sel.type + 's'][sel.idx];
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
function normRect(o) {
|
| 367 |
+
const x = Math.max(0, Math.min(MAP_W, snap(o.x)));
|
| 368 |
+
const y = Math.max(0, Math.min(MAP_H, snap(o.y)));
|
| 369 |
+
const x2 = Math.max(0, Math.min(MAP_W, snap(o.x + o.w)));
|
| 370 |
+
const y2 = Math.max(0, Math.min(MAP_H, snap(o.y + o.h)));
|
| 371 |
+
return { x: Math.min(x, x2), y: Math.min(y, y2), w: Math.abs(x2 - x), h: Math.abs(y2 - y) };
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
function rectsOverlap(a, b) {
|
| 375 |
+
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
function mergeRects(rects) {
|
| 379 |
+
const clean = rects.map(normRect).filter(r => r.w > 0 && r.h > 0);
|
| 380 |
+
if (!clean.length) return [];
|
| 381 |
+
|
| 382 |
+
const xs = [...new Set(clean.flatMap(r => [r.x, r.x + r.w]))].sort((a,b) => a-b);
|
| 383 |
+
const ys = [...new Set(clean.flatMap(r => [r.y, r.y + r.h]))].sort((a,b) => a-b);
|
| 384 |
+
const occupied = new Set();
|
| 385 |
+
|
| 386 |
+
for (let yi = 0; yi < ys.length - 1; yi++) {
|
| 387 |
+
for (let xi = 0; xi < xs.length - 1; xi++) {
|
| 388 |
+
const cell = { x: xs[xi], y: ys[yi], w: xs[xi + 1] - xs[xi], h: ys[yi + 1] - ys[yi] };
|
| 389 |
+
if (clean.some(r => rectsOverlap(cell, r))) occupied.add(`${xi},${yi}`);
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
const used = new Set();
|
| 394 |
+
const has = (xi, yi) => occupied.has(`${xi},${yi}`) && !used.has(`${xi},${yi}`);
|
| 395 |
+
const out = [];
|
| 396 |
+
|
| 397 |
+
for (let yi = 0; yi < ys.length - 1; yi++) {
|
| 398 |
+
for (let xi = 0; xi < xs.length - 1; xi++) {
|
| 399 |
+
if (!has(xi, yi)) continue;
|
| 400 |
+
|
| 401 |
+
let xEnd = xi;
|
| 402 |
+
while (xEnd < xs.length - 1 && has(xEnd, yi)) xEnd++;
|
| 403 |
+
|
| 404 |
+
let yEnd = yi + 1;
|
| 405 |
+
grow:
|
| 406 |
+
while (yEnd < ys.length - 1) {
|
| 407 |
+
for (let xj = xi; xj < xEnd; xj++) {
|
| 408 |
+
if (!has(xj, yEnd)) break grow;
|
| 409 |
+
}
|
| 410 |
+
yEnd++;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
for (let yy = yi; yy < yEnd; yy++) {
|
| 414 |
+
for (let xx = xi; xx < xEnd; xx++) used.add(`${xx},${yy}`);
|
| 415 |
+
}
|
| 416 |
+
out.push({ x: xs[xi], y: ys[yi], w: xs[xEnd] - xs[xi], h: ys[yEnd] - ys[yi] });
|
| 417 |
+
}
|
| 418 |
+
}
|
| 419 |
+
return out;
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
// ──────────────────────────────────────────────────────────
|
| 423 |
+
// HISTORY
|
| 424 |
+
// ──────────────────────────────────────────────────────────
|
| 425 |
+
function saveHistory() {
|
| 426 |
+
state.history.push(JSON.stringify(state.floors));
|
| 427 |
+
if (state.history.length > 60) state.history.shift();
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
function undo() {
|
| 431 |
+
if (!state.history.length) return;
|
| 432 |
+
state.floors = JSON.parse(state.history.pop());
|
| 433 |
+
state.selected = [];
|
| 434 |
+
render(); updateStats(); updateProps();
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
// ──────────────────────────────────────────────────────────
|
| 438 |
+
// COORDINATE TRANSFORM
|
| 439 |
+
// ──────────────────────────────────────────────────────────
|
| 440 |
+
function svgXY(e) {
|
| 441 |
+
const r = document.getElementById('canvas').getBoundingClientRect();
|
| 442 |
+
return { x: (e.clientX - r.left) / SCALE, y: (e.clientY - r.top) / SCALE };
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
// ──────────────────────────────────────────────────────────
|
| 446 |
+
// TOOL SWITCHING
|
| 447 |
+
// ──────────────────────────────────────────────────────────
|
| 448 |
+
const HINTS = {
|
| 449 |
+
select: 'Click/drag to select · Shift=add/remove · Drag selected to move · Del=delete · Ctrl+C/V=copy/paste',
|
| 450 |
+
room: 'Drag to draw room · release to confirm',
|
| 451 |
+
wall: 'Drag to draw wall boundary rect',
|
| 452 |
+
corridor: 'Drag to draw corridor fill',
|
| 453 |
+
elevator: 'Drag to draw elevator shaft',
|
| 454 |
+
};
|
| 455 |
+
|
| 456 |
+
function setTool(t) {
|
| 457 |
+
state.tool = t; state.drawing = null;
|
| 458 |
+
document.querySelectorAll('.tbtn[id^="tool-"]').forEach(b => b.classList.remove('active'));
|
| 459 |
+
document.getElementById('tool-' + t)?.classList.add('active');
|
| 460 |
+
document.getElementById('hintDisp').textContent = HINTS[t] || '';
|
| 461 |
+
render();
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
// ──────────────────────────────────────────────────────────
|
| 465 |
+
// DELETE / DUPLICATE
|
| 466 |
+
// ──────────────────────────────────────────────────────────
|
| 467 |
+
function deleteSelected() {
|
| 468 |
+
if (!state.selected.length) return;
|
| 469 |
+
saveHistory();
|
| 470 |
+
const byFloorType = {};
|
| 471 |
+
state.selected.forEach(s => {
|
| 472 |
+
const key = `${s.floorId}:${s.type}`;
|
| 473 |
+
(byFloorType[key] = byFloorType[key] || []).push(s.idx);
|
| 474 |
+
});
|
| 475 |
+
for (const [key, idxs] of Object.entries(byFloorType)) {
|
| 476 |
+
const [floorId, type] = key.split(':');
|
| 477 |
+
idxs.sort((a,b) => b-a).forEach(i => fd(floorId)[type+'s'].splice(i,1));
|
| 478 |
+
}
|
| 479 |
+
state.selected = [];
|
| 480 |
+
render(); updateStats(); updateProps();
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
function duplicateSelected() {
|
| 484 |
+
if (!state.selected.length) return;
|
| 485 |
+
saveHistory();
|
| 486 |
+
const newSel = [];
|
| 487 |
+
state.selected.forEach(sel => {
|
| 488 |
+
const d = fd(sel.floorId);
|
| 489 |
+
const src = d[sel.type + 's'][sel.idx];
|
| 490 |
+
const copy = { ...src, x: src.x + 10, y: src.y + 10 };
|
| 491 |
+
if (sel.type === 'room') copy.id = 'R' + String(state.roomCtr[state.floor]++).padStart(2,'0');
|
| 492 |
+
else if (sel.type === 'elevator') copy.id = 'E' + (state.elvCtr[state.floor]++);
|
| 493 |
+
d[sel.type + 's'].push(copy);
|
| 494 |
+
newSel.push({ ...sel, idx: d[sel.type+'s'].length - 1 });
|
| 495 |
+
});
|
| 496 |
+
state.selected = newSel;
|
| 497 |
+
render(); updateStats(); updateProps();
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
function copySelected() {
|
| 501 |
+
if (!state.selected.length) return;
|
| 502 |
+
state.clipboard = state.selected.map(s => ({
|
| 503 |
+
type: s.type,
|
| 504 |
+
obj: JSON.parse(JSON.stringify(fd(s.floorId)[s.type+'s'][s.idx])),
|
| 505 |
+
}));
|
| 506 |
+
flashHint(`${state.clipboard.length} item${state.clipboard.length>1?'s':''} copied — Ctrl+V to paste`);
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
function pasteClipboard() {
|
| 510 |
+
if (!state.clipboard || !state.clipboard.length) return;
|
| 511 |
+
saveHistory();
|
| 512 |
+
const newSel = [];
|
| 513 |
+
state.clipboard.forEach(({ type, obj }) => {
|
| 514 |
+
const copy = { ...obj, x: snap(obj.x + 10), y: snap(obj.y + 10) };
|
| 515 |
+
if (type === 'room') {
|
| 516 |
+
if (!state.roomCtr[state.floor]) state.roomCtr[state.floor] = 1;
|
| 517 |
+
copy.id = 'R' + String(state.roomCtr[state.floor]++).padStart(2,'0');
|
| 518 |
+
} else if (type === 'elevator') {
|
| 519 |
+
if (!state.elvCtr[state.floor]) state.elvCtr[state.floor] = 1;
|
| 520 |
+
copy.id = 'E' + (state.elvCtr[state.floor]++);
|
| 521 |
+
}
|
| 522 |
+
fd()[type+'s'].push(copy);
|
| 523 |
+
newSel.push({ type, floorId: state.floor, idx: fd()[type+'s'].length - 1 });
|
| 524 |
+
});
|
| 525 |
+
state.selected = newSel;
|
| 526 |
+
render(); updateStats(); updateProps();
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
function copyToFloor(targetFloor) {
|
| 530 |
+
if (!state.selected.length) return;
|
| 531 |
+
saveHistory();
|
| 532 |
+
state.selected.forEach(s => {
|
| 533 |
+
const { type } = s;
|
| 534 |
+
const copy = JSON.parse(JSON.stringify(fd(s.floorId)[type+'s'][s.idx]));
|
| 535 |
+
if (type === 'room') {
|
| 536 |
+
if (!state.roomCtr[targetFloor]) state.roomCtr[targetFloor] = 1;
|
| 537 |
+
copy.id = 'R' + String(state.roomCtr[targetFloor]++).padStart(2,'0');
|
| 538 |
+
} else if (type === 'elevator') {
|
| 539 |
+
if (!state.elvCtr[targetFloor]) state.elvCtr[targetFloor] = 1;
|
| 540 |
+
copy.id = 'E' + (state.elvCtr[targetFloor]++);
|
| 541 |
+
}
|
| 542 |
+
fd(targetFloor)[type+'s'].push(copy);
|
| 543 |
+
});
|
| 544 |
+
const n = state.selected.length;
|
| 545 |
+
flashHint(`${n} item${n>1?'s':''} copied to ${targetFloor}F`);
|
| 546 |
+
render(); updateStats();
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
function autoBuildWalls() {
|
| 550 |
+
const d = fd();
|
| 551 |
+
saveHistory();
|
| 552 |
+
|
| 553 |
+
d.rooms = d.rooms.map(normRect).map((r, i) => ({ ...d.rooms[i], ...r }));
|
| 554 |
+
d.elevators = d.elevators.map(normRect).map((r, i) => ({ ...d.elevators[i], ...r }));
|
| 555 |
+
d.corridors = mergeRects(d.corridors);
|
| 556 |
+
|
| 557 |
+
const solid = [...d.rooms, ...d.elevators, ...d.corridors].map(normRect).filter(r => r.w > 0 && r.h > 0);
|
| 558 |
+
const xs = [0, MAP_W], ys = [0, MAP_H];
|
| 559 |
+
solid.forEach(r => {
|
| 560 |
+
xs.push(r.x, r.x + r.w);
|
| 561 |
+
ys.push(r.y, r.y + r.h);
|
| 562 |
+
});
|
| 563 |
+
|
| 564 |
+
const xCuts = [...new Set(xs.map(snap))].filter(x => x >= 0 && x <= MAP_W).sort((a,b) => a-b);
|
| 565 |
+
const yCuts = [...new Set(ys.map(snap))].filter(y => y >= 0 && y <= MAP_H).sort((a,b) => a-b);
|
| 566 |
+
const walls = [];
|
| 567 |
+
|
| 568 |
+
for (let yi = 0; yi < yCuts.length - 1; yi++) {
|
| 569 |
+
for (let xi = 0; xi < xCuts.length - 1; xi++) {
|
| 570 |
+
const cell = {
|
| 571 |
+
x: xCuts[xi],
|
| 572 |
+
y: yCuts[yi],
|
| 573 |
+
w: xCuts[xi + 1] - xCuts[xi],
|
| 574 |
+
h: yCuts[yi + 1] - yCuts[yi],
|
| 575 |
+
};
|
| 576 |
+
if (cell.w <= 0 || cell.h <= 0) continue;
|
| 577 |
+
if (!solid.some(o => rectsOverlap(cell, o))) walls.push(cell);
|
| 578 |
+
}
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
d.walls = mergeRects(walls);
|
| 582 |
+
state.selected = [];
|
| 583 |
+
flashHint(`Auto walls: ${d.walls.length} merged wall block${d.walls.length === 1 ? '' : 's'}`);
|
| 584 |
+
render(); updateStats(); updateProps();
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
function flashHint(msg) {
|
| 588 |
+
const el = document.getElementById('hintDisp');
|
| 589 |
+
el.textContent = msg;
|
| 590 |
+
clearTimeout(flashHint._t);
|
| 591 |
+
flashHint._t = setTimeout(() => { el.textContent = HINTS[state.tool] || ''; }, 2000);
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
// ── Door picker ──────────────────────────────────────────────
|
| 595 |
+
const DOOR_COLORS = { top:'#00c8e8', bot:'#00e676', vert:'#ff6b2b', 'vert-w':'#e83a8c' };
|
| 596 |
+
const DOOR_DIRS = [
|
| 597 |
+
{ g: 'bot', sym: '↑', r: 0, c: 1, title: 'North' },
|
| 598 |
+
{ g: 'vert-w', sym: '←', r: 1, c: 0, title: 'West' },
|
| 599 |
+
{ g: 'vert', sym: '→', r: 1, c: 2, title: 'East' },
|
| 600 |
+
{ g: 'top', sym: '↓', r: 2, c: 1, title: 'South' },
|
| 601 |
+
];
|
| 602 |
+
|
| 603 |
+
function doorPickerHTML(currentGroup, onchangeFn) {
|
| 604 |
+
const grid = Array(9).fill(null);
|
| 605 |
+
DOOR_DIRS.forEach(d => { grid[d.r * 3 + d.c] = d; });
|
| 606 |
+
return `<div class="door-picker">${grid.map((d, i) => {
|
| 607 |
+
if (i === 4) return `<div class="dpick-room"></div>`;
|
| 608 |
+
if (!d) return `<div></div>`;
|
| 609 |
+
const active = currentGroup === d.g;
|
| 610 |
+
const col = DOOR_COLORS[d.g] || '#7ab0c8';
|
| 611 |
+
const style = active ? `background:${col}22;border-color:${col};color:${col}` : '';
|
| 612 |
+
return `<button class="dpick-btn" style="${style}"
|
| 613 |
+
onclick="${onchangeFn}('${d.g}')" title="Door ${d.title}">${d.sym}</button>`;
|
| 614 |
+
}).join('')}</div>`;
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
function setDefaultGroup(g) {
|
| 618 |
+
state.defaultGroup = g;
|
| 619 |
+
const el = document.getElementById('defaultDoorPicker');
|
| 620 |
+
if (el) el.innerHTML = doorPickerHTML(g, 'setDefaultGroup');
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
function setPropGroup(g) { setProp('group', g); }
|
| 624 |
+
|
| 625 |
+
// ──────────────────────────────────────────────────────────
|
| 626 |
+
// MOUSE EVENTS
|
| 627 |
+
// ──────────────────────────────────────────────────────────
|
| 628 |
+
const svg = document.getElementById('canvas');
|
| 629 |
+
let mouseIsDown = false;
|
| 630 |
+
|
| 631 |
+
svg.addEventListener('mousedown', e => {
|
| 632 |
+
if (e.button !== 0) return;
|
| 633 |
+
mouseIsDown = true;
|
| 634 |
+
const { x, y } = svgXY(e);
|
| 635 |
+
|
| 636 |
+
if (state.tool === 'select') {
|
| 637 |
+
const hit = hitTest(x, y);
|
| 638 |
+
if (hit) {
|
| 639 |
+
let canDrag = true;
|
| 640 |
+
if (e.shiftKey) {
|
| 641 |
+
const ei = state.selected.findIndex(s => s.type===hit.type && s.idx===hit.idx && s.floorId===hit.floorId);
|
| 642 |
+
if (ei >= 0) { state.selected.splice(ei, 1); canDrag = false; }
|
| 643 |
+
else state.selected.push(hit);
|
| 644 |
+
} else {
|
| 645 |
+
if (!isSelected(hit.type, hit.idx)) state.selected = [hit];
|
| 646 |
+
}
|
| 647 |
+
if (canDrag) {
|
| 648 |
+
state._dragStart = { mx: x, my: y };
|
| 649 |
+
state._dragOrigs = state.selected.map(s => {
|
| 650 |
+
const o = fd(s.floorId)[s.type+'s'][s.idx];
|
| 651 |
+
return { x: o.x, y: o.y };
|
| 652 |
+
});
|
| 653 |
+
state._dragging = false;
|
| 654 |
+
} else {
|
| 655 |
+
state._dragStart = null;
|
| 656 |
+
state._dragOrigs = null;
|
| 657 |
+
state._dragging = false;
|
| 658 |
+
}
|
| 659 |
+
} else {
|
| 660 |
+
if (!e.shiftKey) state.selected = [];
|
| 661 |
+
state.rubberBand = { x0: x, y0: y, x1: x, y1: y, add: e.shiftKey };
|
| 662 |
+
}
|
| 663 |
+
render(); updateProps();
|
| 664 |
+
return;
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
state.drawing = { type: state.tool, x0: snap(x), y0: snap(y), x1: snap(x), y1: snap(y) };
|
| 668 |
+
e.preventDefault();
|
| 669 |
+
});
|
| 670 |
+
|
| 671 |
+
svg.addEventListener('mousemove', e => {
|
| 672 |
+
const { x, y } = svgXY(e);
|
| 673 |
+
document.getElementById('coordDisp').textContent = `x: ${Math.round(x)} y: ${Math.round(y)}`;
|
| 674 |
+
if (!mouseIsDown) return;
|
| 675 |
+
|
| 676 |
+
// Move selected
|
| 677 |
+
if (state.tool === 'select') {
|
| 678 |
+
if (state.selected.length && state._dragStart) {
|
| 679 |
+
const dx = x - state._dragStart.mx;
|
| 680 |
+
const dy = y - state._dragStart.my;
|
| 681 |
+
if (!state._dragging && (Math.abs(dx) > 2 || Math.abs(dy) > 2)) {
|
| 682 |
+
saveHistory(); state._dragging = true;
|
| 683 |
+
}
|
| 684 |
+
if (state._dragging) {
|
| 685 |
+
state.selected.forEach((s, i) => {
|
| 686 |
+
const obj = fd(s.floorId)[s.type+'s'][s.idx];
|
| 687 |
+
obj.x = snap(state._dragOrigs[i].x + dx);
|
| 688 |
+
obj.y = snap(state._dragOrigs[i].y + dy);
|
| 689 |
+
});
|
| 690 |
+
render(); updateProps();
|
| 691 |
+
}
|
| 692 |
+
return;
|
| 693 |
+
}
|
| 694 |
+
if (state.rubberBand) {
|
| 695 |
+
state.rubberBand.x1 = x; state.rubberBand.y1 = y;
|
| 696 |
+
render(); return;
|
| 697 |
+
}
|
| 698 |
+
return;
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
// Update drawing preview
|
| 702 |
+
if (state.drawing) {
|
| 703 |
+
state.drawing.x1 = snap(x);
|
| 704 |
+
state.drawing.y1 = snap(y);
|
| 705 |
+
const w = Math.abs(state.drawing.x1 - state.drawing.x0);
|
| 706 |
+
const h = Math.abs(state.drawing.y1 - state.drawing.y0);
|
| 707 |
+
document.getElementById('sizeDisp').textContent = w > 0 ? `${Math.round(w)} × ${Math.round(h)}` : '';
|
| 708 |
+
render();
|
| 709 |
+
}
|
| 710 |
+
});
|
| 711 |
+
|
| 712 |
+
document.addEventListener('mouseup', e => {
|
| 713 |
+
if (!mouseIsDown) return;
|
| 714 |
+
mouseIsDown = false;
|
| 715 |
+
state._dragStart = null; state._dragOrigs = null; state._dragging = false;
|
| 716 |
+
|
| 717 |
+
if (state.rubberBand) {
|
| 718 |
+
const rb = state.rubberBand;
|
| 719 |
+
state.rubberBand = null;
|
| 720 |
+
const rx = Math.min(rb.x0,rb.x1), ry = Math.min(rb.y0,rb.y1);
|
| 721 |
+
const rw = Math.abs(rb.x1-rb.x0), rh = Math.abs(rb.y1-rb.y0);
|
| 722 |
+
if (rw > 3 && rh > 3) {
|
| 723 |
+
const fdata = fd();
|
| 724 |
+
const hits = [];
|
| 725 |
+
for (const type of ['room','elevator','wall','corridor']) {
|
| 726 |
+
fdata[type+'s'].forEach((o, idx) => {
|
| 727 |
+
if (o.x < rx+rw && o.x+o.w > rx && o.y < ry+rh && o.y+o.h > ry)
|
| 728 |
+
hits.push({ type, floorId: state.floor, idx });
|
| 729 |
+
});
|
| 730 |
+
}
|
| 731 |
+
if (rb.add) {
|
| 732 |
+
hits.forEach(hit => {
|
| 733 |
+
if (!state.selected.some(s => s.type === hit.type && s.idx === hit.idx && s.floorId === hit.floorId))
|
| 734 |
+
state.selected.push(hit);
|
| 735 |
+
});
|
| 736 |
+
} else {
|
| 737 |
+
state.selected = hits;
|
| 738 |
+
}
|
| 739 |
+
}
|
| 740 |
+
render(); updateProps();
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
if (state.drawing) {
|
| 744 |
+
const d = state.drawing;
|
| 745 |
+
const x = Math.min(d.x0, d.x1), y = Math.min(d.y0, d.y1);
|
| 746 |
+
const w = Math.abs(d.x1 - d.x0), h = Math.abs(d.y1 - d.y0);
|
| 747 |
+
|
| 748 |
+
if (w > 4 && h > 4) {
|
| 749 |
+
saveHistory();
|
| 750 |
+
const fdata = fd();
|
| 751 |
+
if (d.type === 'room') {
|
| 752 |
+
const grp = state.defaultGroup;
|
| 753 |
+
const id = 'R' + String(state.roomCtr[state.floor]++).padStart(2, '0');
|
| 754 |
+
fdata.rooms.push({ id, x, y, w, h, group: grp });
|
| 755 |
+
} else if (d.type === 'elevator') {
|
| 756 |
+
const id = 'E' + (state.elvCtr[state.floor]++);
|
| 757 |
+
fdata.elevators.push({ id, x, y, w, h });
|
| 758 |
+
} else if (d.type === 'wall') {
|
| 759 |
+
fdata.walls.push({ x, y, w, h });
|
| 760 |
+
fdata.walls = mergeRects(fdata.walls);
|
| 761 |
+
} else if (d.type === 'corridor') {
|
| 762 |
+
fdata.corridors.push({ x, y, w, h });
|
| 763 |
+
fdata.corridors = mergeRects(fdata.corridors);
|
| 764 |
+
}
|
| 765 |
+
}
|
| 766 |
+
state.drawing = null;
|
| 767 |
+
render(); updateStats();
|
| 768 |
+
}
|
| 769 |
+
});
|
| 770 |
+
|
| 771 |
+
// Double-click to rename room
|
| 772 |
+
svg.addEventListener('dblclick', e => {
|
| 773 |
+
const { x, y } = svgXY(e);
|
| 774 |
+
const hit = hitTest(x, y);
|
| 775 |
+
if (!hit || hit.type !== 'room') return;
|
| 776 |
+
const obj = fd(hit.floorId).rooms[hit.idx];
|
| 777 |
+
const name = prompt('Room ID:', obj.id);
|
| 778 |
+
if (name && name.trim()) {
|
| 779 |
+
saveHistory();
|
| 780 |
+
obj.id = name.trim();
|
| 781 |
+
render(); updateProps();
|
| 782 |
+
}
|
| 783 |
+
});
|
| 784 |
+
|
| 785 |
+
// ──────────────────────────────────────────────────────────
|
| 786 |
+
// HIT TEST
|
| 787 |
+
// ──────────────────────────────────────────────────────────
|
| 788 |
+
function hitTest(x, y) {
|
| 789 |
+
const fdata = fd();
|
| 790 |
+
for (const type of ['room','elevator','wall','corridor']) {
|
| 791 |
+
const arr = fdata[type + 's'];
|
| 792 |
+
for (let i = arr.length - 1; i >= 0; i--) {
|
| 793 |
+
const o = arr[i];
|
| 794 |
+
if (x >= o.x && x <= o.x + o.w && y >= o.y && y <= o.y + o.h)
|
| 795 |
+
return { type, floorId: state.floor, idx: i };
|
| 796 |
+
}
|
| 797 |
+
}
|
| 798 |
+
return null;
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
// ──────────────────────────────────────────────────────────
|
| 802 |
+
// KEYBOARD
|
| 803 |
+
// ──────────────────────────────────────────────────────────
|
| 804 |
+
document.addEventListener('keydown', e => {
|
| 805 |
+
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
|
| 806 |
+
if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); deleteSelected(); }
|
| 807 |
+
if (e.key === 'Escape') { state.drawing = null; state.rubberBand = null; state.selected = []; render(); updateProps(); }
|
| 808 |
+
if (!e.ctrlKey && !e.metaKey) {
|
| 809 |
+
if (e.key === 's' || e.key === 'S') setTool('select');
|
| 810 |
+
if (e.key === 'r' || e.key === 'R') setTool('room');
|
| 811 |
+
if (e.key === 'w' || e.key === 'W') setTool('wall');
|
| 812 |
+
if (e.key === 'c' || e.key === 'C') setTool('corridor');
|
| 813 |
+
if (e.key === 'e' || e.key === 'E') setTool('elevator');
|
| 814 |
+
if (e.key === 'd' || e.key === 'D') duplicateSelected();
|
| 815 |
+
}
|
| 816 |
+
if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); undo(); }
|
| 817 |
+
if ((e.ctrlKey || e.metaKey) && e.key === 'c') { e.preventDefault(); copySelected(); }
|
| 818 |
+
if ((e.ctrlKey || e.metaKey) && e.key === 'v') { e.preventDefault(); pasteClipboard(); }
|
| 819 |
+
});
|
| 820 |
+
|
| 821 |
+
// ──────────────────────────────────────────────────────────
|
| 822 |
+
// FLOORS
|
| 823 |
+
// ──────────────────────────────────────────────────────────
|
| 824 |
+
function renderFloorTabs() {
|
| 825 |
+
document.getElementById('floorTabs').innerHTML = FLOORS.map(f => `
|
| 826 |
+
<button class="ftab ${state.floor === f ? 'active' : ''}" onclick="switchFloor('${f}')">
|
| 827 |
+
${f}F
|
| 828 |
+
</button>
|
| 829 |
+
`).join('');
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
function switchFloor(f) {
|
| 833 |
+
state.floor = f; state.selected = []; state.drawing = null; state.rubberBand = null;
|
| 834 |
+
renderFloorTabs(); render(); updateStats(); updateProps();
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
+
function addFloor() {
|
| 838 |
+
const lbl = prompt('Floor label (e.g. B1, 10, RF):');
|
| 839 |
+
if (!lbl || FLOORS.includes(lbl)) return;
|
| 840 |
+
FLOORS.push(lbl);
|
| 841 |
+
switchFloor(lbl);
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
// ──────────────────────────────────────────────────────────
|
| 845 |
+
// PROPERTIES PANEL
|
| 846 |
+
// ──────────────────────────────────────────────────────────
|
| 847 |
+
function updateProps() {
|
| 848 |
+
const sec = document.getElementById('propsSection');
|
| 849 |
+
const form = document.getElementById('propsForm');
|
| 850 |
+
if (!state.selected.length) { sec.style.display = 'none'; return; }
|
| 851 |
+
|
| 852 |
+
if (state.selected.length > 1) {
|
| 853 |
+
sec.style.display = 'block';
|
| 854 |
+
form.innerHTML = `
|
| 855 |
+
<div style="font-size:10px;color:#3a6070;letter-spacing:1px;margin-bottom:7px;font-family:monospace">
|
| 856 |
+
${state.selected.length} ITEMS SELECTED
|
| 857 |
+
</div>
|
| 858 |
+
<button class="xbtn xbtn-secondary" style="margin-top:4px;font-size:10px" onclick="duplicateSelected()">Duplicate selected (D)</button>`;
|
| 859 |
+
return;
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
const sel = state.selected[0];
|
| 863 |
+
const obj = selectedObject(sel);
|
| 864 |
+
sec.style.display = 'block';
|
| 865 |
+
|
| 866 |
+
let html = `<div style="font-size:10px;color:#3a6070;letter-spacing:1px;margin-bottom:7px;font-family:monospace">${sel.type.toUpperCase()}</div>`;
|
| 867 |
+
|
| 868 |
+
if (sel.type === 'room') {
|
| 869 |
+
html += `
|
| 870 |
+
<div class="prop-row"><label>ID</label>
|
| 871 |
+
<input value="${obj.id}" onchange="setProp('id',this.value)"></div>
|
| 872 |
+
<div class="prop-row"><label>DOOR</label>
|
| 873 |
+
${doorPickerHTML(obj.group, 'setPropGroup')}</div>`;
|
| 874 |
+
} else if (sel.type === 'elevator') {
|
| 875 |
+
html += `<div class="prop-row"><label>ID</label><input value="${obj.id}" onchange="setProp('id',this.value)"></div>`;
|
| 876 |
+
}
|
| 877 |
+
|
| 878 |
+
html += `
|
| 879 |
+
<div class="prop-row"><label>POSITION</label>
|
| 880 |
+
<div class="row2">
|
| 881 |
+
<input type="number" value="${Math.round(obj.x)}" placeholder="x" onchange="setProp('x',+this.value)">
|
| 882 |
+
<input type="number" value="${Math.round(obj.y)}" placeholder="y" onchange="setProp('y',+this.value)">
|
| 883 |
+
</div></div>
|
| 884 |
+
<div class="prop-row"><label>SIZE</label>
|
| 885 |
+
<div class="row2">
|
| 886 |
+
<input type="number" value="${Math.round(obj.w)}" placeholder="w" onchange="setProp('w',+this.value)">
|
| 887 |
+
<input type="number" value="${Math.round(obj.h)}" placeholder="h" onchange="setProp('h',+this.value)">
|
| 888 |
+
</div></div>
|
| 889 |
+
<button class="xbtn xbtn-secondary" style="margin-top:4px;font-size:10px" onclick="duplicateSelected()">⧉ Duplicate (D)</button>`;
|
| 890 |
+
|
| 891 |
+
const otherFloors = FLOORS.filter(f => f !== state.floor);
|
| 892 |
+
if (otherFloors.length) {
|
| 893 |
+
html += `<div class="prop-row" style="margin-top:8px"><label>COPY TO FLOOR</label>
|
| 894 |
+
<div style="display:flex;flex-wrap:wrap;gap:3px;margin-top:3px">
|
| 895 |
+
${otherFloors.map(f =>
|
| 896 |
+
`<button class="tbtn" style="padding:3px 7px;font-size:10px"
|
| 897 |
+
onclick="copyToFloor('${f}')">${f}F</button>`
|
| 898 |
+
).join('')}
|
| 899 |
+
</div></div>`;
|
| 900 |
+
}
|
| 901 |
+
|
| 902 |
+
form.innerHTML = html;
|
| 903 |
+
}
|
| 904 |
+
|
| 905 |
+
function setProp(key, val) {
|
| 906 |
+
if (state.selected.length !== 1) return;
|
| 907 |
+
const obj = selectedObject();
|
| 908 |
+
saveHistory();
|
| 909 |
+
obj[key] = val;
|
| 910 |
+
render(); updateProps();
|
| 911 |
+
}
|
| 912 |
+
|
| 913 |
+
// ──────────────────────────────────────────────────────────
|
| 914 |
+
// STATS
|
| 915 |
+
// ──────────────────────────────────────────────────────────
|
| 916 |
+
function updateStats() {
|
| 917 |
+
const d = fd();
|
| 918 |
+
document.getElementById('sRooms').textContent = d.rooms.length;
|
| 919 |
+
document.getElementById('sElvs').textContent = d.elevators.length;
|
| 920 |
+
document.getElementById('sWalls').textContent = d.walls.length;
|
| 921 |
+
document.getElementById('sCorr').textContent = d.corridors.length;
|
| 922 |
+
}
|
| 923 |
+
|
| 924 |
+
// ──────────────────────────────────────────────────────────
|
| 925 |
+
// RENDER
|
| 926 |
+
// ──────────────────────────────────────────────────────────
|
| 927 |
+
const ROOM_COLORS = {
|
| 928 |
+
top: { fill:'rgba(0,200,232,0.12)', stroke:'#00c8e8' },
|
| 929 |
+
bot: { fill:'rgba(0,230,118,0.12)', stroke:'#00e676' },
|
| 930 |
+
vert: { fill:'rgba(255,107,43,0.12)', stroke:'#ff6b2b' },
|
| 931 |
+
'vert-w': { fill:'rgba(232,58,140,0.12)', stroke:'#e83a8c' },
|
| 932 |
+
other: { fill:'rgba(168,85,247,0.12)', stroke:'#a855f7' },
|
| 933 |
+
};
|
| 934 |
+
|
| 935 |
+
function getDoor(r) {
|
| 936 |
+
if (r.group === 'top') return { x: r.x + r.w/2, y: r.y + r.h };
|
| 937 |
+
if (r.group === 'bot') return { x: r.x + r.w/2, y: r.y };
|
| 938 |
+
if (r.group === 'vert') return { x: r.x + r.w, y: r.y + r.h/2 };
|
| 939 |
+
if (r.group === 'vert-w') return { x: r.x, y: r.y + r.h/2 };
|
| 940 |
+
return { x: r.x + r.w/2, y: r.y + r.h };
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
function selHandles(o) {
|
| 944 |
+
return [[0,0],[o.w,0],[o.w,o.h],[0,o.h]].map(([dx,dy]) =>
|
| 945 |
+
`<rect x="${o.x+dx-3.5}" y="${o.y+dy-3.5}" width="7" height="7" rx="1"
|
| 946 |
+
fill="#fff" stroke="#00c8e8" stroke-width="1.2"/>`
|
| 947 |
+
).join('');
|
| 948 |
+
}
|
| 949 |
+
|
| 950 |
+
function unionLayerHTML(rects, fill, stroke, strokeWidth = 1.2, dash = '') {
|
| 951 |
+
const clean = rects.map(normRect).filter(r => r.w > 0 && r.h > 0);
|
| 952 |
+
if (!clean.length) return '';
|
| 953 |
+
|
| 954 |
+
const xs = [...new Set(clean.flatMap(r => [r.x, r.x + r.w]))].sort((a,b) => a-b);
|
| 955 |
+
const ys = [...new Set(clean.flatMap(r => [r.y, r.y + r.h]))].sort((a,b) => a-b);
|
| 956 |
+
const filled = [];
|
| 957 |
+
const occupied = new Set();
|
| 958 |
+
|
| 959 |
+
for (let yi = 0; yi < ys.length - 1; yi++) {
|
| 960 |
+
for (let xi = 0; xi < xs.length - 1; xi++) {
|
| 961 |
+
const cell = { x: xs[xi], y: ys[yi], w: xs[xi + 1] - xs[xi], h: ys[yi + 1] - ys[yi] };
|
| 962 |
+
if (cell.w <= 0 || cell.h <= 0) continue;
|
| 963 |
+
if (clean.some(r => rectsOverlap(cell, r))) {
|
| 964 |
+
occupied.add(`${xi},${yi}`);
|
| 965 |
+
filled.push(cell);
|
| 966 |
+
}
|
| 967 |
+
}
|
| 968 |
+
}
|
| 969 |
+
|
| 970 |
+
let h = `<g>`;
|
| 971 |
+
filled.forEach(c => {
|
| 972 |
+
h += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="${fill}"/>`;
|
| 973 |
+
});
|
| 974 |
+
|
| 975 |
+
const path = [];
|
| 976 |
+
const has = (xi, yi) => occupied.has(`${xi},${yi}`);
|
| 977 |
+
for (let yi = 0; yi < ys.length - 1; yi++) {
|
| 978 |
+
for (let xi = 0; xi < xs.length - 1; xi++) {
|
| 979 |
+
if (!has(xi, yi)) continue;
|
| 980 |
+
const x1 = xs[xi], x2 = xs[xi + 1], y1 = ys[yi], y2 = ys[yi + 1];
|
| 981 |
+
if (!has(xi, yi - 1)) path.push(`M${x1} ${y1}H${x2}`);
|
| 982 |
+
if (!has(xi + 1, yi)) path.push(`M${x2} ${y1}V${y2}`);
|
| 983 |
+
if (!has(xi, yi + 1)) path.push(`M${x2} ${y2}H${x1}`);
|
| 984 |
+
if (!has(xi - 1, yi)) path.push(`M${x1} ${y2}V${y1}`);
|
| 985 |
+
}
|
| 986 |
+
}
|
| 987 |
+
|
| 988 |
+
if (path.length) {
|
| 989 |
+
h += `<path d="${path.join('')}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
|
| 990 |
+
stroke-linejoin="round" stroke-linecap="round" ${dash ? `stroke-dasharray="${dash}"` : ''}/>`;
|
| 991 |
+
}
|
| 992 |
+
return h + `</g>`;
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
function render() {
|
| 996 |
+
const fdata = fd();
|
| 997 |
+
const W = MAP_W * SCALE, H = MAP_H * SCALE;
|
| 998 |
+
svg.setAttribute('width', W);
|
| 999 |
+
svg.setAttribute('height', H);
|
| 1000 |
+
svg.setAttribute('viewBox', `0 0 ${MAP_W} ${MAP_H}`);
|
| 1001 |
+
|
| 1002 |
+
let h = '';
|
| 1003 |
+
|
| 1004 |
+
// Background
|
| 1005 |
+
h += `<rect width="${MAP_W}" height="${MAP_H}" fill="#050c14"/>`;
|
| 1006 |
+
|
| 1007 |
+
// PGM overlay
|
| 1008 |
+
if (state.overlay.dataURL) {
|
| 1009 |
+
const ov = state.overlay;
|
| 1010 |
+
h += `<image href="${ov.dataURL}"
|
| 1011 |
+
x="${ov.ox}" y="${ov.oy}"
|
| 1012 |
+
width="${MAP_W * ov.sx}" height="${MAP_H * ov.sy}"
|
| 1013 |
+
opacity="${ov.opacity}" preserveAspectRatio="none"
|
| 1014 |
+
style="image-rendering:pixelated"/>`;
|
| 1015 |
+
}
|
| 1016 |
+
|
| 1017 |
+
// Grid
|
| 1018 |
+
h += `<g opacity="0.4">`;
|
| 1019 |
+
for (let x = 0; x <= MAP_W; x += state.gridSize)
|
| 1020 |
+
h += `<line x1="${x}" y1="0" x2="${x}" y2="${MAP_H}" stroke="#0a1e2e" stroke-width="${x % (state.gridSize*5) === 0 ? 1 : 0.5}"/>`;
|
| 1021 |
+
for (let y = 0; y <= MAP_H; y += state.gridSize)
|
| 1022 |
+
h += `<line x1="0" y1="${y}" x2="${MAP_W}" y2="${y}" stroke="#0a1e2e" stroke-width="${y % (state.gridSize*5) === 0 ? 1 : 0.5}"/>`;
|
| 1023 |
+
h += `</g>`;
|
| 1024 |
+
|
| 1025 |
+
// Corridors
|
| 1026 |
+
h += unionLayerHTML(fdata.corridors, '#0d2238', '#1a3a55', 0.9, '5 4');
|
| 1027 |
+
fdata.corridors.forEach((c, i) => {
|
| 1028 |
+
if (!isSelected('corridor', i)) return;
|
| 1029 |
+
h += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}"
|
| 1030 |
+
fill="none" stroke="#ffffff" stroke-width="2"/>`;
|
| 1031 |
+
h += selHandles(c);
|
| 1032 |
+
});
|
| 1033 |
+
|
| 1034 |
+
// Walls
|
| 1035 |
+
h += unionLayerHTML(fdata.walls, '#0a1828', '#1c3a55', 1.5);
|
| 1036 |
+
fdata.walls.forEach((w, i) => {
|
| 1037 |
+
if (!isSelected('wall', i)) return;
|
| 1038 |
+
h += `<rect x="${w.x}" y="${w.y}" width="${w.w}" height="${w.h}"
|
| 1039 |
+
fill="none" stroke="#ffffff" stroke-width="2" rx="2"/>`;
|
| 1040 |
+
h += selHandles(w);
|
| 1041 |
+
});
|
| 1042 |
+
|
| 1043 |
+
// Elevators
|
| 1044 |
+
fdata.elevators.forEach((elv, i) => {
|
| 1045 |
+
const sel = isSelected('elevator', i);
|
| 1046 |
+
h += `<rect x="${elv.x}" y="${elv.y}" width="${elv.w}" height="${elv.h}"
|
| 1047 |
+
fill="rgba(245,158,11,0.15)" stroke="${sel ? '#ffffff' : '#f59e0b'}" stroke-width="${sel ? 2 : 1.5}" rx="3"/>`;
|
| 1048 |
+
h += `<text x="${elv.x+elv.w/2}" y="${elv.y+elv.h/2-4}" text-anchor="middle"
|
| 1049 |
+
fill="${sel ? '#fff' : '#f59e0b'}" font-size="9" font-family="monospace" font-weight="600">ELV</text>`;
|
| 1050 |
+
h += `<text x="${elv.x+elv.w/2}" y="${elv.y+elv.h/2+9}" text-anchor="middle"
|
| 1051 |
+
fill="${sel ? '#fff' : '#f59e0b80'}" font-size="11" font-family="monospace">${elv.id}</text>`;
|
| 1052 |
+
if (sel) h += selHandles(elv);
|
| 1053 |
+
});
|
| 1054 |
+
|
| 1055 |
+
// Rooms
|
| 1056 |
+
fdata.rooms.forEach((r, i) => {
|
| 1057 |
+
const sel = isSelected('room', i);
|
| 1058 |
+
const c = ROOM_COLORS[r.group] || ROOM_COLORS.other;
|
| 1059 |
+
h += `<rect x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}"
|
| 1060 |
+
fill="${c.fill}" stroke="${sel ? '#ffffff' : c.stroke}" stroke-width="${sel ? 2 : 1.5}" rx="3"/>`;
|
| 1061 |
+
h += `<text x="${r.x+r.w/2}" y="${r.y+r.h/2+1}" text-anchor="middle" dominant-baseline="middle"
|
| 1062 |
+
fill="${sel ? '#fff' : c.stroke}" font-size="${Math.min(r.w, r.h) > 24 ? 10 : 8}"
|
| 1063 |
+
font-family="monospace" font-weight="600">${r.id}</text>`;
|
| 1064 |
+
const door = getDoor(r);
|
| 1065 |
+
h += `<circle cx="${door.x}" cy="${door.y}" r="3" fill="${c.stroke}70"/>`;
|
| 1066 |
+
if (sel) h += selHandles(r);
|
| 1067 |
+
});
|
| 1068 |
+
|
| 1069 |
+
// Rubber-band selection
|
| 1070 |
+
if (state.rubberBand) {
|
| 1071 |
+
const rb = state.rubberBand;
|
| 1072 |
+
const x = Math.min(rb.x0, rb.x1), y = Math.min(rb.y0, rb.y1);
|
| 1073 |
+
const w = Math.abs(rb.x1 - rb.x0), h2 = Math.abs(rb.y1 - rb.y0);
|
| 1074 |
+
h += `<rect x="${x}" y="${y}" width="${w}" height="${h2}"
|
| 1075 |
+
fill="rgba(255,255,255,0.06)" stroke="#ffffff" stroke-width="1"
|
| 1076 |
+
stroke-dasharray="4 3" rx="2"/>`;
|
| 1077 |
+
}
|
| 1078 |
+
|
| 1079 |
+
// Drawing preview
|
| 1080 |
+
if (state.drawing) {
|
| 1081 |
+
const d = state.drawing;
|
| 1082 |
+
const x = Math.min(d.x0, d.x1), y = Math.min(d.y0, d.y1);
|
| 1083 |
+
const w = Math.abs(d.x1 - d.x0), h2 = Math.abs(d.y1 - d.y0);
|
| 1084 |
+
const DCOL = { room:'#00c8e8', wall:'#1c3a55', corridor:'#1a3a55', elevator:'#f59e0b' };
|
| 1085 |
+
const DFIL = { room:'rgba(0,200,232,0.07)', wall:'rgba(28,58,85,0.35)', corridor:'rgba(13,34,56,0.4)', elevator:'rgba(245,158,11,0.1)' };
|
| 1086 |
+
h += `<rect x="${x}" y="${y}" width="${w}" height="${h2}"
|
| 1087 |
+
fill="${DFIL[d.type]||DFIL.room}" stroke="${DCOL[d.type]||DCOL.room}"
|
| 1088 |
+
stroke-width="1.5" stroke-dasharray="4 3" rx="2"/>`;
|
| 1089 |
+
h += `<text x="${x+w/2}" y="${y+h2/2+1}" text-anchor="middle" dominant-baseline="middle"
|
| 1090 |
+
fill="${DCOL[d.type]||DCOL.room}" font-size="10" font-family="monospace" opacity="0.7">
|
| 1091 |
+
${Math.round(w)}×${Math.round(h2)}</text>`;
|
| 1092 |
+
}
|
| 1093 |
+
|
| 1094 |
+
// Border
|
| 1095 |
+
h += `<rect x="0" y="0" width="${MAP_W}" height="${MAP_H}" fill="none" stroke="#1a3a55" stroke-width="1.5"/>`;
|
| 1096 |
+
|
| 1097 |
+
svg.innerHTML = h;
|
| 1098 |
+
}
|
| 1099 |
+
|
| 1100 |
+
// ──────────────────────────────────────────────────────────
|
| 1101 |
+
// EXPORT
|
| 1102 |
+
// ──────────────────────────────────────────────────────────
|
| 1103 |
+
function openExport() {
|
| 1104 |
+
const d = fd();
|
| 1105 |
+
let out = exportFloorJS(state.floor, d);
|
| 1106 |
+
|
| 1107 |
+
document.getElementById('exportText').value = out;
|
| 1108 |
+
document.getElementById('exportModal').style.display = 'flex';
|
| 1109 |
+
}
|
| 1110 |
+
|
| 1111 |
+
function exportAllFloors() {
|
| 1112 |
+
let out = `const FLOOR_MAPS = {\n`;
|
| 1113 |
+
FLOORS.forEach(f => {
|
| 1114 |
+
const d = fd(f);
|
| 1115 |
+
out += ` '${f}': {\n`;
|
| 1116 |
+
out += ` walls: [${d.walls.map(w => `{ x:${r(w.x)}, y:${r(w.y)}, w:${r(w.w)}, h:${r(w.h)} }`).join(', ')}],\n`;
|
| 1117 |
+
out += ` corridors: [${d.corridors.map(c => `{ x:${r(c.x)}, y:${r(c.y)}, w:${r(c.w)}, h:${r(c.h)} }`).join(', ')}],\n`;
|
| 1118 |
+
out += ` rooms: [${d.rooms.map(rm => `{ id:'${rm.id}', x:${r(rm.x)}, y:${r(rm.y)}, w:${r(rm.w)}, h:${r(rm.h)}, group:'${rm.group}' }`).join(', ')}],\n`;
|
| 1119 |
+
out += ` elevators: [${d.elevators.map(e => `{ id:'${e.id}', x:${r(e.x)}, y:${r(e.y)}, w:${r(e.w)}, h:${r(e.h)} }`).join(', ')}],\n`;
|
| 1120 |
+
out += ` },\n`;
|
| 1121 |
+
});
|
| 1122 |
+
out += `};\n`;
|
| 1123 |
+
|
| 1124 |
+
document.getElementById('exportText').value = out;
|
| 1125 |
+
document.getElementById('exportModal').style.display = 'flex';
|
| 1126 |
+
}
|
| 1127 |
+
|
| 1128 |
+
function exportFloorJS(floor, d) {
|
| 1129 |
+
let out = `// Floor: ${floor} — generated by Map Editor\n\n`;
|
| 1130 |
+
if (d.walls.length) {
|
| 1131 |
+
out += `const WALLS_DEF = [\n`;
|
| 1132 |
+
d.walls.forEach(w => out += ` { x:${r(w.x)}, y:${r(w.y)}, w:${r(w.w)}, h:${r(w.h)} },\n`);
|
| 1133 |
+
out += `];\n\n`;
|
| 1134 |
+
}
|
| 1135 |
+
|
| 1136 |
+
if (d.corridors.length) {
|
| 1137 |
+
out += `const CORRIDORS_DEF = [\n`;
|
| 1138 |
+
d.corridors.forEach(c => out += ` { x:${r(c.x)}, y:${r(c.y)}, w:${r(c.w)}, h:${r(c.h)} },\n`);
|
| 1139 |
+
out += `];\n\n`;
|
| 1140 |
+
}
|
| 1141 |
+
|
| 1142 |
+
out += `const ROOMS_DEF = [\n`;
|
| 1143 |
+
d.rooms.forEach(rm => out += ` { id:'${rm.id}', x:${r(rm.x)}, y:${r(rm.y)}, w:${r(rm.w)}, h:${r(rm.h)}, group:'${rm.group}' },\n`);
|
| 1144 |
+
out += `];\n`;
|
| 1145 |
+
|
| 1146 |
+
if (d.elevators.length) {
|
| 1147 |
+
out += `\nconst ELVS_DEF = [\n`;
|
| 1148 |
+
d.elevators.forEach(e => out += ` { id:'${e.id}', x:${r(e.x)}, y:${r(e.y)}, w:${r(e.w)}, h:${r(e.h)} },\n`);
|
| 1149 |
+
out += `];\n`;
|
| 1150 |
+
}
|
| 1151 |
+
return out;
|
| 1152 |
+
}
|
| 1153 |
+
|
| 1154 |
+
const r = v => Math.round(v);
|
| 1155 |
+
|
| 1156 |
+
// ──────────────────────────────────────────────────────────
|
| 1157 |
+
// PGM OVERLAY
|
| 1158 |
+
// ──────────────────────────────────────────────────────────
|
| 1159 |
+
function loadPGM(input) {
|
| 1160 |
+
const file = input.files[0];
|
| 1161 |
+
if (!file) return;
|
| 1162 |
+
const reader = new FileReader();
|
| 1163 |
+
reader.onload = e => {
|
| 1164 |
+
try {
|
| 1165 |
+
const dataURL = parsePGMtoDataURL(e.target.result);
|
| 1166 |
+
state.overlay.dataURL = dataURL;
|
| 1167 |
+
// auto-fit: reset transform
|
| 1168 |
+
state.overlay.ox = 0; state.overlay.oy = 0;
|
| 1169 |
+
state.overlay.sx = 1; state.overlay.sy = 1;
|
| 1170 |
+
document.getElementById('ovOX').value = 0;
|
| 1171 |
+
document.getElementById('ovOY').value = 0;
|
| 1172 |
+
document.getElementById('ovSX').value = 1;
|
| 1173 |
+
document.getElementById('ovSY').value = 1;
|
| 1174 |
+
document.getElementById('overlaySection').style.display = 'block';
|
| 1175 |
+
render();
|
| 1176 |
+
} catch(err) { alert('Failed to load PGM: ' + err.message); }
|
| 1177 |
+
};
|
| 1178 |
+
reader.readAsArrayBuffer(file);
|
| 1179 |
+
input.value = '';
|
| 1180 |
+
}
|
| 1181 |
+
|
| 1182 |
+
function parsePGMtoDataURL(buffer) {
|
| 1183 |
+
const bytes = new Uint8Array(buffer);
|
| 1184 |
+
let pos = 0;
|
| 1185 |
+
|
| 1186 |
+
const readToken = () => {
|
| 1187 |
+
while (pos < bytes.length) {
|
| 1188 |
+
if (bytes[pos] === 35) { // '#' comment
|
| 1189 |
+
while (pos < bytes.length && bytes[pos] !== 10) pos++;
|
| 1190 |
+
} else if (bytes[pos] <= 32) { pos++; } // whitespace
|
| 1191 |
+
else break;
|
| 1192 |
+
}
|
| 1193 |
+
let s = '';
|
| 1194 |
+
while (pos < bytes.length && bytes[pos] > 32) s += String.fromCharCode(bytes[pos++]);
|
| 1195 |
+
return s;
|
| 1196 |
+
};
|
| 1197 |
+
|
| 1198 |
+
const magic = readToken();
|
| 1199 |
+
if (magic !== 'P5' && magic !== 'P2') throw new Error('Not a valid PGM (expected P5 or P2, got ' + magic + ')');
|
| 1200 |
+
|
| 1201 |
+
const w = parseInt(readToken());
|
| 1202 |
+
const h = parseInt(readToken());
|
| 1203 |
+
const maxval = parseInt(readToken());
|
| 1204 |
+
if (magic === 'P5') pos++; // single whitespace separator after header
|
| 1205 |
+
|
| 1206 |
+
const canvas = document.createElement('canvas');
|
| 1207 |
+
canvas.width = w; canvas.height = h;
|
| 1208 |
+
const ctx = canvas.getContext('2d');
|
| 1209 |
+
const img = ctx.createImageData(w, h);
|
| 1210 |
+
|
| 1211 |
+
const total = w * h;
|
| 1212 |
+
if (magic === 'P5') {
|
| 1213 |
+
const wide = maxval > 255;
|
| 1214 |
+
for (let i = 0; i < total; i++) {
|
| 1215 |
+
const raw = wide ? ((bytes[pos] << 8) | bytes[pos + 1]) : bytes[pos];
|
| 1216 |
+
const v = Math.round((raw / maxval) * 255);
|
| 1217 |
+
const p = i * 4;
|
| 1218 |
+
img.data[p] = img.data[p+1] = img.data[p+2] = v;
|
| 1219 |
+
img.data[p+3] = 255;
|
| 1220 |
+
pos += wide ? 2 : 1;
|
| 1221 |
+
}
|
| 1222 |
+
} else {
|
| 1223 |
+
for (let i = 0; i < total; i++) {
|
| 1224 |
+
const v = Math.round((parseInt(readToken()) / maxval) * 255);
|
| 1225 |
+
const p = i * 4;
|
| 1226 |
+
img.data[p] = img.data[p+1] = img.data[p+2] = v;
|
| 1227 |
+
img.data[p+3] = 255;
|
| 1228 |
+
}
|
| 1229 |
+
}
|
| 1230 |
+
|
| 1231 |
+
ctx.putImageData(img, 0, 0);
|
| 1232 |
+
return canvas.toDataURL('image/png');
|
| 1233 |
+
}
|
| 1234 |
+
|
| 1235 |
+
function clearOverlay() {
|
| 1236 |
+
state.overlay.dataURL = null;
|
| 1237 |
+
document.getElementById('overlaySection').style.display = 'none';
|
| 1238 |
+
render();
|
| 1239 |
+
}
|
| 1240 |
+
|
| 1241 |
+
function copyExport() {
|
| 1242 |
+
const ta = document.getElementById('exportText');
|
| 1243 |
+
ta.select();
|
| 1244 |
+
document.execCommand('copy');
|
| 1245 |
+
const btn = document.getElementById('copyBtn');
|
| 1246 |
+
btn.textContent = '✓ Copied!';
|
| 1247 |
+
setTimeout(() => btn.textContent = 'Copy to clipboard', 2000);
|
| 1248 |
+
}
|
| 1249 |
+
|
| 1250 |
+
// ──────────────────────────────────────────────────────────
|
| 1251 |
+
// IMPORT
|
| 1252 |
+
// ──────────────────────────────────────────────────────────
|
| 1253 |
+
function openImport() {
|
| 1254 |
+
document.getElementById('importText').value = '';
|
| 1255 |
+
document.getElementById('importModal').style.display = 'flex';
|
| 1256 |
+
}
|
| 1257 |
+
|
| 1258 |
+
function doImport() {
|
| 1259 |
+
const raw = document.getElementById('importText').value.trim();
|
| 1260 |
+
try {
|
| 1261 |
+
const arr = new Function('return ' + raw)();
|
| 1262 |
+
if (!Array.isArray(arr)) throw new Error('Expected an array');
|
| 1263 |
+
saveHistory();
|
| 1264 |
+
const d = fd();
|
| 1265 |
+
d.rooms = arr.map(rm => ({
|
| 1266 |
+
id: rm.id || 'R??', x: rm.x || 0, y: rm.y || 0,
|
| 1267 |
+
w: rm.w || 60, h: rm.h || 50, group: rm.group || 'top',
|
| 1268 |
+
}));
|
| 1269 |
+
const nums = d.rooms.map(rm => parseInt(rm.id.replace(/\D/g,''))).filter(n => !isNaN(n));
|
| 1270 |
+
state.roomCtr[state.floor] = nums.length ? Math.max(...nums) + 1 : d.rooms.length + 1;
|
| 1271 |
+
closeModal('importModal');
|
| 1272 |
+
render(); updateStats();
|
| 1273 |
+
} catch(err) {
|
| 1274 |
+
alert('Parse error: ' + err.message);
|
| 1275 |
+
}
|
| 1276 |
+
}
|
| 1277 |
+
|
| 1278 |
+
function closeModal(id) {
|
| 1279 |
+
document.getElementById(id).style.display = 'none';
|
| 1280 |
+
}
|
| 1281 |
+
|
| 1282 |
+
// Close modals on overlay click
|
| 1283 |
+
document.querySelectorAll('.overlay').forEach(el => {
|
| 1284 |
+
el.addEventListener('click', e => { if (e.target === el) el.style.display = 'none'; });
|
| 1285 |
+
});
|
| 1286 |
+
|
| 1287 |
+
// ──────────────────────────────────────────────────────────
|
| 1288 |
+
// LOCALSTORAGE PERSIST
|
| 1289 |
+
// ──────────────────────────────────────────────────────────
|
| 1290 |
+
function save() {
|
| 1291 |
+
try {
|
| 1292 |
+
localStorage.setItem('mapedit_floors', JSON.stringify(state.floors));
|
| 1293 |
+
localStorage.setItem('mapedit_roomctr', JSON.stringify(state.roomCtr));
|
| 1294 |
+
localStorage.setItem('mapedit_elvctr', JSON.stringify(state.elvCtr));
|
| 1295 |
+
} catch {}
|
| 1296 |
+
}
|
| 1297 |
+
|
| 1298 |
+
function load() {
|
| 1299 |
+
try {
|
| 1300 |
+
const f = localStorage.getItem('mapedit_floors');
|
| 1301 |
+
if (f) state.floors = JSON.parse(f);
|
| 1302 |
+
const rc = localStorage.getItem('mapedit_roomctr');
|
| 1303 |
+
if (rc) state.roomCtr = JSON.parse(rc);
|
| 1304 |
+
const ec = localStorage.getItem('mapedit_elvctr');
|
| 1305 |
+
if (ec) state.elvCtr = JSON.parse(ec);
|
| 1306 |
+
migrateFloorLabels();
|
| 1307 |
+
} catch {}
|
| 1308 |
+
}
|
| 1309 |
+
|
| 1310 |
+
function migrateFloorLabels() {
|
| 1311 |
+
if (!state.floors.G) return;
|
| 1312 |
+
const remap = { G:'1', '1':'2', '2':'3', '3':'4', '4':'5', '5':'6', '6':'7', '7':'8', '8':'9', '9':'10' };
|
| 1313 |
+
const oldFloors = state.floors;
|
| 1314 |
+
const oldRoomCtr = state.roomCtr;
|
| 1315 |
+
const oldElvCtr = state.elvCtr;
|
| 1316 |
+
state.floors = {};
|
| 1317 |
+
state.roomCtr = {};
|
| 1318 |
+
state.elvCtr = {};
|
| 1319 |
+
Object.entries(remap).forEach(([oldKey, newKey]) => {
|
| 1320 |
+
if (oldFloors[oldKey]) state.floors[newKey] = oldFloors[oldKey];
|
| 1321 |
+
if (oldRoomCtr[oldKey]) state.roomCtr[newKey] = oldRoomCtr[oldKey];
|
| 1322 |
+
if (oldElvCtr[oldKey]) state.elvCtr[newKey] = oldElvCtr[oldKey];
|
| 1323 |
+
});
|
| 1324 |
+
state.floor = '1';
|
| 1325 |
+
save();
|
| 1326 |
+
}
|
| 1327 |
+
|
| 1328 |
+
setInterval(save, 8000);
|
| 1329 |
+
window.addEventListener('beforeunload', save);
|
| 1330 |
+
|
| 1331 |
+
// Auto-fit scale to canvas wrap
|
| 1332 |
+
function fitScale() {
|
| 1333 |
+
const wrap = document.getElementById('canvasWrap');
|
| 1334 |
+
const padded = Math.min(
|
| 1335 |
+
(wrap.clientWidth - 48) / MAP_W,
|
| 1336 |
+
(wrap.clientHeight - 48) / MAP_H,
|
| 1337 |
+
);
|
| 1338 |
+
SCALE = Math.max(0.6, Math.min(2.5, padded));
|
| 1339 |
+
}
|
| 1340 |
+
|
| 1341 |
+
// ──────────────────────────────────────────────────────────
|
| 1342 |
+
// INIT
|
| 1343 |
+
// ──────────────────────────────────────────────────────────
|
| 1344 |
+
load();
|
| 1345 |
+
fitScale();
|
| 1346 |
+
renderFloorTabs();
|
| 1347 |
+
render();
|
| 1348 |
+
updateStats();
|
| 1349 |
+
setDefaultGroup(state.defaultGroup);
|
| 1350 |
+
</script>
|
| 1351 |
+
</body>
|
| 1352 |
+
</html>
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.111.0
|
| 2 |
+
uvicorn[standard]>=0.29.0
|
| 3 |
+
sse-starlette>=1.8.2
|
| 4 |
+
pydantic>=2.7.0
|
| 5 |
+
supabase>=2.4.0
|
| 6 |
+
httpx>=0.27.0
|
| 7 |
+
Pillow>=10.0.0
|
static/OrbitControls.js
ADDED
|
@@ -0,0 +1,1506 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
EventDispatcher,
|
| 3 |
+
MOUSE,
|
| 4 |
+
Quaternion,
|
| 5 |
+
Spherical,
|
| 6 |
+
TOUCH,
|
| 7 |
+
Vector2,
|
| 8 |
+
Vector3,
|
| 9 |
+
Plane,
|
| 10 |
+
Ray,
|
| 11 |
+
MathUtils
|
| 12 |
+
} from 'three';
|
| 13 |
+
|
| 14 |
+
// OrbitControls performs orbiting, dollying (zooming), and panning.
|
| 15 |
+
// Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default).
|
| 16 |
+
//
|
| 17 |
+
// Orbit - left mouse / touch: one-finger move
|
| 18 |
+
// Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish
|
| 19 |
+
// Pan - right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move
|
| 20 |
+
|
| 21 |
+
const _changeEvent = { type: 'change' };
|
| 22 |
+
const _startEvent = { type: 'start' };
|
| 23 |
+
const _endEvent = { type: 'end' };
|
| 24 |
+
const _ray = new Ray();
|
| 25 |
+
const _plane = new Plane();
|
| 26 |
+
const TILT_LIMIT = Math.cos( 70 * MathUtils.DEG2RAD );
|
| 27 |
+
|
| 28 |
+
class OrbitControls extends EventDispatcher {
|
| 29 |
+
|
| 30 |
+
constructor( object, domElement ) {
|
| 31 |
+
|
| 32 |
+
super();
|
| 33 |
+
|
| 34 |
+
this.object = object;
|
| 35 |
+
this.domElement = domElement;
|
| 36 |
+
this.domElement.style.touchAction = 'none'; // disable touch scroll
|
| 37 |
+
|
| 38 |
+
// Set to false to disable this control
|
| 39 |
+
this.enabled = true;
|
| 40 |
+
|
| 41 |
+
// "target" sets the location of focus, where the object orbits around
|
| 42 |
+
this.target = new Vector3();
|
| 43 |
+
|
| 44 |
+
// Sets the 3D cursor (similar to Blender), from which the maxTargetRadius takes effect
|
| 45 |
+
this.cursor = new Vector3();
|
| 46 |
+
|
| 47 |
+
// How far you can dolly in and out ( PerspectiveCamera only )
|
| 48 |
+
this.minDistance = 0;
|
| 49 |
+
this.maxDistance = Infinity;
|
| 50 |
+
|
| 51 |
+
// How far you can zoom in and out ( OrthographicCamera only )
|
| 52 |
+
this.minZoom = 0;
|
| 53 |
+
this.maxZoom = Infinity;
|
| 54 |
+
|
| 55 |
+
// Limit camera target within a spherical area around the cursor
|
| 56 |
+
this.minTargetRadius = 0;
|
| 57 |
+
this.maxTargetRadius = Infinity;
|
| 58 |
+
|
| 59 |
+
// How far you can orbit vertically, upper and lower limits.
|
| 60 |
+
// Range is 0 to Math.PI radians.
|
| 61 |
+
this.minPolarAngle = 0; // radians
|
| 62 |
+
this.maxPolarAngle = Math.PI; // radians
|
| 63 |
+
|
| 64 |
+
// How far you can orbit horizontally, upper and lower limits.
|
| 65 |
+
// If set, the interval [ min, max ] must be a sub-interval of [ - 2 PI, 2 PI ], with ( max - min < 2 PI )
|
| 66 |
+
this.minAzimuthAngle = - Infinity; // radians
|
| 67 |
+
this.maxAzimuthAngle = Infinity; // radians
|
| 68 |
+
|
| 69 |
+
// Set to true to enable damping (inertia)
|
| 70 |
+
// If damping is enabled, you must call controls.update() in your animation loop
|
| 71 |
+
this.enableDamping = false;
|
| 72 |
+
this.dampingFactor = 0.05;
|
| 73 |
+
|
| 74 |
+
// This option actually enables dollying in and out; left as "zoom" for backwards compatibility.
|
| 75 |
+
// Set to false to disable zooming
|
| 76 |
+
this.enableZoom = true;
|
| 77 |
+
this.zoomSpeed = 1.0;
|
| 78 |
+
|
| 79 |
+
// Set to false to disable rotating
|
| 80 |
+
this.enableRotate = true;
|
| 81 |
+
this.rotateSpeed = 1.0;
|
| 82 |
+
|
| 83 |
+
// Set to false to disable panning
|
| 84 |
+
this.enablePan = true;
|
| 85 |
+
this.panSpeed = 1.0;
|
| 86 |
+
this.screenSpacePanning = true; // if false, pan orthogonal to world-space direction camera.up
|
| 87 |
+
this.keyPanSpeed = 7.0; // pixels moved per arrow key push
|
| 88 |
+
this.zoomToCursor = false;
|
| 89 |
+
|
| 90 |
+
// Set to true to automatically rotate around the target
|
| 91 |
+
// If auto-rotate is enabled, you must call controls.update() in your animation loop
|
| 92 |
+
this.autoRotate = false;
|
| 93 |
+
this.autoRotateSpeed = 2.0; // 30 seconds per orbit when fps is 60
|
| 94 |
+
|
| 95 |
+
// The four arrow keys
|
| 96 |
+
this.keys = { LEFT: 'ArrowLeft', UP: 'ArrowUp', RIGHT: 'ArrowRight', BOTTOM: 'ArrowDown' };
|
| 97 |
+
|
| 98 |
+
// Mouse buttons
|
| 99 |
+
this.mouseButtons = { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.DOLLY, RIGHT: MOUSE.PAN };
|
| 100 |
+
|
| 101 |
+
// Touch fingers
|
| 102 |
+
this.touches = { ONE: TOUCH.ROTATE, TWO: TOUCH.DOLLY_PAN };
|
| 103 |
+
|
| 104 |
+
// for reset
|
| 105 |
+
this.target0 = this.target.clone();
|
| 106 |
+
this.position0 = this.object.position.clone();
|
| 107 |
+
this.zoom0 = this.object.zoom;
|
| 108 |
+
|
| 109 |
+
// the target DOM element for key events
|
| 110 |
+
this._domElementKeyEvents = null;
|
| 111 |
+
|
| 112 |
+
//
|
| 113 |
+
// public methods
|
| 114 |
+
//
|
| 115 |
+
|
| 116 |
+
this.getPolarAngle = function () {
|
| 117 |
+
|
| 118 |
+
return spherical.phi;
|
| 119 |
+
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
this.getAzimuthalAngle = function () {
|
| 123 |
+
|
| 124 |
+
return spherical.theta;
|
| 125 |
+
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
this.getDistance = function () {
|
| 129 |
+
|
| 130 |
+
return this.object.position.distanceTo( this.target );
|
| 131 |
+
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
this.listenToKeyEvents = function ( domElement ) {
|
| 135 |
+
|
| 136 |
+
domElement.addEventListener( 'keydown', onKeyDown );
|
| 137 |
+
this._domElementKeyEvents = domElement;
|
| 138 |
+
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
this.stopListenToKeyEvents = function () {
|
| 142 |
+
|
| 143 |
+
this._domElementKeyEvents.removeEventListener( 'keydown', onKeyDown );
|
| 144 |
+
this._domElementKeyEvents = null;
|
| 145 |
+
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
this.saveState = function () {
|
| 149 |
+
|
| 150 |
+
scope.target0.copy( scope.target );
|
| 151 |
+
scope.position0.copy( scope.object.position );
|
| 152 |
+
scope.zoom0 = scope.object.zoom;
|
| 153 |
+
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
this.reset = function () {
|
| 157 |
+
|
| 158 |
+
scope.target.copy( scope.target0 );
|
| 159 |
+
scope.object.position.copy( scope.position0 );
|
| 160 |
+
scope.object.zoom = scope.zoom0;
|
| 161 |
+
|
| 162 |
+
scope.object.updateProjectionMatrix();
|
| 163 |
+
scope.dispatchEvent( _changeEvent );
|
| 164 |
+
|
| 165 |
+
scope.update();
|
| 166 |
+
|
| 167 |
+
state = STATE.NONE;
|
| 168 |
+
|
| 169 |
+
};
|
| 170 |
+
|
| 171 |
+
// this method is exposed, but perhaps it would be better if we can make it private...
|
| 172 |
+
this.update = function () {
|
| 173 |
+
|
| 174 |
+
const offset = new Vector3();
|
| 175 |
+
|
| 176 |
+
// so camera.up is the orbit axis
|
| 177 |
+
const quat = new Quaternion().setFromUnitVectors( object.up, new Vector3( 0, 1, 0 ) );
|
| 178 |
+
const quatInverse = quat.clone().invert();
|
| 179 |
+
|
| 180 |
+
const lastPosition = new Vector3();
|
| 181 |
+
const lastQuaternion = new Quaternion();
|
| 182 |
+
const lastTargetPosition = new Vector3();
|
| 183 |
+
|
| 184 |
+
const twoPI = 2 * Math.PI;
|
| 185 |
+
|
| 186 |
+
return function update( deltaTime = null ) {
|
| 187 |
+
|
| 188 |
+
const position = scope.object.position;
|
| 189 |
+
|
| 190 |
+
offset.copy( position ).sub( scope.target );
|
| 191 |
+
|
| 192 |
+
// rotate offset to "y-axis-is-up" space
|
| 193 |
+
offset.applyQuaternion( quat );
|
| 194 |
+
|
| 195 |
+
// angle from z-axis around y-axis
|
| 196 |
+
spherical.setFromVector3( offset );
|
| 197 |
+
|
| 198 |
+
if ( scope.autoRotate && state === STATE.NONE ) {
|
| 199 |
+
|
| 200 |
+
rotateLeft( getAutoRotationAngle( deltaTime ) );
|
| 201 |
+
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
if ( scope.enableDamping ) {
|
| 205 |
+
|
| 206 |
+
spherical.theta += sphericalDelta.theta * scope.dampingFactor;
|
| 207 |
+
spherical.phi += sphericalDelta.phi * scope.dampingFactor;
|
| 208 |
+
|
| 209 |
+
} else {
|
| 210 |
+
|
| 211 |
+
spherical.theta += sphericalDelta.theta;
|
| 212 |
+
spherical.phi += sphericalDelta.phi;
|
| 213 |
+
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
// restrict theta to be between desired limits
|
| 217 |
+
|
| 218 |
+
let min = scope.minAzimuthAngle;
|
| 219 |
+
let max = scope.maxAzimuthAngle;
|
| 220 |
+
|
| 221 |
+
if ( isFinite( min ) && isFinite( max ) ) {
|
| 222 |
+
|
| 223 |
+
if ( min < - Math.PI ) min += twoPI; else if ( min > Math.PI ) min -= twoPI;
|
| 224 |
+
|
| 225 |
+
if ( max < - Math.PI ) max += twoPI; else if ( max > Math.PI ) max -= twoPI;
|
| 226 |
+
|
| 227 |
+
if ( min <= max ) {
|
| 228 |
+
|
| 229 |
+
spherical.theta = Math.max( min, Math.min( max, spherical.theta ) );
|
| 230 |
+
|
| 231 |
+
} else {
|
| 232 |
+
|
| 233 |
+
spherical.theta = ( spherical.theta > ( min + max ) / 2 ) ?
|
| 234 |
+
Math.max( min, spherical.theta ) :
|
| 235 |
+
Math.min( max, spherical.theta );
|
| 236 |
+
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
// restrict phi to be between desired limits
|
| 242 |
+
spherical.phi = Math.max( scope.minPolarAngle, Math.min( scope.maxPolarAngle, spherical.phi ) );
|
| 243 |
+
|
| 244 |
+
spherical.makeSafe();
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
// move target to panned location
|
| 248 |
+
|
| 249 |
+
if ( scope.enableDamping === true ) {
|
| 250 |
+
|
| 251 |
+
scope.target.addScaledVector( panOffset, scope.dampingFactor );
|
| 252 |
+
|
| 253 |
+
} else {
|
| 254 |
+
|
| 255 |
+
scope.target.add( panOffset );
|
| 256 |
+
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// Limit the target distance from the cursor to create a sphere around the center of interest
|
| 260 |
+
scope.target.sub( scope.cursor );
|
| 261 |
+
scope.target.clampLength( scope.minTargetRadius, scope.maxTargetRadius );
|
| 262 |
+
scope.target.add( scope.cursor );
|
| 263 |
+
|
| 264 |
+
// adjust the camera position based on zoom only if we're not zooming to the cursor or if it's an ortho camera
|
| 265 |
+
// we adjust zoom later in these cases
|
| 266 |
+
if ( scope.zoomToCursor && performCursorZoom || scope.object.isOrthographicCamera ) {
|
| 267 |
+
|
| 268 |
+
spherical.radius = clampDistance( spherical.radius );
|
| 269 |
+
|
| 270 |
+
} else {
|
| 271 |
+
|
| 272 |
+
spherical.radius = clampDistance( spherical.radius * scale );
|
| 273 |
+
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
offset.setFromSpherical( spherical );
|
| 277 |
+
|
| 278 |
+
// rotate offset back to "camera-up-vector-is-up" space
|
| 279 |
+
offset.applyQuaternion( quatInverse );
|
| 280 |
+
|
| 281 |
+
position.copy( scope.target ).add( offset );
|
| 282 |
+
|
| 283 |
+
scope.object.lookAt( scope.target );
|
| 284 |
+
|
| 285 |
+
if ( scope.enableDamping === true ) {
|
| 286 |
+
|
| 287 |
+
sphericalDelta.theta *= ( 1 - scope.dampingFactor );
|
| 288 |
+
sphericalDelta.phi *= ( 1 - scope.dampingFactor );
|
| 289 |
+
|
| 290 |
+
panOffset.multiplyScalar( 1 - scope.dampingFactor );
|
| 291 |
+
|
| 292 |
+
} else {
|
| 293 |
+
|
| 294 |
+
sphericalDelta.set( 0, 0, 0 );
|
| 295 |
+
|
| 296 |
+
panOffset.set( 0, 0, 0 );
|
| 297 |
+
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
// adjust camera position
|
| 301 |
+
let zoomChanged = false;
|
| 302 |
+
if ( scope.zoomToCursor && performCursorZoom ) {
|
| 303 |
+
|
| 304 |
+
let newRadius = null;
|
| 305 |
+
if ( scope.object.isPerspectiveCamera ) {
|
| 306 |
+
|
| 307 |
+
// move the camera down the pointer ray
|
| 308 |
+
// this method avoids floating point error
|
| 309 |
+
const prevRadius = offset.length();
|
| 310 |
+
newRadius = clampDistance( prevRadius * scale );
|
| 311 |
+
|
| 312 |
+
const radiusDelta = prevRadius - newRadius;
|
| 313 |
+
scope.object.position.addScaledVector( dollyDirection, radiusDelta );
|
| 314 |
+
scope.object.updateMatrixWorld();
|
| 315 |
+
|
| 316 |
+
} else if ( scope.object.isOrthographicCamera ) {
|
| 317 |
+
|
| 318 |
+
// adjust the ortho camera position based on zoom changes
|
| 319 |
+
const mouseBefore = new Vector3( mouse.x, mouse.y, 0 );
|
| 320 |
+
mouseBefore.unproject( scope.object );
|
| 321 |
+
|
| 322 |
+
scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / scale ) );
|
| 323 |
+
scope.object.updateProjectionMatrix();
|
| 324 |
+
zoomChanged = true;
|
| 325 |
+
|
| 326 |
+
const mouseAfter = new Vector3( mouse.x, mouse.y, 0 );
|
| 327 |
+
mouseAfter.unproject( scope.object );
|
| 328 |
+
|
| 329 |
+
scope.object.position.sub( mouseAfter ).add( mouseBefore );
|
| 330 |
+
scope.object.updateMatrixWorld();
|
| 331 |
+
|
| 332 |
+
newRadius = offset.length();
|
| 333 |
+
|
| 334 |
+
} else {
|
| 335 |
+
|
| 336 |
+
console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - zoom to cursor disabled.' );
|
| 337 |
+
scope.zoomToCursor = false;
|
| 338 |
+
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
// handle the placement of the target
|
| 342 |
+
if ( newRadius !== null ) {
|
| 343 |
+
|
| 344 |
+
if ( this.screenSpacePanning ) {
|
| 345 |
+
|
| 346 |
+
// position the orbit target in front of the new camera position
|
| 347 |
+
scope.target.set( 0, 0, - 1 )
|
| 348 |
+
.transformDirection( scope.object.matrix )
|
| 349 |
+
.multiplyScalar( newRadius )
|
| 350 |
+
.add( scope.object.position );
|
| 351 |
+
|
| 352 |
+
} else {
|
| 353 |
+
|
| 354 |
+
// get the ray and translation plane to compute target
|
| 355 |
+
_ray.origin.copy( scope.object.position );
|
| 356 |
+
_ray.direction.set( 0, 0, - 1 ).transformDirection( scope.object.matrix );
|
| 357 |
+
|
| 358 |
+
// if the camera is 20 degrees above the horizon then don't adjust the focus target to avoid
|
| 359 |
+
// extremely large values
|
| 360 |
+
if ( Math.abs( scope.object.up.dot( _ray.direction ) ) < TILT_LIMIT ) {
|
| 361 |
+
|
| 362 |
+
object.lookAt( scope.target );
|
| 363 |
+
|
| 364 |
+
} else {
|
| 365 |
+
|
| 366 |
+
_plane.setFromNormalAndCoplanarPoint( scope.object.up, scope.target );
|
| 367 |
+
_ray.intersectPlane( _plane, scope.target );
|
| 368 |
+
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
} else if ( scope.object.isOrthographicCamera ) {
|
| 376 |
+
|
| 377 |
+
zoomChanged = scale !== 1;
|
| 378 |
+
|
| 379 |
+
if ( zoomChanged ) {
|
| 380 |
+
|
| 381 |
+
scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / scale ) );
|
| 382 |
+
scope.object.updateProjectionMatrix();
|
| 383 |
+
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
scale = 1;
|
| 389 |
+
performCursorZoom = false;
|
| 390 |
+
|
| 391 |
+
// update condition is:
|
| 392 |
+
// min(camera displacement, camera rotation in radians)^2 > EPS
|
| 393 |
+
// using small-angle approximation cos(x/2) = 1 - x^2 / 8
|
| 394 |
+
|
| 395 |
+
if ( zoomChanged ||
|
| 396 |
+
lastPosition.distanceToSquared( scope.object.position ) > EPS ||
|
| 397 |
+
8 * ( 1 - lastQuaternion.dot( scope.object.quaternion ) ) > EPS ||
|
| 398 |
+
lastTargetPosition.distanceToSquared( scope.target ) > 0 ) {
|
| 399 |
+
|
| 400 |
+
scope.dispatchEvent( _changeEvent );
|
| 401 |
+
|
| 402 |
+
lastPosition.copy( scope.object.position );
|
| 403 |
+
lastQuaternion.copy( scope.object.quaternion );
|
| 404 |
+
lastTargetPosition.copy( scope.target );
|
| 405 |
+
|
| 406 |
+
return true;
|
| 407 |
+
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
return false;
|
| 411 |
+
|
| 412 |
+
};
|
| 413 |
+
|
| 414 |
+
}();
|
| 415 |
+
|
| 416 |
+
this.dispose = function () {
|
| 417 |
+
|
| 418 |
+
scope.domElement.removeEventListener( 'contextmenu', onContextMenu );
|
| 419 |
+
|
| 420 |
+
scope.domElement.removeEventListener( 'pointerdown', onPointerDown );
|
| 421 |
+
scope.domElement.removeEventListener( 'pointercancel', onPointerUp );
|
| 422 |
+
scope.domElement.removeEventListener( 'wheel', onMouseWheel );
|
| 423 |
+
|
| 424 |
+
scope.domElement.removeEventListener( 'pointermove', onPointerMove );
|
| 425 |
+
scope.domElement.removeEventListener( 'pointerup', onPointerUp );
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
if ( scope._domElementKeyEvents !== null ) {
|
| 429 |
+
|
| 430 |
+
scope._domElementKeyEvents.removeEventListener( 'keydown', onKeyDown );
|
| 431 |
+
scope._domElementKeyEvents = null;
|
| 432 |
+
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
//scope.dispatchEvent( { type: 'dispose' } ); // should this be added here?
|
| 436 |
+
|
| 437 |
+
};
|
| 438 |
+
|
| 439 |
+
//
|
| 440 |
+
// internals
|
| 441 |
+
//
|
| 442 |
+
|
| 443 |
+
const scope = this;
|
| 444 |
+
|
| 445 |
+
const STATE = {
|
| 446 |
+
NONE: - 1,
|
| 447 |
+
ROTATE: 0,
|
| 448 |
+
DOLLY: 1,
|
| 449 |
+
PAN: 2,
|
| 450 |
+
TOUCH_ROTATE: 3,
|
| 451 |
+
TOUCH_PAN: 4,
|
| 452 |
+
TOUCH_DOLLY_PAN: 5,
|
| 453 |
+
TOUCH_DOLLY_ROTATE: 6
|
| 454 |
+
};
|
| 455 |
+
|
| 456 |
+
let state = STATE.NONE;
|
| 457 |
+
|
| 458 |
+
const EPS = 0.000001;
|
| 459 |
+
|
| 460 |
+
// current position in spherical coordinates
|
| 461 |
+
const spherical = new Spherical();
|
| 462 |
+
const sphericalDelta = new Spherical();
|
| 463 |
+
|
| 464 |
+
let scale = 1;
|
| 465 |
+
const panOffset = new Vector3();
|
| 466 |
+
|
| 467 |
+
const rotateStart = new Vector2();
|
| 468 |
+
const rotateEnd = new Vector2();
|
| 469 |
+
const rotateDelta = new Vector2();
|
| 470 |
+
|
| 471 |
+
const panStart = new Vector2();
|
| 472 |
+
const panEnd = new Vector2();
|
| 473 |
+
const panDelta = new Vector2();
|
| 474 |
+
|
| 475 |
+
const dollyStart = new Vector2();
|
| 476 |
+
const dollyEnd = new Vector2();
|
| 477 |
+
const dollyDelta = new Vector2();
|
| 478 |
+
|
| 479 |
+
const dollyDirection = new Vector3();
|
| 480 |
+
const mouse = new Vector2();
|
| 481 |
+
let performCursorZoom = false;
|
| 482 |
+
|
| 483 |
+
const pointers = [];
|
| 484 |
+
const pointerPositions = {};
|
| 485 |
+
|
| 486 |
+
let controlActive = false;
|
| 487 |
+
|
| 488 |
+
function getAutoRotationAngle( deltaTime ) {
|
| 489 |
+
|
| 490 |
+
if ( deltaTime !== null ) {
|
| 491 |
+
|
| 492 |
+
return ( 2 * Math.PI / 60 * scope.autoRotateSpeed ) * deltaTime;
|
| 493 |
+
|
| 494 |
+
} else {
|
| 495 |
+
|
| 496 |
+
return 2 * Math.PI / 60 / 60 * scope.autoRotateSpeed;
|
| 497 |
+
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
function getZoomScale( delta ) {
|
| 503 |
+
|
| 504 |
+
const normalizedDelta = Math.abs( delta * 0.01 );
|
| 505 |
+
return Math.pow( 0.95, scope.zoomSpeed * normalizedDelta );
|
| 506 |
+
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
function rotateLeft( angle ) {
|
| 510 |
+
|
| 511 |
+
sphericalDelta.theta -= angle;
|
| 512 |
+
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
function rotateUp( angle ) {
|
| 516 |
+
|
| 517 |
+
sphericalDelta.phi -= angle;
|
| 518 |
+
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
const panLeft = function () {
|
| 522 |
+
|
| 523 |
+
const v = new Vector3();
|
| 524 |
+
|
| 525 |
+
return function panLeft( distance, objectMatrix ) {
|
| 526 |
+
|
| 527 |
+
v.setFromMatrixColumn( objectMatrix, 0 ); // get X column of objectMatrix
|
| 528 |
+
v.multiplyScalar( - distance );
|
| 529 |
+
|
| 530 |
+
panOffset.add( v );
|
| 531 |
+
|
| 532 |
+
};
|
| 533 |
+
|
| 534 |
+
}();
|
| 535 |
+
|
| 536 |
+
const panUp = function () {
|
| 537 |
+
|
| 538 |
+
const v = new Vector3();
|
| 539 |
+
|
| 540 |
+
return function panUp( distance, objectMatrix ) {
|
| 541 |
+
|
| 542 |
+
if ( scope.screenSpacePanning === true ) {
|
| 543 |
+
|
| 544 |
+
v.setFromMatrixColumn( objectMatrix, 1 );
|
| 545 |
+
|
| 546 |
+
} else {
|
| 547 |
+
|
| 548 |
+
v.setFromMatrixColumn( objectMatrix, 0 );
|
| 549 |
+
v.crossVectors( scope.object.up, v );
|
| 550 |
+
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
v.multiplyScalar( distance );
|
| 554 |
+
|
| 555 |
+
panOffset.add( v );
|
| 556 |
+
|
| 557 |
+
};
|
| 558 |
+
|
| 559 |
+
}();
|
| 560 |
+
|
| 561 |
+
// deltaX and deltaY are in pixels; right and down are positive
|
| 562 |
+
const pan = function () {
|
| 563 |
+
|
| 564 |
+
const offset = new Vector3();
|
| 565 |
+
|
| 566 |
+
return function pan( deltaX, deltaY ) {
|
| 567 |
+
|
| 568 |
+
const element = scope.domElement;
|
| 569 |
+
|
| 570 |
+
if ( scope.object.isPerspectiveCamera ) {
|
| 571 |
+
|
| 572 |
+
// perspective
|
| 573 |
+
const position = scope.object.position;
|
| 574 |
+
offset.copy( position ).sub( scope.target );
|
| 575 |
+
let targetDistance = offset.length();
|
| 576 |
+
|
| 577 |
+
// half of the fov is center to top of screen
|
| 578 |
+
targetDistance *= Math.tan( ( scope.object.fov / 2 ) * Math.PI / 180.0 );
|
| 579 |
+
|
| 580 |
+
// we use only clientHeight here so aspect ratio does not distort speed
|
| 581 |
+
panLeft( 2 * deltaX * targetDistance / element.clientHeight, scope.object.matrix );
|
| 582 |
+
panUp( 2 * deltaY * targetDistance / element.clientHeight, scope.object.matrix );
|
| 583 |
+
|
| 584 |
+
} else if ( scope.object.isOrthographicCamera ) {
|
| 585 |
+
|
| 586 |
+
// orthographic
|
| 587 |
+
panLeft( deltaX * ( scope.object.right - scope.object.left ) / scope.object.zoom / element.clientWidth, scope.object.matrix );
|
| 588 |
+
panUp( deltaY * ( scope.object.top - scope.object.bottom ) / scope.object.zoom / element.clientHeight, scope.object.matrix );
|
| 589 |
+
|
| 590 |
+
} else {
|
| 591 |
+
|
| 592 |
+
// camera neither orthographic nor perspective
|
| 593 |
+
console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.' );
|
| 594 |
+
scope.enablePan = false;
|
| 595 |
+
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
};
|
| 599 |
+
|
| 600 |
+
}();
|
| 601 |
+
|
| 602 |
+
function dollyOut( dollyScale ) {
|
| 603 |
+
|
| 604 |
+
if ( scope.object.isPerspectiveCamera || scope.object.isOrthographicCamera ) {
|
| 605 |
+
|
| 606 |
+
scale /= dollyScale;
|
| 607 |
+
|
| 608 |
+
} else {
|
| 609 |
+
|
| 610 |
+
console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
|
| 611 |
+
scope.enableZoom = false;
|
| 612 |
+
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
function dollyIn( dollyScale ) {
|
| 618 |
+
|
| 619 |
+
if ( scope.object.isPerspectiveCamera || scope.object.isOrthographicCamera ) {
|
| 620 |
+
|
| 621 |
+
scale *= dollyScale;
|
| 622 |
+
|
| 623 |
+
} else {
|
| 624 |
+
|
| 625 |
+
console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
|
| 626 |
+
scope.enableZoom = false;
|
| 627 |
+
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
function updateZoomParameters( x, y ) {
|
| 633 |
+
|
| 634 |
+
if ( ! scope.zoomToCursor ) {
|
| 635 |
+
|
| 636 |
+
return;
|
| 637 |
+
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
performCursorZoom = true;
|
| 641 |
+
|
| 642 |
+
const rect = scope.domElement.getBoundingClientRect();
|
| 643 |
+
const dx = x - rect.left;
|
| 644 |
+
const dy = y - rect.top;
|
| 645 |
+
const w = rect.width;
|
| 646 |
+
const h = rect.height;
|
| 647 |
+
|
| 648 |
+
mouse.x = ( dx / w ) * 2 - 1;
|
| 649 |
+
mouse.y = - ( dy / h ) * 2 + 1;
|
| 650 |
+
|
| 651 |
+
dollyDirection.set( mouse.x, mouse.y, 1 ).unproject( scope.object ).sub( scope.object.position ).normalize();
|
| 652 |
+
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
function clampDistance( dist ) {
|
| 656 |
+
|
| 657 |
+
return Math.max( scope.minDistance, Math.min( scope.maxDistance, dist ) );
|
| 658 |
+
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
//
|
| 662 |
+
// event callbacks - update the object state
|
| 663 |
+
//
|
| 664 |
+
|
| 665 |
+
function handleMouseDownRotate( event ) {
|
| 666 |
+
|
| 667 |
+
rotateStart.set( event.clientX, event.clientY );
|
| 668 |
+
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
function handleMouseDownDolly( event ) {
|
| 672 |
+
|
| 673 |
+
updateZoomParameters( event.clientX, event.clientX );
|
| 674 |
+
dollyStart.set( event.clientX, event.clientY );
|
| 675 |
+
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
function handleMouseDownPan( event ) {
|
| 679 |
+
|
| 680 |
+
panStart.set( event.clientX, event.clientY );
|
| 681 |
+
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
function handleMouseMoveRotate( event ) {
|
| 685 |
+
|
| 686 |
+
rotateEnd.set( event.clientX, event.clientY );
|
| 687 |
+
|
| 688 |
+
rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed );
|
| 689 |
+
|
| 690 |
+
const element = scope.domElement;
|
| 691 |
+
|
| 692 |
+
rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height
|
| 693 |
+
|
| 694 |
+
rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight );
|
| 695 |
+
|
| 696 |
+
rotateStart.copy( rotateEnd );
|
| 697 |
+
|
| 698 |
+
scope.update();
|
| 699 |
+
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
function handleMouseMoveDolly( event ) {
|
| 703 |
+
|
| 704 |
+
dollyEnd.set( event.clientX, event.clientY );
|
| 705 |
+
|
| 706 |
+
dollyDelta.subVectors( dollyEnd, dollyStart );
|
| 707 |
+
|
| 708 |
+
if ( dollyDelta.y > 0 ) {
|
| 709 |
+
|
| 710 |
+
dollyOut( getZoomScale( dollyDelta.y ) );
|
| 711 |
+
|
| 712 |
+
} else if ( dollyDelta.y < 0 ) {
|
| 713 |
+
|
| 714 |
+
dollyIn( getZoomScale( dollyDelta.y ) );
|
| 715 |
+
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
dollyStart.copy( dollyEnd );
|
| 719 |
+
|
| 720 |
+
scope.update();
|
| 721 |
+
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
function handleMouseMovePan( event ) {
|
| 725 |
+
|
| 726 |
+
panEnd.set( event.clientX, event.clientY );
|
| 727 |
+
|
| 728 |
+
panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed );
|
| 729 |
+
|
| 730 |
+
pan( panDelta.x, panDelta.y );
|
| 731 |
+
|
| 732 |
+
panStart.copy( panEnd );
|
| 733 |
+
|
| 734 |
+
scope.update();
|
| 735 |
+
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
function handleMouseWheel( event ) {
|
| 739 |
+
|
| 740 |
+
updateZoomParameters( event.clientX, event.clientY );
|
| 741 |
+
|
| 742 |
+
if ( event.deltaY < 0 ) {
|
| 743 |
+
|
| 744 |
+
dollyIn( getZoomScale( event.deltaY ) );
|
| 745 |
+
|
| 746 |
+
} else if ( event.deltaY > 0 ) {
|
| 747 |
+
|
| 748 |
+
dollyOut( getZoomScale( event.deltaY ) );
|
| 749 |
+
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
scope.update();
|
| 753 |
+
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
function handleKeyDown( event ) {
|
| 757 |
+
|
| 758 |
+
let needsUpdate = false;
|
| 759 |
+
|
| 760 |
+
switch ( event.code ) {
|
| 761 |
+
|
| 762 |
+
case scope.keys.UP:
|
| 763 |
+
|
| 764 |
+
if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
|
| 765 |
+
|
| 766 |
+
rotateUp( 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight );
|
| 767 |
+
|
| 768 |
+
} else {
|
| 769 |
+
|
| 770 |
+
pan( 0, scope.keyPanSpeed );
|
| 771 |
+
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
needsUpdate = true;
|
| 775 |
+
break;
|
| 776 |
+
|
| 777 |
+
case scope.keys.BOTTOM:
|
| 778 |
+
|
| 779 |
+
if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
|
| 780 |
+
|
| 781 |
+
rotateUp( - 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight );
|
| 782 |
+
|
| 783 |
+
} else {
|
| 784 |
+
|
| 785 |
+
pan( 0, - scope.keyPanSpeed );
|
| 786 |
+
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
needsUpdate = true;
|
| 790 |
+
break;
|
| 791 |
+
|
| 792 |
+
case scope.keys.LEFT:
|
| 793 |
+
|
| 794 |
+
if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
|
| 795 |
+
|
| 796 |
+
rotateLeft( 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight );
|
| 797 |
+
|
| 798 |
+
} else {
|
| 799 |
+
|
| 800 |
+
pan( scope.keyPanSpeed, 0 );
|
| 801 |
+
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
needsUpdate = true;
|
| 805 |
+
break;
|
| 806 |
+
|
| 807 |
+
case scope.keys.RIGHT:
|
| 808 |
+
|
| 809 |
+
if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
|
| 810 |
+
|
| 811 |
+
rotateLeft( - 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight );
|
| 812 |
+
|
| 813 |
+
} else {
|
| 814 |
+
|
| 815 |
+
pan( - scope.keyPanSpeed, 0 );
|
| 816 |
+
|
| 817 |
+
}
|
| 818 |
+
|
| 819 |
+
needsUpdate = true;
|
| 820 |
+
break;
|
| 821 |
+
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
if ( needsUpdate ) {
|
| 825 |
+
|
| 826 |
+
// prevent the browser from scrolling on cursor keys
|
| 827 |
+
event.preventDefault();
|
| 828 |
+
|
| 829 |
+
scope.update();
|
| 830 |
+
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
function handleTouchStartRotate( event ) {
|
| 837 |
+
|
| 838 |
+
if ( pointers.length === 1 ) {
|
| 839 |
+
|
| 840 |
+
rotateStart.set( event.pageX, event.pageY );
|
| 841 |
+
|
| 842 |
+
} else {
|
| 843 |
+
|
| 844 |
+
const position = getSecondPointerPosition( event );
|
| 845 |
+
|
| 846 |
+
const x = 0.5 * ( event.pageX + position.x );
|
| 847 |
+
const y = 0.5 * ( event.pageY + position.y );
|
| 848 |
+
|
| 849 |
+
rotateStart.set( x, y );
|
| 850 |
+
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
}
|
| 854 |
+
|
| 855 |
+
function handleTouchStartPan( event ) {
|
| 856 |
+
|
| 857 |
+
if ( pointers.length === 1 ) {
|
| 858 |
+
|
| 859 |
+
panStart.set( event.pageX, event.pageY );
|
| 860 |
+
|
| 861 |
+
} else {
|
| 862 |
+
|
| 863 |
+
const position = getSecondPointerPosition( event );
|
| 864 |
+
|
| 865 |
+
const x = 0.5 * ( event.pageX + position.x );
|
| 866 |
+
const y = 0.5 * ( event.pageY + position.y );
|
| 867 |
+
|
| 868 |
+
panStart.set( x, y );
|
| 869 |
+
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
}
|
| 873 |
+
|
| 874 |
+
function handleTouchStartDolly( event ) {
|
| 875 |
+
|
| 876 |
+
const position = getSecondPointerPosition( event );
|
| 877 |
+
|
| 878 |
+
const dx = event.pageX - position.x;
|
| 879 |
+
const dy = event.pageY - position.y;
|
| 880 |
+
|
| 881 |
+
const distance = Math.sqrt( dx * dx + dy * dy );
|
| 882 |
+
|
| 883 |
+
dollyStart.set( 0, distance );
|
| 884 |
+
|
| 885 |
+
}
|
| 886 |
+
|
| 887 |
+
function handleTouchStartDollyPan( event ) {
|
| 888 |
+
|
| 889 |
+
if ( scope.enableZoom ) handleTouchStartDolly( event );
|
| 890 |
+
|
| 891 |
+
if ( scope.enablePan ) handleTouchStartPan( event );
|
| 892 |
+
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
function handleTouchStartDollyRotate( event ) {
|
| 896 |
+
|
| 897 |
+
if ( scope.enableZoom ) handleTouchStartDolly( event );
|
| 898 |
+
|
| 899 |
+
if ( scope.enableRotate ) handleTouchStartRotate( event );
|
| 900 |
+
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
function handleTouchMoveRotate( event ) {
|
| 904 |
+
|
| 905 |
+
if ( pointers.length == 1 ) {
|
| 906 |
+
|
| 907 |
+
rotateEnd.set( event.pageX, event.pageY );
|
| 908 |
+
|
| 909 |
+
} else {
|
| 910 |
+
|
| 911 |
+
const position = getSecondPointerPosition( event );
|
| 912 |
+
|
| 913 |
+
const x = 0.5 * ( event.pageX + position.x );
|
| 914 |
+
const y = 0.5 * ( event.pageY + position.y );
|
| 915 |
+
|
| 916 |
+
rotateEnd.set( x, y );
|
| 917 |
+
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed );
|
| 921 |
+
|
| 922 |
+
const element = scope.domElement;
|
| 923 |
+
|
| 924 |
+
rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height
|
| 925 |
+
|
| 926 |
+
rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight );
|
| 927 |
+
|
| 928 |
+
rotateStart.copy( rotateEnd );
|
| 929 |
+
|
| 930 |
+
}
|
| 931 |
+
|
| 932 |
+
function handleTouchMovePan( event ) {
|
| 933 |
+
|
| 934 |
+
if ( pointers.length === 1 ) {
|
| 935 |
+
|
| 936 |
+
panEnd.set( event.pageX, event.pageY );
|
| 937 |
+
|
| 938 |
+
} else {
|
| 939 |
+
|
| 940 |
+
const position = getSecondPointerPosition( event );
|
| 941 |
+
|
| 942 |
+
const x = 0.5 * ( event.pageX + position.x );
|
| 943 |
+
const y = 0.5 * ( event.pageY + position.y );
|
| 944 |
+
|
| 945 |
+
panEnd.set( x, y );
|
| 946 |
+
|
| 947 |
+
}
|
| 948 |
+
|
| 949 |
+
panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed );
|
| 950 |
+
|
| 951 |
+
pan( panDelta.x, panDelta.y );
|
| 952 |
+
|
| 953 |
+
panStart.copy( panEnd );
|
| 954 |
+
|
| 955 |
+
}
|
| 956 |
+
|
| 957 |
+
function handleTouchMoveDolly( event ) {
|
| 958 |
+
|
| 959 |
+
const position = getSecondPointerPosition( event );
|
| 960 |
+
|
| 961 |
+
const dx = event.pageX - position.x;
|
| 962 |
+
const dy = event.pageY - position.y;
|
| 963 |
+
|
| 964 |
+
const distance = Math.sqrt( dx * dx + dy * dy );
|
| 965 |
+
|
| 966 |
+
dollyEnd.set( 0, distance );
|
| 967 |
+
|
| 968 |
+
dollyDelta.set( 0, Math.pow( dollyEnd.y / dollyStart.y, scope.zoomSpeed ) );
|
| 969 |
+
|
| 970 |
+
dollyOut( dollyDelta.y );
|
| 971 |
+
|
| 972 |
+
dollyStart.copy( dollyEnd );
|
| 973 |
+
|
| 974 |
+
const centerX = ( event.pageX + position.x ) * 0.5;
|
| 975 |
+
const centerY = ( event.pageY + position.y ) * 0.5;
|
| 976 |
+
|
| 977 |
+
updateZoomParameters( centerX, centerY );
|
| 978 |
+
|
| 979 |
+
}
|
| 980 |
+
|
| 981 |
+
function handleTouchMoveDollyPan( event ) {
|
| 982 |
+
|
| 983 |
+
if ( scope.enableZoom ) handleTouchMoveDolly( event );
|
| 984 |
+
|
| 985 |
+
if ( scope.enablePan ) handleTouchMovePan( event );
|
| 986 |
+
|
| 987 |
+
}
|
| 988 |
+
|
| 989 |
+
function handleTouchMoveDollyRotate( event ) {
|
| 990 |
+
|
| 991 |
+
if ( scope.enableZoom ) handleTouchMoveDolly( event );
|
| 992 |
+
|
| 993 |
+
if ( scope.enableRotate ) handleTouchMoveRotate( event );
|
| 994 |
+
|
| 995 |
+
}
|
| 996 |
+
|
| 997 |
+
//
|
| 998 |
+
// event handlers - FSM: listen for events and reset state
|
| 999 |
+
//
|
| 1000 |
+
|
| 1001 |
+
function onPointerDown( event ) {
|
| 1002 |
+
|
| 1003 |
+
if ( scope.enabled === false ) return;
|
| 1004 |
+
|
| 1005 |
+
if ( pointers.length === 0 ) {
|
| 1006 |
+
|
| 1007 |
+
scope.domElement.setPointerCapture( event.pointerId );
|
| 1008 |
+
|
| 1009 |
+
scope.domElement.addEventListener( 'pointermove', onPointerMove );
|
| 1010 |
+
scope.domElement.addEventListener( 'pointerup', onPointerUp );
|
| 1011 |
+
|
| 1012 |
+
}
|
| 1013 |
+
|
| 1014 |
+
//
|
| 1015 |
+
|
| 1016 |
+
addPointer( event );
|
| 1017 |
+
|
| 1018 |
+
if ( event.pointerType === 'touch' ) {
|
| 1019 |
+
|
| 1020 |
+
onTouchStart( event );
|
| 1021 |
+
|
| 1022 |
+
} else {
|
| 1023 |
+
|
| 1024 |
+
onMouseDown( event );
|
| 1025 |
+
|
| 1026 |
+
}
|
| 1027 |
+
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
function onPointerMove( event ) {
|
| 1031 |
+
|
| 1032 |
+
if ( scope.enabled === false ) return;
|
| 1033 |
+
|
| 1034 |
+
if ( event.pointerType === 'touch' ) {
|
| 1035 |
+
|
| 1036 |
+
onTouchMove( event );
|
| 1037 |
+
|
| 1038 |
+
} else {
|
| 1039 |
+
|
| 1040 |
+
onMouseMove( event );
|
| 1041 |
+
|
| 1042 |
+
}
|
| 1043 |
+
|
| 1044 |
+
}
|
| 1045 |
+
|
| 1046 |
+
function onPointerUp( event ) {
|
| 1047 |
+
|
| 1048 |
+
removePointer( event );
|
| 1049 |
+
|
| 1050 |
+
switch ( pointers.length ) {
|
| 1051 |
+
|
| 1052 |
+
case 0:
|
| 1053 |
+
|
| 1054 |
+
scope.domElement.releasePointerCapture( event.pointerId );
|
| 1055 |
+
|
| 1056 |
+
scope.domElement.removeEventListener( 'pointermove', onPointerMove );
|
| 1057 |
+
scope.domElement.removeEventListener( 'pointerup', onPointerUp );
|
| 1058 |
+
|
| 1059 |
+
scope.dispatchEvent( _endEvent );
|
| 1060 |
+
|
| 1061 |
+
state = STATE.NONE;
|
| 1062 |
+
|
| 1063 |
+
break;
|
| 1064 |
+
|
| 1065 |
+
case 1:
|
| 1066 |
+
|
| 1067 |
+
const pointerId = pointers[ 0 ];
|
| 1068 |
+
const position = pointerPositions[ pointerId ];
|
| 1069 |
+
|
| 1070 |
+
// minimal placeholder event - allows state correction on pointer-up
|
| 1071 |
+
onTouchStart( { pointerId: pointerId, pageX: position.x, pageY: position.y } );
|
| 1072 |
+
|
| 1073 |
+
break;
|
| 1074 |
+
|
| 1075 |
+
}
|
| 1076 |
+
|
| 1077 |
+
}
|
| 1078 |
+
|
| 1079 |
+
function onMouseDown( event ) {
|
| 1080 |
+
|
| 1081 |
+
let mouseAction;
|
| 1082 |
+
|
| 1083 |
+
switch ( event.button ) {
|
| 1084 |
+
|
| 1085 |
+
case 0:
|
| 1086 |
+
|
| 1087 |
+
mouseAction = scope.mouseButtons.LEFT;
|
| 1088 |
+
break;
|
| 1089 |
+
|
| 1090 |
+
case 1:
|
| 1091 |
+
|
| 1092 |
+
mouseAction = scope.mouseButtons.MIDDLE;
|
| 1093 |
+
break;
|
| 1094 |
+
|
| 1095 |
+
case 2:
|
| 1096 |
+
|
| 1097 |
+
mouseAction = scope.mouseButtons.RIGHT;
|
| 1098 |
+
break;
|
| 1099 |
+
|
| 1100 |
+
default:
|
| 1101 |
+
|
| 1102 |
+
mouseAction = - 1;
|
| 1103 |
+
|
| 1104 |
+
}
|
| 1105 |
+
|
| 1106 |
+
switch ( mouseAction ) {
|
| 1107 |
+
|
| 1108 |
+
case MOUSE.DOLLY:
|
| 1109 |
+
|
| 1110 |
+
if ( scope.enableZoom === false ) return;
|
| 1111 |
+
|
| 1112 |
+
handleMouseDownDolly( event );
|
| 1113 |
+
|
| 1114 |
+
state = STATE.DOLLY;
|
| 1115 |
+
|
| 1116 |
+
break;
|
| 1117 |
+
|
| 1118 |
+
case MOUSE.ROTATE:
|
| 1119 |
+
|
| 1120 |
+
if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
|
| 1121 |
+
|
| 1122 |
+
if ( scope.enablePan === false ) return;
|
| 1123 |
+
|
| 1124 |
+
handleMouseDownPan( event );
|
| 1125 |
+
|
| 1126 |
+
state = STATE.PAN;
|
| 1127 |
+
|
| 1128 |
+
} else {
|
| 1129 |
+
|
| 1130 |
+
if ( scope.enableRotate === false ) return;
|
| 1131 |
+
|
| 1132 |
+
handleMouseDownRotate( event );
|
| 1133 |
+
|
| 1134 |
+
state = STATE.ROTATE;
|
| 1135 |
+
|
| 1136 |
+
}
|
| 1137 |
+
|
| 1138 |
+
break;
|
| 1139 |
+
|
| 1140 |
+
case MOUSE.PAN:
|
| 1141 |
+
|
| 1142 |
+
if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
|
| 1143 |
+
|
| 1144 |
+
if ( scope.enableRotate === false ) return;
|
| 1145 |
+
|
| 1146 |
+
handleMouseDownRotate( event );
|
| 1147 |
+
|
| 1148 |
+
state = STATE.ROTATE;
|
| 1149 |
+
|
| 1150 |
+
} else {
|
| 1151 |
+
|
| 1152 |
+
if ( scope.enablePan === false ) return;
|
| 1153 |
+
|
| 1154 |
+
handleMouseDownPan( event );
|
| 1155 |
+
|
| 1156 |
+
state = STATE.PAN;
|
| 1157 |
+
|
| 1158 |
+
}
|
| 1159 |
+
|
| 1160 |
+
break;
|
| 1161 |
+
|
| 1162 |
+
default:
|
| 1163 |
+
|
| 1164 |
+
state = STATE.NONE;
|
| 1165 |
+
|
| 1166 |
+
}
|
| 1167 |
+
|
| 1168 |
+
if ( state !== STATE.NONE ) {
|
| 1169 |
+
|
| 1170 |
+
scope.dispatchEvent( _startEvent );
|
| 1171 |
+
|
| 1172 |
+
}
|
| 1173 |
+
|
| 1174 |
+
}
|
| 1175 |
+
|
| 1176 |
+
function onMouseMove( event ) {
|
| 1177 |
+
|
| 1178 |
+
switch ( state ) {
|
| 1179 |
+
|
| 1180 |
+
case STATE.ROTATE:
|
| 1181 |
+
|
| 1182 |
+
if ( scope.enableRotate === false ) return;
|
| 1183 |
+
|
| 1184 |
+
handleMouseMoveRotate( event );
|
| 1185 |
+
|
| 1186 |
+
break;
|
| 1187 |
+
|
| 1188 |
+
case STATE.DOLLY:
|
| 1189 |
+
|
| 1190 |
+
if ( scope.enableZoom === false ) return;
|
| 1191 |
+
|
| 1192 |
+
handleMouseMoveDolly( event );
|
| 1193 |
+
|
| 1194 |
+
break;
|
| 1195 |
+
|
| 1196 |
+
case STATE.PAN:
|
| 1197 |
+
|
| 1198 |
+
if ( scope.enablePan === false ) return;
|
| 1199 |
+
|
| 1200 |
+
handleMouseMovePan( event );
|
| 1201 |
+
|
| 1202 |
+
break;
|
| 1203 |
+
|
| 1204 |
+
}
|
| 1205 |
+
|
| 1206 |
+
}
|
| 1207 |
+
|
| 1208 |
+
function onMouseWheel( event ) {
|
| 1209 |
+
|
| 1210 |
+
if ( scope.enabled === false || scope.enableZoom === false || state !== STATE.NONE ) return;
|
| 1211 |
+
|
| 1212 |
+
event.preventDefault();
|
| 1213 |
+
|
| 1214 |
+
scope.dispatchEvent( _startEvent );
|
| 1215 |
+
|
| 1216 |
+
handleMouseWheel( customWheelEvent( event ) );
|
| 1217 |
+
|
| 1218 |
+
scope.dispatchEvent( _endEvent );
|
| 1219 |
+
|
| 1220 |
+
}
|
| 1221 |
+
|
| 1222 |
+
function customWheelEvent( event ) {
|
| 1223 |
+
|
| 1224 |
+
const mode = event.deltaMode;
|
| 1225 |
+
|
| 1226 |
+
// minimal wheel event altered to meet delta-zoom demand
|
| 1227 |
+
const newEvent = {
|
| 1228 |
+
clientX: event.clientX,
|
| 1229 |
+
clientY: event.clientY,
|
| 1230 |
+
deltaY: event.deltaY,
|
| 1231 |
+
};
|
| 1232 |
+
|
| 1233 |
+
switch ( mode ) {
|
| 1234 |
+
|
| 1235 |
+
case 1: // LINE_MODE
|
| 1236 |
+
newEvent.deltaY *= 16;
|
| 1237 |
+
break;
|
| 1238 |
+
|
| 1239 |
+
case 2: // PAGE_MODE
|
| 1240 |
+
newEvent.deltaY *= 100;
|
| 1241 |
+
break;
|
| 1242 |
+
|
| 1243 |
+
}
|
| 1244 |
+
|
| 1245 |
+
// detect if event was triggered by pinching
|
| 1246 |
+
if ( event.ctrlKey && ! controlActive ) {
|
| 1247 |
+
|
| 1248 |
+
newEvent.deltaY *= 10;
|
| 1249 |
+
|
| 1250 |
+
}
|
| 1251 |
+
|
| 1252 |
+
return newEvent;
|
| 1253 |
+
|
| 1254 |
+
}
|
| 1255 |
+
|
| 1256 |
+
function interceptControlDown( event ) {
|
| 1257 |
+
|
| 1258 |
+
if ( event.key === 'Control' ) {
|
| 1259 |
+
|
| 1260 |
+
controlActive = true;
|
| 1261 |
+
|
| 1262 |
+
|
| 1263 |
+
const document = scope.domElement.getRootNode(); // offscreen canvas compatibility
|
| 1264 |
+
|
| 1265 |
+
document.addEventListener( 'keyup', interceptControlUp, { passive: true, capture: true } );
|
| 1266 |
+
|
| 1267 |
+
}
|
| 1268 |
+
|
| 1269 |
+
}
|
| 1270 |
+
|
| 1271 |
+
function interceptControlUp( event ) {
|
| 1272 |
+
|
| 1273 |
+
if ( event.key === 'Control' ) {
|
| 1274 |
+
|
| 1275 |
+
controlActive = false;
|
| 1276 |
+
|
| 1277 |
+
|
| 1278 |
+
const document = scope.domElement.getRootNode(); // offscreen canvas compatibility
|
| 1279 |
+
|
| 1280 |
+
document.removeEventListener( 'keyup', interceptControlUp, { passive: true, capture: true } );
|
| 1281 |
+
|
| 1282 |
+
}
|
| 1283 |
+
|
| 1284 |
+
}
|
| 1285 |
+
|
| 1286 |
+
function onKeyDown( event ) {
|
| 1287 |
+
|
| 1288 |
+
if ( scope.enabled === false || scope.enablePan === false ) return;
|
| 1289 |
+
|
| 1290 |
+
handleKeyDown( event );
|
| 1291 |
+
|
| 1292 |
+
}
|
| 1293 |
+
|
| 1294 |
+
function onTouchStart( event ) {
|
| 1295 |
+
|
| 1296 |
+
trackPointer( event );
|
| 1297 |
+
|
| 1298 |
+
switch ( pointers.length ) {
|
| 1299 |
+
|
| 1300 |
+
case 1:
|
| 1301 |
+
|
| 1302 |
+
switch ( scope.touches.ONE ) {
|
| 1303 |
+
|
| 1304 |
+
case TOUCH.ROTATE:
|
| 1305 |
+
|
| 1306 |
+
if ( scope.enableRotate === false ) return;
|
| 1307 |
+
|
| 1308 |
+
handleTouchStartRotate( event );
|
| 1309 |
+
|
| 1310 |
+
state = STATE.TOUCH_ROTATE;
|
| 1311 |
+
|
| 1312 |
+
break;
|
| 1313 |
+
|
| 1314 |
+
case TOUCH.PAN:
|
| 1315 |
+
|
| 1316 |
+
if ( scope.enablePan === false ) return;
|
| 1317 |
+
|
| 1318 |
+
handleTouchStartPan( event );
|
| 1319 |
+
|
| 1320 |
+
state = STATE.TOUCH_PAN;
|
| 1321 |
+
|
| 1322 |
+
break;
|
| 1323 |
+
|
| 1324 |
+
default:
|
| 1325 |
+
|
| 1326 |
+
state = STATE.NONE;
|
| 1327 |
+
|
| 1328 |
+
}
|
| 1329 |
+
|
| 1330 |
+
break;
|
| 1331 |
+
|
| 1332 |
+
case 2:
|
| 1333 |
+
|
| 1334 |
+
switch ( scope.touches.TWO ) {
|
| 1335 |
+
|
| 1336 |
+
case TOUCH.DOLLY_PAN:
|
| 1337 |
+
|
| 1338 |
+
if ( scope.enableZoom === false && scope.enablePan === false ) return;
|
| 1339 |
+
|
| 1340 |
+
handleTouchStartDollyPan( event );
|
| 1341 |
+
|
| 1342 |
+
state = STATE.TOUCH_DOLLY_PAN;
|
| 1343 |
+
|
| 1344 |
+
break;
|
| 1345 |
+
|
| 1346 |
+
case TOUCH.DOLLY_ROTATE:
|
| 1347 |
+
|
| 1348 |
+
if ( scope.enableZoom === false && scope.enableRotate === false ) return;
|
| 1349 |
+
|
| 1350 |
+
handleTouchStartDollyRotate( event );
|
| 1351 |
+
|
| 1352 |
+
state = STATE.TOUCH_DOLLY_ROTATE;
|
| 1353 |
+
|
| 1354 |
+
break;
|
| 1355 |
+
|
| 1356 |
+
default:
|
| 1357 |
+
|
| 1358 |
+
state = STATE.NONE;
|
| 1359 |
+
|
| 1360 |
+
}
|
| 1361 |
+
|
| 1362 |
+
break;
|
| 1363 |
+
|
| 1364 |
+
default:
|
| 1365 |
+
|
| 1366 |
+
state = STATE.NONE;
|
| 1367 |
+
|
| 1368 |
+
}
|
| 1369 |
+
|
| 1370 |
+
if ( state !== STATE.NONE ) {
|
| 1371 |
+
|
| 1372 |
+
scope.dispatchEvent( _startEvent );
|
| 1373 |
+
|
| 1374 |
+
}
|
| 1375 |
+
|
| 1376 |
+
}
|
| 1377 |
+
|
| 1378 |
+
function onTouchMove( event ) {
|
| 1379 |
+
|
| 1380 |
+
trackPointer( event );
|
| 1381 |
+
|
| 1382 |
+
switch ( state ) {
|
| 1383 |
+
|
| 1384 |
+
case STATE.TOUCH_ROTATE:
|
| 1385 |
+
|
| 1386 |
+
if ( scope.enableRotate === false ) return;
|
| 1387 |
+
|
| 1388 |
+
handleTouchMoveRotate( event );
|
| 1389 |
+
|
| 1390 |
+
scope.update();
|
| 1391 |
+
|
| 1392 |
+
break;
|
| 1393 |
+
|
| 1394 |
+
case STATE.TOUCH_PAN:
|
| 1395 |
+
|
| 1396 |
+
if ( scope.enablePan === false ) return;
|
| 1397 |
+
|
| 1398 |
+
handleTouchMovePan( event );
|
| 1399 |
+
|
| 1400 |
+
scope.update();
|
| 1401 |
+
|
| 1402 |
+
break;
|
| 1403 |
+
|
| 1404 |
+
case STATE.TOUCH_DOLLY_PAN:
|
| 1405 |
+
|
| 1406 |
+
if ( scope.enableZoom === false && scope.enablePan === false ) return;
|
| 1407 |
+
|
| 1408 |
+
handleTouchMoveDollyPan( event );
|
| 1409 |
+
|
| 1410 |
+
scope.update();
|
| 1411 |
+
|
| 1412 |
+
break;
|
| 1413 |
+
|
| 1414 |
+
case STATE.TOUCH_DOLLY_ROTATE:
|
| 1415 |
+
|
| 1416 |
+
if ( scope.enableZoom === false && scope.enableRotate === false ) return;
|
| 1417 |
+
|
| 1418 |
+
handleTouchMoveDollyRotate( event );
|
| 1419 |
+
|
| 1420 |
+
scope.update();
|
| 1421 |
+
|
| 1422 |
+
break;
|
| 1423 |
+
|
| 1424 |
+
default:
|
| 1425 |
+
|
| 1426 |
+
state = STATE.NONE;
|
| 1427 |
+
|
| 1428 |
+
}
|
| 1429 |
+
|
| 1430 |
+
}
|
| 1431 |
+
|
| 1432 |
+
function onContextMenu( event ) {
|
| 1433 |
+
|
| 1434 |
+
if ( scope.enabled === false ) return;
|
| 1435 |
+
|
| 1436 |
+
event.preventDefault();
|
| 1437 |
+
|
| 1438 |
+
}
|
| 1439 |
+
|
| 1440 |
+
function addPointer( event ) {
|
| 1441 |
+
|
| 1442 |
+
pointers.push( event.pointerId );
|
| 1443 |
+
|
| 1444 |
+
}
|
| 1445 |
+
|
| 1446 |
+
function removePointer( event ) {
|
| 1447 |
+
|
| 1448 |
+
delete pointerPositions[ event.pointerId ];
|
| 1449 |
+
|
| 1450 |
+
for ( let i = 0; i < pointers.length; i ++ ) {
|
| 1451 |
+
|
| 1452 |
+
if ( pointers[ i ] == event.pointerId ) {
|
| 1453 |
+
|
| 1454 |
+
pointers.splice( i, 1 );
|
| 1455 |
+
return;
|
| 1456 |
+
|
| 1457 |
+
}
|
| 1458 |
+
|
| 1459 |
+
}
|
| 1460 |
+
|
| 1461 |
+
}
|
| 1462 |
+
|
| 1463 |
+
function trackPointer( event ) {
|
| 1464 |
+
|
| 1465 |
+
let position = pointerPositions[ event.pointerId ];
|
| 1466 |
+
|
| 1467 |
+
if ( position === undefined ) {
|
| 1468 |
+
|
| 1469 |
+
position = new Vector2();
|
| 1470 |
+
pointerPositions[ event.pointerId ] = position;
|
| 1471 |
+
|
| 1472 |
+
}
|
| 1473 |
+
|
| 1474 |
+
position.set( event.pageX, event.pageY );
|
| 1475 |
+
|
| 1476 |
+
}
|
| 1477 |
+
|
| 1478 |
+
function getSecondPointerPosition( event ) {
|
| 1479 |
+
|
| 1480 |
+
const pointerId = ( event.pointerId === pointers[ 0 ] ) ? pointers[ 1 ] : pointers[ 0 ];
|
| 1481 |
+
|
| 1482 |
+
return pointerPositions[ pointerId ];
|
| 1483 |
+
|
| 1484 |
+
}
|
| 1485 |
+
|
| 1486 |
+
//
|
| 1487 |
+
|
| 1488 |
+
scope.domElement.addEventListener( 'contextmenu', onContextMenu );
|
| 1489 |
+
|
| 1490 |
+
scope.domElement.addEventListener( 'pointerdown', onPointerDown );
|
| 1491 |
+
scope.domElement.addEventListener( 'pointercancel', onPointerUp );
|
| 1492 |
+
scope.domElement.addEventListener( 'wheel', onMouseWheel, { passive: false } );
|
| 1493 |
+
|
| 1494 |
+
const document = scope.domElement.getRootNode(); // offscreen canvas compatibility
|
| 1495 |
+
|
| 1496 |
+
document.addEventListener( 'keydown', interceptControlDown, { passive: true, capture: true } );
|
| 1497 |
+
|
| 1498 |
+
// force an update at start
|
| 1499 |
+
|
| 1500 |
+
this.update();
|
| 1501 |
+
|
| 1502 |
+
}
|
| 1503 |
+
|
| 1504 |
+
}
|
| 1505 |
+
|
| 1506 |
+
export { OrbitControls };
|
static/three-global.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as THREE from './three.module.min.js';
|
| 2 |
+
window.THREE = THREE;
|
static/three.module.min.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|