feat: initial release of interactive electrical circuit & panel mapper
This commit is contained in:
commit
3ed8193565
17 files changed
+7222
No files matched your search
@@ -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(' ', ' ');
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user