Delivery_robot / map_editor.html
minhnghiem32131024429
Deploy delivery robot app
5e8b911
Raw
History Blame Contribute Delete
55 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Floor Map Editor — UEH B1</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #040c14; color: #c8dce8;
font-family: 'Segoe UI', sans-serif;
height: 100vh; display: flex; flex-direction: column; overflow: hidden;
}
/* ── Top bar ── */
.topbar {
height: 48px; background: #06101e;
border-bottom: 1px solid #1a3a55;
display: flex; align-items: center; gap: 10px;
padding: 0 14px; flex-shrink: 0; user-select: none;
}
.logo { font-family: monospace; font-size: 13px; font-weight: bold; color: #00c8e8; letter-spacing: 3px; }
.sep { width: 1px; height: 24px; background: #1a3a55; margin: 0 2px; }
.floor-tabs { display: flex; gap: 3px; }
.ftab {
padding: 4px 10px; border-radius: 4px;
border: 1px solid #1a3a55; background: transparent;
color: #7ab0c8; font-size: 11px; font-family: monospace; cursor: pointer;
transition: all 0.15s;
}
.ftab:hover { border-color: #2a5a75; color: #c8dce8; }
.ftab.active { background: rgba(0,200,232,0.12); border-color: #00c8e8; color: #00c8e8; }
.ftab-add { color: #3a6070; border-style: dashed; }
.tools { display: flex; gap: 3px; }
.tbtn {
padding: 5px 11px; border-radius: 4px;
border: 1px solid #1a3a55; background: transparent;
color: #7ab0c8; font-size: 11px; font-family: monospace;
cursor: pointer; display: flex; align-items: center; gap: 5px;
transition: all 0.15s;
}
.tbtn:hover { border-color: #2a5a75; background: #061018; }
.tbtn.active { background: rgba(0,200,232,0.12); border-color: #00c8e8; color: #00c8e8; }
.tbtn.del { color: #ff6b6b; border-color: #ff6b6b30; }
.tbtn.del:hover { background: rgba(255,107,107,0.1); }
.tbtn.green { color: #00e676; border-color: #00e67630; }
.tbtn.green:hover { background: rgba(0,230,118,0.1); }
.snap-ctrl {
display: flex; align-items: center; gap: 6px;
font-size: 11px; color: #3a6070; font-family: monospace;
margin-left: auto;
}
.snap-ctrl input {
width: 40px; background: #040d18; border: 1px solid #1a3a55;
color: #c8dce8; padding: 3px 6px; border-radius: 3px;
font-size: 11px; font-family: monospace; text-align: center;
}
/* ── Main layout ── */
.content { flex: 1; display: flex; overflow: hidden; }
.canvas-wrap {
flex: 1; overflow: auto; background: #020810;
display: flex; align-items: flex-start;
justify-content: flex-start; padding: 24px;
}
#canvas { display: block; user-select: none; }
/* ── Right panel ── */
.panel {
width: 232px; background: #06101e;
border-left: 1px solid #1a3a55;
padding: 12px; overflow-y: auto;
display: flex; flex-direction: column; gap: 12px;
flex-shrink: 0;
}
.panel h3 {
font-size: 10px; color: #3a6070;
letter-spacing: 2px; text-transform: uppercase;
margin-bottom: 7px; font-family: monospace;
}
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
.stat-box {
background: #040d18; border: 1px solid #0d2238;
border-radius: 4px; padding: 6px; text-align: center;
}
.stat-box .val { font-size: 20px; font-weight: bold; color: #00c8e8; font-family: monospace; }
.stat-box .lbl { font-size: 9px; color: #3a6070; letter-spacing: 1px; }
.prop-row { margin-bottom: 7px; }
.prop-row label { display: block; font-size: 10px; color: #3a6070; margin-bottom: 3px; letter-spacing: 1px; font-family: monospace; }
.prop-row input, .prop-row select {
width: 100%; background: #040d18; border: 1px solid #1a3a55;
color: #c8dce8; padding: 5px 8px; border-radius: 4px;
font-size: 12px; font-family: monospace;
}
.prop-row input:focus, .prop-row select:focus { outline: none; border-color: #00c8e8; }
.prop-row .row2 { display: flex; gap: 5px; }
.prop-row .row2 input { flex: 1; }
/* ── Door picker ── */
.door-picker {
display: grid; grid-template-columns: repeat(3, 1fr);
gap: 3px; margin: 4px 0;
}
.dpick-btn {
height: 26px; border-radius: 3px;
border: 1px solid #1a3a55; background: #040d18;
color: #7ab0c8; font-size: 15px; cursor: pointer;
transition: all 0.12s; display: flex; align-items: center; justify-content: center;
}
.dpick-btn:hover { border-color: #2a5a75; background: #061018; }
.dpick-room {
background: rgba(0,200,232,0.05); border: 1px dashed #1a3a55;
border-radius: 2px; height: 26px;
}
.legend { display: flex; flex-direction: column; gap: 5px; }
.legend-item { display: flex; align-items: center; gap: 7px; font-size: 11px; color: #3a6070; }
.legend-dot { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
.xbtn {
width: 100%; padding: 8px; border-radius: 5px; cursor: pointer;
font-family: monospace; font-size: 11px; letter-spacing: 1px;
transition: all 0.15s;
}
.xbtn-primary {
border: 1px solid #00c8e840; background: rgba(0,200,232,0.08); color: #00c8e8;
}
.xbtn-primary:hover { background: rgba(0,200,232,0.16); }
.xbtn-secondary {
border: 1px solid #1a3a55; background: transparent; color: #7ab0c8;
}
.xbtn-secondary:hover { background: #061018; }
/* ── Status bar ── */
.statusbar {
height: 26px; background: #030c14;
border-top: 1px solid #0d2238;
display: flex; align-items: center;
padding: 0 14px; font-size: 11px; color: #3a6070;
font-family: monospace; gap: 20px; flex-shrink: 0;
}
/* ── Modal ── */
.overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.72); backdrop-filter: blur(3px);
display: flex; align-items: center; justify-content: center;
z-index: 100;
}
.modal {
background: #070f1a; border: 1px solid #1a3a55;
border-radius: 10px; padding: 22px; width: 600px;
max-height: 80vh; overflow: auto;
}
.modal h2 { font-size: 13px; color: #00c8e8; font-family: monospace; letter-spacing: 2px; margin-bottom: 12px; }
.modal p { font-size: 11px; color: #3a6070; margin-bottom: 8px; }
.modal textarea {
width: 100%; height: 320px;
background: #040d18; border: 1px solid #1a3a55;
color: #a0c8d8; font-family: monospace; font-size: 12px;
padding: 10px; border-radius: 5px; resize: vertical; outline: none;
}
.modal textarea:focus { border-color: #00c8e8; }
.modal-actions { display: flex; gap: 8px; margin-top: 10px; }
</style>
</head>
<body>
<!-- ── TOP BAR ── -->
<div class="topbar">
<span class="logo">MAP EDITOR</span>
<div class="sep"></div>
<div class="floor-tabs" id="floorTabs"></div>
<button class="ftab ftab-add" onclick="addFloor()">+ floor</button>
<div class="sep"></div>
<div class="tools">
<button class="tbtn active" id="tool-select" onclick="setTool('select')" title="S">◎ Select</button>
<button class="tbtn" id="tool-room" onclick="setTool('room')" title="R">▬ Room</button>
<button class="tbtn" id="tool-wall" onclick="setTool('wall')" title="W">■ Wall</button>
<button class="tbtn" id="tool-corridor" onclick="setTool('corridor')" title="C">░ Corridor</button>
<button class="tbtn" id="tool-elevator" onclick="setTool('elevator')" title="E">⬆ Elevator</button>
<button class="tbtn del" onclick="deleteSelected()" title="Del">✕ Delete</button>
<div class="sep"></div>
<button class="tbtn" onclick="autoBuildWalls()" title="Fill empty space with walls">Auto walls</button>
<button class="tbtn" onclick="undo()" title="Ctrl+Z">↩ Undo</button>
<button class="tbtn green" onclick="openExport()">↓ Export JS</button>
<div class="sep"></div>
<button class="tbtn" onclick="document.getElementById('pgmInput').click()" title="Load PGM as background">🗺 Load PGM</button>
<input type="file" id="pgmInput" accept=".pgm" style="display:none" onchange="loadPGM(this)">
</div>
<div class="snap-ctrl">
SNAP<input id="snapInput" type="number" value="5" min="1" max="20" onchange="state.snap=+this.value">
GRID<input id="gridInput" type="number" value="10" min="5" max="50" onchange="state.gridSize=+this.value;render()">
</div>
</div>
<!-- ── MAIN ── -->
<div class="content">
<div class="canvas-wrap" id="canvasWrap">
<svg id="canvas" xmlns="http://www.w3.org/2000/svg"></svg>
</div>
<div class="panel">
<div>
<h3>Stats</h3>
<div class="stat-grid">
<div class="stat-box"><div class="val" id="sRooms">0</div><div class="lbl">ROOMS</div></div>
<div class="stat-box"><div class="val" id="sElvs">0</div><div class="lbl">ELEV</div></div>
<div class="stat-box"><div class="val" id="sWalls">0</div><div class="lbl">WALLS</div></div>
<div class="stat-box"><div class="val" id="sCorr">0</div><div class="lbl">CORR</div></div>
</div>
</div>
<div id="propsSection" style="display:none">
<h3>Properties</h3>
<div id="propsForm"></div>
</div>
<div>
<h3>Room defaults</h3>
<div class="prop-row">
<label>DOOR POSITION</label>
<div id="defaultDoorPicker"></div>
</div>
</div>
<div>
<h3>Legend</h3>
<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background:#0a1828;border:1.5px solid #1c3a55"></div>Wall</div>
<div class="legend-item"><div class="legend-dot" style="background:#0d2238;border:1px dashed #1a3a55"></div>Corridor</div>
<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>
<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>
<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>
<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>
<div class="legend-item"><div class="legend-dot" style="background:rgba(245,158,11,0.15);border:1.5px solid #f59e0b"></div>Elevator</div>
</div>
</div>
<div id="overlaySection" style="display:none">
<h3>Map Overlay</h3>
<div class="prop-row">
<label>OPACITY — <span id="opacityVal">0.35</span></label>
<input type="range" min="0" max="1" step="0.05" value="0.35"
style="width:100%;accent-color:#00c8e8;margin-top:4px"
oninput="state.overlay.opacity=+this.value;document.getElementById('opacityVal').textContent=(+this.value).toFixed(2);render()">
</div>
<div class="prop-row">
<label>OFFSET X / Y</label>
<div class="row2">
<input type="number" id="ovOX" value="0" placeholder="x" onchange="state.overlay.ox=+this.value;render()">
<input type="number" id="ovOY" value="0" placeholder="y" onchange="state.overlay.oy=+this.value;render()">
</div>
</div>
<div class="prop-row">
<label>SCALE X / Y</label>
<div class="row2">
<input type="number" id="ovSX" value="1" step="0.01" placeholder="sx" onchange="state.overlay.sx=+this.value;render()">
<input type="number" id="ovSY" value="1" step="0.01" placeholder="sy" onchange="state.overlay.sy=+this.value;render()">
</div>
</div>
<button class="xbtn xbtn-secondary" onclick="clearOverlay()" style="font-size:10px">✕ Remove overlay</button>
</div>
<div style="margin-top:auto;display:flex;flex-direction:column;gap:6px;">
<button class="xbtn xbtn-secondary" onclick="openImport()">↑ Import ROOMS_DEF</button>
<button class="xbtn xbtn-secondary" onclick="exportAllFloors()">↓ Export all floors</button>
<button class="xbtn xbtn-primary" onclick="openExport()">↓ Export current floor</button>
</div>
</div>
</div>
<!-- ── STATUS BAR ── -->
<div class="statusbar">
<span id="coordDisp">x: — y: —</span>
<span id="sizeDisp"></span>
<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>
</div>
<!-- ── EXPORT MODAL ── -->
<div class="overlay" id="exportModal" style="display:none">
<div class="modal">
<h2>EXPORT — JS CONSTANTS</h2>
<textarea id="exportText" readonly></textarea>
<div class="modal-actions">
<button class="xbtn xbtn-primary" onclick="copyExport()" id="copyBtn">Copy to clipboard</button>
<button class="xbtn xbtn-secondary" onclick="closeModal('exportModal')">Close</button>
</div>
</div>
</div>
<!-- ── IMPORT MODAL ── -->
<div class="overlay" id="importModal" style="display:none">
<div class="modal">
<h2>IMPORT — ROOMS_DEF</h2>
<p>Paste an existing ROOMS_DEF array (JS literal) to load into current floor:</p>
<textarea id="importText" placeholder="[&#10; { id:'R01', x:2, y:2, w:90, h:76, group:'top' },&#10; ...&#10;]"></textarea>
<div class="modal-actions">
<button class="xbtn xbtn-primary" onclick="doImport()">Import</button>
<button class="xbtn xbtn-secondary" onclick="closeModal('importModal')">Cancel</button>
</div>
</div>
</div>
<script>
// ──────────────────────────────────────────────────────────
// CONSTANTS
// ──────────────────────────────────────────────────────────
const MAP_W = 700, MAP_H = 490;
let SCALE = 1.5; // display scale (auto-fit on load)
const FLOORS = ['1','2','3','4','5','6','7','8','9','10'];
// ──────────────────────────────────────────────────────────
// STATE
// ──────────────────────────────────────────────────────────
const state = {
tool: 'select',
floor: '1',
floors: {}, // floorId → { rooms, walls, corridors, elevators }
selected: [], // array of { type, floorId, idx }
drawing: null, // { type, x0,y0,x1,y1 }
rubberBand: null, // { x0,y0,x1,y1, add } drag-select box
snap: 5,
gridSize: 10,
history: [],
roomCtr: {}, // floorId → next number
elvCtr: {},
clipboard: null, // [{type, obj}] for cross-floor copy/paste
defaultGroup: 'top',
// drag state
_dragStart: null, // { mx, my }
_dragOrigs: null, // [{x,y}] original positions for multi-move
_dragging: false,
overlay: { dataURL: null, opacity: 0.35, ox: 0, oy: 0, sx: 1, sy: 1 },
};
function fd(fid = state.floor) {
if (!state.floors[fid]) {
state.floors[fid] = { rooms:[], walls:[], corridors:[], elevators:[] };
state.roomCtr[fid] = 1;
state.elvCtr[fid] = 1;
}
return state.floors[fid];
}
const snap = v => Math.round(v / state.snap) * state.snap;
function isSelected(type, idx) {
return state.selected.some(s => s.type === type && s.idx === idx && s.floorId === state.floor);
}
function selectedObject(sel = state.selected[0]) {
if (!sel) return null;
return fd(sel.floorId)[sel.type + 's'][sel.idx];
}
function normRect(o) {
const x = Math.max(0, Math.min(MAP_W, snap(o.x)));
const y = Math.max(0, Math.min(MAP_H, snap(o.y)));
const x2 = Math.max(0, Math.min(MAP_W, snap(o.x + o.w)));
const y2 = Math.max(0, Math.min(MAP_H, snap(o.y + o.h)));
return { x: Math.min(x, x2), y: Math.min(y, y2), w: Math.abs(x2 - x), h: Math.abs(y2 - y) };
}
function rectsOverlap(a, b) {
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;
}
function mergeRects(rects) {
const clean = rects.map(normRect).filter(r => r.w > 0 && r.h > 0);
if (!clean.length) return [];
const xs = [...new Set(clean.flatMap(r => [r.x, r.x + r.w]))].sort((a,b) => a-b);
const ys = [...new Set(clean.flatMap(r => [r.y, r.y + r.h]))].sort((a,b) => a-b);
const occupied = new Set();
for (let yi = 0; yi < ys.length - 1; yi++) {
for (let xi = 0; xi < xs.length - 1; xi++) {
const cell = { x: xs[xi], y: ys[yi], w: xs[xi + 1] - xs[xi], h: ys[yi + 1] - ys[yi] };
if (clean.some(r => rectsOverlap(cell, r))) occupied.add(`${xi},${yi}`);
}
}
const used = new Set();
const has = (xi, yi) => occupied.has(`${xi},${yi}`) && !used.has(`${xi},${yi}`);
const out = [];
for (let yi = 0; yi < ys.length - 1; yi++) {
for (let xi = 0; xi < xs.length - 1; xi++) {
if (!has(xi, yi)) continue;
let xEnd = xi;
while (xEnd < xs.length - 1 && has(xEnd, yi)) xEnd++;
let yEnd = yi + 1;
grow:
while (yEnd < ys.length - 1) {
for (let xj = xi; xj < xEnd; xj++) {
if (!has(xj, yEnd)) break grow;
}
yEnd++;
}
for (let yy = yi; yy < yEnd; yy++) {
for (let xx = xi; xx < xEnd; xx++) used.add(`${xx},${yy}`);
}
out.push({ x: xs[xi], y: ys[yi], w: xs[xEnd] - xs[xi], h: ys[yEnd] - ys[yi] });
}
}
return out;
}
// ──────────────────────────────────────────────────────────
// HISTORY
// ──────────────────────────────────────────────────────────
function saveHistory() {
state.history.push(JSON.stringify(state.floors));
if (state.history.length > 60) state.history.shift();
}
function undo() {
if (!state.history.length) return;
state.floors = JSON.parse(state.history.pop());
state.selected = [];
render(); updateStats(); updateProps();
}
// ──────────────────────────────────────────────────────────
// COORDINATE TRANSFORM
// ──────────────────────────────────────────────────────────
function svgXY(e) {
const r = document.getElementById('canvas').getBoundingClientRect();
return { x: (e.clientX - r.left) / SCALE, y: (e.clientY - r.top) / SCALE };
}
// ──────────────────────────────────────────────────────────
// TOOL SWITCHING
// ──────────────────────────────────────────────────────────
const HINTS = {
select: 'Click/drag to select · Shift=add/remove · Drag selected to move · Del=delete · Ctrl+C/V=copy/paste',
room: 'Drag to draw room · release to confirm',
wall: 'Drag to draw wall boundary rect',
corridor: 'Drag to draw corridor fill',
elevator: 'Drag to draw elevator shaft',
};
function setTool(t) {
state.tool = t; state.drawing = null;
document.querySelectorAll('.tbtn[id^="tool-"]').forEach(b => b.classList.remove('active'));
document.getElementById('tool-' + t)?.classList.add('active');
document.getElementById('hintDisp').textContent = HINTS[t] || '';
render();
}
// ──────────────────────────────────────────────────────────
// DELETE / DUPLICATE
// ──────────────────────────────────────────────────────────
function deleteSelected() {
if (!state.selected.length) return;
saveHistory();
const byFloorType = {};
state.selected.forEach(s => {
const key = `${s.floorId}:${s.type}`;
(byFloorType[key] = byFloorType[key] || []).push(s.idx);
});
for (const [key, idxs] of Object.entries(byFloorType)) {
const [floorId, type] = key.split(':');
idxs.sort((a,b) => b-a).forEach(i => fd(floorId)[type+'s'].splice(i,1));
}
state.selected = [];
render(); updateStats(); updateProps();
}
function duplicateSelected() {
if (!state.selected.length) return;
saveHistory();
const newSel = [];
state.selected.forEach(sel => {
const d = fd(sel.floorId);
const src = d[sel.type + 's'][sel.idx];
const copy = { ...src, x: src.x + 10, y: src.y + 10 };
if (sel.type === 'room') copy.id = 'R' + String(state.roomCtr[state.floor]++).padStart(2,'0');
else if (sel.type === 'elevator') copy.id = 'E' + (state.elvCtr[state.floor]++);
d[sel.type + 's'].push(copy);
newSel.push({ ...sel, idx: d[sel.type+'s'].length - 1 });
});
state.selected = newSel;
render(); updateStats(); updateProps();
}
function copySelected() {
if (!state.selected.length) return;
state.clipboard = state.selected.map(s => ({
type: s.type,
obj: JSON.parse(JSON.stringify(fd(s.floorId)[s.type+'s'][s.idx])),
}));
flashHint(`${state.clipboard.length} item${state.clipboard.length>1?'s':''} copied — Ctrl+V to paste`);
}
function pasteClipboard() {
if (!state.clipboard || !state.clipboard.length) return;
saveHistory();
const newSel = [];
state.clipboard.forEach(({ type, obj }) => {
const copy = { ...obj, x: snap(obj.x + 10), y: snap(obj.y + 10) };
if (type === 'room') {
if (!state.roomCtr[state.floor]) state.roomCtr[state.floor] = 1;
copy.id = 'R' + String(state.roomCtr[state.floor]++).padStart(2,'0');
} else if (type === 'elevator') {
if (!state.elvCtr[state.floor]) state.elvCtr[state.floor] = 1;
copy.id = 'E' + (state.elvCtr[state.floor]++);
}
fd()[type+'s'].push(copy);
newSel.push({ type, floorId: state.floor, idx: fd()[type+'s'].length - 1 });
});
state.selected = newSel;
render(); updateStats(); updateProps();
}
function copyToFloor(targetFloor) {
if (!state.selected.length) return;
saveHistory();
state.selected.forEach(s => {
const { type } = s;
const copy = JSON.parse(JSON.stringify(fd(s.floorId)[type+'s'][s.idx]));
if (type === 'room') {
if (!state.roomCtr[targetFloor]) state.roomCtr[targetFloor] = 1;
copy.id = 'R' + String(state.roomCtr[targetFloor]++).padStart(2,'0');
} else if (type === 'elevator') {
if (!state.elvCtr[targetFloor]) state.elvCtr[targetFloor] = 1;
copy.id = 'E' + (state.elvCtr[targetFloor]++);
}
fd(targetFloor)[type+'s'].push(copy);
});
const n = state.selected.length;
flashHint(`${n} item${n>1?'s':''} copied to ${targetFloor}F`);
render(); updateStats();
}
function autoBuildWalls() {
const d = fd();
saveHistory();
d.rooms = d.rooms.map(normRect).map((r, i) => ({ ...d.rooms[i], ...r }));
d.elevators = d.elevators.map(normRect).map((r, i) => ({ ...d.elevators[i], ...r }));
d.corridors = mergeRects(d.corridors);
const solid = [...d.rooms, ...d.elevators, ...d.corridors].map(normRect).filter(r => r.w > 0 && r.h > 0);
const xs = [0, MAP_W], ys = [0, MAP_H];
solid.forEach(r => {
xs.push(r.x, r.x + r.w);
ys.push(r.y, r.y + r.h);
});
const xCuts = [...new Set(xs.map(snap))].filter(x => x >= 0 && x <= MAP_W).sort((a,b) => a-b);
const yCuts = [...new Set(ys.map(snap))].filter(y => y >= 0 && y <= MAP_H).sort((a,b) => a-b);
const walls = [];
for (let yi = 0; yi < yCuts.length - 1; yi++) {
for (let xi = 0; xi < xCuts.length - 1; xi++) {
const cell = {
x: xCuts[xi],
y: yCuts[yi],
w: xCuts[xi + 1] - xCuts[xi],
h: yCuts[yi + 1] - yCuts[yi],
};
if (cell.w <= 0 || cell.h <= 0) continue;
if (!solid.some(o => rectsOverlap(cell, o))) walls.push(cell);
}
}
d.walls = mergeRects(walls);
state.selected = [];
flashHint(`Auto walls: ${d.walls.length} merged wall block${d.walls.length === 1 ? '' : 's'}`);
render(); updateStats(); updateProps();
}
function flashHint(msg) {
const el = document.getElementById('hintDisp');
el.textContent = msg;
clearTimeout(flashHint._t);
flashHint._t = setTimeout(() => { el.textContent = HINTS[state.tool] || ''; }, 2000);
}
// ── Door picker ──────────────────────────────────────────────
const DOOR_COLORS = { top:'#00c8e8', bot:'#00e676', vert:'#ff6b2b', 'vert-w':'#e83a8c' };
const DOOR_DIRS = [
{ g: 'bot', sym: '↑', r: 0, c: 1, title: 'North' },
{ g: 'vert-w', sym: '←', r: 1, c: 0, title: 'West' },
{ g: 'vert', sym: '→', r: 1, c: 2, title: 'East' },
{ g: 'top', sym: '↓', r: 2, c: 1, title: 'South' },
];
function doorPickerHTML(currentGroup, onchangeFn) {
const grid = Array(9).fill(null);
DOOR_DIRS.forEach(d => { grid[d.r * 3 + d.c] = d; });
return `<div class="door-picker">${grid.map((d, i) => {
if (i === 4) return `<div class="dpick-room"></div>`;
if (!d) return `<div></div>`;
const active = currentGroup === d.g;
const col = DOOR_COLORS[d.g] || '#7ab0c8';
const style = active ? `background:${col}22;border-color:${col};color:${col}` : '';
return `<button class="dpick-btn" style="${style}"
onclick="${onchangeFn}('${d.g}')" title="Door ${d.title}">${d.sym}</button>`;
}).join('')}</div>`;
}
function setDefaultGroup(g) {
state.defaultGroup = g;
const el = document.getElementById('defaultDoorPicker');
if (el) el.innerHTML = doorPickerHTML(g, 'setDefaultGroup');
}
function setPropGroup(g) { setProp('group', g); }
// ──────────────────────────────────────────────────────────
// MOUSE EVENTS
// ──────────────────────────────────────────────────────────
const svg = document.getElementById('canvas');
let mouseIsDown = false;
svg.addEventListener('mousedown', e => {
if (e.button !== 0) return;
mouseIsDown = true;
const { x, y } = svgXY(e);
if (state.tool === 'select') {
const hit = hitTest(x, y);
if (hit) {
let canDrag = true;
if (e.shiftKey) {
const ei = state.selected.findIndex(s => s.type===hit.type && s.idx===hit.idx && s.floorId===hit.floorId);
if (ei >= 0) { state.selected.splice(ei, 1); canDrag = false; }
else state.selected.push(hit);
} else {
if (!isSelected(hit.type, hit.idx)) state.selected = [hit];
}
if (canDrag) {
state._dragStart = { mx: x, my: y };
state._dragOrigs = state.selected.map(s => {
const o = fd(s.floorId)[s.type+'s'][s.idx];
return { x: o.x, y: o.y };
});
state._dragging = false;
} else {
state._dragStart = null;
state._dragOrigs = null;
state._dragging = false;
}
} else {
if (!e.shiftKey) state.selected = [];
state.rubberBand = { x0: x, y0: y, x1: x, y1: y, add: e.shiftKey };
}
render(); updateProps();
return;
}
state.drawing = { type: state.tool, x0: snap(x), y0: snap(y), x1: snap(x), y1: snap(y) };
e.preventDefault();
});
svg.addEventListener('mousemove', e => {
const { x, y } = svgXY(e);
document.getElementById('coordDisp').textContent = `x: ${Math.round(x)} y: ${Math.round(y)}`;
if (!mouseIsDown) return;
// Move selected
if (state.tool === 'select') {
if (state.selected.length && state._dragStart) {
const dx = x - state._dragStart.mx;
const dy = y - state._dragStart.my;
if (!state._dragging && (Math.abs(dx) > 2 || Math.abs(dy) > 2)) {
saveHistory(); state._dragging = true;
}
if (state._dragging) {
state.selected.forEach((s, i) => {
const obj = fd(s.floorId)[s.type+'s'][s.idx];
obj.x = snap(state._dragOrigs[i].x + dx);
obj.y = snap(state._dragOrigs[i].y + dy);
});
render(); updateProps();
}
return;
}
if (state.rubberBand) {
state.rubberBand.x1 = x; state.rubberBand.y1 = y;
render(); return;
}
return;
}
// Update drawing preview
if (state.drawing) {
state.drawing.x1 = snap(x);
state.drawing.y1 = snap(y);
const w = Math.abs(state.drawing.x1 - state.drawing.x0);
const h = Math.abs(state.drawing.y1 - state.drawing.y0);
document.getElementById('sizeDisp').textContent = w > 0 ? `${Math.round(w)} × ${Math.round(h)}` : '';
render();
}
});
document.addEventListener('mouseup', e => {
if (!mouseIsDown) return;
mouseIsDown = false;
state._dragStart = null; state._dragOrigs = null; state._dragging = false;
if (state.rubberBand) {
const rb = state.rubberBand;
state.rubberBand = null;
const rx = Math.min(rb.x0,rb.x1), ry = Math.min(rb.y0,rb.y1);
const rw = Math.abs(rb.x1-rb.x0), rh = Math.abs(rb.y1-rb.y0);
if (rw > 3 && rh > 3) {
const fdata = fd();
const hits = [];
for (const type of ['room','elevator','wall','corridor']) {
fdata[type+'s'].forEach((o, idx) => {
if (o.x < rx+rw && o.x+o.w > rx && o.y < ry+rh && o.y+o.h > ry)
hits.push({ type, floorId: state.floor, idx });
});
}
if (rb.add) {
hits.forEach(hit => {
if (!state.selected.some(s => s.type === hit.type && s.idx === hit.idx && s.floorId === hit.floorId))
state.selected.push(hit);
});
} else {
state.selected = hits;
}
}
render(); updateProps();
}
if (state.drawing) {
const d = state.drawing;
const x = Math.min(d.x0, d.x1), y = Math.min(d.y0, d.y1);
const w = Math.abs(d.x1 - d.x0), h = Math.abs(d.y1 - d.y0);
if (w > 4 && h > 4) {
saveHistory();
const fdata = fd();
if (d.type === 'room') {
const grp = state.defaultGroup;
const id = 'R' + String(state.roomCtr[state.floor]++).padStart(2, '0');
fdata.rooms.push({ id, x, y, w, h, group: grp });
} else if (d.type === 'elevator') {
const id = 'E' + (state.elvCtr[state.floor]++);
fdata.elevators.push({ id, x, y, w, h });
} else if (d.type === 'wall') {
fdata.walls.push({ x, y, w, h });
fdata.walls = mergeRects(fdata.walls);
} else if (d.type === 'corridor') {
fdata.corridors.push({ x, y, w, h });
fdata.corridors = mergeRects(fdata.corridors);
}
}
state.drawing = null;
render(); updateStats();
}
});
// Double-click to rename room
svg.addEventListener('dblclick', e => {
const { x, y } = svgXY(e);
const hit = hitTest(x, y);
if (!hit || hit.type !== 'room') return;
const obj = fd(hit.floorId).rooms[hit.idx];
const name = prompt('Room ID:', obj.id);
if (name && name.trim()) {
saveHistory();
obj.id = name.trim();
render(); updateProps();
}
});
// ──────────────────────────────────────────────────────────
// HIT TEST
// ──────────────────────────────────────────────────────────
function hitTest(x, y) {
const fdata = fd();
for (const type of ['room','elevator','wall','corridor']) {
const arr = fdata[type + 's'];
for (let i = arr.length - 1; i >= 0; i--) {
const o = arr[i];
if (x >= o.x && x <= o.x + o.w && y >= o.y && y <= o.y + o.h)
return { type, floorId: state.floor, idx: i };
}
}
return null;
}
// ──────────────────────────────────────────────────────────
// KEYBOARD
// ──────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); deleteSelected(); }
if (e.key === 'Escape') { state.drawing = null; state.rubberBand = null; state.selected = []; render(); updateProps(); }
if (!e.ctrlKey && !e.metaKey) {
if (e.key === 's' || e.key === 'S') setTool('select');
if (e.key === 'r' || e.key === 'R') setTool('room');
if (e.key === 'w' || e.key === 'W') setTool('wall');
if (e.key === 'c' || e.key === 'C') setTool('corridor');
if (e.key === 'e' || e.key === 'E') setTool('elevator');
if (e.key === 'd' || e.key === 'D') duplicateSelected();
}
if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); undo(); }
if ((e.ctrlKey || e.metaKey) && e.key === 'c') { e.preventDefault(); copySelected(); }
if ((e.ctrlKey || e.metaKey) && e.key === 'v') { e.preventDefault(); pasteClipboard(); }
});
// ──────────────────────────────────────────────────────────
// FLOORS
// ──────────────────────────────────────────────────────────
function renderFloorTabs() {
document.getElementById('floorTabs').innerHTML = FLOORS.map(f => `
<button class="ftab ${state.floor === f ? 'active' : ''}" onclick="switchFloor('${f}')">
${f}F
</button>
`).join('');
}
function switchFloor(f) {
state.floor = f; state.selected = []; state.drawing = null; state.rubberBand = null;
renderFloorTabs(); render(); updateStats(); updateProps();
}
function addFloor() {
const lbl = prompt('Floor label (e.g. B1, 10, RF):');
if (!lbl || FLOORS.includes(lbl)) return;
FLOORS.push(lbl);
switchFloor(lbl);
}
// ──────────────────────────────────────────────────────────
// PROPERTIES PANEL
// ──────────────────────────────────────────────────────────
function updateProps() {
const sec = document.getElementById('propsSection');
const form = document.getElementById('propsForm');
if (!state.selected.length) { sec.style.display = 'none'; return; }
if (state.selected.length > 1) {
sec.style.display = 'block';
form.innerHTML = `
<div style="font-size:10px;color:#3a6070;letter-spacing:1px;margin-bottom:7px;font-family:monospace">
${state.selected.length} ITEMS SELECTED
</div>
<button class="xbtn xbtn-secondary" style="margin-top:4px;font-size:10px" onclick="duplicateSelected()">Duplicate selected (D)</button>`;
return;
}
const sel = state.selected[0];
const obj = selectedObject(sel);
sec.style.display = 'block';
let html = `<div style="font-size:10px;color:#3a6070;letter-spacing:1px;margin-bottom:7px;font-family:monospace">${sel.type.toUpperCase()}</div>`;
if (sel.type === 'room') {
html += `
<div class="prop-row"><label>ID</label>
<input value="${obj.id}" onchange="setProp('id',this.value)"></div>
<div class="prop-row"><label>DOOR</label>
${doorPickerHTML(obj.group, 'setPropGroup')}</div>`;
} else if (sel.type === 'elevator') {
html += `<div class="prop-row"><label>ID</label><input value="${obj.id}" onchange="setProp('id',this.value)"></div>`;
}
html += `
<div class="prop-row"><label>POSITION</label>
<div class="row2">
<input type="number" value="${Math.round(obj.x)}" placeholder="x" onchange="setProp('x',+this.value)">
<input type="number" value="${Math.round(obj.y)}" placeholder="y" onchange="setProp('y',+this.value)">
</div></div>
<div class="prop-row"><label>SIZE</label>
<div class="row2">
<input type="number" value="${Math.round(obj.w)}" placeholder="w" onchange="setProp('w',+this.value)">
<input type="number" value="${Math.round(obj.h)}" placeholder="h" onchange="setProp('h',+this.value)">
</div></div>
<button class="xbtn xbtn-secondary" style="margin-top:4px;font-size:10px" onclick="duplicateSelected()">⧉ Duplicate (D)</button>`;
const otherFloors = FLOORS.filter(f => f !== state.floor);
if (otherFloors.length) {
html += `<div class="prop-row" style="margin-top:8px"><label>COPY TO FLOOR</label>
<div style="display:flex;flex-wrap:wrap;gap:3px;margin-top:3px">
${otherFloors.map(f =>
`<button class="tbtn" style="padding:3px 7px;font-size:10px"
onclick="copyToFloor('${f}')">${f}F</button>`
).join('')}
</div></div>`;
}
form.innerHTML = html;
}
function setProp(key, val) {
if (state.selected.length !== 1) return;
const obj = selectedObject();
saveHistory();
obj[key] = val;
render(); updateProps();
}
// ──────────────────────────────────────────────────────────
// STATS
// ──────────────────────────────────────────────────────────
function updateStats() {
const d = fd();
document.getElementById('sRooms').textContent = d.rooms.length;
document.getElementById('sElvs').textContent = d.elevators.length;
document.getElementById('sWalls').textContent = d.walls.length;
document.getElementById('sCorr').textContent = d.corridors.length;
}
// ──────────────────────────────────────────────────────────
// RENDER
// ──────────────────────────────────────────────────────────
const ROOM_COLORS = {
top: { fill:'rgba(0,200,232,0.12)', stroke:'#00c8e8' },
bot: { fill:'rgba(0,230,118,0.12)', stroke:'#00e676' },
vert: { fill:'rgba(255,107,43,0.12)', stroke:'#ff6b2b' },
'vert-w': { fill:'rgba(232,58,140,0.12)', stroke:'#e83a8c' },
other: { fill:'rgba(168,85,247,0.12)', stroke:'#a855f7' },
};
function getDoor(r) {
if (r.group === 'top') return { x: r.x + r.w/2, y: r.y + r.h };
if (r.group === 'bot') return { x: r.x + r.w/2, y: r.y };
if (r.group === 'vert') return { x: r.x + r.w, y: r.y + r.h/2 };
if (r.group === 'vert-w') return { x: r.x, y: r.y + r.h/2 };
return { x: r.x + r.w/2, y: r.y + r.h };
}
function selHandles(o) {
return [[0,0],[o.w,0],[o.w,o.h],[0,o.h]].map(([dx,dy]) =>
`<rect x="${o.x+dx-3.5}" y="${o.y+dy-3.5}" width="7" height="7" rx="1"
fill="#fff" stroke="#00c8e8" stroke-width="1.2"/>`
).join('');
}
function unionLayerHTML(rects, fill, stroke, strokeWidth = 1.2, dash = '') {
const clean = rects.map(normRect).filter(r => r.w > 0 && r.h > 0);
if (!clean.length) return '';
const xs = [...new Set(clean.flatMap(r => [r.x, r.x + r.w]))].sort((a,b) => a-b);
const ys = [...new Set(clean.flatMap(r => [r.y, r.y + r.h]))].sort((a,b) => a-b);
const filled = [];
const occupied = new Set();
for (let yi = 0; yi < ys.length - 1; yi++) {
for (let xi = 0; xi < xs.length - 1; xi++) {
const cell = { x: xs[xi], y: ys[yi], w: xs[xi + 1] - xs[xi], h: ys[yi + 1] - ys[yi] };
if (cell.w <= 0 || cell.h <= 0) continue;
if (clean.some(r => rectsOverlap(cell, r))) {
occupied.add(`${xi},${yi}`);
filled.push(cell);
}
}
}
let h = `<g>`;
filled.forEach(c => {
h += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" fill="${fill}"/>`;
});
const path = [];
const has = (xi, yi) => occupied.has(`${xi},${yi}`);
for (let yi = 0; yi < ys.length - 1; yi++) {
for (let xi = 0; xi < xs.length - 1; xi++) {
if (!has(xi, yi)) continue;
const x1 = xs[xi], x2 = xs[xi + 1], y1 = ys[yi], y2 = ys[yi + 1];
if (!has(xi, yi - 1)) path.push(`M${x1} ${y1}H${x2}`);
if (!has(xi + 1, yi)) path.push(`M${x2} ${y1}V${y2}`);
if (!has(xi, yi + 1)) path.push(`M${x2} ${y2}H${x1}`);
if (!has(xi - 1, yi)) path.push(`M${x1} ${y2}V${y1}`);
}
}
if (path.length) {
h += `<path d="${path.join('')}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
stroke-linejoin="round" stroke-linecap="round" ${dash ? `stroke-dasharray="${dash}"` : ''}/>`;
}
return h + `</g>`;
}
function render() {
const fdata = fd();
const W = MAP_W * SCALE, H = MAP_H * SCALE;
svg.setAttribute('width', W);
svg.setAttribute('height', H);
svg.setAttribute('viewBox', `0 0 ${MAP_W} ${MAP_H}`);
let h = '';
// Background
h += `<rect width="${MAP_W}" height="${MAP_H}" fill="#050c14"/>`;
// PGM overlay
if (state.overlay.dataURL) {
const ov = state.overlay;
h += `<image href="${ov.dataURL}"
x="${ov.ox}" y="${ov.oy}"
width="${MAP_W * ov.sx}" height="${MAP_H * ov.sy}"
opacity="${ov.opacity}" preserveAspectRatio="none"
style="image-rendering:pixelated"/>`;
}
// Grid
h += `<g opacity="0.4">`;
for (let x = 0; x <= MAP_W; x += state.gridSize)
h += `<line x1="${x}" y1="0" x2="${x}" y2="${MAP_H}" stroke="#0a1e2e" stroke-width="${x % (state.gridSize*5) === 0 ? 1 : 0.5}"/>`;
for (let y = 0; y <= MAP_H; y += state.gridSize)
h += `<line x1="0" y1="${y}" x2="${MAP_W}" y2="${y}" stroke="#0a1e2e" stroke-width="${y % (state.gridSize*5) === 0 ? 1 : 0.5}"/>`;
h += `</g>`;
// Corridors
h += unionLayerHTML(fdata.corridors, '#0d2238', '#1a3a55', 0.9, '5 4');
fdata.corridors.forEach((c, i) => {
if (!isSelected('corridor', i)) return;
h += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}"
fill="none" stroke="#ffffff" stroke-width="2"/>`;
h += selHandles(c);
});
// Walls
h += unionLayerHTML(fdata.walls, '#0a1828', '#1c3a55', 1.5);
fdata.walls.forEach((w, i) => {
if (!isSelected('wall', i)) return;
h += `<rect x="${w.x}" y="${w.y}" width="${w.w}" height="${w.h}"
fill="none" stroke="#ffffff" stroke-width="2" rx="2"/>`;
h += selHandles(w);
});
// Elevators
fdata.elevators.forEach((elv, i) => {
const sel = isSelected('elevator', i);
h += `<rect x="${elv.x}" y="${elv.y}" width="${elv.w}" height="${elv.h}"
fill="rgba(245,158,11,0.15)" stroke="${sel ? '#ffffff' : '#f59e0b'}" stroke-width="${sel ? 2 : 1.5}" rx="3"/>`;
h += `<text x="${elv.x+elv.w/2}" y="${elv.y+elv.h/2-4}" text-anchor="middle"
fill="${sel ? '#fff' : '#f59e0b'}" font-size="9" font-family="monospace" font-weight="600">ELV</text>`;
h += `<text x="${elv.x+elv.w/2}" y="${elv.y+elv.h/2+9}" text-anchor="middle"
fill="${sel ? '#fff' : '#f59e0b80'}" font-size="11" font-family="monospace">${elv.id}</text>`;
if (sel) h += selHandles(elv);
});
// Rooms
fdata.rooms.forEach((r, i) => {
const sel = isSelected('room', i);
const c = ROOM_COLORS[r.group] || ROOM_COLORS.other;
h += `<rect x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}"
fill="${c.fill}" stroke="${sel ? '#ffffff' : c.stroke}" stroke-width="${sel ? 2 : 1.5}" rx="3"/>`;
h += `<text x="${r.x+r.w/2}" y="${r.y+r.h/2+1}" text-anchor="middle" dominant-baseline="middle"
fill="${sel ? '#fff' : c.stroke}" font-size="${Math.min(r.w, r.h) > 24 ? 10 : 8}"
font-family="monospace" font-weight="600">${r.id}</text>`;
const door = getDoor(r);
h += `<circle cx="${door.x}" cy="${door.y}" r="3" fill="${c.stroke}70"/>`;
if (sel) h += selHandles(r);
});
// Rubber-band selection
if (state.rubberBand) {
const rb = state.rubberBand;
const x = Math.min(rb.x0, rb.x1), y = Math.min(rb.y0, rb.y1);
const w = Math.abs(rb.x1 - rb.x0), h2 = Math.abs(rb.y1 - rb.y0);
h += `<rect x="${x}" y="${y}" width="${w}" height="${h2}"
fill="rgba(255,255,255,0.06)" stroke="#ffffff" stroke-width="1"
stroke-dasharray="4 3" rx="2"/>`;
}
// Drawing preview
if (state.drawing) {
const d = state.drawing;
const x = Math.min(d.x0, d.x1), y = Math.min(d.y0, d.y1);
const w = Math.abs(d.x1 - d.x0), h2 = Math.abs(d.y1 - d.y0);
const DCOL = { room:'#00c8e8', wall:'#1c3a55', corridor:'#1a3a55', elevator:'#f59e0b' };
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)' };
h += `<rect x="${x}" y="${y}" width="${w}" height="${h2}"
fill="${DFIL[d.type]||DFIL.room}" stroke="${DCOL[d.type]||DCOL.room}"
stroke-width="1.5" stroke-dasharray="4 3" rx="2"/>`;
h += `<text x="${x+w/2}" y="${y+h2/2+1}" text-anchor="middle" dominant-baseline="middle"
fill="${DCOL[d.type]||DCOL.room}" font-size="10" font-family="monospace" opacity="0.7">
${Math.round(w)}×${Math.round(h2)}</text>`;
}
// Border
h += `<rect x="0" y="0" width="${MAP_W}" height="${MAP_H}" fill="none" stroke="#1a3a55" stroke-width="1.5"/>`;
svg.innerHTML = h;
}
// ──────────────────────────────────────────────────────────
// EXPORT
// ──────────────────────────────────────────────────────────
function openExport() {
const d = fd();
let out = exportFloorJS(state.floor, d);
document.getElementById('exportText').value = out;
document.getElementById('exportModal').style.display = 'flex';
}
function exportAllFloors() {
let out = `const FLOOR_MAPS = {\n`;
FLOORS.forEach(f => {
const d = fd(f);
out += ` '${f}': {\n`;
out += ` walls: [${d.walls.map(w => `{ x:${r(w.x)}, y:${r(w.y)}, w:${r(w.w)}, h:${r(w.h)} }`).join(', ')}],\n`;
out += ` corridors: [${d.corridors.map(c => `{ x:${r(c.x)}, y:${r(c.y)}, w:${r(c.w)}, h:${r(c.h)} }`).join(', ')}],\n`;
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`;
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`;
out += ` },\n`;
});
out += `};\n`;
document.getElementById('exportText').value = out;
document.getElementById('exportModal').style.display = 'flex';
}
function exportFloorJS(floor, d) {
let out = `// Floor: ${floor} — generated by Map Editor\n\n`;
if (d.walls.length) {
out += `const WALLS_DEF = [\n`;
d.walls.forEach(w => out += ` { x:${r(w.x)}, y:${r(w.y)}, w:${r(w.w)}, h:${r(w.h)} },\n`);
out += `];\n\n`;
}
if (d.corridors.length) {
out += `const CORRIDORS_DEF = [\n`;
d.corridors.forEach(c => out += ` { x:${r(c.x)}, y:${r(c.y)}, w:${r(c.w)}, h:${r(c.h)} },\n`);
out += `];\n\n`;
}
out += `const ROOMS_DEF = [\n`;
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`);
out += `];\n`;
if (d.elevators.length) {
out += `\nconst ELVS_DEF = [\n`;
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`);
out += `];\n`;
}
return out;
}
const r = v => Math.round(v);
// ──────────────────────────────────────────────────────────
// PGM OVERLAY
// ──────────────────────────────────────────────────────────
function loadPGM(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
try {
const dataURL = parsePGMtoDataURL(e.target.result);
state.overlay.dataURL = dataURL;
// auto-fit: reset transform
state.overlay.ox = 0; state.overlay.oy = 0;
state.overlay.sx = 1; state.overlay.sy = 1;
document.getElementById('ovOX').value = 0;
document.getElementById('ovOY').value = 0;
document.getElementById('ovSX').value = 1;
document.getElementById('ovSY').value = 1;
document.getElementById('overlaySection').style.display = 'block';
render();
} catch(err) { alert('Failed to load PGM: ' + err.message); }
};
reader.readAsArrayBuffer(file);
input.value = '';
}
function parsePGMtoDataURL(buffer) {
const bytes = new Uint8Array(buffer);
let pos = 0;
const readToken = () => {
while (pos < bytes.length) {
if (bytes[pos] === 35) { // '#' comment
while (pos < bytes.length && bytes[pos] !== 10) pos++;
} else if (bytes[pos] <= 32) { pos++; } // whitespace
else break;
}
let s = '';
while (pos < bytes.length && bytes[pos] > 32) s += String.fromCharCode(bytes[pos++]);
return s;
};
const magic = readToken();
if (magic !== 'P5' && magic !== 'P2') throw new Error('Not a valid PGM (expected P5 or P2, got ' + magic + ')');
const w = parseInt(readToken());
const h = parseInt(readToken());
const maxval = parseInt(readToken());
if (magic === 'P5') pos++; // single whitespace separator after header
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(w, h);
const total = w * h;
if (magic === 'P5') {
const wide = maxval > 255;
for (let i = 0; i < total; i++) {
const raw = wide ? ((bytes[pos] << 8) | bytes[pos + 1]) : bytes[pos];
const v = Math.round((raw / maxval) * 255);
const p = i * 4;
img.data[p] = img.data[p+1] = img.data[p+2] = v;
img.data[p+3] = 255;
pos += wide ? 2 : 1;
}
} else {
for (let i = 0; i < total; i++) {
const v = Math.round((parseInt(readToken()) / maxval) * 255);
const p = i * 4;
img.data[p] = img.data[p+1] = img.data[p+2] = v;
img.data[p+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
return canvas.toDataURL('image/png');
}
function clearOverlay() {
state.overlay.dataURL = null;
document.getElementById('overlaySection').style.display = 'none';
render();
}
function copyExport() {
const ta = document.getElementById('exportText');
ta.select();
document.execCommand('copy');
const btn = document.getElementById('copyBtn');
btn.textContent = '✓ Copied!';
setTimeout(() => btn.textContent = 'Copy to clipboard', 2000);
}
// ──────────────────────────────────────────────────────────
// IMPORT
// ──────────────────────────────────────────────────────────
function openImport() {
document.getElementById('importText').value = '';
document.getElementById('importModal').style.display = 'flex';
}
function doImport() {
const raw = document.getElementById('importText').value.trim();
try {
const arr = new Function('return ' + raw)();
if (!Array.isArray(arr)) throw new Error('Expected an array');
saveHistory();
const d = fd();
d.rooms = arr.map(rm => ({
id: rm.id || 'R??', x: rm.x || 0, y: rm.y || 0,
w: rm.w || 60, h: rm.h || 50, group: rm.group || 'top',
}));
const nums = d.rooms.map(rm => parseInt(rm.id.replace(/\D/g,''))).filter(n => !isNaN(n));
state.roomCtr[state.floor] = nums.length ? Math.max(...nums) + 1 : d.rooms.length + 1;
closeModal('importModal');
render(); updateStats();
} catch(err) {
alert('Parse error: ' + err.message);
}
}
function closeModal(id) {
document.getElementById(id).style.display = 'none';
}
// Close modals on overlay click
document.querySelectorAll('.overlay').forEach(el => {
el.addEventListener('click', e => { if (e.target === el) el.style.display = 'none'; });
});
// ──────────────────────────────────────────────────────────
// LOCALSTORAGE PERSIST
// ──────────────────────────────────────────────────────────
function save() {
try {
localStorage.setItem('mapedit_floors', JSON.stringify(state.floors));
localStorage.setItem('mapedit_roomctr', JSON.stringify(state.roomCtr));
localStorage.setItem('mapedit_elvctr', JSON.stringify(state.elvCtr));
} catch {}
}
function load() {
try {
const f = localStorage.getItem('mapedit_floors');
if (f) state.floors = JSON.parse(f);
const rc = localStorage.getItem('mapedit_roomctr');
if (rc) state.roomCtr = JSON.parse(rc);
const ec = localStorage.getItem('mapedit_elvctr');
if (ec) state.elvCtr = JSON.parse(ec);
migrateFloorLabels();
} catch {}
}
function migrateFloorLabels() {
if (!state.floors.G) return;
const remap = { G:'1', '1':'2', '2':'3', '3':'4', '4':'5', '5':'6', '6':'7', '7':'8', '8':'9', '9':'10' };
const oldFloors = state.floors;
const oldRoomCtr = state.roomCtr;
const oldElvCtr = state.elvCtr;
state.floors = {};
state.roomCtr = {};
state.elvCtr = {};
Object.entries(remap).forEach(([oldKey, newKey]) => {
if (oldFloors[oldKey]) state.floors[newKey] = oldFloors[oldKey];
if (oldRoomCtr[oldKey]) state.roomCtr[newKey] = oldRoomCtr[oldKey];
if (oldElvCtr[oldKey]) state.elvCtr[newKey] = oldElvCtr[oldKey];
});
state.floor = '1';
save();
}
setInterval(save, 8000);
window.addEventListener('beforeunload', save);
// Auto-fit scale to canvas wrap
function fitScale() {
const wrap = document.getElementById('canvasWrap');
const padded = Math.min(
(wrap.clientWidth - 48) / MAP_W,
(wrap.clientHeight - 48) / MAP_H,
);
SCALE = Math.max(0.6, Math.min(2.5, padded));
}
// ──────────────────────────────────────────────────────────
// INIT
// ──────────────────────────────────────────────────────────
load();
fitScale();
renderFloorTabs();
render();
updateStats();
setDefaultGroup(state.defaultGroup);
</script>
</body>
</html>