Spaces:
Sleeping
Sleeping
| <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="[ { id:'R01', x:2, y:2, w:90, h:76, group:'top' }, ... ]"></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> | |