feat: initial release of interactive electrical circuit & panel mapper

This commit is contained in:
wmantly committed 2026-10-20 15:51:46 -04:00
commit 3ed8193565
17 files changed
+7222

No files matched your search

+528
View File
@@ -0,0 +1,528 @@
:root {
--bg-dark: #12161f;
--bg-panel: #1a202c;
--bg-card: #222b3c;
--bg-card-hover: #2b364c;
--border-color: #2e3a50;
--text-main: #e2e8f0;
--text-muted: #94a3b8;
--accent: #3b82f6;
--accent-hover: #2563eb;
--unmapped-pulse: #f59f00;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: var(--bg-dark);
color: var(--text-main);
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Header */
.app-header {
height: 56px;
background-color: var(--bg-panel);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
z-index: 100;
flex-shrink: 0;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: 1.1rem;
letter-spacing: -0.5px;
}
.brand-icon {
color: #fab005;
font-size: 1.3rem;
}
.progress-container {
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-card);
padding: 4px 12px;
border-radius: 20px;
border: 1px solid var(--border-color);
}
.progress-bar-wrap {
width: 140px;
height: 8px;
background: #334155;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #10b981, #3b82f6);
width: 0%;
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 600;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* App Main Body */
.app-body {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
}
/* Left Canvas Area */
.canvas-panel {
flex: 1;
position: relative;
background: #0f131a;
overflow: hidden;
display: flex;
flex-direction: column;
}
.canvas-toolbar {
position: absolute;
top: 14px;
left: 14px;
z-index: 20;
display: flex;
flex-wrap: wrap;
gap: 8px;
background: rgba(26, 32, 44, 0.92);
backdrop-filter: blur(8px);
padding: 6px 10px;
border-radius: 8px;
border: 1px solid var(--border-color);
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.active-painter-badge {
display: none;
align-items: center;
gap: 8px;
padding: 4px 10px;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
color: #fff;
background: #1c7ed6;
border: 1px solid rgba(255,255,255,0.2);
box-shadow: 0 0 12px rgba(28, 126, 214, 0.5);
animation: pulse-border 2s infinite;
}
@keyframes pulse-border {
0% { box-shadow: 0 0 0 0 rgba(28, 126, 214, 0.6); }
70% { box-shadow: 0 0 0 8px rgba(28, 126, 214, 0); }
100% { box-shadow: 0 0 0 0 rgba(28, 126, 214, 0); }
}
.active-painter-badge.active {
display: flex;
}
.svg-viewport {
width: 100%;
height: 100%;
cursor: grab;
}
.svg-viewport:active {
cursor: grabbing;
}
.svg-viewport.painter-cursor {
cursor: crosshair;
}
/* Right Sidebar */
.sidebar {
width: 440px;
background-color: var(--bg-panel);
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
z-index: 30;
}
.sidebar-tabs {
display: flex;
background: var(--bg-dark);
border-bottom: 1px solid var(--border-color);
}
.sidebar-tab {
flex: 1;
padding: 10px 14px;
background: none;
border: none;
color: var(--text-muted);
font-weight: 600;
font-size: 0.88rem;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.sidebar-tab.active {
color: #fff;
border-bottom-color: var(--accent);
background: var(--bg-panel);
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* Panel Header Card */
.panel-info-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px 14px;
}
.panel-title {
font-weight: 700;
font-size: 0.95rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-subtitle {
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 2px;
}
/* Breakers Grid */
.breakers-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.breaker-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 10px;
cursor: pointer;
transition: all 0.15s ease;
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 74px;
user-select: none;
}
.breaker-card:hover {
background: var(--bg-card-hover);
border-color: #475569;
transform: translateY(-1px);
}
.breaker-card.selected-painter {
border-color: #fff;
box-shadow: 0 0 12px rgba(255, 255, 255, 0.4);
}
.breaker-card.highlighted-filter {
outline: 2px solid #38bdf8;
}
.breaker-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.breaker-tag {
font-weight: 800;
font-size: 0.95rem;
padding: 1px 6px;
border-radius: 4px;
color: #fff;
letter-spacing: -0.3px;
}
.breaker-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.72rem;
font-weight: 600;
color: var(--text-muted);
}
.breaker-desc {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-main);
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.breaker-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
font-size: 0.72rem;
}
.device-badge {
background: rgba(255, 255, 255, 0.1);
padding: 2px 6px;
border-radius: 10px;
font-weight: 600;
}
/* Device Inspector Footer Card */
.device-inspector-panel {
background: var(--bg-card);
border-top: 1px solid var(--border-color);
padding: 14px;
flex-shrink: 0;
}
.inspector-title {
font-size: 0.88rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.inspector-field {
margin-bottom: 8px;
}
.inspector-field label {
font-size: 0.75rem;
color: var(--text-muted);
display: block;
margin-bottom: 3px;
font-weight: 600;
}
/* Buttons and Controls */
.btn-tool {
background: var(--bg-card);
border: 1px solid var(--border-color);
color: var(--text-main);
padding: 5px 10px;
border-radius: 6px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.15s;
}
.btn-tool:hover {
background: var(--bg-card-hover);
color: #fff;
border-color: #475569;
}
.btn-tool.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.btn-tool.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn-tool.btn-primary:hover {
background: var(--accent-hover);
}
.btn-tool.btn-danger {
background: #e03131;
border-color: #e03131;
color: #fff;
}
.btn-tool.btn-danger:hover {
background: #c92a2a;
}
.form-select, .form-input {
background: #0f131a;
border: 1px solid var(--border-color);
color: #fff;
padding: 6px 10px;
border-radius: 6px;
font-size: 0.82rem;
width: 100%;
}
.form-select:focus, .form-input:focus {
outline: none;
border-color: var(--accent);
}
/* SVG Stylings */
.svg-room {
fill: #141a24;
stroke: #334155;
stroke-width: 2px;
transition: fill 0.2s, stroke 0.2s;
}
.svg-room:hover {
fill: #1a2332;
stroke: #475569;
}
.svg-room-label {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 32px;
font-weight: 700;
fill: #64748b;
text-anchor: middle;
pointer-events: none;
user-select: none;
}
.svg-subpanel-boundary {
fill: rgba(59, 130, 246, 0.05);
stroke: #3b82f6;
stroke-width: 3px;
stroke-dasharray: 8 6;
pointer-events: none;
}
.svg-subpanel-tag {
font-size: 44px;
font-weight: 800;
fill: #60a5fa;
pointer-events: none;
text-anchor: middle;
}
/* Device node hover & selection */
.svg-device {
cursor: pointer;
transition: transform 0.15s ease, filter 0.15s ease;
}
.svg-device:hover {
filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.8));
}
.svg-device.selected {
filter: drop-shadow(0 0 14px #38bdf8);
}
.svg-device.dimmed {
opacity: 0.15;
}
.svg-unmapped-halo {
animation: pulse-ring 2s infinite;
pointer-events: none;
}
@keyframes pulse-ring {
0% { opacity: 0.8; transform: scale(1); }
50% { opacity: 0.3; transform: scale(1.3); }
100% { opacity: 0.8; transform: scale(1); }
}
.svg-breaker-badge {
font-size: 20px;
font-weight: 800;
font-family: sans-serif;
fill: #ffffff;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
user-select: none;
}
.svg-circuit-line {
fill: none;
stroke-width: 4px;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 8 6;
pointer-events: none;
}
/* Selection Box */
.selection-box {
position: absolute;
border: 1px dashed #38bdf8;
background-color: rgba(56, 189, 248, 0.15);
pointer-events: none;
z-index: 1000;
}
/* Tooltip */
.device-tooltip {
position: absolute;
background: rgba(15, 23, 42, 0.95);
border: 1px solid #334155;
border-radius: 6px;
padding: 8px 12px;
font-size: 0.8rem;
color: #fff;
pointer-events: none;
z-index: 2000;
display: none;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
+758
View File
@@ -0,0 +1,758 @@
'use strict';
// Application State
const state = {
panels: [],
breakers: [],
devices: [],
structures: [],
edges: [],
stats: {},
activePanel: 'subpanel',
activeBreakerId: null, // If set, Painter Mode is active!
selectedDeviceId: null,
highlightedBreakerId: null, // Filter mode
orientation: 'landscape', // 'landscape' (matching field doc) or 'native'
pan: { x: 40, y: 40 },
zoom: 0.22,
isDragging: false,
dragStart: { x: 0, y: 0 },
layers: {
lights: true,
outlets: true,
switches: true,
lines: true,
unmappedHalos: true,
rooms: true,
subpanelBoundary: true
}
};
// Coordinate transformation helpers
// Native drawio: x: 0..5000, y: 0..4800
// Landscape doc: ls_x = 4800 - y, ls_y = x
function transformCoords(x, y, w, h) {
if (state.orientation === 'landscape') {
// 90 deg rotation matching physical field doc
const ls_x = 4800 - (y + h);
const ls_y = x;
return { x: ls_x, y: ls_y, w: h, h: w, cx: ls_x + h / 2, cy: ls_y + w / 2 };
} else {
return { x, y, w, h, cx: x + w / 2, cy: y + h / 2 };
}
}
// Initialize Application
document.addEventListener('DOMContentLoaded', () => {
setupPanZoom();
setupToolbar();
setupSidebarTabs();
loadState();
});
// Load state from API
async function loadState() {
try {
const res = await fetch('/api/state');
const json = await res.json();
if (!json.success) throw new Error(json.error);
state.panels = json.data.panels;
state.breakers = json.data.breakers;
state.devices = json.data.devices;
state.structures = json.data.structures;
state.edges = json.data.edges;
state.stats = json.data.stats;
updateStatsUI();
renderBreakersList();
renderMap();
if (state.selectedDeviceId) {
updateInspector();
}
} catch (err) {
console.error('Failed to load state:', err);
}
}
// Update Top Progress Bar
function updateStatsUI() {
const { totalDevices, mappedDevices, unmappedDevices, progressPercent } = state.stats;
document.getElementById('progress-fill').style.width = `${progressPercent}%`;
document.getElementById('progress-text').textContent = `${mappedDevices} / ${totalDevices} Mapped (${progressPercent}%)`;
}
// Render Sidebar Breakers
function renderBreakersList() {
const container = document.getElementById('breakers-container');
container.innerHTML = '';
const currentBreakers = state.breakers.filter(b => b.panel_id === state.activePanel);
if (state.activePanel === 'subpanel') {
// Render as 2-column physical panel schedule
const leftColBreakers = currentBreakers.filter(b => b.slot % 2 !== 0);
const rightColBreakers = currentBreakers.filter(b => b.slot % 2 === 0);
const grid = document.createElement('div');
grid.className = 'breakers-grid';
// Left Column (Odd)
const leftCol = document.createElement('div');
leftCol.style.display = 'flex';
leftCol.style.flexDirection = 'column';
leftCol.style.gap = '8px';
leftColBreakers.forEach(b => leftCol.appendChild(createBreakerCard(b)));
// Right Column (Even)
const rightCol = document.createElement('div');
rightCol.style.display = 'flex';
rightCol.style.flexDirection = 'column';
rightCol.style.gap = '8px';
rightColBreakers.forEach(b => rightCol.appendChild(createBreakerCard(b)));
grid.appendChild(leftCol);
grid.appendChild(rightCol);
container.appendChild(grid);
} else {
// Main Panel list
const list = document.createElement('div');
list.style.display = 'flex';
list.style.flexDirection = 'column';
list.style.gap = '8px';
currentBreakers.forEach(b => list.appendChild(createBreakerCard(b)));
container.appendChild(list);
}
}
function createBreakerCard(b) {
const card = document.createElement('div');
card.className = 'breaker-card';
card.id = `card-${b.id}`;
if (state.activeBreakerId === b.id) {
card.classList.add('selected-painter');
}
if (state.highlightedBreakerId === b.id) {
card.classList.add('highlighted-filter');
}
card.innerHTML = `
<div class="breaker-header">
<span class="breaker-tag" style="background-color: ${b.color};">${b.label}</span>
<div class="breaker-meta">
<span>${b.amps}A</span>
<span>•</span>
<span>${b.poles}P ${b.voltage}V</span>
</div>
</div>
<div class="breaker-desc" title="${b.description || ''}">${b.description || 'Spare'}</div>
<div class="breaker-footer">
<span class="device-badge" id="count-${b.id}">${b.device_count || 0} dev</span>
<div style="display: flex; gap: 4px;">
<button class="btn-tool" style="padding: 2px 6px; font-size: 0.7rem;" title="Filter this circuit on map" onclick="event.stopPropagation(); toggleHighlightBreaker('${b.id}')">
<i class="fa-solid fa-eye"></i>
</button>
<button class="btn-tool" style="padding: 2px 6px; font-size: 0.7rem;" title="Edit breaker info" onclick="event.stopPropagation(); editBreakerPrompt('${b.id}')">
<i class="fa-solid fa-pen"></i>
</button>
</div>
</div>
`;
card.addEventListener('click', () => {
togglePainterMode(b.id);
});
return card;
}
// Toggle Painter Mode
function togglePainterMode(breakerId) {
if (state.activeBreakerId === breakerId) {
// Turn off painter mode
state.activeBreakerId = null;
} else {
state.activeBreakerId = breakerId;
}
updatePainterBadgeUI();
renderBreakersList();
renderMap();
}
function updatePainterBadgeUI() {
const badge = document.getElementById('active-painter-badge');
const svgElem = document.getElementById('map-svg');
if (state.activeBreakerId) {
const breaker = state.breakers.find(b => b.id === state.activeBreakerId);
badge.classList.add('active');
badge.style.backgroundColor = breaker.color;
document.getElementById('painter-label').textContent = `Painter: [${breaker.label}] ${breaker.description || ''}`;
svgElem.classList.add('painter-cursor');
} else {
badge.classList.remove('active');
svgElem.classList.remove('painter-cursor');
}
}
// Toggle Circuit Highlight Filter
function toggleHighlightBreaker(breakerId) {
if (state.highlightedBreakerId === breakerId) {
state.highlightedBreakerId = null;
} else {
state.highlightedBreakerId = breakerId;
}
renderBreakersList();
renderMap();
}
// Render SVG Map
function renderMap() {
const svg = document.getElementById('viewport-g');
svg.innerHTML = '';
const { orientation, highlightedBreakerId, layers } = state;
// 1. Draw Structures / Rooms / Walls
if (layers.rooms) {
const structGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
structGroup.setAttribute('id', 'layer-structures');
state.structures.forEach(s => {
// Ignore the extreme side black bar in clean rendering
if (s.x < 400 && s.w < 400 && s.h > 4000) return;
const t = transformCoords(s.x, s.y, s.w, s.h);
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('x', t.x);
rect.setAttribute('y', t.y);
rect.setAttribute('width', t.w);
rect.setAttribute('height', t.h);
rect.setAttribute('class', 'svg-room');
rect.setAttribute('rx', '8');
structGroup.appendChild(rect);
if (s.label) {
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', t.cx);
text.setAttribute('y', t.cy + 10);
text.setAttribute('class', 'svg-room-label');
text.textContent = s.label.replace('&nbsp;', ' ');
structGroup.appendChild(text);
}
});
// Subpanel West Wing Boundary Span
if (layers.subpanelBoundary) {
// In landscape, West wing is x: 400..1300, y: 2400..4350
// In native drawio, x: 2400..4350, y: 3450..4400
let subX = 400, subY = 2400, subW = 900, subH = 2000;
if (orientation === 'native') {
subX = 2400; subY = 3400; subW = 2000; subH = 950;
}
const subRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
subRect.setAttribute('x', subX);
subRect.setAttribute('y', subY);
subRect.setAttribute('width', subW);
subRect.setAttribute('height', subH);
subRect.setAttribute('class', 'svg-subpanel-boundary');
subRect.setAttribute('rx', '16');
structGroup.appendChild(subRect);
const subLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
subLabel.setAttribute('x', subX + subW / 2);
subLabel.setAttribute('y', subY + 60);
subLabel.setAttribute('class', 'svg-subpanel-tag');
subLabel.textContent = '⚡ SUBPANEL ZONE (WEST WING)';
structGroup.appendChild(subLabel);
}
svg.appendChild(structGroup);
}
// 2. Circuit Run Lines (lines connecting all devices on the same breaker)
if (layers.lines) {
const linesGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
linesGroup.setAttribute('id', 'layer-circuit-lines');
// Group devices by breaker
const breakerGroups = {};
state.devices.forEach(d => {
if (d.breaker_id) {
if (!breakerGroups[d.breaker_id]) breakerGroups[d.breaker_id] = [];
breakerGroups[d.breaker_id].push(d);
}
});
Object.keys(breakerGroups).forEach(bId => {
// If a breaker is highlighted, only render that one line with prominence
if (highlightedBreakerId && highlightedBreakerId !== bId) return;
const groupDevices = breakerGroups[bId];
if (groupDevices.length > 1) {
const breaker = state.breakers.find(b => b.id === bId);
const color = breaker ? breaker.color : '#3b82f6';
// Connect points
const points = groupDevices.map(d => {
const t = transformCoords(d.orig_x, d.orig_y, d.orig_w, d.orig_h);
return `${t.cx},${t.cy}`;
}).join(' ');
const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
poly.setAttribute('points', points);
poly.setAttribute('class', 'svg-circuit-line');
poly.setAttribute('stroke', color);
if (highlightedBreakerId === bId) {
poly.setAttribute('stroke-width', '6px');
poly.setAttribute('stroke-dasharray', 'none');
}
linesGroup.appendChild(poly);
}
});
svg.appendChild(linesGroup);
}
// 3. Render Devices
const deviceGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
deviceGroup.setAttribute('id', 'layer-devices');
state.devices.forEach(d => {
// Layer filtering
if (d.type === 'light' && !layers.lights) return;
if (d.type === 'outlet' && !layers.outlets) return;
if (d.type === 'switch' && !layers.switches) return;
const t = transformCoords(d.orig_x, d.orig_y, d.orig_w, d.orig_h);
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('class', 'svg-device');
g.setAttribute('id', `dev-elem-${d.id}`);
// If highlighted filter is active and this device does not belong, dim it
if (highlightedBreakerId && d.breaker_id !== highlightedBreakerId) {
g.classList.add('dimmed');
}
if (state.selectedDeviceId === d.id) {
g.classList.add('selected');
}
const isMapped = d.breaker_id !== null;
const breaker = isMapped ? state.breakers.find(b => b.id === d.breaker_id) : null;
const deviceColor = breaker ? breaker.color : d.color;
// Unmapped pulsing halo
if (!isMapped && layers.unmappedHalos) {
const halo = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
halo.setAttribute('cx', t.cx);
halo.setAttribute('cy', t.cy);
halo.setAttribute('r', '32');
halo.setAttribute('fill', 'rgba(245, 159, 0, 0.25)');
halo.setAttribute('stroke', '#f59f00');
halo.setAttribute('stroke-width', '2');
halo.setAttribute('stroke-dasharray', '4 4');
halo.setAttribute('class', 'svg-unmapped-halo');
g.appendChild(halo);
}
if (d.type === 'light') {
// Circle shape
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', t.cx);
circle.setAttribute('cy', t.cy);
circle.setAttribute('r', '26');
circle.setAttribute('fill', isMapped ? deviceColor : '#1971c2');
circle.setAttribute('stroke', '#ffffff');
circle.setAttribute('stroke-width', isMapped ? '3' : '2');
g.appendChild(circle);
// Light icon / glyph inside
const bulb = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
bulb.setAttribute('cx', t.cx);
bulb.setAttribute('cy', t.cy);
bulb.setAttribute('r', '10');
bulb.setAttribute('fill', '#ffffff');
bulb.setAttribute('opacity', '0.6');
g.appendChild(bulb);
} else if (d.type === 'outlet') {
// Green Diamond shape
const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
const r = 26;
const pts = `${t.cx},${t.cy - r} ${t.cx + r},${t.cy} ${t.cx},${t.cy + r} ${t.cx - r},${t.cy}`;
poly.setAttribute('points', pts);
poly.setAttribute('fill', isMapped ? deviceColor : '#2f9e44');
poly.setAttribute('stroke', '#ffffff');
poly.setAttribute('stroke-width', isMapped ? '3' : '2');
g.appendChild(poly);
} else if (d.type === 'switch') {
// Red Diamond shape
const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
const r = 24;
const pts = `${t.cx},${t.cy - r} ${t.cx + r},${t.cy} ${t.cx},${t.cy + r} ${t.cx - r},${t.cy}`;
poly.setAttribute('points', pts);
poly.setAttribute('fill', isMapped ? deviceColor : '#e03131');
poly.setAttribute('stroke', '#ffffff');
poly.setAttribute('stroke-width', isMapped ? '3' : '2');
g.appendChild(poly);
}
// Breaker Text Badge on device
if (isMapped && breaker) {
const badge = document.createElementNS('http://www.w3.org/2000/svg', 'text');
badge.setAttribute('x', t.cx);
badge.setAttribute('y', t.cy);
badge.setAttribute('class', 'svg-breaker-badge');
badge.textContent = breaker.label;
g.appendChild(badge);
}
// Interaction Events
g.addEventListener('mouseenter', (e) => showTooltip(e, d, breaker));
g.addEventListener('mousemove', (e) => moveTooltip(e));
g.addEventListener('mouseleave', hideTooltip);
g.addEventListener('click', (e) => {
e.stopPropagation();
handleDeviceClick(d);
});
g.addEventListener('contextmenu', (e) => {
e.preventDefault();
unassignDevice(d.id);
});
deviceGroup.appendChild(g);
});
svg.appendChild(deviceGroup);
}
// Tooltip Management
const tooltip = document.getElementById('device-tooltip');
function showTooltip(e, d, breaker) {
const statusHtml = breaker
? `<span style="color: ${breaker.color}; font-weight: 700;">[${breaker.label}] ${breaker.description || ''}</span>`
: `<span style="color: #f59f00; font-weight: 700;">⚠️ UNMAPPED</span>`;
tooltip.innerHTML = `
<div style="font-weight: 800; font-size: 0.85rem; margin-bottom: 2px;">${d.label || d.id} (${d.type.toUpperCase()})</div>
<div style="color: #94a3b8; font-size: 0.75rem;">Room: <strong>${d.room || 'Unknown'}</strong></div>
<div style="margin-top: 4px; font-size: 0.78rem;">Circuit: ${statusHtml}</div>
${d.notes ? `<div style="margin-top: 2px; font-size: 0.72rem; color: #cbd5e1;"><em>${d.notes}</em></div>` : ''}
<div style="margin-top: 6px; font-size: 0.68rem; color: #64748b;">Click to inspect / Right-click to unassign</div>
`;
tooltip.style.display = 'block';
moveTooltip(e);
}
function moveTooltip(e) {
tooltip.style.left = `${e.clientX + 14}px`;
tooltip.style.top = `${e.clientY + 14}px`;
}
function hideTooltip() {
tooltip.style.display = 'none';
}
// Device Click Handling (Painter Mode vs Select Mode)
async function handleDeviceClick(device) {
if (state.activeBreakerId) {
// PAINTER MODE: Instantly assign this device to the active breaker!
await assignDevices([device.id], state.activeBreakerId);
} else {
// SELECT MODE: Open Inspector Card
state.selectedDeviceId = device.id;
updateInspector();
renderMap();
}
}
// Assign Devices API Call
async function assignDevices(deviceIds, breakerId) {
try {
const res = await fetch('/api/devices/assign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceIds, breakerId })
});
const json = await res.json();
if (!json.success) throw new Error(json.error);
// Update local state
state.devices.forEach(d => {
if (deviceIds.includes(d.id)) {
d.breaker_id = breakerId;
}
});
// Update stats locally
const mapped = state.devices.filter(d => d.breaker_id !== null).length;
state.stats.mappedDevices = mapped;
state.stats.unmappedDevices = state.devices.length - mapped;
state.stats.progressPercent = Math.round((mapped / state.devices.length) * 100);
// Update breaker counts
state.breakers.forEach(b => {
b.device_count = state.devices.filter(d => d.breaker_id === b.id).length;
});
updateStatsUI();
renderBreakersList();
renderMap();
if (state.selectedDeviceId && deviceIds.includes(state.selectedDeviceId)) {
updateInspector();
}
} catch (err) {
console.error('Assign failed:', err);
alert('Failed to assign device: ' + err.message);
}
}
// Unassign Device
async function unassignDevice(deviceId) {
await assignDevices([deviceId], null);
}
// Update Inspector Card
function updateInspector() {
const panel = document.getElementById('device-inspector');
if (!state.selectedDeviceId) {
panel.style.display = 'none';
return;
}
const d = state.devices.find(x => x.id === state.selectedDeviceId);
if (!d) {
panel.style.display = 'none';
return;
}
panel.style.display = 'block';
document.getElementById('insp-dev-id').textContent = d.id;
document.getElementById('insp-dev-type').textContent = d.type.toUpperCase();
document.getElementById('insp-dev-room').textContent = d.room || 'General';
// Fill Breaker Dropdown
const select = document.getElementById('insp-breaker-select');
select.innerHTML = '<option value="">-- Unassigned --</option>';
state.breakers.forEach(b => {
const opt = document.createElement('option');
opt.value = b.id;
opt.textContent = `[${b.label}] ${b.description || ''} (${b.amps}A)`;
if (d.breaker_id === b.id) opt.selected = true;
select.appendChild(opt);
});
document.getElementById('insp-dev-notes').value = d.notes || '';
document.getElementById('insp-room-btn').textContent = `Assign all in ${d.room || 'this room'}`;
}
// Save Device Notes
async function saveDeviceNotes() {
if (!state.selectedDeviceId) return;
const notes = document.getElementById('insp-dev-notes').value;
try {
await fetch(`/api/devices/${state.selectedDeviceId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes })
});
const d = state.devices.find(x => x.id === state.selectedDeviceId);
if (d) d.notes = notes;
} catch (err) {
console.error('Failed to save notes:', err);
}
}
// Change Breaker via Inspector
async function onInspectorBreakerChange() {
if (!state.selectedDeviceId) return;
const breakerId = document.getElementById('insp-breaker-select').value || null;
await assignDevices([state.selectedDeviceId], breakerId);
}
// Assign All in Room
async function assignAllInCurrentRoom() {
if (!state.selectedDeviceId) return;
const d = state.devices.find(x => x.id === state.selectedDeviceId);
if (!d || !d.room) return;
const breakerId = document.getElementById('insp-breaker-select').value || null;
const roomDevices = state.devices.filter(x => x.room === d.room).map(x => x.id);
if (confirm(`Assign all ${roomDevices.length} devices in "${d.room}" to this circuit?`)) {
await assignDevices(roomDevices, breakerId);
}
}
// Setup Pan and Zoom
function setupPanZoom() {
const container = document.getElementById('canvas-container');
const viewportG = document.getElementById('viewport-g');
function updateTransform() {
viewportG.setAttribute('transform', `translate(${state.pan.x}, ${state.pan.y}) scale(${state.zoom})`);
}
updateTransform();
container.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomFactor = e.deltaY < 0 ? 1.15 : 0.85;
const newZoom = Math.min(Math.max(state.zoom * zoomFactor, 0.05), 3.0);
// Zoom towards mouse position
const rect = container.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
state.pan.x = mouseX - (mouseX - state.pan.x) * (newZoom / state.zoom);
state.pan.y = mouseY - (mouseY - state.pan.y) * (newZoom / state.zoom);
state.zoom = newZoom;
updateTransform();
});
container.addEventListener('mousedown', (e) => {
if (e.button !== 0) return; // Only left click drag
state.isDragging = true;
state.dragStart = { x: e.clientX - state.pan.x, y: e.clientY - state.pan.y };
});
window.addEventListener('mousemove', (e) => {
if (!state.isDragging) return;
state.pan.x = e.clientX - state.dragStart.x;
state.pan.y = e.clientY - state.dragStart.y;
updateTransform();
});
window.addEventListener('mouseup', () => {
state.isDragging = false;
});
// Zoom control buttons
document.getElementById('btn-zoom-in').addEventListener('click', () => {
state.zoom = Math.min(state.zoom * 1.25, 3.0);
updateTransform();
});
document.getElementById('btn-zoom-out').addEventListener('click', () => {
state.zoom = Math.max(state.zoom * 0.8, 0.05);
updateTransform();
});
document.getElementById('btn-zoom-fit').addEventListener('click', () => {
state.zoom = 0.22;
state.pan = { x: 40, y: 40 };
updateTransform();
});
}
// Setup Toolbar Controls
function setupToolbar() {
// Orientation toggle
const btnOrient = document.getElementById('btn-orientation');
btnOrient.addEventListener('click', () => {
state.orientation = state.orientation === 'landscape' ? 'native' : 'landscape';
btnOrient.innerHTML = state.orientation === 'landscape'
? '<i class="fa-solid fa-file-lines"></i> View: Field Doc (Landscape)'
: '<i class="fa-solid fa-compass-drafting"></i> View: Draw.io (Native)';
renderMap();
});
// Layer Toggles
setupLayerToggle('toggle-lights', 'lights');
setupLayerToggle('toggle-outlets', 'outlets');
setupLayerToggle('toggle-switches', 'switches');
setupLayerToggle('toggle-lines', 'lines');
setupLayerToggle('toggle-halos', 'unmappedHalos');
// Exit Painter Button
document.getElementById('btn-exit-painter').addEventListener('click', () => {
state.activeBreakerId = null;
updatePainterBadgeUI();
renderBreakersList();
renderMap();
});
// Export buttons
document.getElementById('btn-export-drawio').addEventListener('click', () => {
window.location.href = '/api/export/drawio';
});
document.getElementById('btn-export-csv').addEventListener('click', () => {
window.location.href = '/api/export/csv';
});
document.getElementById('btn-export-json').addEventListener('click', () => {
window.location.href = '/api/export/json';
});
// Reset button
document.getElementById('btn-reset').addEventListener('click', async () => {
if (confirm('Are you sure you want to reset all mappings back to the initial field seed state?')) {
const res = await fetch('/api/reset', { method: 'POST' });
const json = await res.json();
if (json.success) {
await loadState();
}
}
});
}
function setupLayerToggle(elementId, layerKey) {
const el = document.getElementById(elementId);
if (!el) return;
el.addEventListener('click', () => {
state.layers[layerKey] = !state.layers[layerKey];
el.classList.toggle('active', state.layers[layerKey]);
renderMap();
});
}
// Setup Sidebar Tabs (Subpanel vs Main Panel)
function setupSidebarTabs() {
const tabs = document.querySelectorAll('.sidebar-tab');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
state.activePanel = tab.dataset.panel;
document.getElementById('panel-card-title').textContent = state.activePanel === 'subpanel'
? 'GE PowerMark Gold Load Center (Subpanel)'
: 'Main Service Panel (Square D QO)';
document.getElementById('panel-card-subtitle').textContent = state.activePanel === 'subpanel'
? '125A 120/240V • Half-inch THQP Slim Tandem Compatible'
: '100A/200A 120/240V • Service Entrance & Disconnect';
renderBreakersList();
});
});
}
// Prompt to edit breaker metadata
async function editBreakerPrompt(breakerId) {
const b = state.breakers.find(x => x.id === breakerId);
if (!b) return;
const newDesc = prompt(`Edit Circuit Description for Slot [${b.label}]:`, b.description || '');
if (newDesc === null) return;
const newAmps = prompt(`Breaker Amperage (15, 20, 30, 40, 50, etc.):`, b.amps);
if (newAmps === null) return;
try {
const res = await fetch(`/api/breakers/${b.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
description: newDesc,
amps: parseInt(newAmps, 10) || b.amps
})
});
const json = await res.json();
if (json.success) {
b.description = newDesc;
b.amps = parseInt(newAmps, 10) || b.amps;
renderBreakersList();
renderMap();
}
} catch (err) {
alert('Failed to update breaker: ' + err.message);
}
}