'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 = `
${b.label}
${b.amps}A • ${b.poles}P ${b.voltage}V
${b.description || 'Spare'}
`; 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 ? `[${breaker.label}] ${breaker.description || ''}` : `⚠️ UNMAPPED`; tooltip.innerHTML = `
${d.label || d.id} (${d.type.toUpperCase()})
Room: ${d.room || 'Unknown'}
Circuit: ${statusHtml}
${d.notes ? `
${d.notes}
` : ''}
Click to inspect / Right-click to unassign
`; 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 = ''; 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' ? ' View: Field Doc (Landscape)' : ' 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); } }