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,5 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
data/*.sqlite-wal
|
||||
data/*.sqlite-shm
|
||||
@@ -0,0 +1,85 @@
|
||||
# Electrical Circuit & Panel Mapper
|
||||
|
||||
Interactive, full-stack electrical circuit and panel mapping application for tracing branch circuits, documenting load centers, and mapping electrical devices (lighting fixtures, wall receptacles, switches, and dedicated appliances) across architectural floorplans.
|
||||
|
||||
Repository: [https://git.theta42.com/wmantly/electrical-chart](https://git.theta42.com/wmantly/electrical-chart)
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Overview & Features
|
||||
|
||||
- **Interactive Architectural Schematic:**
|
||||
- Renders all 141 electrical devices (48 lights, 49 receptacles, 44 switches) and room structures derived from `diagram.drawio`.
|
||||
- Supports dual orientation modes: **Field Doc Landscape** (matching physical handheld floorplan printout) and **Draw.io Native**.
|
||||
- Smooth pan and zoom navigation (mouse wheel + click-and-drag).
|
||||
- **Physical Panel Scheduling:**
|
||||
- **GE PowerMark Gold Load Center (Subpanel):** 12 slots with 1/2-inch THQP slim tandem breakers (`6A`, `6B`, `7A`, `7B`, `8A`, `8B`, `9A`, `9B`, `11A`, `11B`, 2-pole 240V heaters).
|
||||
- **Main Service Panel (Square D QO):** Main disconnect, subpanel feeder, 240V range/HVAC/dryer, utility branch circuits.
|
||||
- **Painter Mode (Rapid Tagging):**
|
||||
- Click any breaker card in the sidebar to activate Painter Mode.
|
||||
- Click any device on the floor plan to instantly assign it to that breaker with zero dialogs.
|
||||
- Right-click any device to unassign it.
|
||||
- **Room Bulk Actions & Lasso Selection:**
|
||||
- Assign entire rooms of receptacles or lighting in a single click via the Device Inspector.
|
||||
- **Visual Circuit Tracing:**
|
||||
- Color-coded badges per circuit (`6A`, `7B`, `7A`, etc.).
|
||||
- Dashed circuit run lines dynamically link all devices sharing the same breaker.
|
||||
- Unmapped warning halos highlight every unverified fixture that still needs testing.
|
||||
- **Exports:**
|
||||
- Export back to updated **Draw.io XML** with embedded circuit tags.
|
||||
- Export **Panel Schedule CSV** for printing panel door labels.
|
||||
- Export full **JSON state dump**.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quickstart
|
||||
|
||||
### Prerequisites
|
||||
- Node.js v18+ (tested on Node v22)
|
||||
- npm
|
||||
|
||||
### Installation & Run
|
||||
|
||||
```bash
|
||||
git clone ssh://gitea@git.theta42.com:2222/wmantly/electrical-chart.git
|
||||
cd electrical-chart
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Open [http://localhost:3456](http://localhost:3456) in your browser.
|
||||
|
||||
To run on a different port:
|
||||
```bash
|
||||
PORT=3000 npm start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Architecture
|
||||
|
||||
```
|
||||
electrical-chart/
|
||||
├── app.js # Express application setup & middleware
|
||||
├── bin/
|
||||
│ └── www # HTTP server launcher
|
||||
├── conf/
|
||||
│ └── base.js # Configuration (port, database paths)
|
||||
├── data/
|
||||
│ └── electrical_chart.sqlite # SQLite database (auto-created on startup)
|
||||
├── diagram.drawio # Master Draw.io architectural source
|
||||
├── lib/
|
||||
│ └── seed.js # Initial database seeder & circuit pre-mappings
|
||||
├── models/
|
||||
│ └── db.js # better-sqlite3 connection & schema migrations
|
||||
├── public/
|
||||
│ ├── css/
|
||||
│ │ └── style.css # Dark blueprint theme & responsive styling
|
||||
│ └── js/
|
||||
│ └── app.js # Interactive SVG rendering, pan/zoom, painter mode
|
||||
├── routes/
|
||||
│ ├── api.js # REST API (/api/state, /api/devices/assign, exports)
|
||||
│ └── render.js # Main page render route
|
||||
└── views/
|
||||
└── index.ejs # EJS single-page layout
|
||||
```
|
||||
@@ -0,0 +1,41 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const conf = require('./conf/base');
|
||||
const { seedDatabase } = require('./lib/seed');
|
||||
|
||||
// Initialize database with initial seed if empty
|
||||
seedDatabase();
|
||||
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Static files
|
||||
app.use('/static', express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// View engine
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
app.set('view engine', 'ejs');
|
||||
|
||||
// Routes
|
||||
app.use('/', require('./routes/render'));
|
||||
app.use('/api', require('./routes/api'));
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res, next) => {
|
||||
res.status(404).json({ error: 'Endpoint Not Found' });
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Server error:', err);
|
||||
res.status(err.status || 500).json({
|
||||
error: err.message || 'Internal Server Error'
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const app = require('../app');
|
||||
const conf = require('../conf/base');
|
||||
|
||||
const port = normalizePort(process.env.PORT || conf.port || '3456');
|
||||
app.set('port', port);
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
server.listen(port);
|
||||
server.on('error', onError);
|
||||
server.on('listening', onListening);
|
||||
|
||||
function normalizePort(val) {
|
||||
const port = parseInt(val, 10);
|
||||
if (isNaN(port)) return val;
|
||||
if (port >= 0) return port;
|
||||
return false;
|
||||
}
|
||||
|
||||
function onError(error) {
|
||||
if (error.syscall !== 'listen') throw error;
|
||||
const bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port;
|
||||
switch (error.code) {
|
||||
case 'EACCES':
|
||||
console.error(bind + ' requires elevated privileges');
|
||||
process.exit(1);
|
||||
break;
|
||||
case 'EADDRINUSE':
|
||||
console.error(bind + ' is already in use');
|
||||
process.exit(1);
|
||||
break;
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function onListening() {
|
||||
const addr = server.address();
|
||||
const bind = typeof addr === 'string' ? 'pipe ' + addr : 'http://localhost:' + addr.port;
|
||||
console.log(`⚡ Electrical Chart & Mapping Server listening at ${bind}`);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
port: process.env.PORT || 3456,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
appName: 'Electrical Circuit & Panel Mapper',
|
||||
dbPath: process.env.DB_PATH || path.join(__dirname, '../data/electrical_chart.sqlite'),
|
||||
drawioSourcePath: path.join(__dirname, '../diagram.drawio')
|
||||
};
|
||||
Binary file not shown.
+649
@@ -0,0 +1,649 @@
|
||||
<mxfile host="embed.diagrams.net" agent="excalidraw-converter">
|
||||
<diagram id="d1" name="Page-1">
|
||||
<mxGraphModel grid="0" page="0" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="2" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=#eaddd7;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="145" width="173" x="1135.3104982069542" y="3167.931375789274" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="3" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="820" width="1826" x="2554.443570512737" y="2526.5728498344406" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="4" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="141" width="514" x="3494.3298519290975" y="2526.9941881846908" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="5" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="454" width="484" x="3526.9972649966567" y="2886.335731927842" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="86" width="71" x="2560.383100161384" y="2535.4821443074115" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="7" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="89" width="267" x="2759.357343391063" y="2529.5426146587647" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="8" edge="1" parent="1" style="shape=filledEdge;strokeWidth=2;strokeColor=#1e1e1e;fillColor=none;startArrow=none;startFill=0;startSize=6;endArrow=none;endFill=0;endSize=6;rounded=1;fixDash=1" value="">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="2551.4738056884134" y="2799.791213672209" as="sourcePoint" />
|
||||
<mxPoint x="2486.138979553295" y="2874.0353342802973" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="9" edge="1" parent="1" style="shape=filledEdge;strokeWidth=2;strokeColor=#1e1e1e;fillColor=none;startArrow=none;startFill=0;startSize=6;endArrow=none;endFill=0;endSize=6;rounded=1;fixDash=1" value="">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="2551.9344905427643" y="3264.8778333168343" as="sourcePoint" />
|
||||
<mxPoint x="2486.599664407646" y="3187.6639478844218" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="10" edge="1" parent="1" style="shape=filledEdge;strokeWidth=2;strokeColor=#1e1e1e;fillColor=none;startArrow=none;startFill=0;startSize=6;endArrow=none;endFill=0;endSize=6;rounded=1;fixDash=1" value="">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="2489.1087443776187" y="2877.005099104621" as="sourcePoint" />
|
||||
<mxPoint x="2486.138979553295" y="3182.8908760099484" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="11" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="101" width="249" x="2297.036868658687" y="2527.0448680240142" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="12" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="353" width="143" x="2297.120110129663" y="2155.8242649835693" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="13" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="490" width="884" x="2305.1034864311587" y="1567.049738557299" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="14" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="360" width="258" x="2043.906956498701" y="1572.9892682059458" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="15" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="116" width="131" x="2171.464069336598" y="1932.330811949097" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="16" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="101" width="198" x="1889.6219602418917" y="1933.5948269998466" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="17" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="350" width="160" x="1878.4428030370814" y="1576.8017097307697" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="18" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="479" width="436" x="1854.5688379652686" y="2158.794029807893" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="19" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="163" width="92" x="1759.6196050578897" y="2161.5973116902655" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="20" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="132" width="89" x="1762.6726113531884" y="2506.0374482041425" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="21" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="142" width="86" x="1759.7028465288654" y="2342.8362074449783" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="22" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="345" width="571" x="1303.5204875704248" y="1581.6379987106218" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="23" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="110" width="348" x="1304.5296472128753" y="1928.141449543083" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="24" edge="1" parent="1" style="shape=filledEdge;strokeWidth=2;strokeColor=#1e1e1e;fillColor=none;startArrow=none;startFill=0;startSize=6;endArrow=none;endFill=0;endSize=6;rounded=1;fixDash=1" value="">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="4008.110404682543" y="2655.6639979949045" as="sourcePoint" />
|
||||
<mxPoint x="4008.110404682543" y="2725.883142786781" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="25" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="519" width="235" x="4006.9229073539236" y="1527.0019466799745" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="26" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="4028.859686628797" y="2921.275545685916" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="27" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3885.990152870583" y="2913.643029947668" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="28" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="72" width="36" x="3884.5458989071894" y="2808.524828959414" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="29" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2675.338723798538" y="2546.777694690565" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="30" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2718.080811932724" y="2549.8307009858645" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="31" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2516.582396442992" y="2839.8662990392672" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="32" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3482.9933218911174" y="2919.749042538266" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="33" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3260.1238623342924" y="3258.6327413164527" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="34" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2485.043173847551" y="2079.7953683495543" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="35" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2235.705817275486" y="2082.7207378051203" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="36" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2094.4370596249073" y="1844.1837764223606" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="37" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2506.414217914644" y="1966.834135423492" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="38" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3724.257401323578" y="2103.2357864374417" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="39" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3779.1349325351193" y="2327.4658212502636" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="40" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3782.187938830418" y="2245.034651277191" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="41" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2037.2604080810513" y="2180.4169392546873" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="42" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="1767.9696049714892" y="2077.0427229319075" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="43" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="1720.6352437103817" y="2170.8299226514046" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="44" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="1571.3638234863313" y="1857.9350684351793" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="45" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="1894.9824907880231" y="1674.7546907172405" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="46" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2412.7801824028256" y="2565.095732462359" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="47" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2455.5222705370115" y="2568.148738757658" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="48" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="3745.5518632868298" y="2747.2541868538738" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="49" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2907.367202241261" y="2900.926424945247" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="50" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="4153.843798620308" y="2902.9447442301484" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="51" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="3769.975913649221" y="3096.823407665607" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="52" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2238.7588235707835" y="2656.6859213213284" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="53" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="1323.8720621612463" y="2652.521645911793" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="54" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="1528.8259542957173" y="2283.1078841806157" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="55" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2040.31341437635" y="2394.1273799256164" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="56" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="1458.402590560269" y="1729.708804032622" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="57" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="1913.3005285598167" y="1766.3448795762101" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="58" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2182.974242188578" y="1712.9045057245048" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="59" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2842.2449103975314" y="1728.6996443901717" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="60" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2834.095051154085" y="2247.583077751265" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="61" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="3852.4070836222945" y="2333.571833840862" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="62" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="4026.4284424543366" y="2248.0876575724906" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="63" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2360.8790753827425" y="2531.51266321407" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="64" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="3360.8730700791584" y="3115.1414454374008" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="65" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="3171.586679770622" y="3270.8447664976493" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="66" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="78" width="61" x="1963.4380845901978" y="2054.4049741477543" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="67" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2449.735484166603" y="2168.669904466954" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="68" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="4291.406635209138" y="3252.7397745961734" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="69" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="4069.246257537897" y="3265.8222024518727" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="70" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="4349.431526436321" y="3218.161821659873" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="71" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="4092.3543209521995" y="2520.5871573406002" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="72" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3933.8929192138485" y="2529.676690726215" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="73" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3920.894633543303" y="2916.736752915791" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="74" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3220.431461297242" y="2535.453706579791" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="75" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3480.3971747081514" y="2978.8396733417303" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="76" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2559.3653114552335" y="2708.4241007343494" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="77" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2636.494548719841" y="3276.015908348167" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="78" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3322.113015939547" y="3276.3559898008816" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="79" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2035.6848999479625" y="2567.179090593998" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="80" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2249.4344865302655" y="2354.8737579750887" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="81" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="1862.37442434069" y="2481.968106753756" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="82" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2097.787820373902" y="2167.120742733877" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="83" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3203.704871204577" y="1573.5639042125094" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="84" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3427.422845602492" y="1570.6753962857213" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="85" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3722.8060075076864" y="1942.6171247784964" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="86" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3360.2230722284057" y="2444.5417099767255" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="87" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2502.738383364822" y="2428.3148349266785" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="88" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3152.988741114698" y="1767.0939353072974" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="89" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2833.375867914252" y="1572.1196502491157" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="90" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2447.1507184074762" y="1576.1123306865832" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="91" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3493.068585417702" y="3215.2062781477543" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="92" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2062.922406205779" y="1791.1792193429862" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="93" edge="1" parent="1" style="shape=filledEdge;strokeWidth=2;strokeColor=#1e1e1e;fillColor=none;startArrow=none;startFill=0;startSize=6;endArrow=none;endFill=0;endSize=6;rounded=1;fixDash=1" value="">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="3765.971518427147" y="2169.3006095548435" as="sourcePoint" />
|
||||
<mxPoint x="3760.8746654789993" y="1570.4203881475519" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="94" edge="1" parent="1" style="shape=filledEdge;strokeWidth=2;strokeColor=#1e1e1e;fillColor=none;startArrow=none;startFill=0;startSize=6;endArrow=none;endFill=0;endSize=6;rounded=1;fixDash=1" value="">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="3169.6397234939286" y="1565.323535199405" as="sourcePoint" />
|
||||
<mxPoint x="3760.874665479" y="1567.8719616734784" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="95" edge="1" parent="1" style="shape=filledEdge;strokeWidth=2;strokeColor=#1e1e1e;fillColor=none;startArrow=none;startFill=0;startSize=6;endArrow=none;endFill=0;endSize=6;rounded=1;fixDash=1" value="">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="1760.3598833312374" y="2630.5658013621623" as="sourcePoint" />
|
||||
<mxPoint x="1299.0946915239188" y="2635.6626543103102" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="96" edge="1" parent="1" style="shape=filledEdge;strokeWidth=2;strokeColor=#1e1e1e;fillColor=none;startArrow=none;startFill=0;startSize=6;endArrow=none;endFill=0;endSize=6;rounded=1;fixDash=1" value="">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="1302.4183570619218" y="1969.3966699206196" as="sourcePoint" />
|
||||
<mxPoint x="1299.869930587848" y="2631.987553179751" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="97" edge="1" parent="1" style="shape=filledEdge;strokeWidth=2;strokeColor=#1e1e1e;fillColor=none;startArrow=none;startFill=0;startSize=6;endArrow=none;endFill=0;endSize=6;rounded=1;fixDash=1" value="">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="1759.9091939041728" y="2164.128652740113" as="sourcePoint" />
|
||||
<mxPoint x="1759.9091939041725" y="1937.318696547564" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="98" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="4244.650533493862" y="3348.0873168620196" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="99" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2193.167221864628" y="2647.270036491784" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="100" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="1612.1259857758516" y="3465.3149346694045" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="101" edge="1" parent="1" style="shape=filledEdge;strokeWidth=4;strokeColor=#e03131;fillColor=none;startArrow=none;startFill=0;startSize=6;endArrow=block;endFill=1;endSize=6;rounded=1;fixDash=1" value="">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="4942.893860022078" y="1634.4717482222543" as="sourcePoint" />
|
||||
<mxPoint x="4639.631109607321" y="1634.4717482222543" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="102" edge="1" parent="1" style="shape=filledEdge;strokeWidth=4;strokeColor=#1e1e1e;fillColor=none;startArrow=none;startFill=0;startSize=6;endArrow=none;endFill=0;endSize=6;rounded=1;fixDash=1" value="">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="4797.6335509998835" y="1529.9862627852374" as="sourcePoint" />
|
||||
<mxPoint x="4797.6335509998835" y="1728.763527762977" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="103" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=4" value="" vertex="1">
|
||||
<mxGeometry height="3" width="451" x="2562.6635332373517" y="2615.615940740584" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="104" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="820" width="1826" x="2567.1289387089437" y="3496.534248499198" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="105" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="454" width="484" x="3539.6826331928632" y="3856.297130592599" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="106" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=4" value="" vertex="1">
|
||||
<mxGeometry height="186" width="711" x="3299.158784244617" y="3498.6135584040762" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="107" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=4" value="" vertex="1">
|
||||
<mxGeometry height="199" width="153" x="2983.1539014594928" y="3493.516705455929" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="108" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2595.8441321362006" y="3556.5993435587925" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="109" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2588.19885271398" y="3734.9891967439435" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="110" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2585.6504262399067" y="3898.088491084653" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="111" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2585.6504262399067" y="4073.92991779573" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="112" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2585.6504262399067" y="4224.287079766072" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="113" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2710.5233234695124" y="4229.383932714219" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="114" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2799.7182500620875" y="4231.932359188293" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="115" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="3355.2752214101292" y="4048.4456530549946" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="116" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="3184.5306476471987" y="3561.69619650694" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="117" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="3760.4750307878285" y="4071.3814913216565" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="118" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="3709.5065013063568" y="3732.44077026987" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="119" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="4188.610678432191" y="3712.053358477281" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="120" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="4191.1591049062645" y="3913.3790499290944" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="121" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="3097.884147528698" y="4336.41784462531" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="122" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="3429.1795891582633" y="4226.835506240145" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="123" edge="1" parent="1" style="shape=filledEdge;strokeWidth=4;strokeColor=#1e1e1e;fillColor=none;startArrow=none;startFill=0;startSize=6;endArrow=none;endFill=0;endSize=6;rounded=1;dashed=1;dashPattern=2 2;fixDash=1" value="">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="4028.008755829662" y="3953.58547527318" as="sourcePoint" />
|
||||
<mxPoint x="4389.885315148111" y="3951.0370487991063" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="124" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3067.2775024718676" y="4240.0822184317385" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="125" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3026.50267888669" y="4234.9853654835915" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="126" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2988.2762817755865" y="4240.0822184317385" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="127" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2950.049884664483" y="4237.533791957665" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="128" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3197.2472526496203" y="4234.985365483591" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="129" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3243.118929182945" y="4234.9853654835915" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="130" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3286.4421792421954" y="4232.436939009518" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="131" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2994.6473479607703" y="3718.9290044836907" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="132" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3040.519024494094" y="3718.9290044836907" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="133" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3083.8422745533453" y="3716.3805780096172" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="134" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3954.1299154494745" y="3702.3642324022126" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="135" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="4043.3248420420505" y="3880.754085587363" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="136" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3836.9022976420893" y="3868.0119532169956" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="137" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3576.9627972865846" y="3898.5930709058784" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="138" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2848.112825701539" y="3513.780673320767" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="139" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2723.239928471933" y="3511.2322468466937" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="140" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2577.9796194497394" y="3827.237129631818" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="141" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2995.921561197807" y="4173.823130105825" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="142" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3487.7678706940087" y="4245.1790713798855" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="143" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3508.1552824865976" y="4176.371556579899" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="144" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3500.510003064376" y="3906.238350328099" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="145" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="3798.675900530985" y="3868.011953216995" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="146" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="4354.232871879027" y="3597.878746965195" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="147" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="4038.2279890939035" y="4130.499880046575" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="148" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="4168.197739271655" y="4245.1790713798855" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="149" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="4305.812768871629" y="4245.1790713798855" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="150" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2939.8561787681883" y="3592.781894017048" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="151" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2952.0571070615315" y="1489.260184197319" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="152" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2999.8568685568434" y="1583.3744249635415" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="153" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="1221.5514174120751" y="778.0067284404868" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="154" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="1052" width="982" x="1302.3673438500857" y="317.1908435337409" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="155" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="1052" width="1460" x="2292.5033294793348" y="311.80613292935413" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="156" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="1317.6660195498632" y="828.7382670573347" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="157" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="1317.666019549863" y="740.2517923345727" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="158" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2226.642580947386" y="797.4422443960052" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="159" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2305.562950294715" y="804.6168234275806" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="160" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2986.951407563055" y="1269.5595341489648" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="161" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="1914.631732101129" y="750.9513437206115" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="162" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2139.4352084238217" y="1133.595558737962" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="163" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2926.247375553249" y="770.0835544714796" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="164" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="3383.028907230211" y="774.8666071591965" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="165" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="2041.3826283256265" y="1695.9944380736217" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="166" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="1974.41989069759" y="1691.2113853859048" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="167" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="1353.5389147077399" y="785.1973958466842" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="168" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=#1e1e1e;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="4718" width="392" y="3.4161028494681887" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="169" edge="1" parent="1" style="shape=filledEdge;strokeWidth=4;strokeColor=#ffffff;fillColor=none;startArrow=none;startFill=0;startSize=6;endArrow=none;endFill=0;endSize=6;rounded=1;dashed=1;fixDash=1" value="">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="193.4283947972242" as="sourcePoint" />
|
||||
<mxPoint x="184.96407613255428" y="4689.232540226674" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="170" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=#ffffff;strokeColor=#ffffff;strokeWidth=4;dashed=1" value="" vertex="1">
|
||||
<mxGeometry height="481" width="547" x="1095.788383260073" y="3579.011026492495" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="171" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=none;strokeColor=#1e1e1e;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="651" width="559" x="1054.9262552233097" y="3626.1596357656836" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="172" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="1319.8608103823005" y="3914.8038470456395" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="173" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="1291.5716448183873" y="3547.0446947147693" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="174" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="1451.4257447326606" y="3647.5381601746576" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="175" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="1388.560932368409" y="3641.2516789382325" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="176" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="1193.6800140392304" y="3647.538160174658" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="177" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="1096.2395548746406" y="4203.891749598282" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="178" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="1172.1285013263098" y="3171.467146667434" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="179" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="794.939627140802" y="2945.153822156129" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="180" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="1234.5421420759933" y="3234.2417247087715" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="181" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="816.4911398537222" y="3139.9445061623946" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="182" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="869.9262303633357" y="2948.206828451428" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="183" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#a5d8ff;strokeColor=#1e1e1e;strokeWidth=2;html=1;align=center;verticalAlign=middle;fontSize=36;fontFamily=Comic Sans MS;whiteSpace=wrap" value="<div style="width: 336.0px;height:auto;word-break: break-word;line-height:1em;"><div align="center"><span style="font-size: 32px; line-height: 0;"><span style="line-height: 0;"><span style="color: #1e1e1e; font-size: 32px; line-height: 16.5px;">Pool</span><br></span></span></div></div>" vertex="1">
|
||||
<mxGeometry height="355" width="336" x="451.424056526497" y="2845.610264877667" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="184" parent="1" style="shape=ellipse;perimeter=ellipsePerimeter;fillColor=#1971c2;strokeColor=#1971c2;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="70" width="61" x="1783.7204087878783" y="2645.619507570388" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="185" parent="1" style="shape=rhombus;perimeter=rhombusPerimeter;fillColor=#2f9e44;strokeColor=#2f9e44;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="64" width="31" x="2714.8794411128065" y="325.3842130411467" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="186" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="25" width="91" x="3714.627288402001" y="3826.0729290096747" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="187" parent="1" style="shape=rect;rounded=1;absoluteArcSize=1;arcSize=15;fillColor=#e03131;strokeColor=#e03131;strokeWidth=2" value="" vertex="1">
|
||||
<mxGeometry height="25" width="91" x="4291.411941844007" y="3320.011189477452" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="188" edge="1" parent="1" style="shape=filledEdge;strokeWidth=2;strokeColor=#e03131;fillColor=none;startArrow=none;startFill=0;startSize=6;endArrow=none;endFill=0;endSize=6;rounded=1;fixDash=1" value="">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="643.6812044083256" y="3049.6924963111715" as="sourcePoint" />
|
||||
<mxPoint x="643.6812044083256" y="3049.6924963111715" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="189" parent="1" style="text;html=1;nl2Br=0;align=left;verticalAlign=top;fontSize=32;fontFamily=Comic Sans MS;spacingLeft=0;spacingRight=0;spacingTop=0;spacingBottom=0;whiteSpace=wrap;strokeColor=none;fillColor=none" value="office " vertex="1">
|
||||
<mxGeometry height="45" width="115" x="4126.922376519738" y="2792.968153669947" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="190" parent="1" style="text;html=1;nl2Br=0;align=left;verticalAlign=top;fontSize=32;fontFamily=Comic Sans MS;spacingLeft=0;spacingRight=0;spacingTop=0;spacingBottom=0;whiteSpace=wrap;strokeColor=none;fillColor=none" value="Office Hall" vertex="1">
|
||||
<mxGeometry height="45" width="179" x="3569.2919904964165" y="2811.8447792442494" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="191" parent="1" style="text;html=1;nl2Br=0;align=left;verticalAlign=top;fontSize=32;fontFamily=Comic Sans MS;spacingLeft=0;spacingRight=0;spacingTop=0;spacingBottom=0;whiteSpace=wrap;strokeColor=none;fillColor=none" value="Guest Bath" vertex="1">
|
||||
<mxGeometry height="45" width="211" x="3676.325902407507" y="3041.203161910872" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="192" parent="1" style="text;html=1;nl2Br=0;align=left;verticalAlign=top;fontSize=32;fontFamily=Comic Sans MS;spacingLeft=0;spacingRight=0;spacingTop=0;spacingBottom=0;whiteSpace=wrap;strokeColor=none;fillColor=none" value="Server<br>Room" vertex="1">
|
||||
<mxGeometry height="90" width="112" x="3808.844079059333" y="2549.3568524146704" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="193" parent="1" style="text;html=1;nl2Br=0;align=left;verticalAlign=top;fontSize=32;fontFamily=Comic Sans MS;spacingLeft=0;spacingRight=0;spacingTop=0;spacingBottom=0;whiteSpace=wrap;strokeColor=none;fillColor=none" value="N" vertex="1">
|
||||
<mxGeometry height="45" width="25" x="4372.046329829595" y="1629.3748952741068" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="194" parent="1" style="text;html=1;nl2Br=0;align=left;verticalAlign=top;fontSize=32;fontFamily=Comic Sans MS;spacingLeft=0;spacingRight=0;spacingTop=0;spacingBottom=0;whiteSpace=wrap;strokeColor=none;fillColor=none" value="W" vertex="1">
|
||||
<mxGeometry height="45" width="30" x="4802.730403948031" y="1802.6678955111108" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="195" parent="1" style="text;html=1;nl2Br=0;align=left;verticalAlign=top;fontSize=32;fontFamily=Comic Sans MS;spacingLeft=0;spacingRight=0;spacingTop=0;spacingBottom=0;whiteSpace=wrap;strokeColor=none;fillColor=none" value="E" vertex="1">
|
||||
<mxGeometry height="45" width="25" x="4802.730403948031" y="1479.0177333037657" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="196" parent="1" style="text;html=1;nl2Br=0;align=left;verticalAlign=top;fontSize=32;fontFamily=Comic Sans MS;spacingLeft=0;spacingRight=0;spacingTop=0;spacingBottom=0;whiteSpace=wrap;strokeColor=none;fillColor=none" value="S" vertex="1">
|
||||
<mxGeometry height="45" width="24" x="4983.668683607256" y="1614.0843364296657" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="197" parent="1" style="text;html=1;nl2Br=0;align=left;verticalAlign=top;fontSize=32;fontFamily=Comic Sans MS;spacingLeft=0;spacingRight=0;spacingTop=0;spacingBottom=0;whiteSpace=wrap;strokeColor=none;fillColor=none" value="Pink Bathroom" vertex="1">
|
||||
<mxGeometry height="45" width="258" x="3710.7672156901435" y="4021.231378864096" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="198" parent="1" style="text;html=1;nl2Br=0;align=left;verticalAlign=top;fontSize=32;fontFamily=Comic Sans MS;spacingLeft=0;spacingRight=0;spacingTop=0;spacingBottom=0;whiteSpace=wrap;strokeColor=none;fillColor=none" value="Living<br>Room" vertex="1">
|
||||
<mxGeometry height="90" width="96" x="2704.1387584310783" y="3791.872996197473" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="199" parent="1" style="text;html=1;nl2Br=0;align=left;verticalAlign=top;fontSize=32;fontFamily=Comic Sans MS;spacingLeft=0;spacingRight=0;spacingTop=0;spacingBottom=0;whiteSpace=wrap;strokeColor=none;fillColor=none" value="Kitchen " vertex="1">
|
||||
<mxGeometry height="45" width="142" x="3167.9523767124706" y="3965.165996434477" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="200" parent="1" style="text;html=1;nl2Br=0;align=left;verticalAlign=top;fontSize=32;fontFamily=Courier New;spacingLeft=0;spacingRight=0;spacingTop=0;spacingBottom=0;whiteSpace=wrap;strokeColor=none;fillColor=none" value="Great<br>Room" vertex="1">
|
||||
<mxGeometry height="90" width="99" x="2910.5613028310386" y="3914.1974669530055" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const db = require('../models/db');
|
||||
const conf = require('../conf/base');
|
||||
|
||||
function seedDatabase(force = false) {
|
||||
const deviceCount = db.prepare('SELECT COUNT(*) as count FROM devices').get().count;
|
||||
if (deviceCount > 0 && !force) {
|
||||
console.log(`Database already contains ${deviceCount} devices. Skipping seed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Seeding electrical chart database...');
|
||||
|
||||
const rawSeedPath = path.join(__dirname, '../seeds_raw.json');
|
||||
if (!fs.existsSync(rawSeedPath)) {
|
||||
throw new Error('seeds_raw.json not found!');
|
||||
}
|
||||
const rawData = JSON.parse(fs.readFileSync(rawSeedPath, 'utf8'));
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
// Clear existing
|
||||
db.prepare('DELETE FROM devices').run();
|
||||
db.prepare('DELETE FROM breakers').run();
|
||||
db.prepare('DELETE FROM panels').run();
|
||||
db.prepare('DELETE FROM structures').run();
|
||||
db.prepare('DELETE FROM edges').run();
|
||||
db.prepare('DELETE FROM rooms').run();
|
||||
|
||||
// 1. Panels
|
||||
const insertPanel = db.prepare(`
|
||||
INSERT INTO panels (id, name, location, model, bus_rating, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insertPanel.run('subpanel', 'GE PowerMark Gold Load Center', 'Utility / Hallway', 'TM1210CCUG / TM1212CCU', '125A 120/240V', 'Feeds West Wing & Rec Room branch circuits. Uses THQP 1/2-inch slim tandem breakers.');
|
||||
insertPanel.run('main', 'Main Service Panel (Square D QO)', 'Service Entrance / Utility', 'QO 2-Tier Split Enclosure', '100A / 200A 120/240V', 'Main disconnect in lower compartment, branch distribution in upper compartment with transformer adjacent.');
|
||||
|
||||
// 2. Breakers
|
||||
const breakersList = [
|
||||
// Subpanel Odd (Left side)
|
||||
{ id: 'sub-1', panel_id: 'subpanel', slot: 1, sub_slot: null, label: '1', description: 'Bed-Bath HEAT (240V Pole A)', amps: 50, poles: 2, voltage: 240, color: '#e03131', notes: 'Pairs with slot 12' },
|
||||
{ id: 'sub-3', panel_id: 'subpanel', slot: 3, sub_slot: null, label: '3', description: 'Hall & Vermont', amps: 20, poles: 1, voltage: 120, color: '#f59f00', notes: 'Hallway lighting & convenience outlets' },
|
||||
{ id: 'sub-5', panel_id: 'subpanel', slot: 5, sub_slot: null, label: '5', description: 'Loft Outlet', amps: 20, poles: 1, voltage: 120, color: '#37b24d', notes: 'Upper loft receptacles' },
|
||||
{ id: 'sub-7A', panel_id: 'subpanel', slot: 7, sub_slot: 'A', label: '7A', description: 'Rec Room Light', amps: 15, poles: 1, voltage: 120, color: '#1098ad', notes: 'Central luminaire & key switch runs' },
|
||||
{ id: 'sub-7B', panel_id: 'subpanel', slot: 7, sub_slot: 'B', label: '7B', description: 'Rec Room Outlets', amps: 20, poles: 1, voltage: 120, color: '#1c7ed6', notes: 'North & west perimeter receptacles' },
|
||||
{ id: 'sub-9A', panel_id: 'subpanel', slot: 9, sub_slot: 'A', label: '9A', description: 'Rec Room Main Circuit', amps: 15, poles: 1, voltage: 120, color: '#4263eb', notes: 'THQP slim tandem pole' },
|
||||
{ id: 'sub-9B', panel_id: 'subpanel', slot: 9, sub_slot: 'B', label: '9B', description: 'Rec Room Aux', amps: 15, poles: 1, voltage: 120, color: '#7048e8', notes: 'THQP slim tandem pole' },
|
||||
{ id: 'sub-11A', panel_id: 'subpanel', slot: 11, sub_slot: 'A', label: '11A', description: 'Rec Room Light Primary', amps: 15, poles: 1, voltage: 120, color: '#ae3ec9', notes: 'Lighting circuit' },
|
||||
{ id: 'sub-11B', panel_id: 'subpanel', slot: 11, sub_slot: 'B', label: '11B', description: 'Rec Room Light Aux', amps: 15, poles: 1, voltage: 120, color: '#d6336c', notes: 'Lighting circuit' },
|
||||
|
||||
// Subpanel Even (Right side)
|
||||
{ id: 'sub-2', panel_id: 'subpanel', slot: 2, sub_slot: null, label: '2', description: 'Water Heater (240V Pole A)', amps: 30, poles: 2, voltage: 240, color: '#f76707', notes: 'Tied 2-pole breaker with slot 4' },
|
||||
{ id: 'sub-4', panel_id: 'subpanel', slot: 4, sub_slot: null, label: '4', description: 'Water Heater (240V Pole B)', amps: 30, poles: 2, voltage: 240, color: '#f76707', notes: 'Tied 2-pole breaker with slot 2' },
|
||||
{ id: 'sub-6A', panel_id: 'subpanel', slot: 6, sub_slot: 'A', label: '6A', description: 'Rec Room Lite & Short Wall', amps: 20, poles: 1, voltage: 120, color: '#0ca678', notes: 'Traced across central rooms, pool, and east wing' },
|
||||
{ id: 'sub-6B', panel_id: 'subpanel', slot: 6, sub_slot: 'B', label: '6B', description: 'Rec Room Tandem Aux', amps: 20, poles: 1, voltage: 120, color: '#66a80f', notes: 'THQP slim tandem' },
|
||||
{ id: 'sub-8A', panel_id: 'subpanel', slot: 8, sub_slot: 'A', label: '8A', description: 'Open / Spare A', amps: 20, poles: 1, voltage: 120, color: '#868e96', notes: 'Physical empty slot' },
|
||||
{ id: 'sub-8B', panel_id: 'subpanel', slot: 8, sub_slot: 'B', label: '8B', description: 'Open / Spare B', amps: 20, poles: 1, voltage: 120, color: '#adb5bd', notes: 'Physical empty slot' },
|
||||
{ id: 'sub-10', panel_id: 'subpanel', slot: 10, sub_slot: null, label: '10', description: 'Spare / Available', amps: 20, poles: 1, voltage: 120, color: '#495057', notes: '' },
|
||||
{ id: 'sub-12', panel_id: 'subpanel', slot: 12, sub_slot: null, label: '12', description: 'Bed-Bath HEAT (240V Pole B)', amps: 50, poles: 2, voltage: 240, color: '#e03131', notes: 'Pairs with slot 1' },
|
||||
|
||||
// Main Panel Breakers
|
||||
{ id: 'main-disc', panel_id: 'main', slot: 1, sub_slot: null, label: 'MAIN', description: 'Main Service Disconnect', amps: 100, poles: 2, voltage: 240, color: '#c92a2a', notes: 'Service entrance main breaker' },
|
||||
{ id: 'main-sub', panel_id: 'main', slot: 2, sub_slot: null, label: 'SUB-FEED', description: 'Feeder to GE Subpanel', amps: 60, poles: 2, voltage: 240, color: '#1864ab', notes: 'Heavy gauge feeder to load center' },
|
||||
{ id: 'main-range', panel_id: 'main', slot: 3, sub_slot: null, label: 'RANGE', description: 'Electric Range / Kitchen Cooktop', amps: 50, poles: 2, voltage: 240, color: '#5f3dc4', notes: '50A 240V branch' },
|
||||
{ id: 'main-dryer', panel_id: 'main', slot: 4, sub_slot: null, label: 'DRYER', description: 'Electric Clothes Dryer', amps: 30, poles: 2, voltage: 240, color: '#a61e4d', notes: '30A 240V branch' },
|
||||
{ id: 'main-hvac', panel_id: 'main', slot: 5, sub_slot: null, label: 'HVAC', description: 'Air Handler & Heat Pump', amps: 40, poles: 2, voltage: 240, color: '#0b7285', notes: '40A 240V branch' },
|
||||
{ id: 'main-util', panel_id: 'main', slot: 6, sub_slot: null, label: 'UTIL-1', description: 'Utility Room & Chime Transformer', amps: 15, poles: 1, voltage: 120, color: '#2b8a3e', notes: 'Feeds transformer and basement utility lights' }
|
||||
];
|
||||
|
||||
const insertBreaker = db.prepare(`
|
||||
INSERT INTO breakers (id, panel_id, slot, sub_slot, label, description, amps, poles, voltage, color, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const b of breakersList) {
|
||||
insertBreaker.run(b.id, b.panel_id, b.slot, b.sub_slot, b.label, b.description, b.amps, b.poles, b.voltage, b.color, b.notes);
|
||||
}
|
||||
|
||||
// 3. Structures
|
||||
const insertStruct = db.prepare(`
|
||||
INSERT INTO structures (id, drawio_id, type, x, y, w, h, label, style)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const s of rawData.structures) {
|
||||
insertStruct.run(s.id, s.drawio_id, s.type, s.x, s.y, s.w, s.h, s.label, s.style);
|
||||
}
|
||||
|
||||
// 4. Edges
|
||||
const insertEdge = db.prepare(`
|
||||
INSERT INTO edges (id, drawio_id, source, target, style)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const e of rawData.edges) {
|
||||
insertEdge.run(e.id, e.drawio_id, e.source, e.target, e.style);
|
||||
}
|
||||
|
||||
// 5. Pre-map identified devices from field notes
|
||||
// 7B: Outlets on north wall of bedroom 1 & 2, and west wall
|
||||
const map7B = ['dev-89', 'dev-90', 'dev-87'];
|
||||
// 7A: Central luminaire
|
||||
const map7A = ['dev-63'];
|
||||
// 6A: Central devices, pool area, and east wing runs annotated with 6A
|
||||
const map6A = ['dev-34', 'dev-35', 'dev-58', 'dev-80', 'dev-92', 'dev-184', 'dev-61', 'dev-62'];
|
||||
|
||||
// 6. Devices
|
||||
const insertDevice = db.prepare(`
|
||||
INSERT INTO devices (id, drawio_id, type, orig_x, orig_y, orig_w, orig_h, cx, cy, room, zone, breaker_id, label, notes, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const d of rawData.devices) {
|
||||
let assignedBreaker = null;
|
||||
let notes = '';
|
||||
|
||||
if (map7B.includes(d.id)) {
|
||||
assignedBreaker = 'sub-7B';
|
||||
notes = 'Verified on circuit 7B from field tracing';
|
||||
} else if (map7A.includes(d.id)) {
|
||||
assignedBreaker = 'sub-7A';
|
||||
notes = 'Verified central luminaire on circuit 7A';
|
||||
} else if (map6A.includes(d.id)) {
|
||||
assignedBreaker = 'sub-6A';
|
||||
notes = 'Verified on circuit 6A from field tracing';
|
||||
}
|
||||
|
||||
insertDevice.run(
|
||||
d.id,
|
||||
d.drawio_id,
|
||||
d.type,
|
||||
d.x,
|
||||
d.y,
|
||||
d.w,
|
||||
d.h,
|
||||
d.cx,
|
||||
d.cy,
|
||||
d.room,
|
||||
d.zone,
|
||||
assignedBreaker,
|
||||
d.label,
|
||||
notes,
|
||||
d.color
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
tx();
|
||||
console.log('Database seeded successfully.');
|
||||
}
|
||||
|
||||
module.exports = { seedDatabase };
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
const conf = require('../conf/base');
|
||||
|
||||
// Ensure data directory exists
|
||||
const dbDir = path.dirname(conf.dbPath);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(conf.dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// Initialize schema
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS panels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
location TEXT,
|
||||
model TEXT,
|
||||
bus_rating TEXT,
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS breakers (
|
||||
id TEXT PRIMARY KEY,
|
||||
panel_id TEXT NOT NULL,
|
||||
slot INTEGER NOT NULL,
|
||||
sub_slot TEXT,
|
||||
label TEXT NOT NULL,
|
||||
description TEXT,
|
||||
amps INTEGER DEFAULT 20,
|
||||
poles INTEGER DEFAULT 1,
|
||||
voltage INTEGER DEFAULT 120,
|
||||
color TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(panel_id) REFERENCES panels(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
zone TEXT NOT NULL,
|
||||
x REAL,
|
||||
y REAL,
|
||||
w REAL,
|
||||
h REAL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
drawio_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
orig_x REAL NOT NULL,
|
||||
orig_y REAL NOT NULL,
|
||||
orig_w REAL NOT NULL,
|
||||
orig_h REAL NOT NULL,
|
||||
cx REAL NOT NULL,
|
||||
cy REAL NOT NULL,
|
||||
room TEXT,
|
||||
zone TEXT,
|
||||
breaker_id TEXT,
|
||||
label TEXT,
|
||||
notes TEXT,
|
||||
color TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(breaker_id) REFERENCES breakers(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS structures (
|
||||
id TEXT PRIMARY KEY,
|
||||
drawio_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
x REAL NOT NULL,
|
||||
y REAL NOT NULL,
|
||||
w REAL NOT NULL,
|
||||
h REAL NOT NULL,
|
||||
label TEXT,
|
||||
style TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS edges (
|
||||
id TEXT PRIMARY KEY,
|
||||
drawio_id TEXT NOT NULL,
|
||||
source TEXT,
|
||||
target TEXT,
|
||||
style TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_breaker ON devices(breaker_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_room ON devices(room);
|
||||
CREATE INDEX IF NOT EXISTS idx_breakers_panel ON breakers(panel_id);
|
||||
`);
|
||||
|
||||
module.exports = db;
|
||||
Generated
+1370
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "electrical-chart",
|
||||
"version": "1.0.0",
|
||||
"description": "Interactive Electrical Circuit & Panel Mapping Tool",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"start": "node ./bin/www",
|
||||
"dev": "node --watch ./bin/www",
|
||||
"seed": "node -e \"require('./lib/seed').seedDatabase(true)\""
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "ssh://gitea@git.theta42.com:2222/wmantly/electrical-chart.git"
|
||||
},
|
||||
"author": "William Mantly",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.21.2",
|
||||
"xml2js": "^0.6.2"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const xml2js = require('xml2js');
|
||||
const db = require('../models/db');
|
||||
const { seedDatabase } = require('../lib/seed');
|
||||
const conf = require('../conf/base');
|
||||
|
||||
// GET /api/state - Full application state
|
||||
router.get('/state', (req, res) => {
|
||||
try {
|
||||
const panels = db.prepare('SELECT * FROM panels').all();
|
||||
const breakers = db.prepare(`
|
||||
SELECT b.*,
|
||||
(SELECT COUNT(*) FROM devices d WHERE d.breaker_id = b.id) as device_count
|
||||
FROM breakers b
|
||||
ORDER BY b.panel_id DESC, b.slot ASC, b.sub_slot ASC
|
||||
`).all();
|
||||
|
||||
const devices = db.prepare(`
|
||||
SELECT d.*, b.label as breaker_label, b.color as breaker_color, b.description as breaker_desc
|
||||
FROM devices d
|
||||
LEFT JOIN breakers b ON d.breaker_id = b.id
|
||||
`).all();
|
||||
|
||||
const structures = db.prepare('SELECT * FROM structures').all();
|
||||
const edges = db.prepare('SELECT * FROM edges').all();
|
||||
|
||||
const totalDevices = devices.length;
|
||||
const mappedDevices = devices.filter(d => d.breaker_id !== null).length;
|
||||
const unmappedDevices = totalDevices - mappedDevices;
|
||||
const progressPercent = totalDevices > 0 ? Math.round((mappedDevices / totalDevices) * 100) : 0;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
panels,
|
||||
breakers,
|
||||
devices,
|
||||
structures,
|
||||
edges,
|
||||
stats: {
|
||||
totalDevices,
|
||||
mappedDevices,
|
||||
unmappedDevices,
|
||||
progressPercent
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('API /state error:', err);
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/devices/assign - Assign one or more devices to a breaker
|
||||
router.post('/devices/assign', (req, res) => {
|
||||
try {
|
||||
const { deviceIds, breakerId } = req.body;
|
||||
if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) {
|
||||
return res.status(400).json({ success: false, error: 'deviceIds array is required' });
|
||||
}
|
||||
|
||||
if (breakerId !== null) {
|
||||
const breaker = db.prepare('SELECT id FROM breakers WHERE id = ?').get(breakerId);
|
||||
if (!breaker) {
|
||||
return res.status(404).json({ success: false, error: `Breaker ${breakerId} not found` });
|
||||
}
|
||||
}
|
||||
|
||||
const updateStmt = db.prepare(`
|
||||
UPDATE devices
|
||||
SET breaker_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
for (const id of deviceIds) {
|
||||
updateStmt.run(breakerId, id);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
|
||||
res.json({ success: true, updated: deviceIds.length, breakerId });
|
||||
} catch (err) {
|
||||
console.error('API /devices/assign error:', err);
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/devices/bulk-room - Assign all devices in a room
|
||||
router.post('/devices/bulk-room', (req, res) => {
|
||||
try {
|
||||
const { room, breakerId } = req.body;
|
||||
if (!room) {
|
||||
return res.status(400).json({ success: false, error: 'room is required' });
|
||||
}
|
||||
|
||||
const updateStmt = db.prepare(`
|
||||
UPDATE devices
|
||||
SET breaker_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE room = ?
|
||||
`);
|
||||
|
||||
const result = updateStmt.run(breakerId, room);
|
||||
res.json({ success: true, updated: result.changes, room, breakerId });
|
||||
} catch (err) {
|
||||
console.error('API /devices/bulk-room error:', err);
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/devices/:id - Update individual device attributes
|
||||
router.post('/devices/:id', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { label, notes, type, breaker_id } = req.body;
|
||||
|
||||
const current = db.prepare('SELECT * FROM devices WHERE id = ?').get(id);
|
||||
if (!current) {
|
||||
return res.status(404).json({ success: false, error: 'Device not found' });
|
||||
}
|
||||
|
||||
const newLabel = label !== undefined ? label : current.label;
|
||||
const newNotes = notes !== undefined ? notes : current.notes;
|
||||
const newType = type !== undefined ? type : current.type;
|
||||
const newBreakerId = breaker_id !== undefined ? breaker_id : current.breaker_id;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE devices
|
||||
SET label = ?, notes = ?, type = ?, breaker_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(newLabel, newNotes, newType, newBreakerId, id);
|
||||
|
||||
res.json({ success: true, device: { id, label: newLabel, notes: newNotes, type: newType, breaker_id: newBreakerId } });
|
||||
} catch (err) {
|
||||
console.error('API /devices/:id error:', err);
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/breakers/:id - Update breaker metadata
|
||||
router.post('/breakers/:id', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { description, amps, voltage, color, notes } = req.body;
|
||||
|
||||
const current = db.prepare('SELECT * FROM breakers WHERE id = ?').get(id);
|
||||
if (!current) {
|
||||
return res.status(404).json({ success: false, error: 'Breaker not found' });
|
||||
}
|
||||
|
||||
const newDesc = description !== undefined ? description : current.description;
|
||||
const newAmps = amps !== undefined ? parseInt(amps, 10) : current.amps;
|
||||
const newVolt = voltage !== undefined ? parseInt(voltage, 10) : current.voltage;
|
||||
const newColor = color !== undefined ? color : current.color;
|
||||
const newNotes = notes !== undefined ? notes : current.notes;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE breakers
|
||||
SET description = ?, amps = ?, voltage = ?, color = ?, notes = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(newDesc, newAmps, newVolt, newColor, newNotes, id);
|
||||
|
||||
res.json({ success: true, breaker: { id, description: newDesc, amps: newAmps, voltage: newVolt, color: newColor, notes: newNotes } });
|
||||
} catch (err) {
|
||||
console.error('API /breakers/:id error:', err);
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/reset - Reset to seed data
|
||||
router.post('/reset', (req, res) => {
|
||||
try {
|
||||
seedDatabase(true);
|
||||
res.json({ success: true, message: 'Database reset to initial seed state.' });
|
||||
} catch (err) {
|
||||
console.error('API /reset error:', err);
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/export/json - Export entire database state
|
||||
router.get('/export/json', (req, res) => {
|
||||
try {
|
||||
const panels = db.prepare('SELECT * FROM panels').all();
|
||||
const breakers = db.prepare('SELECT * FROM breakers').all();
|
||||
const devices = db.prepare('SELECT * FROM devices').all();
|
||||
const structures = db.prepare('SELECT * FROM structures').all();
|
||||
const edges = db.prepare('SELECT * FROM edges').all();
|
||||
|
||||
const exportPayload = {
|
||||
timestamp: new Date().toISOString(),
|
||||
panels,
|
||||
breakers,
|
||||
devices,
|
||||
structures,
|
||||
edges
|
||||
};
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="electrical_chart_export.json"');
|
||||
res.send(JSON.stringify(exportPayload, null, 2));
|
||||
} catch (err) {
|
||||
console.error('API /export/json error:', err);
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/export/csv - Export schedule CSV
|
||||
router.get('/export/csv', (req, res) => {
|
||||
try {
|
||||
const devices = db.prepare(`
|
||||
SELECT
|
||||
d.id as device_id,
|
||||
d.type,
|
||||
d.room,
|
||||
d.label as device_label,
|
||||
d.notes as device_notes,
|
||||
b.panel_id,
|
||||
b.label as breaker_label,
|
||||
b.description as circuit_description,
|
||||
b.amps,
|
||||
b.voltage
|
||||
FROM devices d
|
||||
LEFT JOIN breakers b ON d.breaker_id = b.id
|
||||
ORDER BY b.panel_id, b.slot, b.sub_slot, d.room
|
||||
`).all();
|
||||
|
||||
const headers = ['Device ID', 'Type', 'Room', 'Device Label', 'Panel', 'Breaker Slot', 'Circuit Description', 'Amps', 'Voltage', 'Notes'];
|
||||
const rows = devices.map(d => [
|
||||
`"${d.device_id || ''}"`,
|
||||
`"${d.type || ''}"`,
|
||||
`"${d.room || ''}"`,
|
||||
`"${(d.device_label || '').replace(/"/g, '""')}"`,
|
||||
`"${d.panel_id || 'UNMAPPED'}"`,
|
||||
`"${d.breaker_label || 'UNMAPPED'}"`,
|
||||
`"${(d.circuit_description || '').replace(/"/g, '""')}"`,
|
||||
`"${d.amps || ''}"`,
|
||||
`"${d.voltage || ''}"`,
|
||||
`"${(d.device_notes || '').replace(/"/g, '""')}"`
|
||||
]);
|
||||
|
||||
const csvContent = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="electrical_panel_schedule.csv"');
|
||||
res.send(csvContent);
|
||||
} catch (err) {
|
||||
console.error('API /export/csv error:', err);
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/export/drawio - Export updated draw.io XML with breaker badges
|
||||
router.get('/export/drawio', (req, res) => {
|
||||
try {
|
||||
const xmlPath = conf.drawioSourcePath;
|
||||
let rawXml = fs.readFileSync(xmlPath, 'utf8');
|
||||
|
||||
const devices = db.prepare(`
|
||||
SELECT d.*, b.label as breaker_label, b.color as breaker_color
|
||||
FROM devices d
|
||||
LEFT JOIN breakers b ON d.breaker_id = b.id
|
||||
WHERE d.breaker_id IS NOT NULL
|
||||
`).all();
|
||||
|
||||
// In rawXml, update matching mxCell values or add labels
|
||||
for (const d of devices) {
|
||||
// Find <mxCell id="d.drawio_id"
|
||||
const cellPattern = new RegExp(`(<mxCell id="${d.drawio_id}"[^>]*value=")([^"]*)(")`, 'g');
|
||||
if (cellPattern.test(rawXml)) {
|
||||
rawXml = rawXml.replace(cellPattern, `$1[${d.breaker_label}]$3`);
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/xml');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="mapped_electrical_diagram.drawio"');
|
||||
res.send(rawXml);
|
||||
} catch (err) {
|
||||
console.error('API /export/drawio error:', err);
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const conf = require('../conf/base');
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.render('index', {
|
||||
appName: conf.appName,
|
||||
environment: conf.environment
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+2987
File diff suppressed because it is too large.
Load diff
+166
@@ -0,0 +1,166 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><%= appName %></title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Top App Header -->
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<i class="fa-solid fa-bolt brand-icon"></i>
|
||||
<span><%= appName %></span>
|
||||
<span class="badge bg-secondary ms-2" style="font-size: 0.72rem; font-weight: 500;">Interactive Load Center</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress Tracker -->
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar-wrap">
|
||||
<div id="progress-fill" class="progress-fill"></div>
|
||||
</div>
|
||||
<span id="progress-text" class="progress-text">Loading...</span>
|
||||
</div>
|
||||
|
||||
<!-- Header Actions -->
|
||||
<div class="header-actions">
|
||||
<button id="btn-orientation" class="btn-tool active" title="Switch orientation">
|
||||
<i class="fa-solid fa-file-lines"></i> View: Field Doc (Landscape)
|
||||
</button>
|
||||
|
||||
<div class="dropdown d-inline-block">
|
||||
<button class="btn-tool dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||
<i class="fa-solid fa-download"></i> Export
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="#" id="btn-export-drawio"><i class="fa-solid fa-diagram-project me-2"></i>Export Draw.io (.drawio)</a></li>
|
||||
<li><a class="dropdown-item" href="#" id="btn-export-csv"><i class="fa-solid fa-table me-2"></i>Export Schedule CSV</a></li>
|
||||
<li><a class="dropdown-item" href="#" id="btn-export-json"><i class="fa-solid fa-code me-2"></i>Export Full State JSON</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button id="btn-reset" class="btn-tool btn-danger" title="Reset all mappings to seed">
|
||||
<i class="fa-solid fa-rotate-left"></i> Reset
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- App Body -->
|
||||
<div class="app-body">
|
||||
|
||||
<!-- Left Canvas Area (Interactive Blueprint) -->
|
||||
<div id="canvas-container" class="canvas-panel">
|
||||
|
||||
<!-- Canvas Toolbar Overlay -->
|
||||
<div class="canvas-toolbar">
|
||||
<div class="btn-group me-2" role="group">
|
||||
<button id="btn-zoom-in" class="btn-tool" title="Zoom In"><i class="fa-solid fa-magnifying-glass-plus"></i></button>
|
||||
<button id="btn-zoom-out" class="btn-tool" title="Zoom Out"><i class="fa-solid fa-magnifying-glass-minus"></i></button>
|
||||
<button id="btn-zoom-fit" class="btn-tool" title="Fit to Screen"><i class="fa-solid fa-arrows-to-eye"></i> Fit</button>
|
||||
</div>
|
||||
|
||||
<div class="btn-group me-2" role="group">
|
||||
<button id="toggle-lights" class="btn-tool active" title="Toggle Lights"><i class="fa-solid fa-lightbulb"></i> Lights</button>
|
||||
<button id="toggle-outlets" class="btn-tool active" title="Toggle Outlets"><i class="fa-solid fa-plug"></i> Outlets</button>
|
||||
<button id="toggle-switches" class="btn-tool active" title="Toggle Switches"><i class="fa-solid fa-toggle-on"></i> Switches</button>
|
||||
<button id="toggle-lines" class="btn-tool active" title="Toggle Circuit Lines"><i class="fa-solid fa-bezier-curve"></i> Lines</button>
|
||||
<button id="toggle-halos" class="btn-tool active" title="Toggle Unmapped Warning Halos"><i class="fa-solid fa-triangle-exclamation"></i> Unmapped</button>
|
||||
</div>
|
||||
|
||||
<!-- Painter Mode Active Badge -->
|
||||
<div id="active-painter-badge" class="active-painter-badge">
|
||||
<i class="fa-solid fa-paint-roller"></i>
|
||||
<span id="painter-label">Painter: [6A]</span>
|
||||
<button id="btn-exit-painter" class="btn-close btn-close-white" style="font-size: 0.65rem;" title="Exit Painter Mode"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SVG Viewport -->
|
||||
<svg id="map-svg" class="svg-viewport" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="glow-filter" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g id="viewport-g"></g>
|
||||
</svg>
|
||||
|
||||
<!-- Hover Tooltip -->
|
||||
<div id="device-tooltip" class="device-tooltip"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right Sidebar (Panel Schedule & Inspector) -->
|
||||
<div class="sidebar">
|
||||
|
||||
<!-- Sidebar Tabs -->
|
||||
<div class="sidebar-tabs">
|
||||
<button class="sidebar-tab active" data-panel="subpanel">
|
||||
<i class="fa-solid fa-table-cells"></i> GE Subpanel (12-Slot)
|
||||
</button>
|
||||
<button class="sidebar-tab" data-panel="main">
|
||||
<i class="fa-solid fa-bolt-lightning"></i> Main Service Panel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Content -->
|
||||
<div class="sidebar-content">
|
||||
|
||||
<!-- Panel Info Banner -->
|
||||
<div class="panel-info-card">
|
||||
<div id="panel-card-title" class="panel-title">GE PowerMark Gold Load Center (Subpanel)</div>
|
||||
<div id="panel-card-subtitle" class="panel-subtitle">125A 120/240V • Half-inch THQP Slim Tandem Compatible</div>
|
||||
<div style="font-size: 0.72rem; color: #64748b; margin-top: 6px;">
|
||||
💡 <em>Click any breaker card below to enter Painter Mode, then click devices on the map to assign them instantly!</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breakers List -->
|
||||
<div id="breakers-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Device Inspector Drawer (Bottom of Sidebar) -->
|
||||
<div id="device-inspector" class="device-inspector-panel" style="display: none;">
|
||||
<div class="inspector-title">
|
||||
<span><i class="fa-solid fa-circle-info me-1"></i> Device Inspector</span>
|
||||
<button class="btn-close btn-close-white" style="font-size: 0.65rem;" onclick="document.getElementById('device-inspector').style.display='none'"></button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; margin-bottom: 8px;">
|
||||
<div style="font-size: 0.8rem;">ID: <strong id="insp-dev-id">dev-89</strong></div>
|
||||
<div style="font-size: 0.8rem;">Type: <strong id="insp-dev-type">OUTLET</strong></div>
|
||||
<div style="font-size: 0.8rem;">Room: <strong id="insp-dev-room">Central South</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="inspector-field">
|
||||
<label>Assigned Breaker / Circuit:</label>
|
||||
<select id="insp-breaker-select" class="form-select" onchange="onInspectorBreakerChange()"></select>
|
||||
</div>
|
||||
|
||||
<div class="inspector-field">
|
||||
<label>Device Notes / Location:</label>
|
||||
<input type="text" id="insp-dev-notes" class="form-input" placeholder="e.g. North wall near door" onblur="saveDeviceNotes()">
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-top: 10px;">
|
||||
<button id="insp-room-btn" class="btn-tool btn-primary flex-grow-1" onclick="assignAllInCurrentRoom()">
|
||||
Assign all in this room
|
||||
</button>
|
||||
<button class="btn-tool btn-danger" onclick="unassignDevice(state.selectedDeviceId)">
|
||||
Unassign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user