/** * Layout Builder – powered by Konva.js * * Three modes: * structure – draw walls, rooms, doors, windows, fences, text * devices – drag board entities (relays/inputs) onto the canvas * view – live read-only view; relays are clickable for toggling * * Persistence schema (canvas_json): * { * structure: [ { id, tool, x, y, w, h, rotation, text, stroke, fill }, … ], * devices: [ { id, boardId, entityType, entityNum, x, y }, … ] * } */ (function () { "use strict"; const CFG = window.LB_CONFIG; // ── constants ────────────────────────────────────────────────────────────── const GRID = 20; const SNAP = true; // Colour palette for structure tools const TOOL_STYLE = { wall: { fill: "#4a4a52", stroke: "#6e7681", strokeWidth: 2 }, room: { fill: "rgba(79,140,205,.07)", stroke: "#4f8ccd", strokeWidth: 1.5, dash: [] }, door: { fill: "transparent", stroke: "#f0883e", strokeWidth: 2 }, window: { fill: "transparent", stroke: "#79c0ff", strokeWidth: 2, dash: [8, 4] }, fence: { fill: "transparent", stroke: "#8b5cf6", strokeWidth: 1.5, dash: [4, 4] }, }; // ── state ────────────────────────────────────────────────────────────────── let mode = "structure"; // structure | devices | view let activeTool = "select"; // select | pan | wall | room | door | window | fence | text let isDrawing = false; let drawStart = null; let tempShape = null; let selectedNode = null; let selectedDeviceGroup = null; let transformer = null; let saveTimer = null; let isDirty = false; // incremental ID let nextId = Date.now(); const uid = () => `s${nextId++}`; // ── stage & layers ───────────────────────────────────────────────────────── const container = document.getElementById("konva-container"); const stageWrap = document.getElementById("lb-stage"); const stage = new Konva.Stage({ container: "konva-container", width: stageWrap.clientWidth, height: stageWrap.clientHeight, }); const gridLayer = new Konva.Layer({ listening: false }); const structureLayer = new Konva.Layer(); const deviceLayer = new Konva.Layer(); const uiLayer = new Konva.Layer(); // transformer lives here stage.add(gridLayer); stage.add(structureLayer); stage.add(deviceLayer); stage.add(uiLayer); // ── Transformer (selection handles) ─────────────────────────────────────── transformer = new Konva.Transformer({ rotateAnchorOffset: 20, enabledAnchors: ["top-left","top-right","bottom-left","bottom-right", "middle-left","middle-right","top-center","bottom-center"], boundBoxFunc: (old, nw) => ({ ...nw, x: snap(nw.x), y: snap(nw.y), width: Math.max(GRID, nw.width), height: Math.max(GRID, nw.height), }), }); uiLayer.add(transformer); // ── helpers ──────────────────────────────────────────────────────────────── function snap(v) { return SNAP ? Math.round(v / GRID) * GRID : v; } function stagePos(e) { const pt = stage.getPointerPosition(); const sc = stage.scaleX(); const off = stage.position(); return { x: (pt.x - off.x) / sc, y: (pt.y - off.y) / sc, }; } function setDirty() { isDirty = true; setSaveStatus("Unsaved"); clearTimeout(saveTimer); saveTimer = setTimeout(() => LB.save(), 8000); // auto-save after 8 s idle } function setSaveStatus(msg, cls = "") { const el = document.getElementById("lb-save-status"); el.textContent = msg; el.className = cls; } function deselect() { transformer.nodes([]); selectedNode = null; uiLayer.batchDraw(); } function colorForState(isOn, onColor = "warning", offColor = "secondary") { const map = { success : "#3fb950", warning: "#f0883e", danger: "#f85149", info : "#58a6ff", primary: "#4f8ccd", secondary: "#484f58", dark: "#30363d", }; return isOn ? (map[onColor] || "#3fb950") : (map[offColor] || "#484f58"); } // ── grid ─────────────────────────────────────────────────────────────────── function drawGrid() { gridLayer.destroyChildren(); const sc = stage.scaleX(); const off = stage.position(); const W = stage.width(); const H = stage.height(); const step = GRID * sc; const startX = ((-off.x / sc) - ((-off.x / sc) % GRID)); const startY = ((-off.y / sc) - ((-off.y / sc) % GRID)); for (let x = startX; x < (-off.x + W) / sc; x += GRID) { gridLayer.add(new Konva.Line({ points: [x, -off.y / sc, x, (-off.y + H) / sc], stroke: "#1c2129", strokeWidth: 1 / sc, listening: false, })); } for (let y = startY; y < (-off.y + H) / sc; y += GRID) { gridLayer.add(new Konva.Line({ points: [-off.x / sc, y, (-off.x + W) / sc, y], stroke: "#1c2129", strokeWidth: 1 / sc, listening: false, })); } gridLayer.batchDraw(); } // ── draw structure shapes ────────────────────────────────────────────────── function shapeForTool(tool, x, y, w, h) { const st = TOOL_STYLE[tool] || TOOL_STYLE.wall; if (tool === "door") { // arc that represents a door swing return new Konva.Arc({ x, y, innerRadius: 0, outerRadius: Math.max(Math.abs(w), Math.abs(h)), angle: 90, fill: st.fill, stroke: st.stroke, strokeWidth: st.strokeWidth, rotation: w < 0 ? 90 : 0, id: uid(), name: "structure-shape", toolType: tool, }); } if (tool === "window" || tool === "fence") { return new Konva.Rect({ x: Math.min(x, x + w), y: Math.min(y, y + h), width: Math.abs(w), height: Math.abs(h), fill: st.fill, stroke: st.stroke, strokeWidth: st.strokeWidth, dash: st.dash ?? [8, 4], id: uid(), name: "structure-shape", toolType: tool, draggable: false, }); } // wall and room return new Konva.Rect({ x: Math.min(x, x + w), y: Math.min(y, y + h), width: Math.abs(w), height: Math.abs(h), fill: st.fill, stroke: st.stroke, strokeWidth: st.strokeWidth, id: uid(), name: "structure-shape", toolType: tool, draggable: false, }); } // ── device widget ────────────────────────────────────────────────────────── function deviceIcon(iconClass) { // Map Bootstrap Icon class to a unicode character for Konva.Text // We embed the Bootstrap Icons font directly via CSS; Konva can render it // because Konva.Text uses the page's loaded fonts. // Strip "bi-" prefix and build the proper text character: // We'll fall back to simple label if the font isn't available. return iconClass.replace("bi-", ""); } function createDeviceGroup(opts) { /* * opts: { id, boardId, entityType, entityNum, x, y, * name, icon, stateColor, isRelay } */ const W = 120, H = 52; const group = new Konva.Group({ id: opts.id, x: snap(opts.x - W / 2), y: snap(opts.y - H / 2), draggable: true, name: "device-group", // store binding in attrs boardId: opts.boardId, entityType: opts.entityType, entityNum: opts.entityNum, }); // background card const bg = new Konva.Rect({ width: W, height: H, cornerRadius: 8, fill: "#1c2129", stroke: "#30363d", strokeWidth: 1, shadowColor: "#000", shadowBlur: 8, shadowOpacity: .4, shadowOffset: {x:0,y:2}, name: "device-bg", }); // colored state indicator dot const dot = new Konva.Circle({ x: W - 14, y: 14, radius: 5, fill: opts.stateColor || "#484f58", name: "state-dot", }); // icon (Bootstrap Icons via font) const ico = new Konva.Text({ x: 10, y: H / 2 - 10, text: "\uf4a5", // fallback lightning bolt – overridden below fontSize: 18, fontFamily: "'bootstrap-icons'", fill: opts.stateColor || "#8b949e", name: "device-icon", }); // label const lbl = new Konva.Text({ x: 36, y: 10, width: W - 46, text: opts.name || "Device", fontSize: 11, fontFamily: "system-ui, sans-serif", fill: "#c9d1d9", ellipsis: true, wrap: "none", name: "device-label", }); // board name sub-label const sub = new Konva.Text({ x: 36, y: 26, width: W - 46, text: opts.boardName || "", fontSize: 9.5, fontFamily: "system-ui, sans-serif", fill: "#484f58", ellipsis: true, wrap: "none", name: "device-sub", }); // relay type badge const typeText = opts.entityType === "relay" ? "relay" : opts.entityType === "sonoff" ? "sonoff" : "input"; const badge = new Konva.Text({ x: 36, y: 38, text: typeText, fontSize: 9, fontFamily: "system-ui, sans-serif", fill: opts.entityType === "relay" ? "#f0883e" : opts.entityType === "sonoff" ? "#58a6ff" : "#3fb950", name: "device-type", }); group.add(bg, dot, ico, lbl, sub, badge); // Also store extra fields in group attrs group.setAttr('deviceId', opts.deviceId || null); group.setAttr('channel', opts.channel != null ? opts.channel : null); // hover highlight group.on("mouseenter", () => { bg.stroke("#4f8ccd"); uiLayer.batchDraw(); deviceLayer.batchDraw(); }); group.on("mouseleave", () => { bg.stroke("#30363d"); uiLayer.batchDraw(); deviceLayer.batchDraw(); }); // drag snap group.on("dragmove", () => { group.x(snap(group.x())); group.y(snap(group.y())); deviceLayer.batchDraw(); setDirty(); }); // right-click = delete in structure / devices mode group.on("contextmenu", (e) => { if (mode === "view") return; e.evt.preventDefault(); if (confirm(`Remove "${opts.name}" from layout?`)) { group.destroy(); deviceLayer.batchDraw(); setDirty(); } }); // click = select in devices mode; toggle relay in view mode group.on("click tap", async (e) => { if (mode === "devices") { e.cancelBubble = true; // prevent stage click from immediately deselecting selectDevice(group); return; } if (mode !== "view") return; let url = null; if (opts.entityType === "relay") { url = CFG.toggleBase .replace("{boardId}", opts.boardId) .replace("{relayNum}", opts.entityNum); } else if (opts.entityType === "sonoff") { url = `/sonoff/${opts.boardId}/device/${encodeURIComponent(opts.deviceId)}/ch/${opts.channel}/toggle`; } else { return; // inputs are read-only } try { await fetch(url, { method: "POST", headers: { "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" }, }); } catch (_) {} }); return group; } function updateDeviceState(group, isOn, onColor, offColor) { const color = colorForState(isOn, onColor, offColor); const dot = group.findOne(".state-dot"); const ico = group.findOne(".device-icon"); if (dot) dot.fill(color); if (ico) ico.fill(isOn ? color : "#484f58"); deviceLayer.batchDraw(); } // ── load saved canvas ────────────────────────────────────────────────────── function loadCanvas(data) { if (!data) return; // Structure shapes (data.structure || []).forEach(s => { if (s.tool === "text") { const t = new Konva.Text({ id: s.id, x: s.x, y: s.y, text: s.text || "Label", fontSize: 14, fontFamily: "system-ui, sans-serif", fill: "#e6edf3", draggable: false, name: "structure-shape", toolType: "text", rotation: s.rotation || 0, }); makeStructureDraggable(t); structureLayer.add(t); } else { let sh; if (s.tool === "door") { // restore Arc using saved outerRadius (or fall back to w/h for old saves) const st = TOOL_STYLE.door; const radius = s.r != null ? s.r : Math.max(Math.abs(s.w || GRID), Math.abs(s.h || GRID)); sh = new Konva.Arc({ id: s.id, x: s.x, y: s.y, innerRadius: 0, outerRadius: radius, angle: 90, fill: st.fill, stroke: st.stroke, strokeWidth: st.strokeWidth, rotation: s.rotation || 0, name: "structure-shape", toolType: "door", }); } else { sh = shapeForTool(s.tool, s.x, s.y, s.w, s.h); sh.id(s.id); if (s.rotation) sh.rotation(s.rotation); } // restore Transformer scale if shape was resized if (s.sx != null) sh.scaleX(s.sx); if (s.sy != null) sh.scaleY(s.sy); makeStructureDraggable(sh); structureLayer.add(sh); } }); // Devices (data.devices || []).forEach(d => { const board = CFG.boards.find(b => b.id === d.boardId); if (!board) return; let entityInfo = null; let isOn = false; let onColor = "warning"; let offColor = "secondary"; if (d.entityType === "sonoff") { // Match by deviceId + channel stored in sonoff_channels entityInfo = board.sonoff_channels && board.sonoff_channels.find(sc => sc.deviceId === d.deviceId && sc.channel === d.channel); if (!entityInfo) return; isOn = entityInfo.isOn; onColor = entityInfo.kind === "light" ? "warning" : "success"; offColor = "secondary"; } else if (d.entityType === "relay") { entityInfo = board.relays.find(r => r.num === d.entityNum); if (!entityInfo) return; isOn = entityInfo.isOn; onColor = entityInfo.onColor; offColor = entityInfo.offColor; } else { entityInfo = board.inputs.find(i => i.num === d.entityNum); if (!entityInfo) return; isOn = !entityInfo.rawState; onColor = entityInfo.activeColor; offColor = entityInfo.idleColor; } const group = createDeviceGroup({ id: d.id, boardId: d.boardId, entityType: d.entityType, entityNum: d.entityNum || null, // Sonoff-specific deviceId: d.deviceId || null, channel: d.channel != null ? d.channel : null, x: d.x + 60, y: d.y + 26, name: entityInfo.name, boardName: board.name, icon: entityInfo.icon, stateColor: colorForState(isOn, onColor, offColor), isRelay: d.entityType === "relay" || d.entityType === "sonoff", }); deviceLayer.add(group); }); structureLayer.batchDraw(); deviceLayer.batchDraw(); } // ── persist structure shapes (make selectable + draggable) ───────────────── function makeStructureDraggable(shape) { shape.draggable(false); // only move via transformer shape.on("click tap", () => { if (mode !== "structure" || activeTool !== "select") return; deselect(); transformer.nodes([shape]); selectedNode = shape; uiLayer.batchDraw(); }); shape.on("dragend", () => setDirty()); } // ── save ─────────────────────────────────────────────────────────────────── window.LB = window.LB || {}; LB.save = async function () { setSaveStatus("Saving…", "saving"); const structure = []; structureLayer.find(".structure-shape").forEach(sh => { if (sh.className === "Text") { structure.push({ id: sh.id(), tool: "text", x: sh.x(), y: sh.y(), text: sh.text(), rotation: sh.rotation(), }); } else { const isArc = sh.className === "Arc"; const entry = { id: sh.id(), tool: sh.attrs.toolType || "wall", x: sh.x(), y: sh.y(), rotation: sh.rotation(), }; if (isArc) { entry.r = sh.outerRadius(); // save radius, not bounding-box w/h } else { entry.w = sh.width(); entry.h = sh.height(); } // preserve Transformer-applied scale so resized shapes survive reload const sx = sh.scaleX(), sy = sh.scaleY(); if (sx !== 1) entry.sx = sx; if (sy !== 1) entry.sy = sy; structure.push(entry); } }); const devices = []; deviceLayer.find(".device-group").forEach(g => { const entry = { id: g.id(), boardId: g.attrs.boardId, entityType: g.attrs.entityType, entityNum: g.attrs.entityNum || null, // Sonoff extra fields deviceId: g.attrs.deviceId || null, channel: g.attrs.channel != null ? g.attrs.channel : null, x: g.x(), y: g.y(), }; devices.push(entry); }); const thumbnail = stage.toDataURL({ pixelRatio: 0.4 }); try { const resp = await fetch(CFG.saveUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ canvas: { structure, devices }, thumbnail }), }); if (resp.ok) { isDirty = false; setSaveStatus("Saved ✓", "saved"); setTimeout(() => setSaveStatus(""), 3000); } else { setSaveStatus("Save failed", "saving"); } } catch (e) { setSaveStatus("Save failed", "saving"); } }; // ── zoom ─────────────────────────────────────────────────────────────────── LB.zoom = function (factor) { const oldScale = stage.scaleX(); const newScale = Math.min(5, Math.max(0.15, oldScale * factor)); const cx = stage.width() / 2; const cy = stage.height() / 2; const pos = stage.position(); stage.scale({ x: newScale, y: newScale }); stage.position({ x: cx - (cx - pos.x) * (newScale / oldScale), y: cy - (cy - pos.y) * (newScale / oldScale), }); stage.batchDraw(); drawGrid(); }; LB.resetView = function () { stage.scale({ x: 1, y: 1 }); stage.position({ x: 0, y: 0 }); stage.batchDraw(); drawGrid(); }; // ── clear helpers ────────────────────────────────────────────────────────── LB.clearStructure = function () { if (!confirm("Remove all structure shapes? This cannot be undone.")) return; structureLayer.destroyChildren(); structureLayer.batchDraw(); deselect(); setDirty(); }; LB.clearDevices = function () { if (!confirm("Remove all devices from canvas? This cannot be undone.")) return; deselectDevice(); deviceLayer.destroyChildren(); deviceLayer.batchDraw(); setDirty(); }; // ── device selection ─────────────────────────────────────────────────────── function selectDevice(group) { // un-highlight previous if (selectedDeviceGroup && selectedDeviceGroup !== group) { const prevBg = selectedDeviceGroup.findOne(".device-bg"); if (prevBg) prevBg.stroke("#30363d"); } selectedDeviceGroup = group; // highlight selected const bg = group.findOne(".device-bg"); if (bg) bg.stroke("#4f8ccd"); deviceLayer.batchDraw(); // populate sidebar info const nameEl = document.getElementById("lb-sel-name"); const boardEl = document.getElementById("lb-sel-board"); const typeEl = document.getElementById("lb-sel-type"); const lbl = group.findOne(".device-label"); const sub = group.findOne(".device-sub"); const typ = group.findOne(".device-type"); if (nameEl) nameEl.textContent = lbl ? lbl.text() : ""; if (boardEl) boardEl.textContent = sub ? sub.text() : ""; if (typeEl) typeEl.textContent = typ ? typ.text() : ""; const panel = document.getElementById("lb-device-selected"); const hint = document.getElementById("lb-drag-hint"); if (panel) panel.style.display = ""; if (hint) hint.style.display = "none"; } function deselectDevice() { if (selectedDeviceGroup) { const bg = selectedDeviceGroup.findOne(".device-bg"); if (bg) bg.stroke("#30363d"); deviceLayer.batchDraw(); } selectedDeviceGroup = null; const panel = document.getElementById("lb-device-selected"); const hint = document.getElementById("lb-drag-hint"); if (panel) panel.style.display = "none"; if (hint) hint.style.display = ""; } LB.removeSelectedDevice = function () { if (!selectedDeviceGroup) return; const lbl = selectedDeviceGroup.findOne(".device-label"); const name = lbl ? lbl.text() : "device"; if (!confirm(`Remove "${name}" from layout?`)) return; selectedDeviceGroup.destroy(); deviceLayer.batchDraw(); deselectDevice(); setDirty(); }; LB.deselectDevice = function () { deselectDevice(); }; // ── mode switching ───────────────────────────────────────────────────────── function applyMode(newMode) { mode = newMode; deselect(); deselectDevice(); // toolbar buttons document.querySelectorAll(".tb-mode").forEach(b => b.classList.toggle("active", b.dataset.mode === mode)); // sidebar panels document.getElementById("lb-structure-palette").style.display = mode === "structure" ? "" : "none"; document.getElementById("lb-devices-palette").style.display = mode === "devices" ? "" : "none"; document.getElementById("lb-view-info").style.display = mode === "view" ? "" : "none"; // clear-devices button visibility document.getElementById("btn-clear-devices").style.display = mode === "devices" ? "" : "none"; // cursor container.style.cursor = mode === "view" ? "default" : "crosshair"; if (mode === "structure") { selectTool("select"); } else if (mode === "view") { activeTool = "select"; // make devices clickable, structures not draggable deviceLayer.listening(true); structureLayer.listening(false); } if (mode !== "view") { structureLayer.listening(true); } } // ── tool switching ───────────────────────────────────────────────────────── function selectTool(tool) { activeTool = tool; document.querySelectorAll(".lb-tool-btn").forEach(b => b.classList.toggle("active", b.dataset.tool === tool)); const isPan = tool === "pan"; stage.draggable(isPan); container.style.cursor = isPan ? "grab" : tool === "select" ? "default" : "crosshair"; deselect(); } // ── stage mouse events for drawing ──────────────────────────────────────── stage.on("mousedown touchstart", (e) => { if (mode !== "structure") return; if (activeTool === "pan" || activeTool === "select") return; if (e.target !== stage && e.target.name() === "structure-shape") return; const pos = stagePos(e); isDrawing = true; drawStart = { x: snap(pos.x), y: snap(pos.y) }; if (activeTool === "text") { isDrawing = false; const label = prompt("Label text:", "Label"); if (!label) return; const t = new Konva.Text({ id: uid(), x: snap(pos.x), y: snap(pos.y), text: label, fontSize: 14, fontFamily: "system-ui, sans-serif", fill: "#e6edf3", draggable: false, name: "structure-shape", toolType: "text", }); makeStructureDraggable(t); structureLayer.add(t); structureLayer.batchDraw(); setDirty(); return; } tempShape = shapeForTool(activeTool, snap(pos.x), snap(pos.y), 0, 0); structureLayer.add(tempShape); }); stage.on("mousemove touchmove", (e) => { if (!isDrawing || !tempShape) return; const pos = stagePos(e); const w = snap(pos.x) - drawStart.x; const h = snap(pos.y) - drawStart.y; if (activeTool === "door") { tempShape.outerRadius(Math.max(Math.abs(w), Math.abs(h))); tempShape.rotation(w < 0 && h >= 0 ? 90 : w >= 0 && h < 0 ? -90 : w < 0 && h < 0 ? 180 : 0); } else { tempShape.x(Math.min(drawStart.x, drawStart.x + w)); tempShape.y(Math.min(drawStart.y, drawStart.y + h)); tempShape.width(Math.abs(w)); tempShape.height(Math.abs(h)); } structureLayer.batchDraw(); }); stage.on("mouseup touchend", () => { if (!isDrawing || !tempShape) return; isDrawing = false; const tooSmall = ( tempShape.className !== "Arc" && (tempShape.width() < 4 || tempShape.height() < 4) ) || ( tempShape.className === "Arc" && tempShape.outerRadius() < 4 ); if (tooSmall) { tempShape.destroy(); } else { tempShape.draggable(false); makeStructureDraggable(tempShape); setDirty(); } tempShape = null; structureLayer.batchDraw(); }); // click empty stage = deselect stage.on("click", (e) => { if (e.target === stage) { deselect(); deselectDevice(); } }); // Delete key = remove selected structure shape or device window.addEventListener("keydown", (e) => { if (e.key !== "Delete" && e.key !== "Backspace") return; if (document.activeElement !== document.body) return; if (selectedNode) { selectedNode.destroy(); deselect(); structureLayer.batchDraw(); setDirty(); } else if (selectedDeviceGroup) { LB.removeSelectedDevice(); } }); // ── mouse-wheel zoom ─────────────────────────────────────────────────────── container.addEventListener("wheel", (e) => { e.preventDefault(); const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1; const pointer = stage.getPointerPosition(); const oldScale = stage.scaleX(); const newScale = Math.min(5, Math.max(0.15, oldScale * factor)); const mousePos = { x: (pointer.x - stage.x()) / oldScale, y: (pointer.y - stage.y()) / oldScale, }; stage.scale({ x: newScale, y: newScale }); stage.position({ x: pointer.x - mousePos.x * newScale, y: pointer.y - mousePos.y * newScale, }); stage.batchDraw(); drawGrid(); }, { passive: false }); // pan on drag in pan mode stage.on("dragend", () => drawGrid()); // ── resize ───────────────────────────────────────────────────────────────── window.addEventListener("resize", () => { stage.width(stageWrap.clientWidth); stage.height(stageWrap.clientHeight); stage.batchDraw(); drawGrid(); }); // ── toolbar mode buttons ─────────────────────────────────────────────────── document.querySelectorAll(".tb-mode").forEach(btn => { btn.addEventListener("click", () => applyMode(btn.dataset.mode)); }); // ── sidebar tool buttons ────────────────────────────────────────────────── document.querySelectorAll(".lb-tool-btn[data-tool]").forEach(btn => { btn.addEventListener("click", () => selectTool(btn.dataset.tool)); }); // ── build device palette sidebar ───────────────────────────────────────── function buildDevicePalette() { const list = document.getElementById("lb-device-list"); list.innerHTML = ""; if (!CFG.boards || CFG.boards.length === 0) { list.innerHTML = '

No boards configured.

'; return; } CFG.boards.forEach(board => { const grp = document.createElement("div"); grp.className = "lb-board-group"; const hdr = document.createElement("div"); hdr.className = "lb-board-name"; hdr.innerHTML = ` ${escHtml(board.name)}`; grp.appendChild(hdr); let hasAny = false; board.relays.forEach(r => { grp.appendChild(makeChip(board, "relay", r)); hasAny = true; }); board.inputs.forEach(inp => { grp.appendChild(makeChip(board, "input", inp)); hasAny = true; }); // ── Sonoff sub-devices ─────────────────────────────────────────── if (board.sonoff_channels && board.sonoff_channels.length) { board.sonoff_channels.forEach(sc => { grp.appendChild(makeSonoffChip(board, sc)); hasAny = true; }); } if (hasAny) list.appendChild(grp); }); } function escHtml(s) { return s.replace(/&/g,"&").replace(//g,">"); } function makeChip(board, entityType, entity) { const chip = document.createElement("div"); chip.className = "lb-device-chip"; chip.draggable = true; chip.dataset.boardId = board.id; chip.dataset.entityType = entityType; chip.dataset.entityNum = entity.num; chip.dataset.name = entity.name; chip.dataset.boardName = board.name; chip.dataset.onColor = entity.onColor || entity.activeColor || "warning"; chip.dataset.offColor = entity.offColor || entity.idleColor || "secondary"; chip.dataset.isOn = entityType === "relay" ? entity.isOn : !entity.rawState; const dotColor = colorForState(chip.dataset.isOn === "true", chip.dataset.onColor, chip.dataset.offColor); chip.innerHTML = ` ${escHtml(entity.name)} ${entityType}`; // ── HTML5 drag from sidebar onto canvas ───────────────────────────────── chip.addEventListener("dragstart", (e) => { e.dataTransfer.effectAllowed = "copy"; e.dataTransfer.setData("application/json", JSON.stringify({ boardId: parseInt(board.id), entityType, entityNum: entity.num, name: entity.name, boardName: board.name, onColor: chip.dataset.onColor, offColor: chip.dataset.offColor, isOn: chip.dataset.isOn === "true", })); }); return chip; } function makeSonoffChip(board, sc) { const chip = document.createElement("div"); chip.className = "lb-device-chip"; chip.draggable = true; chip.dataset.boardId = board.id; chip.dataset.entityType = "sonoff"; chip.dataset.deviceId = sc.deviceId; chip.dataset.channel = sc.channel; chip.dataset.name = sc.name; chip.dataset.boardName = board.name; chip.dataset.isOn = sc.isOn; const onColor = sc.kind === "light" ? "warning" : "success"; const dotColor = colorForState(sc.isOn, onColor, "secondary"); chip.innerHTML = ` ${escHtml(sc.name)} ${escHtml(sc.kind || 'switch')}`; chip.addEventListener("dragstart", (e) => { e.dataTransfer.effectAllowed = "copy"; e.dataTransfer.setData("application/json", JSON.stringify({ boardId: parseInt(board.id), entityType: "sonoff", entityNum: null, deviceId: sc.deviceId, channel: sc.channel, name: sc.name, boardName: board.name, onColor, offColor: "secondary", isOn: sc.isOn, })); }); return chip; } // ── canvas drop target ──────────────────────────────────────────────────── const ghost = document.getElementById("lb-drag-ghost"); container.addEventListener("dragover", (e) => { if (mode !== "devices") return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; container.classList.add("drag-over"); ghost.style.display = "block"; ghost.style.left = (e.clientX + 14) + "px"; ghost.style.top = (e.clientY + 14) + "px"; ghost.textContent = "+ Drop device here"; }); container.addEventListener("dragleave", () => { container.classList.remove("drag-over"); ghost.style.display = "none"; }); container.addEventListener("drop", (e) => { e.preventDefault(); container.classList.remove("drag-over"); ghost.style.display = "none"; if (mode !== "devices") return; let dragged; try { dragged = JSON.parse(e.dataTransfer.getData("application/json")); } catch (_) { return; } // convert page coords → stage coords const rect = container.getBoundingClientRect(); const sc = stage.scaleX(); const off = stage.position(); const stageX = (e.clientX - rect.left - off.x) / sc; const stageY = (e.clientY - rect.top - off.y) / sc; const stateColor = colorForState(dragged.isOn, dragged.onColor, dragged.offColor); const group = createDeviceGroup({ id: uid(), boardId: dragged.boardId, entityType: dragged.entityType, entityNum: dragged.entityNum || null, // Sonoff-specific deviceId: dragged.deviceId || null, channel: dragged.channel != null ? dragged.channel : null, x: stageX, y: stageY, name: dragged.name, boardName: dragged.boardName, stateColor, isRelay: dragged.entityType === "relay" || dragged.entityType === "sonoff", }); deviceLayer.add(group); deviceLayer.batchDraw(); setDirty(); }); // ── socket.io live updates ──────────────────────────────────────────────── // Re-use the socket already created by the base template if (typeof io !== "undefined") { const socket = io(); socket.on("board_update", (data) => { const bid = data.board_id; deviceLayer.find(".device-group").forEach(group => { if (group.attrs.boardId !== bid) return; if (group.attrs.entityType === "relay" && data.relay_states) { const isOn = data.relay_states[`relay_${group.attrs.entityNum}`]; // find onColor / offColor from CFG const board = CFG.boards.find(b => b.id === bid); const relay = board && board.relays.find(r => r.num === group.attrs.entityNum); if (relay) { updateDeviceState(group, isOn, relay.onColor, relay.offColor); } } if (group.attrs.entityType === "input" && data.input_states) { const rawState = data.input_states[`input_${group.attrs.entityNum}`]; const isActive = !rawState; // NC inversion const board = CFG.boards.find(b => b.id === bid); const inp = board && board.inputs.find(i => i.num === group.attrs.entityNum); if (inp) { updateDeviceState(group, isActive, inp.activeColor, inp.idleColor); } } }); }); // Sonoff sub-devices emit a separate "sonoff_update" event socket.on("sonoff_update", (data) => { const bid = data.board_id; deviceLayer.find(".device-group").forEach(group => { if (group.attrs.boardId !== bid) return; if (group.attrs.entityType !== "sonoff") return; if (group.attrs.deviceId !== data.device_id) return; if (group.attrs.channel !== data.channel) return; const board = CFG.boards.find(b => b.id === bid); const sc = board && board.sonoff_channels && board.sonoff_channels.find(s => s.deviceId === data.device_id && s.channel === data.channel); const onColor = sc ? (sc.kind === "light" ? "warning" : "success") : "success"; updateDeviceState(group, data.state, onColor, "secondary"); }); }); } // ── init ─────────────────────────────────────────────────────────────────── drawGrid(); buildDevicePalette(); applyMode("structure"); selectTool("select"); // Load persisted data if (CFG.canvasJson) { loadCanvas(CFG.canvasJson); } setSaveStatus(CFG.canvasJson ? "" : "New layout"); document.getElementById("btn-clear-devices").style.display = "none"; // Warn on close if unsaved window.addEventListener("beforeunload", (e) => { if (!isDirty) return; e.preventDefault(); e.returnValue = ""; }); })();