minhnghiem32131024429 commited on
Commit
5e8b911
·
1 Parent(s): 1e9a5a9

Deploy delivery robot app

Browse files
.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="[&#10; { id:'R01', x:2, y:2, w:90, h:76, group:'top' },&#10; ...&#10;]"></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