feat: standard parts library, fuse box configurator, and theming

Standard parts (partsLibrary.js)
- ACDC TXL palette: 15 solids plus the 16 stocked stripe combinations
- 29 wire functions fix colour, stripe and gauge per signal, so a wire
  drawn from a given pin comes out identical on every build
- 16 pre-labelled parts: Skudak VCU (GRAY/BLACK), openinverter LDU
  23-pin, Dilong OBC/DCDC, BMW pedal, Honeywell CSSV1500, Bender
  ISO175, Bosch iBooster, Prius EPS, Volvo PS pump, cluster, DNR
  switch, 32-way enclosure bulkhead, contactors, VW MEB BMS
- OEM colours override the in-house standard where we splice into a
  factory loom (VW MEB LV connector, recovered from the VW bms diagram)
- Brown is ground, matching the MEB loom and European practice; orange
  is reserved for AC mains and OEM high-voltage runs

Fuse box configurator (pdmLibrary.js)
- GEP FRH-A12/A24 and the 48-way PDM as 280-footprint cavity grids
- Place, drag and rotate fuses, relays, diodes, breakers and bus bars
- Bus bars occupy their own collision layer and feed the blades they
  cross, suppressing those inputs so one wire supplies the run
- Cavity capacity is tracked; Apply is blocked while a placement
  overlaps or overhangs
- Naming a circuit renames its pins: "VCU" gives "VCU in" / "VCU out"

Theming (theme.js)
- Light and dark modes; canvas background, grid dots, part fill and
  part outline are configurable and persisted
- CSS converted to custom properties with property-aware light-mode
  pairs, since a colour used as text and as a surface must flip
  differently; no rule falls below 3:1 contrast in either theme
- Canvas devices, pins, labels, cable rows and the harness view are
  themed too, since Konva bakes colours in at draw time and cannot
  read CSS variables

Also
- Pass-through bulkheads: one device covering both connector faces,
  with a single centred label per circuit
- Pin labels scale with the device font instead of a fixed 8px
- Keyboard handling is modal-scoped, so Delete no longer removes the
  canvas device while the fuse box editor is open

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 10:54:49 -04:00
parent 43c0ea46c6
commit cca03cd2f3
8 changed files with 2410 additions and 200 deletions
+450 -135
View File
@@ -1,24 +1,232 @@
:root {
--bg-inset: #0e0e1e;
--bg-body: #12121c;
--bg-canvas: #131320;
--bg-panel: #14142a;
--bg-modal: #16162a;
--bg-surface: #1a1a2e;
--bg-surface-2: #1a1a30;
--bg-surface-3: #1e1e34;
--bg-selected: #1e1e3a;
--bg-raised: #222238;
--border-faint: #26263f;
--border: #2a2a44;
--grid-dot: #2e2e50;
--border-strong: #3a3a5a;
--text-ghost: #33334f;
--text-faint: #444466;
--text-dim: #555577;
--text-muted: #8899bb;
--text: #c0c4e8;
--text-strong: #c8ccee;
--text-bright: #dde0f5;
--accent: #5566aa;
/* Auto-derived light-mode pairs. --cXXXXXX is a surface colour, --tXXXXXX
the same value used as text; the two flip differently because a pale
blue that works as a border is unreadable as text on white. Mid-tone
semantic colours (danger, status, wire colours) stay literal. */
--c090914: #090914;
--c0a1a14: #0a1a14;
--c0c0c1c: #0c0c1c;
--c0d0d20: #0d0d20;
--c0d1a28: #0d1a28;
--c0d2235: #0d2235;
--c0e0e1a: #0e0e1a;
--c0f2a1a: #0f2a1a;
--c10101e: #10101e;
--c111122: #111122;
--c111e30: #111e30;
--c141428: #141428;
--c151528: #151528;
--c18182e: #18182e;
--c1a1a38: #1a1a38;
--c1a1a3a: #1a1a3a;
--c1a1a40: #1a1a40;
--c1a2840: #1a2840;
--c1a2a1a: #1a2a1a;
--c1a2a4a: #1a2a4a;
--c1a3a2a: #1a3a2a;
--c1e1e36: #1e1e36;
--c1e1e38: #1e1e38;
--c1e1e40: #1e1e40;
--c1e2a4a: #1e2a4a;
--c1e3355: #1e3355;
--c1e3a6a: #1e3a6a;
--c1e4a2a: #1e4a2a;
--c22223a: #22223a;
--c22224a: #22224a;
--c223355: #223355;
--c23233c: #23233c;
--c264a8a: #264a8a;
--c2a1a4a: #2a1a4a;
--c2a2a50: #2a2a50;
--c2a2a5a: #2a2a5a;
--c2e2e54: #2e2e54;
--c333355: #333355;
--c334466: #334466;
--c336633: #336633;
--c3a1414: #3a1414;
--c3a3a66: #3a3a66;
--c3d3d63: #3d3d63;
--c554422: #554422;
--c662222: #662222;
--t6688cc: #6688cc;
--t7788aa: #7788aa;
--t77aaee: #77aaee;
--t8888aa: #8888aa;
--t88aadd: #88aadd;
--t88aaff: #88aaff;
--t88bbdd: #88bbdd;
--t88bbff: #88bbff;
--t88cc88: #88cc88;
--t88ee88: #88ee88;
--t9090cc: #9090cc;
--t9966ee: #9966ee;
--t9999cc: #9999cc;
--t99aacc: #99aacc;
--t99aadd: #99aadd;
--ta0a4c8: #a0a4c8;
--taaa: #aaa;
--taaccee: #aaccee;
--taaccff: #aaccff;
--tbbc: #bbc;
--tbbc0ee: #bbc0ee;
--tc0c8f0: #c0c8f0;
--td65a4a: #d65a4a;
--tdd6666: #dd6666;
--tddaa44: #ddaa44;
--tff6666: #ff6666;
--tff8888: #ff8888;
--tfff: #fff;
--t00cc88: #00cc88;
--t4caa77: #4caa77;
--t445566: #7f90ab;
color-scheme: dark;
}
:root[data-theme="light"] {
--bg-inset: #ffffff;
--bg-body: #eceef3;
--bg-canvas: #f7f8fa;
--bg-panel: #f1f3f8;
--bg-modal: #ffffff;
--bg-surface: #f3f4f9;
--bg-surface-2: #e8ebf3;
--bg-surface-3: #e4e7f0;
--bg-selected: #dde3f4;
--bg-raised: #e6e9f1;
--border-faint: #dbdfea;
--border: #ccd1e0;
--grid-dot: #c3c9d8;
--border-strong: #b2b8cb;
--text-ghost: #b8bdcc;
--text-faint: #969cb0;
--text-dim: #7b8296;
--text-muted: #59627a;
--text: #242939;
--text-strong: #151a29;
--text-bright: #0f1320;
--accent: #5a6cba;
--c090914: #f2f2f6;
--c0a1a14: #eef4f2;
--c0c0c1c: #ededf2;
--c0d0d20: #eaeaf1;
--c0d1a28: #e6eaef;
--c0d2235: #dee6ec;
--c0e0e1a: #ededf2;
--c0f2a1a: #e4eee8;
--c10101e: #ebebf0;
--c111122: #e8e8ee;
--c111e30: #e0e5eb;
--c141428: #e4e4eb;
--c151528: #e4e4eb;
--c18182e: #e0e0e8;
--c1a1a38: #dadae4;
--c1a1a3a: #d9d9e4;
--c1a1a40: #d5d5e3;
--c1a2840: #d5dae3;
--c1a2a1a: #e2e7e2;
--c1a2a4a: #d0d5e0;
--c1a3a2a: #d9e4de;
--c1e1e36: #dadae2;
--c1e1e38: #d9d9e2;
--c1e1e40: #d4d4e0;
--c1e2a4a: #cfd3de;
--c1e3355: #c8d0dc;
--c1e3a6a: #bdc6d7;
--c1e4a2a: #cfded3;
--c22223a: #d7d7df;
--c22224a: #cecedc;
--c223355: #c8ceda;
--c23233c: #d6d6de;
--c264a8a: #a9b5cc;
--c2a1a4a: #d5d0e0;
--c2a2a50: #c9c9d6;
--c2a2a5a: #c3c3d4;
--c2e2e54: #c6c6d3;
--c333355: #c4c4d0;
--c334466: #bac0cc;
--c336633: #baccba;
--c3a1414: #e7dada;
--c3a3a66: #b9b9c8;
--c3d3d63: #babac7;
--c554422: #dad4c8;
--c662222: #d6bebe;
--t6688cc: #375fb0;
--t7788aa: #5c7097;
--t77aaee: #1154ad;
--t8888aa: #5f5f87;
--t88aadd: #285497;
--t88aaff: #002fa4;
--t88bbdd: #286a97;
--t88bbff: #0046a4;
--t88cc88: #3b913b;
--t88ee88: #1c6b28;
--t9090cc: #3c3c89;
--t9966ee: #5110bc;
--t9999cc: #3e3e81;
--t99aacc: #3e5481;
--t99aadd: #2a4187;
--ta0a4c8: #444979;
--taaa: #666666;
--taaccee: #174b80;
--taaccff: #00378a;
--tbbc: #46465e;
--tbbc0ee: #192270;
--tc0c8f0: #17266d;
--td65a4a: #cb3c29;
--tdd6666: #b52424;
--tddaa44: #d29721;
--tff6666: #bf0000;
--tff8888: #a40000;
--tfff: #292929;
--t00cc88: #00663d;
--t4caa77: #1d6941;
--t445566: #4a5568;
color-scheme: light;
}
/* ── Reset & base ─────────────────────────────────────────────────────────── */ /* ── Reset & base ─────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #app { height: 100%; overflow: hidden; font-family: "Segoe UI", system-ui, sans-serif; font-size: 13px; } html, body, #app { height: 100%; overflow: hidden; font-family: "Segoe UI", system-ui, sans-serif; font-size: 13px; }
body { background: #12121c; color: #c8ccee; } body { background: var(--bg-body); color: var(--text-strong); }
/* ── Layout ───────────────────────────────────────────────────────────────── */ /* ── Layout ───────────────────────────────────────────────────────────────── */
#app { display: flex; flex-direction: column; } #app { display: flex; flex-direction: column; }
#toolbar { #toolbar {
display: flex; align-items: center; gap: 8px; padding: 0 12px; display: flex; align-items: center; gap: 8px; padding: 0 12px;
height: 48px; background: #0e0e1a; border-bottom: 1px solid #2a2a44; height: 48px; background: var(--c0e0e1a); border-bottom: 1px solid var(--border);
flex-shrink: 0; z-index: 10; flex-shrink: 0; z-index: 10;
} }
.toolbar-left { display: flex; align-items: center; gap: 10px; min-width: 220px; } .toolbar-left { display: flex; align-items: center; gap: 10px; min-width: 220px; }
.toolbar-center { display: flex; align-items: center; gap: 5px; flex: 1; justify-content: center; flex-wrap: wrap; } .toolbar-center { display: flex; align-items: center; gap: 5px; flex: 1; justify-content: center; flex-wrap: wrap; }
.toolbar-right { display: flex; align-items: center; gap: 8px; min-width: 180px; justify-content: flex-end; } .toolbar-right { display: flex; align-items: center; gap: 8px; min-width: 180px; justify-content: flex-end; }
.toolbar-label { font-size: 11px; color: #555577; white-space: nowrap; } .toolbar-label { font-size: 11px; color: var(--text-dim); white-space: nowrap; }
#mode-indicator { #mode-indicator {
text-align: center; font-size: 11px; color: #00cc88; background: #0a1a14; text-align: center; font-size: 11px; color: var(--t00cc88); background: var(--c0a1a14);
border-bottom: 1px solid #1a3a2a; height: 22px; flex-shrink: 0; border-bottom: 1px solid var(--c1a3a2a); height: 22px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
} }
@@ -26,33 +234,33 @@ body { background: #12121c; color: #c8ccee; }
/* ── Sidebars ─────────────────────────────────────────────────────────────── */ /* ── Sidebars ─────────────────────────────────────────────────────────────── */
#left-sidebar { #left-sidebar {
width: 240px; flex-shrink: 0; background: #14142a; width: 240px; flex-shrink: 0; background: var(--bg-panel);
border-right: 1px solid #2a2a44; display: flex; flex-direction: column; overflow: hidden; border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden;
} }
#right-sidebar { #right-sidebar {
width: 248px; flex-shrink: 0; background: #14142a; width: 248px; flex-shrink: 0; background: var(--bg-panel);
border-left: 1px solid #2a2a44; overflow-y: auto; border-left: 1px solid var(--border); overflow-y: auto;
} }
.sidebar-section { display: flex; flex-direction: column; flex-shrink: 0; } .sidebar-section { display: flex; flex-direction: column; flex-shrink: 0; }
.section-header { .section-header {
padding: 7px 10px; font-size: 11px; font-weight: 600; letter-spacing: .05em; padding: 7px 10px; font-size: 11px; font-weight: 600; letter-spacing: .05em;
text-transform: uppercase; color: #6666aa; background: #10101e; text-transform: uppercase; color: #6666aa; background: var(--c10101e);
border-bottom: 1px solid #222238; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--bg-raised); display: flex; align-items: center; justify-content: space-between;
flex-shrink: 0; flex-shrink: 0;
} }
/* ── Sidebar tabs ─────────────────────────────────────────────────────────── */ /* ── Sidebar tabs ─────────────────────────────────────────────────────────── */
.tab-bar { .tab-bar {
display: flex; border-bottom: 1px solid #222238; background: #10101e; flex-shrink: 0; display: flex; border-bottom: 1px solid var(--bg-raised); background: var(--c10101e); flex-shrink: 0;
} }
.sidebar-tab { .sidebar-tab {
flex: 1; padding: 6px 4px; font-size: 11px; font-weight: 500; text-align: center; flex: 1; padding: 6px 4px; font-size: 11px; font-weight: 500; text-align: center;
background: transparent; border: none; border-radius: 0; color: #555577; background: transparent; border: none; border-radius: 0; color: var(--text-dim);
border-bottom: 2px solid transparent; cursor: pointer; transition: color .15s; border-bottom: 2px solid transparent; cursor: pointer; transition: color .15s;
} }
.sidebar-tab:hover { color: #9999cc; background: #18182e; } .sidebar-tab:hover { color: var(--t9999cc); background: var(--c18182e); }
.sidebar-tab.active { color: #88aaff; border-bottom-color: #5577dd; } .sidebar-tab.active { color: var(--t88aaff); border-bottom-color: #5577dd; }
.tab-panel { display: flex; flex-direction: column; } .tab-panel { display: flex; flex-direction: column; }
@@ -63,98 +271,98 @@ body { background: #12121c; color: #c8ccee; }
display: flex; justify-content: space-between; align-items: baseline; display: flex; justify-content: space-between; align-items: baseline;
border: 1px solid transparent; margin-bottom: 2px; border: 1px solid transparent; margin-bottom: 2px;
} }
.diagram-item:hover { background: #1e1e3a; border-color: #3a3a5a; } .diagram-item:hover { background: var(--bg-selected); border-color: var(--border-strong); }
.diagram-item.active { background: #1a2a4a; border-color: #4466aa; } .diagram-item.active { background: var(--c1a2a4a); border-color: #4466aa; }
.di-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; font-size: 12px; cursor: pointer; } .di-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; font-size: 12px; cursor: pointer; }
.di-date { font-size: 10px; color: #555577; margin-left: 6px; white-space: nowrap; cursor: pointer; } .di-date { font-size: 10px; color: var(--text-dim); margin-left: 6px; white-space: nowrap; cursor: pointer; }
.di-del-btn { .di-del-btn {
flex-shrink: 0; opacity: 0; padding: 1px 5px; font-size: 13px; line-height: 1; flex-shrink: 0; opacity: 0; padding: 1px 5px; font-size: 13px; line-height: 1;
background: transparent; border-color: transparent; color: #ff6666; background: transparent; border-color: transparent; color: var(--tff6666);
transition: opacity .15s; margin-left: 4px; transition: opacity .15s; margin-left: 4px;
} }
.diagram-item:hover .di-del-btn { opacity: 1; } .diagram-item:hover .di-del-btn { opacity: 1; }
.di-del-btn:hover { background: #3a1414; border-color: #aa3333; } .di-del-btn:hover { background: var(--c3a1414); border-color: #aa3333; }
.di-dup-btn { .di-dup-btn {
flex-shrink: 0; opacity: 0; padding: 1px 5px; font-size: 13px; line-height: 1; flex-shrink: 0; opacity: 0; padding: 1px 5px; font-size: 13px; line-height: 1;
background: transparent; border-color: transparent; color: #88aaff; background: transparent; border-color: transparent; color: var(--t88aaff);
transition: opacity .15s; margin-left: 4px; transition: opacity .15s; margin-left: 4px;
} }
.diagram-item:hover .di-dup-btn { opacity: 1; } .diagram-item:hover .di-dup-btn { opacity: 1; }
.di-dup-btn:hover { background: #1a1a40; border-color: #4466aa; } .di-dup-btn:hover { background: var(--c1a1a40); border-color: #4466aa; }
/* ── View tab bar ────────────────────────────────────────────────────────── */ /* ── View tab bar ────────────────────────────────────────────────────────── */
#view-tab-bar { #view-tab-bar {
display: flex; align-items: center; gap: 2px; display: flex; align-items: center; gap: 2px;
padding: 3px 8px; background: #0c0c1c; border-bottom: 1px solid #2a2a44; padding: 3px 8px; background: var(--c0c0c1c); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
} }
.view-tab { .view-tab {
padding: 3px 12px; font-size: 11px; border-radius: 4px 4px 0 0; padding: 3px 12px; font-size: 11px; border-radius: 4px 4px 0 0;
background: #1a1a30; border: 1px solid #2a2a44; border-bottom: none; background: var(--bg-surface-2); border: 1px solid var(--border); border-bottom: none;
color: #8888aa; cursor: pointer; color: var(--t8888aa); cursor: pointer;
} }
.view-tab { display: inline-flex; align-items: center; gap: 4px; } .view-tab { display: inline-flex; align-items: center; gap: 4px; }
.view-tab.active { background: #1e2a4a; border-color: #4466aa; color: #c0c8f0; } .view-tab.active { background: var(--c1e2a4a); border-color: #4466aa; color: var(--tc0c8f0); }
.view-tab:hover:not(.active) { background: #1e1e38; color: #aaa; } .view-tab:hover:not(.active) { background: var(--c1e1e38); color: var(--taaa); }
.view-tab-close { .view-tab-close {
font-size: 13px; line-height: 1; opacity: 0.4; padding: 0 2px; font-size: 13px; line-height: 1; opacity: 0.4; padding: 0 2px;
border-radius: 3px; border-radius: 3px;
} }
.view-tab-close:hover { opacity: 1; background: rgba(255,80,80,0.25); color: #ff8888; } .view-tab-close:hover { opacity: 1; background: rgba(255,80,80,0.25); color: var(--tff8888); }
.view-tab-sep { width: 1px; background: #2a2a44; height: 18px; margin: 0 4px; } .view-tab-sep { width: 1px; background: var(--border); height: 18px; margin: 0 4px; }
.fb-tab { color: #6688aa; border-color: #1e3355; } .fb-tab { color: #6688aa; border-color: var(--c1e3355); }
.fb-tab.active { background: #0d2235; border-color: #336699; color: #88bbdd; } .fb-tab.active { background: var(--c0d2235); border-color: #336699; color: var(--t88bbdd); }
.fb-tab:hover:not(.active) { background: #0d1a28; color: #aaccee; } .fb-tab:hover:not(.active) { background: var(--c0d1a28); color: var(--taaccee); }
#fb-toolbar { #fb-toolbar {
display: flex; align-items: center; gap: 4px; flex-wrap: wrap; display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
padding: 4px 8px; background: #090914; border-bottom: 1px solid #1e3355; padding: 4px 8px; background: var(--c090914); border-bottom: 1px solid var(--c1e3355);
flex-shrink: 0; flex-shrink: 0;
} }
.fb-mode-btn { .fb-mode-btn {
padding: 3px 10px; font-size: 11px; border-radius: 4px; padding: 3px 10px; font-size: 11px; border-radius: 4px;
background: #0d1a28; border: 1px solid #1e3355; color: #5577aa; cursor: pointer; background: var(--c0d1a28); border: 1px solid var(--c1e3355); color: #5577aa; cursor: pointer;
} }
.fb-mode-btn.active { background: #0d2235; border-color: #336699; color: #88bbdd; } .fb-mode-btn.active { background: var(--c0d2235); border-color: #336699; color: var(--t88bbdd); }
.fb-mode-btn:hover:not(.active) { background: #111e30; color: #aaccee; } .fb-mode-btn:hover:not(.active) { background: var(--c111e30); color: var(--taaccee); }
#fb-container { flex: 1; overflow: hidden; } #fb-container { flex: 1; overflow: hidden; }
#fb-canvas { width: 100%; height: 100%; } #fb-canvas { width: 100%; height: 100%; }
#btn-add-view { #btn-add-view {
padding: 2px 8px; font-size: 14px; background: transparent; padding: 2px 8px; font-size: 14px; background: transparent;
border: 1px dashed #333355; border-radius: 4px; color: #556; cursor: pointer; border: 1px dashed var(--c333355); border-radius: 4px; color: #556; cursor: pointer;
} }
#btn-add-view:hover { border-color: #5566aa; color: #99aadd; } #btn-add-view:hover { border-color: var(--accent); color: var(--t99aadd); }
/* ── Device library ───────────────────────────────────────────────────────── */ /* ── Device library ───────────────────────────────────────────────────────── */
#device-library { padding: 6px; display: flex; flex-direction: column; gap: 3px; } #device-library { padding: 6px; display: flex; flex-direction: column; gap: 3px; }
.lib-item { .lib-item {
display: flex; align-items: center; gap: 8px; padding: 7px 8px; display: flex; align-items: center; gap: 8px; padding: 7px 8px;
border: 1px solid #2a2a44; border-radius: 5px; cursor: grab; user-select: none; border: 1px solid var(--border); border-radius: 5px; cursor: grab; user-select: none;
background: #18182e; transition: background .1s, border-color .1s; background: var(--c18182e); transition: background .1s, border-color .1s;
} }
.lib-item:hover { background: #22224a; border-color: #5555aa; } .lib-item:hover { background: var(--c22224a); border-color: #5555aa; }
.lib-item:active { cursor: grabbing; } .lib-item:active { cursor: grabbing; }
.lib-icon { font-size: 16px; width: 22px; text-align: center; } .lib-icon { font-size: 16px; width: 22px; text-align: center; }
.lib-label { font-size: 12px; } .lib-label { font-size: 12px; }
/* ── Connector library ────────────────────────────────────────────────────── */ /* ── Connector library ────────────────────────────────────────────────────── */
.lib-conn-item { .lib-conn-item {
padding: 7px 8px; border: 1px solid #1e1e38; border-radius: 5px; cursor: grab; padding: 7px 8px; border: 1px solid var(--c1e1e38); border-radius: 5px; cursor: grab;
background: #141428; margin-bottom: 3px; user-select: none; transition: background .1s, border-color .1s; background: var(--c141428); margin-bottom: 3px; user-select: none; transition: background .1s, border-color .1s;
} }
.lib-conn-item:hover { background: #1e1e40; border-color: #4444aa; } .lib-conn-item:hover { background: var(--c1e1e40); border-color: #4444aa; }
.lib-conn-item:active { cursor: grabbing; } .lib-conn-item:active { cursor: grabbing; }
.conn-name { font-size: 12px; font-weight: 500; color: #bbc0ee; } .conn-name { font-size: 12px; font-weight: 500; color: var(--tbbc0ee); }
.conn-meta { font-size: 10px; color: #556688; margin-top: 1px; } .conn-meta { font-size: 10px; color: #556688; margin-top: 1px; }
/* ── Canvas area ──────────────────────────────────────────────────────────── */ /* ── Canvas area ──────────────────────────────────────────────────────────── */
#canvas-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; } #canvas-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
#canvas-container { #canvas-container {
flex: 1; width: 100%; flex: 1; width: 100%;
background-color: #131320; background-color: var(--bg-canvas);
background-image: radial-gradient(circle, #2e2e50 1px, transparent 1px); background-image: radial-gradient(circle, var(--grid-dot) 1px, transparent 1px);
background-size: 20px 20px; background-size: 20px 20px;
} }
#canvas-container.wire-mode { cursor: crosshair; } #canvas-container.wire-mode { cursor: crosshair; }
@@ -169,207 +377,207 @@ body { background: #12121c; color: #c8ccee; }
/* ── Buttons ──────────────────────────────────────────────────────────────── */ /* ── Buttons ──────────────────────────────────────────────────────────────── */
button { button {
background: #22223a; border: 1px solid #3a3a5a; color: #c0c4e8; background: var(--c22223a); border: 1px solid var(--border-strong); color: var(--text);
padding: 5px 11px; border-radius: 5px; cursor: pointer; font-size: 12px; padding: 5px 11px; border-radius: 5px; cursor: pointer; font-size: 12px;
transition: background .12s, border-color .12s; white-space: nowrap; transition: background .12s, border-color .12s; white-space: nowrap;
} }
button:hover { background: #2e2e54; border-color: #5555aa; } button:hover { background: var(--c2e2e54); border-color: #5555aa; }
button:active { background: #1a1a3a; } button:active { background: var(--c1a1a3a); }
.mode-btn { padding: 5px 12px; } .mode-btn { padding: 5px 12px; }
.mode-btn.active { background: #1e3a6a; border-color: #4488dd; color: #88bbff; } .mode-btn.active { background: var(--c1e3a6a); border-color: #4488dd; color: var(--t88bbff); }
.route-btn { padding: 4px 10px; font-size: 11px; } .route-btn { padding: 4px 10px; font-size: 11px; }
.route-btn.active { background: #1a2a4a; border-color: #336699; color: #77aaee; } .route-btn.active { background: var(--c1a2a4a); border-color: #336699; color: var(--t77aaee); }
#btn-harness.active { background: #1a2a1a; border-color: #336633; color: #88ee88; } #btn-harness.active { background: var(--c1a2a1a); border-color: var(--c336633); color: var(--t88ee88); }
.danger-btn { color: #ff6666; border-color: #662222; } .danger-btn { color: var(--tff6666); border-color: var(--c662222); }
.danger-btn:hover { background: #3a1414; border-color: #aa3333; } .danger-btn:hover { background: var(--c3a1414); border-color: #aa3333; }
.app-title { font-weight: 700; font-size: 15px; color: #88aaff; white-space: nowrap; } .app-title { font-weight: 700; font-size: 15px; color: var(--t88aaff); white-space: nowrap; }
.diagram-name-input { .diagram-name-input {
background: #1a1a2e; border: 1px solid #2a2a44; color: #c8ccee; background: var(--bg-surface); border: 1px solid var(--border); color: var(--text-strong);
padding: 4px 8px; border-radius: 4px; font-size: 13px; width: 180px; padding: 4px 8px; border-radius: 4px; font-size: 13px; width: 180px;
} }
.diagram-name-input:focus { outline: none; border-color: #5566aa; } .diagram-name-input:focus { outline: none; border-color: var(--accent); }
/* Export dropdown */ /* Export dropdown */
.export-wrap { position: relative; } .export-wrap { position: relative; }
#export-menu { #export-menu {
display: none; position: absolute; right: 0; top: calc(100% + 4px); display: none; position: absolute; right: 0; top: calc(100% + 4px);
background: #1a1a2e; border: 1px solid #3a3a5a; border-radius: 6px; background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: 6px;
min-width: 170px; z-index: 100; overflow: hidden; flex-direction: column; min-width: 170px; z-index: 100; overflow: hidden; flex-direction: column;
} }
#export-menu.open { display: flex; } #export-menu.open { display: flex; }
#export-menu button { border: none; border-radius: 0; text-align: left; padding: 8px 14px; border-bottom: 1px solid #222238; } #export-menu button { border: none; border-radius: 0; text-align: left; padding: 8px 14px; border-bottom: 1px solid var(--bg-raised); }
#export-menu button:last-child { border-bottom: none; } #export-menu button:last-child { border-bottom: none; }
/* Saved indicator */ /* Saved indicator */
#saved-indicator { #saved-indicator {
font-size: 11px; color: #4caa77; background: #0f2a1a; font-size: 11px; color: var(--t4caa77); background: var(--c0f2a1a);
border: 1px solid #1e4a2a; padding: 4px 10px; border-radius: 4px; border: 1px solid var(--c1e4a2a); padding: 4px 10px; border-radius: 4px;
opacity: 0; transition: opacity 0.3s; pointer-events: none; white-space: nowrap; opacity: 0; transition: opacity 0.3s; pointer-events: none; white-space: nowrap;
} }
#saved-indicator.show { opacity: 1; } #saved-indicator.show { opacity: 1; }
/* ── Octopart search results ──────────────────────────────────────────────── */ /* ── Octopart search results ──────────────────────────────────────────────── */
#octopart-results { #octopart-results {
margin-top: 5px; border: 1px solid #2a2a44; border-radius: 4px; margin-top: 5px; border: 1px solid var(--border); border-radius: 4px;
background: #0e0e1e; max-height: 220px; overflow-y: auto; background: var(--bg-inset); max-height: 220px; overflow-y: auto;
} }
.op-result { .op-result {
padding: 7px 9px; cursor: pointer; border-bottom: 1px solid #1a1a30; padding: 7px 9px; cursor: pointer; border-bottom: 1px solid var(--bg-surface-2);
transition: background 0.1s; transition: background 0.1s;
} }
.op-result:last-child { border-bottom: none; } .op-result:last-child { border-bottom: none; }
.op-result:hover { background: #1a1a38; } .op-result:hover { background: var(--c1a1a38); }
.op-mpn { font-size: 11px; font-weight: 600; color: #c8ccee; } .op-mpn { font-size: 11px; font-weight: 600; color: var(--text-strong); }
.op-mfr { font-size: 10px; color: #7788aa; margin-top: 1px; } .op-mfr { font-size: 10px; color: var(--t7788aa); margin-top: 1px; }
.op-desc { font-size: 10px; color: #4a5566; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .op-desc { font-size: 10px; color: #4a5566; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.op-ds { font-size: 10px; color: #4466aa; margin-top: 1px; } .op-ds { font-size: 10px; color: #4466aa; margin-top: 1px; }
.op-no-results { padding: 10px; font-size: 11px; color: #444466; text-align: center; } .op-no-results { padding: 10px; font-size: 11px; color: var(--text-faint); text-align: center; }
#btn-octopart-search.searching { opacity: 0.5; pointer-events: none; } #btn-octopart-search.searching { opacity: 0.5; pointer-events: none; }
/* ── Properties panel ─────────────────────────────────────────────────────── */ /* ── Properties panel ─────────────────────────────────────────────────────── */
.prop-row { padding: 7px 10px; border-bottom: 1px solid #1e1e36; } .prop-row { padding: 7px 10px; border-bottom: 1px solid var(--c1e1e36); }
.prop-row label { .prop-row label {
display: block; font-size: 10px; font-weight: 600; text-transform: uppercase; display: block; font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: .06em; color: #555577; margin-bottom: 4px; letter-spacing: .06em; color: var(--text-dim); margin-bottom: 4px;
} }
.prop-row input[type="text"], .prop-row input[type="number"], .prop-row input[type="text"], .prop-row input[type="number"],
.prop-row select, .prop-row textarea { .prop-row select, .prop-row textarea {
width: 100%; background: #0e0e1e; border: 1px solid #2a2a44; width: 100%; background: var(--bg-inset); border: 1px solid var(--border);
color: #c0c4e8; padding: 5px 7px; border-radius: 4px; font-size: 12px; font-family: inherit; color: var(--text); padding: 5px 7px; border-radius: 4px; font-size: 12px; font-family: inherit;
} }
.prop-row input:focus, .prop-row select:focus, .prop-row textarea:focus { outline: none; border-color: #5566aa; } .prop-row input:focus, .prop-row select:focus, .prop-row textarea:focus { outline: none; border-color: var(--accent); }
.prop-value { font-size: 12px; color: #99aacc; } .prop-value { font-size: 12px; color: var(--t99aacc); }
.prop-value.mono { font-family: monospace; } .prop-value.mono { font-family: monospace; }
.color-swatch { width: 36px; height: 28px; padding: 2px; cursor: pointer; border-radius: 4px; flex-shrink: 0; border: 1px solid #3a3a5a; } .color-swatch { width: 36px; height: 28px; padding: 2px; cursor: pointer; border-radius: 4px; flex-shrink: 0; border: 1px solid var(--border-strong); }
/* Pin table */ /* Pin table */
.pin-table { width: 100%; border-collapse: collapse; font-size: 11px; } .pin-table { width: 100%; border-collapse: collapse; font-size: 11px; }
.pin-table th { text-align: left; padding: 3px 4px; color: #555577; font-weight: 600; border-bottom: 1px solid #222238; } .pin-table th { text-align: left; padding: 3px 4px; color: var(--text-dim); font-weight: 600; border-bottom: 1px solid var(--bg-raised); }
.pin-table td { padding: 2px 4px; border-bottom: 1px solid #1a1a30; vertical-align: middle; } .pin-table td { padding: 2px 4px; border-bottom: 1px solid var(--bg-surface-2); vertical-align: middle; }
.pin-table .pin-side { color: #444466; font-size: 10px; } .pin-table .pin-side { color: var(--text-faint); font-size: 10px; }
.pin-input { width: 100%; background: #0e0e1e; border: 1px solid #2a2a44; color: #c0c4e8; padding: 2px 4px; border-radius: 3px; font-family: monospace; font-size: 11px; } .pin-input { width: 100%; background: var(--bg-inset); border: 1px solid var(--border); color: var(--text); padding: 2px 4px; border-radius: 3px; font-family: monospace; font-size: 11px; }
.pin-side-select { width: 100%; background: #0e0e1e; border: 1px solid #2a2a44; color: #c0c4e8; padding: 2px 3px; border-radius: 3px; font-size: 11px; cursor: pointer; } .pin-side-select { width: 100%; background: var(--bg-inset); border: 1px solid var(--border); color: var(--text); padding: 2px 3px; border-radius: 3px; font-size: 11px; cursor: pointer; }
/* ── Connector library extras ─────────────────────────────────────────────── */ /* ── Connector library extras ─────────────────────────────────────────────── */
.lib-section-header { .lib-section-header {
font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
color: #555577; padding: 8px 8px 3px; border-top: 1px solid #1a1a30; margin-top: 4px; color: var(--text-dim); padding: 8px 8px 3px; border-top: 1px solid var(--bg-surface-2); margin-top: 4px;
} }
.lib-section-header:first-child { border-top: none; margin-top: 0; } .lib-section-header:first-child { border-top: none; margin-top: 0; }
.conn-item-body { flex: 1; min-width: 0; } .conn-item-body { flex: 1; min-width: 0; }
.conn-custom-badge { .conn-custom-badge {
display: inline-block; font-size: 9px; font-weight: 600; letter-spacing: .06em; display: inline-block; font-size: 9px; font-weight: 600; letter-spacing: .06em;
text-transform: uppercase; background: #2a1a4a; color: #9966ee; text-transform: uppercase; background: var(--c2a1a4a); color: var(--t9966ee);
border: 1px solid #4422aa; border-radius: 3px; padding: 0 4px; vertical-align: middle; border: 1px solid #4422aa; border-radius: 3px; padding: 0 4px; vertical-align: middle;
margin-left: 4px; margin-left: 4px;
} }
.lib-conn-item { display: flex; align-items: center; gap: 6px; } .lib-conn-item { display: flex; align-items: center; gap: 6px; }
.conn-edit-btn { .conn-edit-btn {
flex-shrink: 0; opacity: 0; padding: 3px 6px; font-size: 12px; flex-shrink: 0; opacity: 0; padding: 3px 6px; font-size: 12px;
background: #1e1e3a; border-color: #3a3a5a; transition: opacity .15s; background: var(--bg-selected); border-color: var(--border-strong); transition: opacity .15s;
} }
.lib-conn-item:hover .conn-edit-btn { opacity: 1; } .lib-conn-item:hover .conn-edit-btn { opacity: 1; }
.conn-edit-btn:hover { background: #2a2a5a; border-color: #6666aa; } .conn-edit-btn:hover { background: var(--c2a2a5a); border-color: #6666aa; }
/* ── Modal ───────────────────────────────────────────────────────────────── */ /* ── Modal ───────────────────────────────────────────────────────────────── */
#connector-modal, #drc-modal, #git-modal, #pinout-modal { #connector-modal, #drc-modal, #git-modal, #pinout-modal, #pdm-modal, #theme-modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 1000; position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 1000;
align-items: center; justify-content: center; align-items: center; justify-content: center;
} }
.modal-box { .modal-box {
background: #16162a; border: 1px solid #3a3a5a; border-radius: 8px; background: var(--bg-modal); border: 1px solid var(--border-strong); border-radius: 8px;
width: 480px; max-width: 94vw; max-height: 86vh; width: 480px; max-width: 94vw; max-height: 86vh;
display: flex; flex-direction: column; box-shadow: 0 8px 40px rgba(0,0,0,0.7); display: flex; flex-direction: column; box-shadow: 0 8px 40px rgba(0,0,0,0.7);
} }
.modal-header { .modal-header {
padding: 14px 18px 12px; border-bottom: 1px solid #222238; flex-shrink: 0; padding: 14px 18px 12px; border-bottom: 1px solid var(--bg-raised); flex-shrink: 0;
} }
.modal-header h3 { font-size: 15px; font-weight: 600; color: #c8ccee; } .modal-header h3 { font-size: 15px; font-weight: 600; color: var(--text-strong); }
.modal-body { .modal-body {
padding: 14px 18px; overflow-y: auto; flex: 1; padding: 14px 18px; overflow-y: auto; flex: 1;
display: flex; flex-direction: column; gap: 10px; display: flex; flex-direction: column; gap: 10px;
} }
.modal-footer { .modal-footer {
padding: 10px 18px; border-top: 1px solid #222238; padding: 10px 18px; border-top: 1px solid var(--bg-raised);
display: flex; align-items: center; gap: 8px; flex-shrink: 0; display: flex; align-items: center; gap: 8px; flex-shrink: 0;
} }
/* ── DRC ────────────────────────────────────────────────────────────────── */ /* ── DRC ────────────────────────────────────────────────────────────────── */
.drc-category { border: 1px solid #2a2a44; border-radius: 5px; overflow: hidden; } .drc-category { border: 1px solid var(--border); border-radius: 5px; overflow: hidden; }
.drc-cat-header { .drc-cat-header {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 8px 12px; background: #1a1a30; cursor: pointer; padding: 8px 12px; background: var(--bg-surface-2); cursor: pointer;
user-select: none; user-select: none;
} }
.drc-cat-header:hover { background: #1e1e3a; } .drc-cat-header:hover { background: var(--bg-selected); }
.drc-cat-icon { font-size: 13px; } .drc-cat-icon { font-size: 13px; }
.drc-cat-label { flex: 1; font-size: 12px; font-weight: 600; color: #c0c4e8; } .drc-cat-label { flex: 1; font-size: 12px; font-weight: 600; color: var(--text); }
.drc-cat-count { .drc-cat-count {
font-size: 11px; font-weight: 700; background: #2a2a50; font-size: 11px; font-weight: 700; background: var(--c2a2a50);
padding: 1px 7px; border-radius: 10px; color: #9090cc; padding: 1px 7px; border-radius: 10px; color: var(--t9090cc);
} }
.drc-cat-body { padding: 4px 0; background: #0e0e1e; } .drc-cat-body { padding: 4px 0; background: var(--bg-inset); }
.drc-row { .drc-row {
padding: 5px 14px; font-size: 11px; color: #a0a4c8; padding: 5px 14px; font-size: 11px; color: var(--ta0a4c8);
border-bottom: 1px solid #151528; border-bottom: 1px solid var(--c151528);
} }
.drc-row:last-child { border-bottom: none; } .drc-row:last-child { border-bottom: none; }
.drc-none { color: #444466; font-style: italic; } .drc-none { color: var(--text-faint); font-style: italic; }
/* ── Git modal ────────────────────────────────────────────────────────────── */ /* ── Git modal ────────────────────────────────────────────────────────────── */
.git-badge { .git-badge {
font-size: 11px; padding: 2px 8px; border-radius: 10px; font-size: 11px; padding: 2px 8px; border-radius: 10px;
background: #1a1a30; color: #8899bb; border: 1px solid #2a2a44; background: var(--bg-surface-2); color: var(--text-muted); border: 1px solid var(--border);
} }
.git-badge.git-clean { color: #88cc88; border-color: #336633; } .git-badge.git-clean { color: var(--t88cc88); border-color: var(--c336633); }
.git-badge.git-dirty { color: #ddaa44; border-color: #554422; } .git-badge.git-dirty { color: var(--tddaa44); border-color: var(--c554422); }
.git-section { display: flex; flex-direction: column; gap: 5px; } .git-section { display: flex; flex-direction: column; gap: 5px; }
.git-section-title { .git-section-title {
font-size: 10px; font-weight: 600; text-transform: uppercase; font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: .06em; color: #555577; letter-spacing: .06em; color: var(--text-dim);
} }
.git-log-row { .git-log-row {
display: grid; grid-template-columns: 52px 1fr 80px auto; display: grid; grid-template-columns: 52px 1fr 80px auto;
align-items: center; gap: 8px; align-items: center; gap: 8px;
padding: 4px 6px; border-radius: 3px; background: #0d0d20; padding: 4px 6px; border-radius: 3px; background: var(--c0d0d20);
border: 1px solid transparent; border: 1px solid transparent;
} }
.git-log-row:hover { border-color: #2a2a44; } .git-log-row:hover { border-color: var(--border); }
.git-hash { font-family: monospace; color: #6688cc; font-size: 11px; } .git-hash { font-family: monospace; color: var(--t6688cc); font-size: 11px; }
.git-msg { color: #bbc; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .git-msg { color: var(--tbbc); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.git-date { color: #445; font-size: 10px; text-align: right; } .git-date { color: #445; font-size: 10px; text-align: right; }
.git-scope-btn { .git-scope-btn {
font-size: 10px; padding: 2px 8px; font-size: 10px; padding: 2px 8px;
background: #111122; border: 1px solid #2a2a44; color: #445566; cursor: pointer; background: var(--c111122); border: 1px solid var(--border); color: var(--t445566); cursor: pointer;
} }
.git-scope-btn:first-of-type { border-radius: 3px 0 0 3px; } .git-scope-btn:first-of-type { border-radius: 3px 0 0 3px; }
.git-scope-btn:last-of-type { border-radius: 0 3px 3px 0; border-left: none; } .git-scope-btn:last-of-type { border-radius: 0 3px 3px 0; border-left: none; }
.git-scope-btn.active { background: #1a2840; border-color: #334466; color: #88aadd; } .git-scope-btn.active { background: var(--c1a2840); border-color: var(--c334466); color: var(--t88aadd); }
.git-scope-btn:disabled { opacity: 0.35; cursor: default; } .git-scope-btn:disabled { opacity: 0.35; cursor: default; }
.git-restore-btn { .git-restore-btn {
font-size: 10px; padding: 2px 6px; font-size: 10px; padding: 2px 6px;
background: #1a2840; border: 1px solid #334466; border-radius: 3px; background: var(--c1a2840); border: 1px solid var(--c334466); border-radius: 3px;
color: #88aadd; cursor: pointer; white-space: nowrap; color: var(--t88aadd); cursor: pointer; white-space: nowrap;
} }
.git-restore-btn:hover { background: #223355; color: #aaccff; } .git-restore-btn:hover { background: var(--c223355); color: var(--taaccff); }
.cm-label { .cm-label {
display: block; font-size: 10px; font-weight: 600; text-transform: uppercase; display: block; font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: .06em; color: #555577; margin-bottom: 4px; letter-spacing: .06em; color: var(--text-dim); margin-bottom: 4px;
} }
.cm-input { .cm-input {
width: 100%; background: #0e0e1e; border: 1px solid #2a2a44; width: 100%; background: var(--bg-inset); border: 1px solid var(--border);
color: #c0c4e8; padding: 6px 8px; border-radius: 4px; font-size: 12px; font-family: inherit; color: var(--text); padding: 6px 8px; border-radius: 4px; font-size: 12px; font-family: inherit;
} }
.cm-input:focus { outline: none; border-color: #5566aa; } .cm-input:focus { outline: none; border-color: var(--accent); }
.cm-row { display: flex; flex-direction: column; } .cm-row { display: flex; flex-direction: column; }
.cm-row-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .cm-row-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
@@ -380,28 +588,28 @@ button:active { background: #1a1a3a; }
.pin-label-row { display: flex; align-items: center; gap: 5px; } .pin-label-row { display: flex; align-items: center; gap: 5px; }
.pin-idx { .pin-idx {
width: 22px; text-align: right; flex-shrink: 0; width: 22px; text-align: right; flex-shrink: 0;
font-size: 10px; font-weight: 600; color: #555577; font-family: monospace; font-size: 10px; font-weight: 600; color: var(--text-dim); font-family: monospace;
} }
.pin-label-input { .pin-label-input {
flex: 1; background: #0e0e1e; border: 1px solid #222238; flex: 1; background: var(--bg-inset); border: 1px solid var(--bg-raised);
color: #c0c4e8; padding: 4px 6px; border-radius: 3px; font-size: 11px; font-family: monospace; color: var(--text); padding: 4px 6px; border-radius: 3px; font-size: 11px; font-family: monospace;
} }
.pin-label-input:focus { outline: none; border-color: #5566aa; } .pin-label-input:focus { outline: none; border-color: var(--accent); }
.btn-primary { .btn-primary {
background: #1e3a6a; border-color: #4488dd; color: #88bbff; font-weight: 600; background: var(--c1e3a6a); border-color: #4488dd; color: var(--t88bbff); font-weight: 600;
} }
.btn-primary:hover { background: #264a8a; border-color: #66aaff; } .btn-primary:hover { background: var(--c264a8a); border-color: #66aaff; }
.btn-primary:disabled { opacity: 0.5; cursor: default; } .btn-primary:disabled { opacity: 0.5; cursor: default; }
/* Pin remove button */ /* Pin remove button */
.pin-del-btn { .pin-del-btn {
padding: 0 4px; font-size: 12px; line-height: 1.4; padding: 0 4px; font-size: 12px; line-height: 1.4;
background: transparent; border-color: transparent; color: #ff6666; background: transparent; border-color: transparent; color: var(--tff6666);
opacity: 0; transition: opacity .15s; opacity: 0; transition: opacity .15s;
} }
.pin-table tr:hover .pin-del-btn { opacity: 1; } .pin-table tr:hover .pin-del-btn { opacity: 1; }
.pin-del-btn:hover { background: #3a1414; border-color: #aa3333; } .pin-del-btn:hover { background: var(--c3a1414); border-color: #aa3333; }
/* Wire drag cursor hint */ /* Wire drag cursor hint */
#canvas-container.wire-drag { cursor: grabbing; } #canvas-container.wire-drag { cursor: grabbing; }
@@ -409,26 +617,133 @@ button:active { background: #1a1a3a; }
/* ── Context menu ─────────────────────────────────────────────────────────── */ /* ── Context menu ─────────────────────────────────────────────────────────── */
#ctx-menu { #ctx-menu {
position: fixed; z-index: 9000; display: none; position: fixed; z-index: 9000; display: none;
background: #1a1a2e; border: 1px solid #3a3a66; border-radius: 6px; background: var(--bg-surface); border: 1px solid var(--c3a3a66); border-radius: 6px;
box-shadow: 0 6px 24px rgba(0,0,0,.55); padding: 4px 0; min-width: 170px; box-shadow: 0 6px 24px rgba(0,0,0,.55); padding: 4px 0; min-width: 170px;
user-select: none; user-select: none;
} }
#ctx-menu.open { display: block; } #ctx-menu.open { display: block; }
.ctx-item { .ctx-item {
padding: 7px 14px; font-size: 12px; color: #c0c4e8; cursor: pointer; padding: 7px 14px; font-size: 12px; color: var(--text); cursor: pointer;
display: flex; align-items: center; gap: 8px; white-space: nowrap; display: flex; align-items: center; gap: 8px; white-space: nowrap;
} }
.ctx-item:hover { background: #2a2a50; color: #fff; } .ctx-item:hover { background: var(--c2a2a50); color: var(--tfff); }
.ctx-item.danger { color: #dd6666; } .ctx-item.danger { color: var(--tdd6666); }
.ctx-item.danger:hover { background: #3a1414; color: #ff8888; } .ctx-item.danger:hover { background: var(--c3a1414); color: var(--tff8888); }
.ctx-sep { height: 1px; background: #2a2a44; margin: 4px 0; } .ctx-sep { height: 1px; background: var(--border); margin: 4px 0; }
/* Misc */ /* Misc */
input[type="checkbox"] { accent-color: #5588dd; width: 14px; height: 14px; cursor: pointer; } input[type="checkbox"] { accent-color: #5588dd; width: 14px; height: 14px; cursor: pointer; }
::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #2a2a44; border-radius: 3px; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #3a3a66; } ::-webkit-scrollbar-thumb:hover { background: var(--c3a3a66); }
.muted { color: #444466; } .muted { color: var(--text-faint); }
.small { font-size: 11px; } .small { font-size: 11px; }
.sep { width: 1px; height: 22px; background: #2a2a44; margin: 0 3px; } .sep { width: 1px; height: 22px; background: var(--border); margin: 0 3px; }
/* ── Fuse box configurator ─────────────────────────────────────────────── */
.pdm-capacity { display: flex; align-items: center; gap: 10px; }
.pdm-bar {
flex: 1; height: 8px; border-radius: 4px;
background: var(--bg-inset); border: 1px solid var(--border); overflow: hidden;
}
.pdm-bar-fill { height: 100%; width: 0; background: #4a7dd6; transition: width .15s ease; }
.pdm-bar-fill.warn { background: #d6a44a; }
.pdm-bar-fill.over { background: #d65a4a; }
.pdm-capacity-text {
font-size: 11px; color: var(--text-muted); font-variant-numeric: tabular-nums; white-space: nowrap;
}
.pdm-add {
font-size: 11px; padding: 4px 9px; border-radius: 4px;
background: var(--bg-surface-2); border: 1px solid var(--border); color: var(--text); cursor: pointer;
}
.pdm-add:hover { background: var(--c23233c); border-color: var(--c3d3d63); }
.pdm-add:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.pdm-table { width: 100%; border-collapse: collapse; font-size: 11.5px; }
.pdm-table th {
text-align: left; font-weight: 600; color: var(--text-muted); padding: 5px 6px;
border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg-panel);
}
.pdm-table td { padding: 3px 6px; border-bottom: 1px solid var(--bg-surface-3); }
.pdm-cavity {
font-family: ui-monospace, Consolas, monospace; color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.pdm-table .cm-input { padding: 3px 6px; font-size: 11.5px; width: 100%; }
.pdm-del {
background: none; border: none; color: #8a6a6a; cursor: pointer; font-size: 13px; padding: 2px 5px;
}
.pdm-del:hover { color: var(--td65a4a); }
.pdm-warn { font-size: 11px; color: var(--td65a4a); }
/* ── Fuse box grid editor ──────────────────────────────────────────────── */
.pdm-split { display: grid; grid-template-columns: auto 1fr; gap: 14px; align-items: start; }
.pdm-grid-pane { display: flex; flex-direction: column; gap: 8px; }
.pdm-grid {
position: relative; background: var(--bg-inset);
border: 1px solid var(--border); border-radius: 6px; padding: 6px;
user-select: none; touch-action: none;
}
.pdm-grid:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.pdm-cell {
position: absolute; box-sizing: border-box;
border: 1px dashed var(--border-faint); border-radius: 3px;
}
.pdm-cell-num {
position: absolute; font-size: 8px; color: var(--text-ghost);
top: 1px; left: 2px; pointer-events: none;
font-family: ui-monospace, Consolas, monospace;
}
.pdm-part {
position: absolute; box-sizing: border-box; border-radius: 4px;
border: 1px solid rgba(255,255,255,.22); cursor: grab;
display: flex; flex-direction: column; align-items: center; justify-content: center;
font-size: 9.5px; line-height: 1.15; color: var(--tfff); text-align: center;
overflow: hidden; padding: 2px;
}
.pdm-part:active { cursor: grabbing; }
.pdm-part.sel { outline: 2px solid #e8edf7; outline-offset: -1px; z-index: 3; }
.pdm-part.bad { border-color: #ff6a58; box-shadow: 0 0 0 2px rgba(255,106,88,.5) inset; }
.pdm-part-tag { font-weight: 700; letter-spacing: .3px; }
.pdm-part-name {
font-size: 8.5px; opacity: .85; max-width: 100%;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pdm-term {
position: absolute; width: 13px; height: 13px; border-radius: 2px;
background: rgba(0,0,0,.45); border: 1px solid rgba(255,255,255,.3);
font-size: 7px; color: var(--tfff); display: flex; align-items: center; justify-content: center;
pointer-events: none; font-family: ui-monospace, Consolas, monospace;
}
.pdm-side { display: flex; flex-direction: column; min-width: 0; }
.pdm-pinout-wrap {
border: 1px solid var(--border); border-radius: 5px;
max-height: 288px; overflow-y: auto; background: var(--bg-inset);
}
/* Bus bars lie across the cavities they link, so they draw as a narrow strip
on top of the components they feed rather than as a filled tile. */
.pdm-bus {
position: absolute; box-sizing: border-box; border-radius: 3px;
border: 1px solid rgba(255,255,255,.45); cursor: grab; z-index: 4;
box-shadow: 0 1px 4px rgba(0,0,0,.55);
}
.pdm-bus:active { cursor: grabbing; }
.pdm-bus.sel { outline: 2px solid #e8edf7; outline-offset: 1px; z-index: 5; }
.pdm-bus.bad { border-color: #ff6a58; box-shadow: 0 0 0 2px rgba(255,106,88,.6); }
.pdm-term.bus-fed { background: #b0563c; border-color: rgba(255,255,255,.55); }
/* ── Appearance panel ──────────────────────────────────────────────────── */
.theme-pick { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.theme-pick input[type="color"] {
width: 46px; height: 28px; padding: 0; cursor: pointer;
background: var(--bg-inset); border: 1px solid var(--border); border-radius: 4px;
}
.theme-inline { display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--text-muted); }
.theme-swatches { display: flex; gap: 5px; }
.theme-swatch {
width: 24px; height: 24px; border-radius: 4px; cursor: pointer;
border: 1px solid var(--border-strong);
}
.theme-swatch:hover { outline: 2px solid var(--accent); outline-offset: 1px; }
.theme-swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
+149
View File
@@ -38,6 +38,7 @@
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<button id="btn-theme" title="Appearance — theme and colours">◐ Theme</button>
<button id="btn-git" title="Git version control">⎇ Git</button> <button id="btn-git" title="Git version control">⎇ Git</button>
<button id="btn-drc" title="Run design-rule check">⚠ DRC</button> <button id="btn-drc" title="Run design-rule check">⚠ DRC</button>
<div id="saved-indicator">✓ Saved</div> <div id="saved-indicator">✓ Saved</div>
@@ -233,6 +234,9 @@
<div style="margin-top:4px"> <div style="margin-top:4px">
<button id="prop-pinout-btn" style="display:none;width:100%;font-size:11px;padding:3px 6px">⊙ View Pinout</button> <button id="prop-pinout-btn" style="display:none;width:100%;font-size:11px;padding:3px 6px">⊙ View Pinout</button>
</div> </div>
<div style="margin-top:4px">
<button id="prop-pdm-btn" class="btn-primary" style="display:none;width:100%;font-size:11px;padding:4px 6px">▦ Configure Fuse Box</button>
</div>
</div> </div>
</div> </div>
@@ -515,6 +519,148 @@
</div> </div>
</div> </div>
<div id="theme-modal" style="display:none">
<div class="modal-box" style="min-width:420px;max-width:520px">
<div class="modal-header"><h3>◐ Appearance</h3></div>
<div class="modal-body" style="padding:14px;gap:14px">
<div class="cm-row">
<label class="cm-label">Mode</label>
<div style="display:flex;gap:6px">
<button id="theme-dark" class="pdm-add" style="flex:1">Dark</button>
<button id="theme-light" class="pdm-add" style="flex:1">Light</button>
</div>
</div>
<div class="cm-row">
<label class="cm-label">Canvas background</label>
<div class="theme-pick">
<input id="theme-canvas-bg" type="color">
<div id="theme-bg-swatches" class="theme-swatches"></div>
</div>
</div>
<div class="cm-row">
<label class="cm-label">Grid dots</label>
<div class="theme-pick"><input id="theme-grid-dot" type="color"></div>
</div>
<div class="cm-row">
<label class="cm-label">Part outline</label>
<div class="theme-pick"><input id="theme-dev-stroke" type="color"></div>
</div>
<div class="cm-row">
<label class="cm-label">Part fill</label>
<div class="theme-pick">
<input id="theme-dev-fill" type="color">
<label class="theme-inline">
<input id="theme-fill-bytype" type="checkbox"> Colour by device type
</label>
</div>
</div>
<p class="muted small" style="margin:0">
Wires keep their own colours — these control what they sit against.
</p>
</div>
<div class="modal-footer">
<button id="theme-reset">Reset to defaults</button>
<div style="flex:1"></div>
<button id="theme-close" class="btn-primary">Done</button>
</div>
</div>
</div>
<!-- Fuse box configurator — edits the selected PDM device on the canvas -->
<div id="pdm-modal" style="display:none">
<div class="modal-box" style="min-width:720px;max-width:900px">
<div class="modal-header">
<h3>▦ Fuse Box</h3>
<span id="pdm-linked-ref" class="git-badge"></span>
</div>
<div class="modal-body" style="padding:12px;gap:10px">
<div class="cm-row-2col">
<div class="cm-row">
<label class="cm-label">Module</label>
<select id="pdm-module" class="cm-input"></select>
</div>
<div class="cm-row">
<label class="cm-label">Reference / label</label>
<input id="pdm-label" class="cm-input" type="text" placeholder="e.g. PDM1 — front">
</div>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
<button id="pdm-add-fuse" class="pdm-add">+ Fuse</button>
<button id="pdm-add-spdt" class="pdm-add">+ Relay 5-pin</button>
<button id="pdm-add-spst" class="pdm-add">+ Relay 4-pin</button>
<button id="pdm-add-diode" class="pdm-add">+ Diode</button>
<button id="pdm-add-breaker" class="pdm-add">+ Breaker</button>
<button id="pdm-add-bus" class="pdm-add">+ Bus bar</button>
<div style="width:1px;height:18px;background:#2a2a44"></div>
<button id="pdm-rotate" class="pdm-add" title="Rotate selected (R)">⟳ Rotate</button>
<button id="pdm-delete" class="pdm-add" title="Remove selected (Del)">✕ Remove</button>
</div>
<div class="pdm-split">
<!-- Cavity grid -->
<div class="pdm-grid-pane">
<div id="pdm-grid" class="pdm-grid" tabindex="0"></div>
<div id="pdm-capacity" class="pdm-capacity">
<div class="pdm-bar"><div id="pdm-bar-fill" class="pdm-bar-fill"></div></div>
<span id="pdm-capacity-text" class="pdm-capacity-text"></span>
</div>
<p class="muted small" style="margin:0">Drag to move · R to rotate · Del to remove</p>
</div>
<!-- Selected component + full pinout -->
<div class="pdm-side">
<div id="pdm-sel" style="display:none">
<label class="cm-label">Circuit name</label>
<input id="pdm-sel-name" class="cm-input" type="text" placeholder="e.g. Coolant pump">
<div class="cm-row-2col" style="margin-top:6px">
<div class="cm-row">
<label class="cm-label">Component</label>
<select id="pdm-sel-type" class="cm-input"></select>
</div>
<div class="cm-row">
<label class="cm-label">Rating</label>
<select id="pdm-sel-rating" class="cm-input"></select>
</div>
</div>
</div>
<p id="pdm-sel-empty" class="muted small" style="padding:8px 0">
Select a component in the grid to name it.
</p>
<label class="cm-label" style="margin-top:8px">Pinout</label>
<div class="pdm-pinout-wrap">
<table class="pdm-table">
<thead>
<tr><th style="width:54px">Cavity</th><th style="width:46px">Term</th><th>Pin name</th></tr>
</thead>
<tbody id="pdm-pinout"></tbody>
</table>
<p id="pdm-empty" class="muted small" style="padding:12px;text-align:center">
Empty box. Add a fuse or relay to begin.
</p>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<span id="pdm-warn" class="pdm-warn"></span>
<div style="flex:1"></div>
<button id="pdm-cancel">Cancel</button>
<button id="pdm-save" class="btn-primary">Apply to device</button>
</div>
</div>
</div>
<div id="git-modal" style="display:none"> <div id="git-modal" style="display:none">
<div class="modal-box" style="min-width:520px;max-width:700px"> <div class="modal-box" style="min-width:520px;max-width:700px">
<div class="modal-header"> <div class="modal-header">
@@ -563,9 +709,12 @@
</div> </div>
<script src="https://unpkg.com/konva@9/konva.min.js"></script> <script src="https://unpkg.com/konva@9/konva.min.js"></script>
<script src="/js/theme.js"></script>
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/deviceTypes.js"></script> <script src="/js/deviceTypes.js"></script>
<script src="/js/connectorLibrary.js"></script> <script src="/js/connectorLibrary.js"></script>
<script src="/js/partsLibrary.js"></script>
<script src="/js/pdmLibrary.js"></script>
<script src="/js/canvas.js"></script> <script src="/js/canvas.js"></script>
<script src="/js/formboard.js"></script> <script src="/js/formboard.js"></script>
<script src="/js/app.js"></script> <script src="/js/app.js"></script>
+521 -2
View File
@@ -54,6 +54,8 @@ class WiringApp {
this._bindPinoutModal(); this._bindPinoutModal();
this._bindLibrarySearch(); this._bindLibrarySearch();
this._bindFormboard(); this._bindFormboard();
this._bindPdm();
this._bindTheme();
this.loadDiagramList() this.loadDiagramList()
.then(() => this._autoOpenLast()) .then(() => this._autoOpenLast())
@@ -226,6 +228,20 @@ class WiringApp {
item.addEventListener("dblclick", () => { if (!this.diagramId) return this._needDiagram(); this.addDevice(key, 200 + Math.random() * 100, 150 + Math.random() * 100); }); item.addEventListener("dblclick", () => { if (!this.diagramId) return this._needDiagram(); this.addDevice(key, 200 + Math.random() * 100, 150 + Math.random() * 100); });
container.appendChild(item); container.appendChild(item);
}); });
// Standard parts (partsLibrary.js) also live here, not just under the
// Connector Library tab — they are devices, and this is where you look for
// one. They reuse the connector add path, so a dropped part arrives with
// its pins labelled and its standard wire colours attached.
if (typeof SKUDAK_PARTS !== "undefined") {
Object.entries(SKUDAK_PARTS).forEach(([category, parts]) => {
const header = document.createElement("div");
header.className = "lib-section-header";
header.textContent = category;
container.appendChild(header);
parts.forEach(part => container.appendChild(this._makeBuiltinItem(part)));
});
}
} }
// ── Connector library ───────────────────────────────────────────────────────── // ── Connector library ─────────────────────────────────────────────────────────
@@ -996,9 +1012,20 @@ class WiringApp {
document.getElementById(`btn-route-${mode}`)?.classList.add("active"); document.getElementById(`btn-route-${mode}`)?.classList.add("active");
} }
// True while any modal overlay is up. Canvas shortcuts must stand down then,
// otherwise Delete inside the fuse box editor deletes the device itself.
_anyModalOpen() {
return ["connector-modal", "pinout-modal", "drc-modal", "git-modal", "pdm-modal", "theme-modal"]
.some((id) => {
const el = document.getElementById(id);
return el && el.style.display && el.style.display !== "none";
});
}
_bindKeyboard() { _bindKeyboard() {
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) return; if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) return;
if (this._anyModalOpen()) return;
if (e.key === "Delete" || e.key === "Backspace") this.deleteSelected(); if (e.key === "Delete" || e.key === "Backspace") this.deleteSelected();
if (e.key === "Escape") { this.setMode("select"); this._closeCtx(); } if (e.key === "Escape") { this.setMode("select"); this._closeCtx(); }
if (e.key === "w") this.setMode("wire"); if (e.key === "w") this.setMode("wire");
@@ -1725,17 +1752,507 @@ class WiringApp {
return d.properties?.conductors?.[parseInt(m[1]) - 1]?.color || null; return d.properties?.conductors?.[parseInt(m[1]) - 1]?.color || null;
} }
// Standard wire for a pin on a standard part — see partsLibrary.js.
// Returns null for ordinary connectors so existing behaviour is unchanged.
_standardWireForPin(deviceId, pinId) {
if (typeof standardWireForPin !== "function") return null;
return standardWireForPin(this.canvas.deviceData.get(deviceId), pinId);
}
// ── Appearance ────────────────────────────────────────────────────────────
// Theme changes repaint the canvas explicitly: Konva bakes colours in when it
// draws, so unlike the CSS-driven chrome it will not update on its own.
_bindTheme() {
const modal = document.getElementById("theme-modal");
if (!modal) return;
// Handy backgrounds for the case this exists to solve — a black wire on a
// near-black canvas, or a white one on white.
const BG_SWATCHES = ["#131320", "#1c1c1c", "#243447", "#0d1b16", "#f7f8fa", "#e8e4d9", "#ffffff"];
const wrap = document.getElementById("theme-bg-swatches");
BG_SWATCHES.forEach((hex) => {
const b = document.createElement("button");
b.className = "theme-swatch";
b.style.background = hex;
b.title = hex;
b.addEventListener("click", () => Theme.set({ canvasBg: hex }));
wrap.appendChild(b);
});
const sync = () => {
const t = Theme.get();
const p = Theme.preset();
document.getElementById("theme-dark").classList.toggle("active", t.mode === "dark");
document.getElementById("theme-light").classList.toggle("active", t.mode === "light");
document.getElementById("theme-canvas-bg").value = t.canvasBg || p.canvasBg;
document.getElementById("theme-grid-dot").value = t.gridDot || p.gridDot;
document.getElementById("theme-dev-stroke").value = t.deviceStroke || p.deviceStroke;
const byType = !t.deviceFill;
document.getElementById("theme-fill-bytype").checked = byType;
const fill = document.getElementById("theme-dev-fill");
fill.disabled = byType;
fill.style.opacity = byType ? 0.4 : 1;
if (!byType) fill.value = t.deviceFill;
};
Theme.onChange(() => { sync(); this.canvas?.repaintAll(); });
document.getElementById("btn-theme")?.addEventListener("click", () => {
sync();
modal.style.display = "flex";
});
const close = () => { modal.style.display = "none"; };
document.getElementById("theme-close")?.addEventListener("click", close);
modal.addEventListener("mousedown", (e) => { if (e.target === modal) close(); });
document.getElementById("theme-dark")?.addEventListener("click", () => Theme.set({ mode: "dark" }));
document.getElementById("theme-light")?.addEventListener("click", () => Theme.set({ mode: "light" }));
document.getElementById("theme-canvas-bg")?.addEventListener("input", (e) => Theme.set({ canvasBg: e.target.value }));
document.getElementById("theme-grid-dot")?.addEventListener("input", (e) => Theme.set({ gridDot: e.target.value }));
document.getElementById("theme-dev-stroke")?.addEventListener("input", (e) => Theme.set({ deviceStroke: e.target.value }));
document.getElementById("theme-dev-fill")?.addEventListener("input", (e) => Theme.set({ deviceFill: e.target.value }));
document.getElementById("theme-fill-bytype")?.addEventListener("change", (e) => {
// Unticking needs a concrete starting colour, so seed from the current
// connector tint rather than dropping the user on black.
Theme.set({ deviceFill: e.target.checked ? null : (Theme.get().deviceFill || "#2b3550") });
});
document.getElementById("theme-reset")?.addEventListener("click", () => Theme.reset());
sync();
}
// ── Fuse box configurator ─────────────────────────────────────────────────
// A spatial editor over the module's 280-cavity grid. Work happens on a draft
// copy so Cancel genuinely discards; Apply rewrites the device's properties
// and regenerates its pins, so what you place becomes wireable immediately.
openPdmConfigurator(device) {
if (!device || device.device_type !== "pdm" || typeof PDM_MODULES === "undefined") return;
this._pdmDevice = device;
this._pdmDraft = {
moduleId: device.properties?.moduleId || "gep-frh-a24",
circuits: JSON.parse(JSON.stringify(device.properties?.circuits || [])),
};
this._pdmSel = null;
const sel = document.getElementById("pdm-module");
sel.innerHTML = "";
Object.values(PDM_MODULES).forEach((m) => {
const o = document.createElement("option");
o.value = m.id;
o.textContent = `${m.name}${m.cols * m.rows} cavities (${m.cols}x${m.rows})`;
sel.appendChild(o);
});
sel.value = this._pdmDraft.moduleId;
document.getElementById("pdm-label").value = device.label || "";
document.getElementById("pdm-linked-ref").textContent =
device.reference ? `linked to ${device.reference}` : `linked to device #${device.id}`;
this._renderPdm();
document.getElementById("pdm-modal").style.display = "flex";
document.getElementById("pdm-grid").focus();
}
_closePdm() {
document.getElementById("pdm-modal").style.display = "none";
this._pdmDevice = null;
this._pdmDraft = null;
this._pdmSel = null;
}
_pdmCell() { return 38; }
_renderPdm() {
const draft = this._pdmDraft;
if (!draft) return;
const v = pdmValidate(draft);
const mod = v.module;
const CELL = this._pdmCell();
const PAD = 6;
// ── grid ──
const grid = document.getElementById("pdm-grid");
grid.innerHTML = "";
grid.style.width = `${mod.cols * CELL + PAD * 2}px`;
grid.style.height = `${mod.rows * CELL + PAD * 2}px`;
for (let r = 0; r < mod.rows; r++) {
for (let c = 0; c < mod.cols; c++) {
const cell = document.createElement("div");
cell.className = "pdm-cell";
cell.style.left = `${PAD + c * CELL}px`;
cell.style.top = `${PAD + r * CELL}px`;
cell.style.width = `${CELL}px`;
cell.style.height = `${CELL}px`;
const num = document.createElement("span");
num.className = "pdm-cell-num";
num.textContent = r * mod.cols + c + 1;
cell.appendChild(num);
grid.appendChild(cell);
}
}
// Bus bars paint last so they lie visibly across the blades they feed.
const painted = [...(draft.circuits || [])].sort(
(a, b) => (pdmIsBus(a) ? 1 : 0) - (pdmIsBus(b) ? 1 : 0));
painted.forEach((ci) => {
const comp = PDM_COMPONENTS[ci.type];
if (!comp) return;
const { w, h } = pdmSize(comp, ci.rot || 0);
const el = document.createElement("div");
el.className = (comp.isBus ? "pdm-bus" : "pdm-part")
+ (this._pdmSel === ci.id ? " sel" : "")
+ (v.badIds.has(ci.id) ? " bad" : "");
el.style.background = comp.colour;
el.dataset.id = ci.id;
if (comp.isBus) {
// Drawn as a narrow strip lying across the run, so the components it
// feeds stay visible underneath it.
const T = 15;
const horiz = w > h;
el.style.left = `${PAD + ci.col * CELL + (horiz ? 3 : CELL / 2 - T / 2)}px`;
el.style.top = `${PAD + ci.row * CELL + (horiz ? CELL / 2 - T / 2 : 3)}px`;
el.style.width = `${horiz ? w * CELL - 6 : T}px`;
el.style.height = `${horiz ? T : h * CELL - 6}px`;
el.title = `${ci.name || comp.label} — links ${Math.max(w, h)} cavities`;
} else {
el.style.left = `${PAD + ci.col * CELL + 2}px`;
el.style.top = `${PAD + ci.row * CELL + 2}px`;
el.style.width = `${w * CELL - 4}px`;
el.style.height = `${h * CELL - 4}px`;
const tag = document.createElement("span");
tag.className = "pdm-part-tag";
tag.textContent = `${comp.short} ${ci.rating}`;
el.appendChild(tag);
if (ci.name) {
const nm = document.createElement("span");
nm.className = "pdm-part-name";
nm.textContent = ci.name;
el.appendChild(nm);
}
// terminal markers, positioned in the rotated footprint. A terminal
// sitting on a bus bar is fed by it, so it is marked rather than wired.
pdmTerminals(ci, mod).forEach((t) => {
const onBus = t.fn === "KL30" && pdmBusAt(draft, t.c, t.r);
const m = document.createElement("span");
m.className = "pdm-term" + (onBus ? " bus-fed" : "");
m.textContent = t.t;
if (onBus) m.title = "Fed by bus bar";
m.style.left = `${(t.c - ci.col) * CELL + CELL / 2 - 6.5 - 2}px`;
m.style.top = `${(t.r - ci.row) * CELL + CELL - 15 - 2}px`;
el.appendChild(m);
});
}
el.addEventListener("mousedown", (e) => this._pdmDragStart(e, ci));
grid.appendChild(el);
});
// ── capacity ──
const pct = Math.min(100, (v.used / v.total) * 100);
const fill = document.getElementById("pdm-bar-fill");
fill.style.width = `${pct}%`;
fill.className = "pdm-bar-fill" + (!v.ok ? " over" : pct > 85 ? " warn" : "");
document.getElementById("pdm-capacity-text").textContent =
`${v.used} / ${v.total} cavities · ${(draft.circuits || []).length} components`;
document.getElementById("pdm-warn").textContent = v.ok ? "" : v.errors[0];
document.getElementById("pdm-save").disabled = !v.ok;
this._renderPdmSel();
this._renderPdmPinout(mod);
}
_renderPdmSel() {
const ci = (this._pdmDraft?.circuits || []).find((x) => x.id === this._pdmSel);
document.getElementById("pdm-sel").style.display = ci ? "" : "none";
document.getElementById("pdm-sel-empty").style.display = ci ? "none" : "";
if (!ci) return;
const comp = PDM_COMPONENTS[ci.type];
document.getElementById("pdm-sel-name").value = ci.name || "";
const selType = document.getElementById("pdm-sel-type");
selType.innerHTML = "";
Object.values(PDM_COMPONENTS).forEach((c) => {
const o = document.createElement("option");
o.value = c.key;
o.textContent = `${c.label} (${c.w}x${c.h})`;
selType.appendChild(o);
});
selType.value = ci.type;
const selRating = document.getElementById("pdm-sel-rating");
selRating.innerHTML = "";
comp.ratings.forEach((r) => {
const o = document.createElement("option");
o.value = r; o.textContent = r;
selRating.appendChild(o);
});
selRating.value = ci.rating;
}
_renderPdmPinout(mod) {
const body = document.getElementById("pdm-pinout");
body.innerHTML = "";
const rows = [];
(this._pdmDraft?.circuits || []).forEach((ci) => {
const comp = PDM_COMPONENTS[ci.type];
if (!comp) return;
pdmTerminals(ci, mod).forEach((t) => {
rows.push({ ci, comp, t });
});
});
rows.sort((a, b) => a.t.cavity - b.t.cavity);
rows.forEach(({ ci, comp, t }) => {
const tr = document.createElement("tr");
if (this._pdmSel === ci.id) tr.style.background = "#1e1e3a";
const c1 = document.createElement("td");
c1.className = "pdm-cavity";
c1.textContent = t.cavity;
tr.appendChild(c1);
const c2 = document.createElement("td");
c2.className = "pdm-cavity";
c2.textContent = t.t;
tr.appendChild(c2);
const c3 = document.createElement("td");
// Show the pin name the device will actually get, so naming a circuit
// "VCU" visibly turns its terminals into "VCU in" / "VCU out".
c3.textContent = pdmPinName(ci, comp, t);
if (!ci.name) c3.style.color = "var(--text-muted)";
tr.appendChild(c3);
tr.addEventListener("click", () => { this._pdmSel = ci.id; this._renderPdm(); });
body.appendChild(tr);
});
document.getElementById("pdm-empty").style.display = rows.length ? "none" : "";
}
// ── grid drag ──
_pdmDragStart(e, circuit) {
e.preventDefault();
this._pdmSel = circuit.id;
this._renderPdm();
const grid = document.getElementById("pdm-grid");
const CELL = this._pdmCell();
const PAD = 6;
const rect = grid.getBoundingClientRect();
const startCol = circuit.col;
const startRow = circuit.row;
// where inside the component the grab happened, in cells
const grabC = Math.floor((e.clientX - rect.left - PAD) / CELL) - startCol;
const grabR = Math.floor((e.clientY - rect.top - PAD) / CELL) - startRow;
// The component follows the cursor anywhere inside the grid, overlapping or
// not. Refusing invalid positions would mean an already-overlapping part
// could never be dragged apart — validation marks the clash in red and
// blocks Apply instead, which leaves you a way out.
const move = (ev) => {
const p = pdmClamp(
this._pdmDraft, circuit,
Math.floor((ev.clientX - rect.left - PAD) / CELL) - grabC,
Math.floor((ev.clientY - rect.top - PAD) / CELL) - grabR,
circuit.rot || 0,
);
if (p.col === circuit.col && p.row === circuit.row) return;
circuit.col = p.col;
circuit.row = p.row;
this._renderPdm();
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
this._renderPdm();
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
_pdmRotate() {
const ci = (this._pdmDraft?.circuits || []).find((x) => x.id === this._pdmSel);
if (!ci) return;
// Always rotate; just pull the footprint back inside the grid. Overlap is
// allowed and flagged, same as dragging.
const next = ((ci.rot || 0) + 90) % 360;
const p = pdmClamp(this._pdmDraft, ci, ci.col, ci.row, next);
ci.rot = next;
ci.col = p.col;
ci.row = p.row;
this._renderPdm();
}
_pdmAdd(typeKey) {
const draft = this._pdmDraft;
if (!draft) return;
const ci = pdmNewCircuit(typeKey);
const spot = pdmAutoPlace(draft, ci);
if (!spot) {
document.getElementById("pdm-warn").textContent = "No free cavities for that component.";
return;
}
Object.assign(ci, spot);
draft.circuits.push(ci);
this._pdmSel = ci.id;
this._renderPdm();
document.getElementById("pdm-sel-name").focus();
}
_pdmRemove() {
const draft = this._pdmDraft;
if (!draft || !this._pdmSel) return;
const i = draft.circuits.findIndex((x) => x.id === this._pdmSel);
if (i < 0) return;
draft.circuits.splice(i, 1);
this._pdmSel = null;
this._renderPdm();
}
async _savePdm() {
const device = this._pdmDevice;
const draft = this._pdmDraft;
if (!device || !draft) return;
const v = pdmValidate(draft);
if (!v.ok) {
document.getElementById("pdm-warn").textContent = v.errors[0];
return;
}
const mod = v.module;
const label = document.getElementById("pdm-label").value.trim() || device.label || mod.name;
const props = {
...device.properties,
moduleId: draft.moduleId,
circuits: draft.circuits,
partNumber: mod.partNumber,
manufacturer: mod.manufacturer,
};
const size = DEVICE_TYPES.pdm.defaultSize(props);
const pins = pdmPins(props, size.w, size.h);
try {
const updated = await api.devices.update(device.id, {
label, properties: props, pins,
width: size.w, height: size.h,
});
this.canvas.updateDevice(updated);
this._showDeviceProps(updated);
this._flashSaved();
this._closePdm();
} catch (e) {
console.error("Fuse box save failed:", e);
document.getElementById("pdm-warn").textContent = "Could not save — see console.";
}
}
_bindPdm() {
document.getElementById("prop-pdm-btn")?.addEventListener("click", () => {
if (this._propDevice) this.openPdmConfigurator(this._propDevice);
});
document.getElementById("pdm-cancel")?.addEventListener("click", () => this._closePdm());
document.getElementById("pdm-save")?.addEventListener("click", () => this._savePdm());
document.getElementById("pdm-rotate")?.addEventListener("click", () => this._pdmRotate());
document.getElementById("pdm-delete")?.addEventListener("click", () => this._pdmRemove());
document.getElementById("pdm-module")?.addEventListener("change", (e) => {
if (!this._pdmDraft) return;
this._pdmDraft.moduleId = e.target.value;
// A narrower or shorter box would otherwise strand tiles off the edge
// where they cannot be clicked, so pull everything back in.
pdmClampAll(this._pdmDraft);
this._renderPdm();
});
const ADD = { fuse: "fuse", spdt: "relay_spdt", spst: "relay_spst", diode: "diode", breaker: "breaker", bus: "bus4" };
Object.entries(ADD).forEach(([kind, typeKey]) => {
document.getElementById(`pdm-add-${kind}`)?.addEventListener("click", () => this._pdmAdd(typeKey));
});
document.getElementById("pdm-sel-name")?.addEventListener("input", (e) => {
const ci = (this._pdmDraft?.circuits || []).find((x) => x.id === this._pdmSel);
if (!ci) return;
ci.name = e.target.value;
// repaint the tile label without stealing focus from the field
const el = document.querySelector(`.pdm-part[data-id="${ci.id}"] .pdm-part-name`);
if (el) el.textContent = ci.name;
else this._renderPdm();
this._renderPdmPinout(PDM_MODULES[this._pdmDraft.moduleId]);
});
document.getElementById("pdm-sel-rating")?.addEventListener("change", (e) => {
const ci = (this._pdmDraft?.circuits || []).find((x) => x.id === this._pdmSel);
if (ci) { ci.rating = e.target.value; this._renderPdm(); }
});
document.getElementById("pdm-sel-type")?.addEventListener("change", (e) => {
const ci = (this._pdmDraft?.circuits || []).find((x) => x.id === this._pdmSel);
if (!ci) return;
const prev = { type: ci.type, rating: ci.rating, rot: ci.rot };
ci.type = e.target.value;
ci.rating = PDM_COMPONENTS[ci.type].defaultRating;
if (!pdmFits(this._pdmDraft, ci, ci.col, ci.row, ci.rot || 0)) {
const spot = pdmAutoPlace(this._pdmDraft, ci);
if (spot) Object.assign(ci, spot);
else {
Object.assign(ci, prev);
document.getElementById("pdm-warn").textContent = "No room for that component.";
}
}
this._renderPdm();
});
// Bound on document, not the grid: clicking a tile preventDefaults the
// mousedown so the grid never reliably holds focus.
document.addEventListener("keydown", (e) => {
const modal = document.getElementById("pdm-modal");
if (!modal || modal.style.display === "none" || !modal.style.display) return;
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) return;
if (e.key === "r" || e.key === "R") { e.preventDefault(); this._pdmRotate(); }
if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); this._pdmRemove(); }
if (e.key === "Escape") { e.preventDefault(); this._closePdm(); }
});
document.getElementById("pdm-grid")?.addEventListener("mousedown", (e) => {
if (e.target.id === "pdm-grid" || e.target.classList.contains("pdm-cell")) {
this._pdmSel = null;
this._renderPdm();
}
});
document.getElementById("pdm-modal")?.addEventListener("mousedown", (e) => {
if (e.target.id === "pdm-modal") this._closePdm();
});
}
async createWire(fromDev, fromPin, toDev, toPin) { async createWire(fromDev, fromPin, toDev, toPin) {
if (!this.diagramId) return; if (!this.diagramId) return;
try { try {
const autoColor = this._conductorColorForPin(fromDev, fromPin) // Standard parts win: a pin that declares a wire function fixes the
// colour, stripe and gauge so the same connection is identical on every
// build. Cable conductor colour is next, then the plain red fallback.
const std = this._standardWireForPin(fromDev, fromPin)
|| this._standardWireForPin(toDev, toPin);
const autoColor = std?.color_primary
|| this._conductorColorForPin(fromDev, fromPin)
|| this._conductorColorForPin(toDev, toPin) || this._conductorColorForPin(toDev, toPin)
|| "#CC0000"; || "#CC0000";
const wire = await api.wires.create({ const wire = await api.wires.create({
diagram_id: this.diagramId, diagram_id: this.diagramId,
from_device_id: fromDev, from_pin: fromPin, from_device_id: fromDev, from_pin: fromPin,
to_device_id: toDev, to_pin: toPin, to_device_id: toDev, to_pin: toPin,
color_primary: autoColor, gauge: "18 AWG", color_primary: autoColor,
color_stripe: std?.color_stripe ?? null,
gauge: std?.gauge || "18 AWG",
twisted_pair: std?.twisted_pair || false,
label: std?.signal || std?.label || "",
}); });
this.canvas.addWire(wire); this.canvas.addWire(wire);
this.setMode("select"); this.setMode("select");
@@ -2169,6 +2686,8 @@ class WiringApp {
document.getElementById("pin-table-body").closest(".prop-row").style.display = isGroup ? "none" : ""; document.getElementById("pin-table-body").closest(".prop-row").style.display = isGroup ? "none" : "";
document.getElementById("prop-pinout-btn").style.display = document.getElementById("prop-pinout-btn").style.display =
(!isGroup && (device.pins || []).length > 0) ? "" : "none"; (!isGroup && (device.pins || []).length > 0) ? "" : "none";
const pdmBtn = document.getElementById("prop-pdm-btn");
if (pdmBtn) pdmBtn.style.display = device.device_type === "pdm" ? "" : "none";
// Reset any open search results // Reset any open search results
const res = document.getElementById("octopart-results"); const res = document.getElementById("octopart-results");
const msg = document.getElementById("octopart-status-msg"); const msg = document.getElementById("octopart-status-msg");
+59 -51
View File
@@ -173,7 +173,7 @@ class DiagramCanvas {
// Loom background // Loom background
this.harnessLayer.add(new Konva.Line({ this.harnessLayer.add(new Konva.Line({
points: [cx, cy, ex, ey], points: [cx, cy, ex, ey],
stroke: '#0a0a18', strokeWidth: trunkW + 4, stroke: Theme.loomShadow(), strokeWidth: trunkW + 4,
lineCap: 'round', listening: false, lineCap: 'round', listening: false,
})); }));
// Colored wire stripes // Colored wire stripes
@@ -192,7 +192,7 @@ class DiagramCanvas {
// Split-off circle at loom end // Split-off circle at loom end
this.harnessLayer.add(new Konva.Circle({ this.harnessLayer.add(new Konva.Circle({
x: ex, y: ey, radius: trunkW / 2 + 4, x: ex, y: ey, radius: trunkW / 2 + 4,
fill: '#12122a', stroke: '#4466cc', strokeWidth: 2, listening: false, fill: Theme.harnessBg(), stroke: Theme.harnessEdge(), strokeWidth: 2, listening: false,
})); }));
}); });
@@ -238,7 +238,7 @@ class DiagramCanvas {
// Trunk background // Trunk background
this.harnessLayer.add(new Konva.Line({ this.harnessLayer.add(new Konva.Line({
points: [ax, ay, bx, by], points: [ax, ay, bx, by],
stroke: '#0a0a18', strokeWidth: trunkW + 4, stroke: Theme.loomShadow(), strokeWidth: trunkW + 4,
lineCap: 'round', lineJoin: 'round', listening: false, lineCap: 'round', lineJoin: 'round', listening: false,
})); }));
// Colored wire stripes // Colored wire stripes
@@ -260,12 +260,12 @@ class DiagramCanvas {
this.harnessLayer.add(new Konva.Rect({ this.harnessLayer.add(new Konva.Rect({
x: mx - 22, y: my - 9, x: mx - 22, y: my - 9,
width: 44, height: 18, cornerRadius: 4, width: 44, height: 18, cornerRadius: 4,
fill: '#12122a', stroke: '#4466cc', strokeWidth: 1.5, listening: false, fill: Theme.harnessBg(), stroke: Theme.harnessEdge(), strokeWidth: 1.5, listening: false,
})); }));
this.harnessLayer.add(new Konva.Text({ this.harnessLayer.add(new Konva.Text({
x: mx - 22, y: my - 6, x: mx - 22, y: my - 6,
text: badgeText, width: 44, align: 'center', text: badgeText, width: 44, align: 'center',
fontSize: 9, fontFamily: 'monospace', fill: '#99bbff', listening: false, fontSize: 9, fontFamily: 'monospace', fill: Theme.harnessText(), listening: false,
})); }));
} }
}); });
@@ -619,7 +619,7 @@ class DiagramCanvas {
if (!g) return; if (!g) return;
const d = this.deviceData.get(id); const d = this.deviceData.get(id);
const isGroup = d?.device_type === "group"; const isGroup = d?.device_type === "group";
const offColor = isGroup ? (d.properties?.fillColor || "#2828a0") : "#5a5a8a"; const offColor = isGroup ? (d.properties?.fillColor || "#2828a0") : Theme.deviceStroke();
g.findOne("Rect").stroke(on ? "#4db8ff" : offColor); g.findOne("Rect").stroke(on ? "#4db8ff" : offColor);
(isGroup ? this.groupLayer : this.deviceLayer).batchDraw(); (isGroup ? this.groupLayer : this.deviceLayer).batchDraw();
} }
@@ -638,6 +638,13 @@ class DiagramCanvas {
this.wireLayer.batchDraw(); this.wireLayer.batchDraw();
} }
// Re-render every device. Konva bakes colours in at draw time rather than
// reading CSS, so a theme change needs an explicit repaint.
repaintAll() {
[...this.deviceData.values()].forEach((d) => this.updateDevice(d));
this.stage.batchDraw();
}
clearSelection() { clearSelection() {
this._netHighlightIds.forEach(id => this._wireHighlight(id, false)); this._netHighlightIds.forEach(id => this._wireHighlight(id, false));
this._netHighlightIds.clear(); this._netHighlightIds.clear();
@@ -1026,26 +1033,10 @@ class DiagramCanvas {
// ── Device rendering ────────────────────────────────────────────────────────── // ── Device rendering ──────────────────────────────────────────────────────────
// Fills, outline and label colours come from the theme so light mode can
// re-tint them; see theme.js. Konva cannot read CSS custom properties.
_deviceFill(type) { _deviceFill(type) {
return { return Theme.deviceFill(type);
connector: "#12253a",
terminal_block: "#122a1a",
component: "#1e1230",
splice: "#2a1e10",
label: "#22220e",
fuse: "#2a1c08",
relay: "#0a1628",
switch: "#0a2218",
bulb: "#24220a",
motor: "#1a0a28",
diode: "#28081a",
resistor: "#1a1a08",
capacitor: "#081a1a",
ground: "#0e140e",
power: "#1a0808",
cable: "#1a1a1a",
group: "rgba(40,40,80,0.35)",
}[type] || "#1e1e2e";
} }
_renderDevice(device) { _renderDevice(device) {
@@ -1072,7 +1063,7 @@ class DiagramCanvas {
group.add(new Konva.Text({ group.add(new Konva.Text({
name: "device-label", name: "device-label",
x: 8, y: 4, width: device.width - 16, x: 8, y: 4, width: device.width - 16,
text: device.label, fontSize, fontFamily: "monospace", fill: "#ffffff", text: device.label, fontSize, fontFamily: "monospace", fill: Theme.deviceText(),
fontStyle: "bold", listening: false, fontStyle: "bold", listening: false,
})); }));
} else { } else {
@@ -1080,7 +1071,7 @@ class DiagramCanvas {
const rect = new Konva.Rect({ const rect = new Konva.Rect({
name: "device-rect", name: "device-rect",
width: device.width, height: device.height, width: device.width, height: device.height,
fill: this._deviceFill(device.device_type), stroke: "#5a5a8a", strokeWidth: 2, cornerRadius: 4, fill: this._deviceFill(device.device_type), stroke: Theme.deviceStroke(), strokeWidth: 2, cornerRadius: 4,
}); });
group.add(rect); group.add(rect);
@@ -1089,7 +1080,7 @@ class DiagramCanvas {
group.add(new Konva.Text({ group.add(new Konva.Text({
name: "device-label", name: "device-label",
x: 8, y: 8, width: device.width - 16, height: device.height - 16, x: 8, y: 8, width: device.width - 16, height: device.height - 16,
text: device.label, fontSize, fontFamily: "monospace", fill: "#dde0f5", text: device.label, fontSize, fontFamily: "monospace", fill: Theme.deviceText(),
align: "left", verticalAlign: "top", wrap: "word", align: "left", verticalAlign: "top", wrap: "word",
})); }));
} else if (device.device_type === "cable") { } else if (device.device_type === "cable") {
@@ -1160,25 +1151,25 @@ class DiagramCanvas {
// Header jacket bar // Header jacket bar
group.add(new Konva.Rect({ x: 2, y: 2, width: device.width - 4, height: 22, fill: jacket, cornerRadius: [3,3,0,0] })); group.add(new Konva.Rect({ x: 2, y: 2, width: device.width - 4, height: 22, fill: jacket, cornerRadius: [3,3,0,0] }));
if (device.reference) { if (device.reference) {
group.add(new Konva.Text({ x: 6, y: 6, text: device.reference, fontSize: 9, fontFamily: "monospace", fill: "#99aaee", fontStyle: "bold" })); group.add(new Konva.Text({ x: 6, y: 6, text: device.reference, fontSize: 9, fontFamily: "monospace", fill: Theme.deviceRef(), fontStyle: "bold" }));
} }
group.add(new Konva.Text({ group.add(new Konva.Text({
name: "device-label", name: "device-label",
x: 4, y: 6, width: device.width - 8, align: "center", x: 4, y: 6, width: device.width - 8, align: "center",
text: device.label, fontSize: 10, fontFamily: "monospace", fill: "#dde0f5", text: device.label, fontSize: 10, fontFamily: "monospace", fill: Theme.deviceText(),
})); }));
// Conductor rows // Conductor rows
conductors.forEach((cond, i) => { conductors.forEach((cond, i) => {
const rowY = 26 + i * 24; const rowY = 26 + i * 24;
const color = cond.color || "#888888"; const color = cond.color || "#888888";
group.add(new Konva.Rect({ x: 1, y: rowY, width: device.width - 2, height: 24, fill: "#0e0e1a" })); group.add(new Konva.Rect({ x: 1, y: rowY, width: device.width - 2, height: 24, fill: Theme.rowBg() }));
group.add(new Konva.Line({ group.add(new Konva.Line({
points: [14, rowY + 12, device.width - 14, rowY + 12], points: [14, rowY + 12, device.width - 14, rowY + 12],
stroke: color, strokeWidth: 5, lineCap: "round", listening: false, stroke: color, strokeWidth: 5, lineCap: "round", listening: false,
})); }));
group.add(new Konva.Text({ group.add(new Konva.Text({
x: 16, y: rowY + 4, text: cond.name || String(i + 1), x: 16, y: rowY + 4, text: cond.name || String(i + 1),
fontSize: 9, fontFamily: "monospace", fill: "#c8ccee", listening: false, fontSize: 9, fontFamily: "monospace", fill: Theme.deviceText(), listening: false,
})); }));
}); });
// Footer jacket bar // Footer jacket bar
@@ -1186,7 +1177,7 @@ class DiagramCanvas {
group.add(new Konva.Text({ group.add(new Konva.Text({
name: "device-type", name: "device-type",
x: 4, y: device.height - 14, width: device.width - 8, align: "right", x: 4, y: device.height - 14, width: device.width - 8, align: "right",
text: "cable", fontSize: 8, fontFamily: "monospace", fill: "#888888", text: "cable", fontSize: 8, fontFamily: "monospace", fill: Theme.deviceSubtext(),
})); }));
} else if (device.device_type === "connector" && device.properties?.shape === "circular") { } else if (device.device_type === "connector" && device.properties?.shape === "circular") {
rect.visible(false); rect.visible(false);
@@ -1197,32 +1188,32 @@ class DiagramCanvas {
group.add(new Konva.Circle({ group.add(new Konva.Circle({
name: "device-body", name: "device-body",
x: cx, y: cy, radius: r, x: cx, y: cy, radius: r,
fill: this._deviceFill("connector"), stroke: "#5a5a8a", strokeWidth: 2, fill: this._deviceFill("connector"), stroke: Theme.deviceStroke(), strokeWidth: 2,
})); }));
// Key notch anchored to body top edge // Key notch anchored to body top edge
group.add(new Konva.Rect({ group.add(new Konva.Rect({
x: cx - 5, y: cy - r - 3, width: 10, height: 7, cornerRadius: 2, x: cx - 5, y: cy - r - 3, width: 10, height: 7, cornerRadius: 2,
fill: "#333355", stroke: "#5a5a8a", strokeWidth: 1, listening: false, fill: Theme.notch(), stroke: Theme.deviceStroke(), strokeWidth: 1, listening: false,
})); }));
group.add(new Konva.Text({ group.add(new Konva.Text({
name: "device-label", name: "device-label",
x: 4, y: cy - 6, width: device.width - 8, x: 4, y: cy - 6, width: device.width - 8,
text: device.label, fontSize: Math.min(10, fontSize), text: device.label, fontSize: Math.min(10, fontSize),
fontFamily: "monospace", fill: "#dde0f5", align: "center", fontFamily: "monospace", fill: Theme.deviceText(), align: "center",
})); }));
} else { } else {
if (device.reference) { if (device.reference) {
group.add(new Konva.Text({ x: 6, y: 5, text: device.reference, fontSize: 10, fontFamily: "monospace", fill: "#99aaee", fontStyle: "bold" })); group.add(new Konva.Text({ x: 6, y: 5, text: device.reference, fontSize: 10, fontFamily: "monospace", fill: Theme.deviceRef(), fontStyle: "bold" }));
} }
group.add(new Konva.Text({ group.add(new Konva.Text({
name: "device-label", name: "device-label",
x: 4, y: device.height / 2 - fontSize / 2 - 2, width: device.width - 8, align: "center", x: 4, y: device.height / 2 - fontSize / 2 - 2, width: device.width - 8, align: "center",
text: device.label, fontSize, fontFamily: "monospace", fill: "#dde0f5", wrap: "word", text: device.label, fontSize, fontFamily: "monospace", fill: Theme.deviceText(), wrap: "word",
})); }));
group.add(new Konva.Text({ group.add(new Konva.Text({
name: "device-type", name: "device-type",
x: 4, y: device.height - 14, width: device.width - 8, align: "right", x: 4, y: device.height - 14, width: device.width - 8, align: "right",
text: device.device_type, fontSize: 8, fontFamily: "monospace", fill: "#444466", text: device.device_type, fontSize: 8, fontFamily: "monospace", fill: Theme.deviceSubtext(),
})); }));
} }
} // end non-group else } // end non-group else
@@ -1491,7 +1482,7 @@ class DiagramCanvas {
_addPin(group, device, pin) { _addPin(group, device, pin) {
const circle = new Konva.Circle({ const circle = new Konva.Circle({
x: pin.x_offset, y: pin.y_offset, radius: 5, x: pin.x_offset, y: pin.y_offset, radius: 5,
fill: "#0a0f1a", stroke: "#5566aa", strokeWidth: 1.5, fill: Theme.pinFill(), stroke: Theme.pinStroke(), strokeWidth: 1.5,
}); });
const hit = new Konva.Circle({ const hit = new Konva.Circle({
x: pin.x_offset, y: pin.y_offset, radius: 14, x: pin.x_offset, y: pin.y_offset, radius: 14,
@@ -1499,29 +1490,46 @@ class DiagramCanvas {
}); });
hit._pinMeta = { deviceId: device.id, pinId: pin.id, side: pin.side }; hit._pinMeta = { deviceId: device.id, pinId: pin.id, side: pin.side };
// Position label inside the device body, clear of the pin circle (r=5, gap=3 → offset 8) // Pin labels scale with the device font rather than sitting at a fixed 8px,
// so raising the font size makes the whole device readable, not just its
// title. The ratios below reproduce the previous look exactly at the
// default size of 12.
const baseFont = device.properties?.fontSize || 12;
const pinFont = Math.max(6, Math.round(baseFont * 0.7));
const GAP = 8; const GAP = 8;
let lx, ly, lw, la; const lw = Math.round(pinFont * 2.5);
const vc = Math.round(pinFont * 0.62); // vertical centring on the pin
let lx, ly, la;
switch (pin.side) { switch (pin.side) {
case "right": case "right":
lx = pin.x_offset - 28; ly = pin.y_offset - 5; lw = 20; la = "right"; break; lx = pin.x_offset - (GAP + lw); ly = pin.y_offset - vc; la = "right"; break;
case "top": case "top":
lx = pin.x_offset - 10; ly = pin.y_offset + GAP; lw = 20; la = "center"; break; lx = pin.x_offset - lw / 2; ly = pin.y_offset + GAP; la = "center"; break;
case "bottom": case "bottom":
lx = pin.x_offset - 10; ly = pin.y_offset - 13; lw = 20; la = "center"; break; lx = pin.x_offset - lw / 2; ly = pin.y_offset - GAP - vc; la = "center"; break;
default: // left default: // left
lx = pin.x_offset + GAP; ly = pin.y_offset - 5; lw = 20; la = "left"; lx = pin.x_offset + GAP; ly = pin.y_offset - vc; la = "left";
} }
// Pass-through devices (bulkheads) carry the same signal on both faces, so
// one pin of the pair draws the name centred between them and the other
// draws nothing — one label per circuit rather than two.
if (!pin.hide_label) {
const centred = !!pin.center_label;
group.add(new Konva.Text({ group.add(new Konva.Text({
x: lx, y: ly, width: lw, align: la, x: centred ? 0 : lx,
text: pin.name, fontSize: 8, fontFamily: "monospace", fill: "#556688", y: centred ? pin.y_offset - vc : ly,
width: centred ? device.width : lw,
align: centred ? "center" : la,
text: pin.name, fontSize: pinFont, fontFamily: "monospace",
fill: Theme.deviceSubtext(), listening: false,
})); }));
}
group.add(circle); group.add(circle);
group.add(hit); group.add(hit);
const highlightPin = (active) => { const highlightPin = (active) => {
circle.fill(active ? "#003300" : "#0a0f1a"); circle.fill(active ? "#003300" : Theme.pinFill());
circle.stroke(active ? "#00dd00" : "#5566aa"); circle.stroke(active ? "#00dd00" : Theme.pinStroke());
circle.radius(active ? 7 : 5); circle.radius(active ? 7 : 5);
this.deviceLayer.batchDraw(); this.deviceLayer.batchDraw();
}; };
@@ -1685,7 +1693,7 @@ class DiagramCanvas {
lbl = new Konva.Text({ lbl = new Konva.Text({
x: mx + 3, y: my - 10, x: mx + 3, y: my - 10,
text: labelText, text: labelText,
fontSize: 9, fontFamily: "monospace", fill: "#aabbcc", listening: false, fontSize: 9, fontFamily: "monospace", fill: Theme.wireLabel(), listening: false,
shadowColor: "#000", shadowBlur: 3, shadowOpacity: 0.8, shadowColor: "#000", shadowBlur: 3, shadowOpacity: 0.8,
}); });
} }
+72 -9
View File
@@ -213,16 +213,77 @@ function connectorToDevice(connId, diagramId) {
}; };
} }
// Standard rectangular connector // Standard rectangular connector.
const w = 120; // Parts carrying pinSpecs (see partsLibrary.js) get their standard wire
const h = Math.max(60, pinCount * 18 + 20); // function stamped onto each pin, so wires drawn off them always come out the
const pins = Array.from({ length: pinCount }, (_, i) => ({ // same colour, stripe and gauge.
const specs = conn.pinSpecs || null;
// Pass-through parts (bulkheads) present both faces of the same connector as
// one device: every circuit gets an IN pin on the left and an OUT pin on the
// right at the same height, sharing a single centred label. Wiring both sides
// of a bulkhead then needs one device, not a mated pair.
if (conn.passThrough) {
const w = 240;
const h = Math.max(60, pinCount * 18 + 24);
const pins = [];
(specs || Array.from({ length: pinCount })).forEach((spec, i) => {
const y = ((i + 1) / (pinCount + 1)) * h;
const base = {
name: spec ? spec.name : String(i + 1),
pin_number: spec ? spec.pin : String(i + 1),
wire_fn: spec ? spec.fn : null,
wire_oem: spec && spec.oem ? spec.oem : null,
note: spec ? spec.note : "",
};
pins.push({ ...base, id: `pin_${i + 1}_in`, side: "left", x_offset: 0, y_offset: y, center_label: true });
pins.push({ ...base, id: `pin_${i + 1}_out`, side: "right", x_offset: w, y_offset: y, hide_label: true });
});
return {
diagram_id: diagramId,
device_type: "connector",
label: conn.name,
reference: "",
x: 200, y: 200,
width: w, height: h,
properties: {
pinCount,
orientation: "right",
passThrough: true,
partNumber: conn.partNumber || "",
manufacturer: conn.manufacturer || "",
connectorLibraryId: connId,
standardPart: !!specs,
verifyPinout: !!conn.verify,
},
pins,
};
}
// Wide parts get two columns so a 20- or 32-way body stays readable.
const split = pinCount > 12;
const perSide = split ? Math.ceil(pinCount / 2) : pinCount;
const w = split ? 200 : 120;
const h = Math.max(60, perSide * 18 + 20);
const pins = Array.from({ length: pinCount }, (_, i) => {
const spec = specs ? specs[i] : null;
const onRight = !split || i >= perSide;
const idx = onRight && split ? i - perSide : i;
const count = onRight && split ? pinCount - perSide : perSide;
return {
id: `pin_${i + 1}`, id: `pin_${i + 1}`,
name: conn.pinLabels ? (conn.pinLabels[i] || String(i + 1)) : String(i + 1), name: spec ? spec.name : (conn.pinLabels ? (conn.pinLabels[i] || String(i + 1)) : String(i + 1)),
side: "right", side: onRight ? "right" : "left",
x_offset: w, x_offset: onRight ? w : 0,
y_offset: ((i + 1) / (pinCount + 1)) * h, y_offset: ((idx + 1) / (count + 1)) * h,
})); // Standard-parts metadata — consumed by createWire() and the props panel.
pin_number: spec ? spec.pin : String(i + 1),
wire_fn: spec ? spec.fn : null,
wire_oem: spec && spec.oem ? spec.oem : null,
note: spec ? spec.note : "",
};
});
return { return {
diagram_id: diagramId, diagram_id: diagramId,
@@ -237,6 +298,8 @@ function connectorToDevice(connId, diagramId) {
partNumber: conn.partNumber || "", partNumber: conn.partNumber || "",
manufacturer: conn.manufacturer || "", manufacturer: conn.manufacturer || "",
connectorLibraryId: connId, connectorLibraryId: connId,
standardPart: !!specs,
verifyPinout: !!conn.verify,
}, },
pins, pins,
}; };
+558
View File
@@ -0,0 +1,558 @@
// ─────────────────────────────────────────────────────────────────────────────
// Skudak standard parts + wire colour standard
//
// Loaded AFTER connectorLibrary.js. Merges standard parts into CONNECTOR_LIBRARY
// so they appear in the existing library panel, search and drag-to-canvas with
// no UI changes.
//
// The point of this file: every pin on a standard part declares the wire that
// belongs on it. Drag a wire off VCU GRAY pin 3 and it is Red/White 14 AWG every
// time, on every build, without anyone remembering to set it.
//
// Wire stock: ACDC Wire Supply TXL. Solids plus the 16 stocked stripe combos.
// ─────────────────────────────────────────────────────────────────────────────
// ── TXL solid colours ────────────────────────────────────────────────────────
const TXL_SOLID = {
black: { name: "Black", hex: "#1A1A1A" },
brown: { name: "Brown", hex: "#6B3F1D" },
red: { name: "Red", hex: "#CC0000" },
orange: { name: "Orange", hex: "#FF8C00" },
yellow: { name: "Yellow", hex: "#FFD700" },
green: { name: "Green", hex: "#007700" },
darkGreen: { name: "Dark Green", hex: "#14532D" },
lightGreen: { name: "Light Green", hex: "#7CB342" },
blue: { name: "Blue", hex: "#0000CC" },
lightBlue: { name: "Light Blue", hex: "#4FA8DC" },
purple: { name: "Purple", hex: "#7B00CC" },
grey: { name: "Grey", hex: "#808080" },
white: { name: "White", hex: "#E8E8E8" },
pink: { name: "Pink", hex: "#FF69B4" },
tan: { name: "Tan", hex: "#D2B48C" },
};
// ── TXL stocked stripe combinations ──────────────────────────────────────────
// Exactly the 16 combos carried by ACDC. Nothing outside this list is buildable.
const TXL_STRIPED = {
whiteRed: { name: "White / Red stripe", hex: "#E8E8E8", stripe: "#CC0000" },
whiteBlue: { name: "White / Blue stripe", hex: "#E8E8E8", stripe: "#0000CC" },
pinkGreen: { name: "Pink / Green stripe", hex: "#FF69B4", stripe: "#007700" },
tanBlack: { name: "Tan / Black stripe", hex: "#D2B48C", stripe: "#1A1A1A" },
lightGreenDark: { name: "Light Green / Dark Green stripe", hex: "#7CB342", stripe: "#14532D" },
yellowRed: { name: "Yellow / Red stripe", hex: "#FFD700", stripe: "#CC0000" },
orangeBlack: { name: "Orange / Black stripe", hex: "#FF8C00", stripe: "#1A1A1A" },
brownWhite: { name: "Brown / White stripe", hex: "#6B3F1D", stripe: "#E8E8E8" },
greenWhite: { name: "Green / White stripe", hex: "#007700", stripe: "#E8E8E8" },
purpleRed: { name: "Purple / Red stripe", hex: "#7B00CC", stripe: "#CC0000" },
lightBlueWhite: { name: "Light Blue / White stripe", hex: "#4FA8DC", stripe: "#E8E8E8" },
blackWhite: { name: "Black / White stripe", hex: "#1A1A1A", stripe: "#E8E8E8" },
blackRed: { name: "Black / Red stripe", hex: "#1A1A1A", stripe: "#CC0000" },
redBlack: { name: "Red / Black stripe", hex: "#CC0000", stripe: "#1A1A1A" },
blackYellow: { name: "Black / Yellow stripe", hex: "#1A1A1A", stripe: "#FFD700" },
redWhite: { name: "Red / White stripe", hex: "#CC0000", stripe: "#E8E8E8" },
};
// ── Function → wire standard ─────────────────────────────────────────────────
// This is the standard. A pin names a function; the function fixes the colour,
// stripe and gauge. Change it here and every build follows.
const WIRE_STANDARD = {
// Power. Brown is ground, matching the MEB loom, the Land Rover build and
// European practice — an in-house ground lands next to an OEM ground at the
// battery, so the two must not disagree.
KL30: { label: "Permanent 12 V (KL30)", ...TXL_SOLID.red, gauge: "14 AWG" },
KL15: { label: "Switched 12 V (KL15)", ...TXL_STRIPED.redBlack, gauge: "16 AWG" },
LOAD_12V: { label: "VCU-switched load feed", ...TXL_STRIPED.redWhite, gauge: "14 AWG" },
GND: { label: "Chassis ground", ...TXL_SOLID.brown, gauge: "14 AWG" },
GND_SIG: { label: "Signal ground", ...TXL_STRIPED.brownWhite, gauge: "18 AWG" },
V5_REF: { label: "5 V sensor reference", ...TXL_STRIPED.pinkGreen, gauge: "20 AWG" },
// Networks — CAN pairs are twisted
CAN1_H: { label: "CAN1 High (powertrain)", ...TXL_SOLID.yellow, gauge: "20 AWG", twisted: true },
CAN1_L: { label: "CAN1 Low (powertrain)", ...TXL_SOLID.green, gauge: "20 AWG", twisted: true },
CAN2_H: { label: "CAN2 High (HV pack)", ...TXL_STRIPED.yellowRed, gauge: "20 AWG", twisted: true },
CAN2_L: { label: "CAN2 Low (HV pack)", ...TXL_STRIPED.greenWhite, gauge: "20 AWG", twisted: true },
CAN3_H: { label: "CAN3 High (charge/body)", ...TXL_STRIPED.whiteBlue, gauge: "20 AWG", twisted: true },
CAN3_L: { label: "CAN3 Low (charge/body)", ...TXL_STRIPED.lightBlueWhite, gauge: "20 AWG", twisted: true },
LIN: { label: "LIN bus", ...TXL_SOLID.grey, gauge: "20 AWG" },
SHIELD: { label: "Shield / drain", ...TXL_STRIPED.blackYellow, gauge: "20 AWG" },
// Safety. HVIL is violet, not orange — orange is reserved (see below) so the
// interlock can never be mistaken for a live AC mains or OEM HV run.
HVIL_OUT: { label: "HVIL drive", ...TXL_SOLID.purple, gauge: "18 AWG" },
HVIL_RTN: { label: "HVIL return", ...TXL_STRIPED.purpleRed, gauge: "18 AWG" },
COIL: { label: "Contactor coil", ...TXL_SOLID.black, gauge: "16 AWG" },
WELD: { label: "Weld detect / aux contact", ...TXL_STRIPED.blackWhite, gauge: "20 AWG" },
INHIBIT: { label: "Enable / inhibit", ...TXL_STRIPED.blackRed, gauge: "20 AWG" },
// Signals
DIG_IN: { label: "Digital input", ...TXL_STRIPED.whiteRed, gauge: "20 AWG" },
DIG_OUT: { label: "Digital output", ...TXL_SOLID.darkGreen, gauge: "18 AWG" },
PWM_OUT: { label: "PWM output", ...TXL_SOLID.lightGreen, gauge: "18 AWG" },
FREQ_IN: { label: "PWM / frequency input", ...TXL_SOLID.lightBlue, gauge: "20 AWG" },
ANALOG_1: { label: "Analog signal 1", ...TXL_STRIPED.tanBlack, gauge: "20 AWG" },
ANALOG_2: { label: "Analog signal 2", ...TXL_SOLID.tan, gauge: "20 AWG" },
ANALOG_3: { label: "Analog signal 3", ...TXL_STRIPED.lightGreenDark, gauge: "20 AWG" },
FAULT: { label: "Fault / lamp output", ...TXL_SOLID.pink, gauge: "18 AWG" },
HV_SENSE: { label: "HV instrumentation", ...TXL_SOLID.blue, gauge: "18 AWG" },
// Quadrature channel B needs its own colour. Channel A is an ordinary
// FREQ_IN, but two identical wires in one shielded encoder bundle is how you
// end up with A and B swapped — which reverses the sensed direction of the
// motor. White is reclaimed from the old SPARE entry: an unassigned pin
// should get no wire at all, not a wire in "spare" colour.
ENC_B: { label: "Encoder channel B", ...TXL_SOLID.white, gauge: "20 AWG" },
};
// ── Reserved colours ─────────────────────────────────────────────────────────
// Deliberately NOT in WIRE_STANDARD, so nothing in-house is ever auto-assigned
// them. Orange belongs to AC mains and to OEM high-voltage looms; taking it for
// a signal would put a low-voltage wire in the colour that means "this can kill
// you". Set these by hand when drawing the runs they describe.
const RESERVED_COLOURS = {
AC_MAINS: { label: "AC mains (L1 / L2 / AC ground)", ...TXL_SOLID.orange, gauge: "6.0 mm²" },
OEM_HV: { label: "OEM high-voltage / HV-CAN", ...TXL_STRIPED.orangeBlack, gauge: "varies" },
};
function wireSpecFor(fnKey) {
const s = WIRE_STANDARD[fnKey];
if (!s) return null;
return {
fn: fnKey,
color_primary: s.hex,
color_stripe: s.stripe || null,
gauge: s.gauge,
twisted_pair: !!s.twisted,
label: s.label,
};
}
// Shorthand: p("3", "GPIOHP1", "LOAD_12V", "Coolant pump 12 V")
function p(pin, name, fn, note) {
return { pin, name, fn, note: note || "" };
}
// ── OEM harness colours ──────────────────────────────────────────────────────
// Where we splice into an existing factory harness the wire colour is not ours
// to choose — it has to match what is already in the loom. These override the
// WIRE_STANDARD function colours.
//
// VW MEB battery LV connector, recovered from the "VW bms" diagram
// (diagrams/0005_VW_bms.json). Colours are confirmed OEM; the signal each pin
// carries is NOT yet confirmed — the source diagram had no pin labels.
const OEM = {
meb: {
brown: { name: "Brown (VW ground)", hex: "#8B4513", stripe: null, gauge: "16 AWG" },
greenBlack: { name: "Green / Black stripe", hex: "#007700", stripe: "#000000", gauge: "18 AWG" },
greenRed: { name: "Green / Red stripe", hex: "#007700", stripe: "#FF0000", gauge: "18 AWG" },
greenWhite: { name: "Green / White stripe", hex: "#007700", stripe: "#CCCCCC", gauge: "18 AWG" },
orangeBlue: { name: "Orange / Blue stripe", hex: "#FF8C00", stripe: "#0000FF", gauge: "20 AWG" },
orangeRed: { name: "Orange / Red stripe", hex: "#FF8C00", stripe: "#FF0000", gauge: "20 AWG" },
},
};
// Shorthand for an OEM-coloured pin: po("9", "CAN H?", OEM.meb.orangeBlue, "…")
function po(pin, name, oemColor, note) {
return {
pin, name, fn: null, note: note || "",
oem: {
color_primary: oemColor.hex,
color_stripe: oemColor.stripe,
gauge: oemColor.gauge,
label: oemColor.name,
},
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Standard parts
// ─────────────────────────────────────────────────────────────────────────────
const SKUDAK_PARTS = {
"Skudak VCU": [
{
id: "skudak-vcu-gray",
name: "Skudak VCU — GRAY",
manufacturer: "Skudak",
partNumber: "VCU-001 (GRAY)",
description: "VCUv2 rev A left receptacle — power, GPIOHP high-current channels, relay coils, LIN, wake, fault, aux input. Mates Molex 0334722007.",
pinCount: 20,
pinSpecs: [
p("1", "12V IN", "KL30", "Fused supply"),
p("2", "12V IN", "KL30", "Fused supply"),
p("3", "GPIOHP1", "LOAD_12V", "High-current switched output, 8 A"),
p("4", "GPIOHP3", "LOAD_12V", "High-current switched output, 8 A"),
p("5", "GPIOHP5", "LOAD_12V", "High-current switched output, 8 A"),
p("6", "GPIOHP7", "LOAD_12V", "High-current switched output, 8 A"),
p("7", "RLY1_A", "COIL", "Relay 1 coil, terminal A"),
p("8", "RLY2_A", "COIL", "Relay 2 coil, terminal A"),
p("9", "LIN", "LIN", "LIN bus"),
p("10", "WAKE", "KL15", "Ignition / wake input"),
p("11", "GND", "GND", "Ground"),
p("12", "GND", "GND", "Ground"),
p("13", "GPIOHP2", "LOAD_12V", "High-current switched output, 8 A"),
p("14", "GPIOHP4", "LOAD_12V", "High-current switched output, 8 A"),
p("15", "GPIOHP6", "LOAD_12V", "High-current switched output, 8 A"),
p("16", "GPIOHP8", "LOAD_12V", "High-current switched output, 8 A"),
p("17", "RLY1_B", "COIL", "Relay 1 coil, terminal B"),
p("18", "RLY2_B", "COIL", "Relay 2 coil, terminal B"),
p("19", "FAULT_OUT", "FAULT", "Latched check-engine output"),
p("20", "AUX_IN", "ANALOG_1", "012 V analog / frequency input"),
],
},
{
id: "skudak-vcu-black",
name: "Skudak VCU — BLACK",
manufacturer: "Skudak",
partNumber: "VCU-001 (BLACK)",
description: "VCUv2 rev A right receptacle — 12 MPIO signal channels, HVIL, 3× CAN. Mates Molex 0334722006.",
pinCount: 20,
pinSpecs: [
p("1", "MPIO1", "PWM_OUT", "Source / sink / Hi-Z, PWM, analog or freq in"),
p("2", "MPIO2", "FREQ_IN", "Source / sink / Hi-Z"),
p("3", "MPIO3", "PWM_OUT", "Source / sink / Hi-Z"),
p("4", "MPIO4", "FREQ_IN", "Source / sink / Hi-Z"),
p("5", "MPIO5", "DIG_IN", "Source / sink / Hi-Z"),
p("6", "MPIO6", "DIG_IN", "Source / sink / Hi-Z"),
p("7", "HVIL OUT", "HVIL_OUT", "Interlock drive, VCU is master"),
p("8", "CAN1 H", "CAN1_H", "Powertrain bus"),
p("9", "CAN2 H", "CAN2_H", "HV pack bus"),
p("10", "CAN3 H", "CAN3_H", "Charge & body bus"),
p("11", "MPIO7", "DIG_OUT", "Source / sink / Hi-Z"),
p("12", "MPIO8", "DIG_OUT", "Source / sink / Hi-Z"),
p("13", "MPIO9", "DIG_IN", "Source / sink / Hi-Z"),
p("14", "MPIO10", "DIG_IN", "Source / sink / Hi-Z"),
p("15", "MPIO11", "ANALOG_2", "Source / sink / Hi-Z"),
p("16", "MPIO12", "ANALOG_3", "Source / sink / Hi-Z"),
p("17", "HVIL RTN", "HVIL_RTN", "Interlock return"),
p("18", "CAN1 L", "CAN1_L", "Powertrain bus"),
p("19", "CAN2 L", "CAN2_L", "HV pack bus"),
p("20", "CAN3 L", "CAN3_L", "Charge & body bus"),
],
},
],
"Skudak Standard Parts": [
{
id: "dilong-obc",
name: "Dilong OBC / DCDC",
manufacturer: "Dilong",
partNumber: "DA8KM22A",
description: "On-board charger + DC/DC. Controlled entirely over CAN — see Dilong_DA8KM22A_OBC_DCDC_Rev0.dbc. LV connector only; HV and AC are separate.",
pinCount: 6,
verify: true,
pinSpecs: [
p("1", "12V+", "KL30", "Permanent supply"),
p("2", "GND", "GND", "Ground"),
p("3", "CAN H", "CAN3_H", "Charge & body bus"),
p("4", "CAN L", "CAN3_L", "Charge & body bus"),
p("5", "ENABLE", "DIG_OUT", "Charge enable"),
p("6", "INTERLOCK", "HVIL_OUT", "Loop through HV connector"),
],
},
{
id: "openinverter-ldu-23p",
name: "Openinverter LDU — 23-pin Tesla",
manufacturer: "openinverter / Damien Maguire",
partNumber: "Tesla LDU logic board",
description:
"Openinverter logic board in a Tesla Large Drive Unit, 23-way Tesla connector. " +
"The board drives precharge (pin 3) and the main contactor (pin 6) from its own outputs, and takes the pedal, " +
"shifter, brake, cruise and start as discrete inputs. All of those can move onto CAN instead " +
"(potmode=2/3/6, cruisemode=2, CANIOS bitfield) if you would rather the VCU own them. Pins 19 and 20 are unused.",
pinCount: 23,
pinSpecs: [
p("1", "IGN +12V", "KL15", "Ignition feed to the logic board"),
p("2", "BRAKE ON", "DIG_IN", "brake_in — required for shift lockout"),
p("3", "PRECHARGE RELAY", "COIL", "prec_out — board drives the precharge coil"),
p("4", "CAN HIGH", "CAN1_H", "Powertrain bus"),
p("5", "CAN LOW", "CAN1_L", "Powertrain bus"),
p("6", "MAIN CONTACTOR", "COIL", "dcsw_out — board drives the main contactor coil"),
p("7", "FORWARD", "DIG_IN", "fwd_in"),
p("8", "REVERSE", "DIG_IN", "rev_in"),
p("9", "ENC +5V", "V5_REF", "Encoder supply"),
p("10", "ENC A", "FREQ_IN", "Quadrature channel A"),
p("11", "GND", "GND", "Power ground"),
p("12", "ACCEL 5V", "V5_REF", "Pedal supply"),
p("13", "ACCEL INPUT", "ANALOG_1", "Pedal wiper"),
p("14", "BRAKE TRANSDUCER", "ANALOG_2", "Analog brake pressure"),
p("15", "ACCEL GND", "GND_SIG", "Pedal return"),
p("16", "ENC B", "ENC_B", "Quadrature channel B"),
p("17", "ENC GND", "GND_SIG", "Encoder return"),
p("18", "ENC SHIELD", "SHIELD", "Encoder cable drain"),
p("19", "—", null, "Unused"),
p("20", "—", null, "Unused"),
p("21", "CRUISE IN", "DIG_IN", "cruise_in"),
p("22", "GND", "GND", "Power ground"),
p("23", "START", "DIG_IN", "start_in"),
],
},
{
id: "bmw-pedal",
name: "BMW Accelerator Pedal",
manufacturer: "BMW / Bosch",
partNumber: "BMW E-series",
description: "Dual-channel hall pedal. Channel 2 reads roughly half of channel 1 for plausibility. Verify pin order against your specific pedal before crimping.",
pinCount: 6,
verify: true,
pinSpecs: [
p("1", "SEN2 GND", "GND_SIG", "Channel 2 ground"),
p("2", "SEN2 +5V", "V5_REF", "Channel 2 supply"),
p("3", "SEN2 SIG", "ANALOG_2", "Channel 2 signal (half-scale)"),
p("4", "SEN1 SIG", "ANALOG_1", "Channel 1 signal (full-scale)"),
p("5", "SEN1 +5V", "V5_REF", "Channel 1 supply"),
p("6", "SEN1 GND", "GND_SIG", "Channel 1 ground"),
],
},
{
id: "honeywell-cssv1500",
name: "Honeywell Current Sensor",
manufacturer: "Honeywell",
partNumber: "CSSV1500",
description: "CAN pack-current sensor. 24-bit high-precision + 16-bit low-precision current. Lives inside the battery enclosure.",
pinCount: 4,
pinSpecs: [
p("1", "12V+", "KL15", "Enclosure 12 V bus"),
p("2", "GND", "GND", "Enclosure ground"),
p("3", "CAN H", "CAN2_H", "HV pack bus"),
p("4", "CAN L", "CAN2_L", "HV pack bus"),
],
},
{
id: "bender-iso175",
name: "Bender ISO175 IMD",
manufacturer: "Bender",
partNumber: "ISO175",
description: "Insulation monitoring device. CAN reporting; isolation below 100 kΩ aborts charging. Lives inside the battery enclosure.",
pinCount: 6,
pinSpecs: [
p("1", "12V+", "KL15", "Enclosure 12 V bus"),
p("2", "GND", "GND", "Enclosure ground"),
p("3", "CAN H", "CAN2_H", "HV pack bus"),
p("4", "CAN L", "CAN2_L", "HV pack bus"),
p("5", "HV+", "HV_SENSE", "Pack positive sense"),
p("6", "HV", "HV_SENSE", "Pack negative sense"),
],
},
{
id: "bosch-ibooster",
name: "Bosch iBooster",
manufacturer: "Bosch",
partNumber: "iBooster Gen2",
description: "Electromechanical brake booster. CAN-commanded with its own power feed. PINOUT UNVERIFIED — confirm against your generation and connector before building.",
pinCount: 8,
verify: true,
pinSpecs: [
p("1", "B+", "KL30", "Battery positive, heavy feed"),
p("2", "GND", "GND", "Power ground"),
p("3", "KL15", "KL15", "Ignition"),
p("4", "CAN H", "CAN1_H", "Chassis / powertrain bus"),
p("5", "CAN L", "CAN1_L", "Chassis / powertrain bus"),
p("6", "BLS", "DIG_IN", "Brake light switch out"),
p("7", "WAKE", "DIG_OUT", "Wake line"),
p("8", "GND SIG", "GND_SIG", "Signal ground"),
],
},
{
id: "prius-eps",
name: "Toyota Prius EPS Column",
manufacturer: "Toyota",
partNumber: "Prius EPS",
description: "Electric power steering column, torque sensor + motor + ECU. PINOUT UNVERIFIED — confirm generation (Gen2 / Gen3) and connector before building.",
pinCount: 6,
verify: true,
pinSpecs: [
p("1", "B+", "KL30", "Heavy motor feed"),
p("2", "GND", "GND", "Power ground"),
p("3", "IG", "KL15", "Ignition / enable"),
p("4", "TRQ", "ANALOG_1", "Torque sensor signal"),
p("5", "SPD", "FREQ_IN", "Vehicle speed input"),
p("6", "DIAG", "DIG_IN", "Diagnostic line"),
],
},
{
id: "volvo-ps-pump",
name: "Volvo Steering Pump",
manufacturer: "Volvo",
partNumber: "Electro-hydraulic PS",
description: "Electro-hydraulic power steering pump. Speed commanded by PWM or LIN depending on variant. PINOUT UNVERIFIED — confirm variant before building.",
pinCount: 4,
verify: true,
pinSpecs: [
p("1", "B+", "KL30", "Heavy motor feed"),
p("2", "GND", "GND", "Power ground"),
p("3", "CTRL", "PWM_OUT", "Speed command"),
p("4", "DIAG", "FREQ_IN", "Status / diagnostic feedback"),
],
},
{
id: "instrument-cluster",
name: "Instrument Cluster",
manufacturer: "varies",
partNumber: "varies",
description: "Gauge cluster — switched 12 V and CAN only. VCU broadcasts SOC (0x3D0) and gauge control (0x3D2).",
pinCount: 4,
pinSpecs: [
p("1", "12V", "KL15", "Switched supply"),
p("2", "GND", "GND", "Ground"),
p("3", "CAN H", "CAN3_H", "Charge & body bus"),
p("4", "CAN L", "CAN3_L", "Charge & body bus"),
],
},
{
id: "dnr-switch",
name: "DNR Switch",
manufacturer: "varies",
partNumber: "varies",
description: "Drive / Neutral / Reverse selector. Discrete switched-to-ground lines, one per position; firmware rejects any state where more than one is active.",
pinCount: 4,
pinSpecs: [
p("1", "COM", "GND", "Switch common to ground"),
p("2", "DRIVE", "DIG_IN", "Drive position"),
p("3", "NEUTRAL", "DIG_IN", "Neutral position"),
p("4", "REVERSE", "DIG_IN", "Reverse position"),
],
},
],
"Battery Enclosure": [
{
id: "batt-enclosure-32p",
name: "Battery Enclosure Bulkhead — 32-pin",
manufacturer: "TE Connectivity (Deutsch)",
partNumber: "TBD — confirm 32-way P/N",
description:
"Single bulkhead between the battery enclosure and the vehicle harness. Everything inside shares one 12 V bus and one ground, fed on pins 14. " +
"All contactor coils are LOW-SIDE switched by the VCU: coil+ ties to the internal 12 V bus and stays in the box, so only the coil return crosses the bulkhead — " +
"one wire per contactor on pins 913. PROPOSED ALLOCATION — confirm the Deutsch part number and re-order to suit the keying.",
pinCount: 32,
verify: true,
// One device covering both faces of the bulkhead: 32 IN pins on the left,
// 32 OUT on the right, one label per circuit down the middle.
passThrough: true,
pinSpecs: [
p("1", "12V BUS", "KL15", "Enclosure 12 V bus feed"),
p("2", "12V BUS", "KL15", "Enclosure 12 V bus feed (paralleled)"),
p("3", "GND", "GND", "Enclosure ground"),
p("4", "GND", "GND", "Enclosure ground (paralleled)"),
p("5", "CAN2 H", "CAN2_H", "BMS + IMD + current sensor"),
p("6", "CAN2 L", "CAN2_L", "BMS + IMD + current sensor"),
p("7", "HVIL IN", "HVIL_OUT", "Interlock into enclosure"),
p("8", "HVIL OUT", "HVIL_RTN", "Interlock out of enclosure"),
// Coils are low-side switched by the VCU. Coil+ sits on the internal
// 12 V bus and never crosses the bulkhead, so each contactor takes one
// wire out, not a pair — the VCU sinks it to close the contactor.
p("9", "MAIN COIL ", "COIL", "Main (positive) contactor — VCU sinks to close"),
p("10", "NEG COIL ", "COIL", "Negative contactor — VCU sinks to close"),
p("11", "PRE COIL ", "COIL", "Precharge relay — VCU sinks to close"),
p("12", "AC COIL ", "COIL", "A/C contactor — VCU sinks to close"),
p("13", "HEAT COIL ", "COIL", "Heat contactor — VCU sinks to close"),
p("14", "MAIN WELD", "WELD", "Main contactor aux contact"),
p("15", "MAIN WELD RTN","WELD", "Main aux return — drop if the aux references the internal ground bus"),
p("16", "NEG WELD", "WELD", "Negative contactor aux contact"),
p("17", "NEG WELD RTN", "WELD", "Negative aux return — drop if the aux references the internal ground bus"),
p("18", "BMS WAKE", "INHIBIT", "BMS wake / enable"),
p("19", "PRE FEEDBACK", "ANALOG_1", "Precharge bus voltage feedback"),
p("20", "PACK TEMP", "ANALOG_2", "Spare pack thermistor"),
p("21", "SHIELD", "SHIELD", "CAN shield drain"),
p("22", "—", null, "Spare"),
p("23", "—", null, "Spare"),
p("24", "—", null, "Spare"),
p("25", "—", null, "Spare"),
p("26", "—", null, "Spare"),
p("27", "—", null, "Spare"),
p("28", "—", null, "Spare"),
p("29", "—", null, "Spare"),
p("30", "—", null, "Spare"),
p("31", "—", null, "Spare"),
p("32", "—", null, "Spare"),
],
},
{
id: "contactor-coil-aux",
name: "HV Contactor (coil + aux)",
manufacturer: "varies",
partNumber: "varies",
description: "HV contactor with auxiliary weld-detect contacts. Use for main, negative, A/C and heat. Coil must have a built-in economizer. Inside the enclosure COIL A ties to the shared 12 V bus; only COIL B leaves via the bulkhead, where the VCU sinks it.",
pinCount: 4,
pinSpecs: [
p("1", "COIL A", "COIL", "Coil terminal A"),
p("2", "COIL B", "COIL", "Coil terminal B"),
p("3", "AUX A", "WELD", "Auxiliary contact — weld detect"),
p("4", "AUX B", "WELD", "Auxiliary contact — weld detect"),
],
},
{
id: "precharge-relay",
name: "Precharge Relay",
manufacturer: "varies",
partNumber: "varies",
description: "Precharge relay and series resistor. Closes with the negative contactor, opens once the bus reaches threshold. COIL A ties to the enclosure 12 V bus; COIL B is the low-side return to the VCU.",
pinCount: 2,
pinSpecs: [
p("1", "COIL A", "COIL", "Coil terminal A"),
p("2", "COIL B", "COIL", "Coil terminal B"),
],
},
{
id: "vw-meb-bms",
name: "VW MEB BMS — LV connector",
manufacturer: "Volkswagen",
partNumber: "MEB",
description:
"VW MEB battery management master, 12-way LV connector. Pins 712 carry OEM harness colours recovered from the VW bms diagram and must match the factory loom. " +
"SIGNAL ASSIGNMENT UNCONFIRMED — the source diagram recorded colours but no pin labels. Pins 16 were unused there.",
pinCount: 12,
verify: true,
pinSpecs: [
p("1", "—", null, "Unused in the recovered harness"),
p("2", "—", null, "Unused in the recovered harness"),
p("3", "—", null, "Unused in the recovered harness"),
p("4", "—", null, "Unused in the recovered harness"),
p("5", "—", null, "Unused in the recovered harness"),
p("6", "—", null, "Unused in the recovered harness"),
po("7", "OEM 7", OEM.meb.greenWhite, "OEM Green/White — signal unconfirmed"),
po("8", "CAN L?", OEM.meb.orangeRed, "OEM Orange/Red — VW HV-CAN pair with pin 9, polarity unconfirmed"),
po("9", "CAN H?", OEM.meb.orangeBlue, "OEM Orange/Blue — VW HV-CAN pair with pin 8, polarity unconfirmed"),
po("10", "OEM 10", OEM.meb.greenRed, "OEM Green/Red — signal unconfirmed"),
po("11", "GND", OEM.meb.greenBlack, "OEM Green/Black — ran to chassis ground in the recovered harness"),
po("12", "OEM 12", OEM.meb.brown, "OEM Brown — VW convention is ground; ran to the pigtail, not to chassis"),
],
},
],
};
// ── Merge into the connector library ─────────────────────────────────────────
// Parts become ordinary library entries, so search, category filter and
// drag-to-canvas all work with no changes to app.js.
for (const [category, parts] of Object.entries(SKUDAK_PARTS)) {
CONNECTOR_LIBRARY[category] = parts;
for (const part of parts) {
part.pinLabels = part.pinSpecs.map(s => s.name);
_CONN_BY_ID[part.id] = part;
}
}
// ── Wire default lookup ──────────────────────────────────────────────────────
// Given a device and pin id, return the standard wire for that pin, or null.
function standardWireForPin(device, pinId) {
if (!device) return null;
const pin = (device.pins || []).find(p => p.id === pinId);
if (!pin) return null;
// An OEM colour is not ours to choose — it has to match the factory loom,
// so it wins over the in-house standard.
// The signal name is what makes a wire unambiguous on the bench. A connector
// can legitimately carry five digital inputs in the same colour; the pin name
// is what tells FORWARD from REVERSE.
const signal = pin.name && pin.name !== "—" ? pin.name : null;
if (pin.wire_oem) return { fn: "OEM", twisted_pair: false, signal, ...pin.wire_oem };
if (pin.wire_fn) {
const spec = wireSpecFor(pin.wire_fn);
return spec ? { ...spec, signal } : null;
}
return null;
}
// Expose the palette for the wire-colour swatch picker.
const TXL_WIRE_COLORS = [
...Object.values(TXL_SOLID).map(c => ({ name: c.name, hex: c.hex, stripe: null })),
...Object.values(TXL_STRIPED).map(c => ({ name: c.name, hex: c.hex, stripe: c.stripe })),
];
+410
View File
@@ -0,0 +1,410 @@
// ─────────────────────────────────────────────────────────────────────────────
// GEP power distribution modules — fuse box grid model
//
// Loaded AFTER partsLibrary.js. Registers a `pdm` device type whose pins are
// derived from what is physically placed in the box, so arranging the grid in
// the fuse-box tool immediately changes what you can wire to on the canvas.
//
// GEP FRH / PDM modules are open-plan 280-footprint grids: the "way" count is
// the number of Metri-Pack 280 cavities and you place your own mix of
// components anywhere in them. GEP quote the FRH-A24 as taking up to 4 five-
// prong relays, or 6 four-prong relays, or 12 mini fuses — all three come to
// exactly 24 cavities on a 4x6 grid with the footprints below, which is where
// the geometry comes from.
// ─────────────────────────────────────────────────────────────────────────────
const PDM_MODULES = {
"gep-frh-a24": {
id: "gep-frh-a24",
name: "GEP FRH-A24",
manufacturer: "GEP Power Products",
partNumber: "FRH-A24",
cols: 4, rows: 6,
description: "Sealed 24-cavity 280-footprint fuse / relay holder, IP66/IP67. ~60 x 50 x 60 mm.",
},
"gep-frh-a12": {
id: "gep-frh-a12",
name: "GEP FRH-A12",
manufacturer: "GEP Power Products",
partNumber: "FRH-A12",
cols: 4, rows: 3,
description: "Sealed 12-cavity 280-footprint fuse / relay holder.",
},
"gep-pdm-48": {
id: "gep-pdm-48",
name: "GEP PDM 48-way",
manufacturer: "GEP Power Products",
partNumber: "PDM-R4A01",
cols: 4, rows: 12,
description: "Sealed stackable 48-cavity 280-footprint power distribution module.",
},
};
// ── Component footprints ─────────────────────────────────────────────────────
// w/h are in cavities. Terminals sit at (c,r) inside the unrotated footprint;
// a 5-prong relay is a 2x3 block with one cavity unused, which is why four of
// them exactly fill a 24-way.
const PDM_COMPONENTS = {
fuse: {
key: "fuse",
label: "Mini fuse",
short: "FUSE",
w: 1, h: 2,
colour: "#4a7dd6",
ratings: ["2A", "3A", "5A", "7.5A", "10A", "15A", "20A", "25A", "30A"],
defaultRating: "10A",
terminals: [
{ t: "IN", c: 0, r: 0, fn: "KL30" },
{ t: "OUT", c: 0, r: 1, fn: "LOAD_12V" },
],
},
relay_spdt: {
key: "relay_spdt",
label: "Micro relay, 5-prong SPDT",
short: "RLY5",
w: 2, h: 3,
colour: "#8a5fd6",
ratings: ["20A/10A", "30A/20A", "40A/30A"],
defaultRating: "30A/20A",
terminals: [
{ t: "86", c: 0, r: 0, fn: "COIL" },
{ t: "85", c: 1, r: 0, fn: "COIL" },
{ t: "30", c: 0, r: 1, fn: "KL30" },
{ t: "87a", c: 1, r: 1, fn: "LOAD_12V" },
{ t: "87", c: 0, r: 2, fn: "LOAD_12V" },
],
},
relay_spst: {
key: "relay_spst",
label: "Micro relay, 4-prong SPST",
short: "RLY4",
w: 2, h: 2,
colour: "#6f4fc0",
ratings: ["20A", "30A", "40A"],
defaultRating: "30A",
terminals: [
{ t: "86", c: 0, r: 0, fn: "COIL" },
{ t: "85", c: 1, r: 0, fn: "COIL" },
{ t: "30", c: 0, r: 1, fn: "KL30" },
{ t: "87", c: 1, r: 1, fn: "LOAD_12V" },
],
},
diode: {
key: "diode",
label: "Mini diode",
short: "DIODE",
w: 1, h: 2,
colour: "#3f8f6a",
ratings: ["3A", "6A"],
defaultRating: "6A",
terminals: [
{ t: "A", c: 0, r: 0, fn: "LOAD_12V" },
{ t: "K", c: 0, r: 1, fn: "LOAD_12V" },
],
},
// Bus bars link a run of cavities into one node, so a single feed wire
// supplies every component blade sitting on the bar. They therefore SHARE
// cavities with those blades by design — `isBus` puts them on their own
// layer, colliding only with each other, and any input terminal landing on a
// bar stops needing its own wire.
bus2: {
key: "bus2", label: "Bus bar, 2-way", short: "BUS2", isBus: true,
w: 1, h: 2, colour: "#b0563c",
ratings: ["—"], defaultRating: "—",
terminals: [{ t: "FEED", c: 0, r: 0, fn: "KL30" }],
},
bus3: {
key: "bus3", label: "Bus bar, 3-way", short: "BUS3", isBus: true,
w: 1, h: 3, colour: "#b0563c",
ratings: ["—"], defaultRating: "—",
terminals: [{ t: "FEED", c: 0, r: 0, fn: "KL30" }],
},
bus4: {
key: "bus4", label: "Bus bar, 4-way", short: "BUS4", isBus: true,
w: 1, h: 4, colour: "#b0563c",
ratings: ["—"], defaultRating: "—",
terminals: [{ t: "FEED", c: 0, r: 0, fn: "KL30" }],
},
bus6: {
key: "bus6", label: "Bus bar, 6-way", short: "BUS6", isBus: true,
w: 1, h: 6, colour: "#b0563c",
ratings: ["—"], defaultRating: "—",
terminals: [{ t: "FEED", c: 0, r: 0, fn: "KL30" }],
},
breaker: {
key: "breaker",
label: "Mini circuit breaker",
short: "CB",
w: 1, h: 2,
colour: "#b5813a",
ratings: ["5A", "10A", "15A", "20A", "25A", "30A"],
defaultRating: "20A",
terminals: [
{ t: "IN", c: 0, r: 0, fn: "KL30" },
{ t: "OUT", c: 0, r: 1, fn: "LOAD_12V" },
],
},
};
// ── Rotation ─────────────────────────────────────────────────────────────────
// Clockwise. Returns the footprint size at that rotation.
function pdmSize(comp, rot) {
return (rot === 90 || rot === 270)
? { w: comp.h, h: comp.w }
: { w: comp.w, h: comp.h };
}
// Maps a terminal's cell inside the unrotated footprint to its cell inside the
// rotated one.
function pdmRotateCell(c, r, w, h, rot) {
switch (rot) {
case 90: return { c: h - 1 - r, r: c };
case 180: return { c: w - 1 - c, r: h - 1 - r };
case 270: return { c: r, r: w - 1 - c };
default: return { c, r };
}
}
let _pdmSeq = 0;
function pdmNewCircuit(typeKey) {
const comp = PDM_COMPONENTS[typeKey] || PDM_COMPONENTS.fuse;
return {
id: `c${Date.now().toString(36)}${(_pdmSeq++).toString(36)}`,
type: comp.key,
rating: comp.defaultRating,
name: "",
col: 0, row: 0, rot: 0,
};
}
// Every cavity a placement covers, in absolute grid coordinates.
function pdmCells(circuit) {
const comp = PDM_COMPONENTS[circuit.type];
if (!comp) return [];
const { w, h } = pdmSize(comp, circuit.rot || 0);
const cells = [];
for (let r = 0; r < h; r++) {
for (let c = 0; c < w; c++) cells.push({ c: circuit.col + c, r: circuit.row + r });
}
return cells;
}
// Absolute cavity of each terminal, plus its 1-based cavity number.
function pdmTerminals(circuit, mod) {
const comp = PDM_COMPONENTS[circuit.type];
if (!comp) return [];
const rot = circuit.rot || 0;
return comp.terminals.map((t) => {
const m = pdmRotateCell(t.c, t.r, comp.w, comp.h, rot);
const c = circuit.col + m.c;
const r = circuit.row + m.r;
return { ...t, c, r, cavity: r * mod.cols + c + 1 };
});
}
// Validation: overlaps and out-of-bounds. Both are physical impossibilities,
// so the tool refuses to save them rather than warning and letting them through.
function pdmIsBus(circuit) {
return !!PDM_COMPONENTS[circuit?.type]?.isBus;
}
// Which bus bar, if any, covers this cavity.
function pdmBusAt(props, c, r) {
for (const ci of props.circuits || []) {
if (!pdmIsBus(ci)) continue;
if (pdmCells(ci).some((x) => x.c === c && x.r === r)) return ci;
}
return null;
}
function pdmValidate(props) {
const mod = PDM_MODULES[props.moduleId] || PDM_MODULES["gep-frh-a24"];
const circuits = props.circuits || [];
// Two layers: components collide with components, bus bars with bus bars.
// A bar crossing a fuse blade is the whole point of a bar, not a clash.
const layers = { part: new Map(), bus: new Map() };
const errors = [];
const badIds = new Set();
const nameOf = (id) => {
const x = circuits.find((y) => y.id === id);
return x ? (x.name || PDM_COMPONENTS[x.type]?.short || "component") : "component";
};
circuits.forEach((ci) => {
const comp = PDM_COMPONENTS[ci.type];
if (!comp) return;
const { w, h } = pdmSize(comp, ci.rot || 0);
if (ci.col < 0 || ci.row < 0 || ci.col + w > mod.cols || ci.row + h > mod.rows) {
errors.push(`${ci.name || comp.short} hangs outside the box`);
badIds.add(ci.id);
return;
}
const occupied = layers[comp.isBus ? "bus" : "part"];
pdmCells(ci).forEach(({ c, r }) => {
const key = `${c},${r}`;
if (occupied.has(key)) {
errors.push(
`${ci.name || comp.short} overlaps ${nameOf(occupied.get(key))} at cavity ${r * mod.cols + c + 1}`);
badIds.add(ci.id);
badIds.add(occupied.get(key));
} else {
occupied.set(key, ci.id);
}
});
});
return {
module: mod,
used: layers.part.size,
total: mod.cols * mod.rows,
busUsed: layers.bus.size,
errors: [...new Set(errors)],
badIds,
ok: errors.length === 0,
};
}
// Can this component sit here without overlapping anything or leaving the box?
function pdmFits(props, circuit, col, row, rot) {
const mod = PDM_MODULES[props.moduleId] || PDM_MODULES["gep-frh-a24"];
const comp = PDM_COMPONENTS[circuit.type];
if (!comp) return false;
const { w, h } = pdmSize(comp, rot);
if (col < 0 || row < 0 || col + w > mod.cols || row + h > mod.rows) return false;
// Only same-layer components block each other — a bus bar is meant to lie
// across the blades it feeds.
const bus = !!comp.isBus;
const taken = new Set();
(props.circuits || []).forEach((o) => {
if (o.id === circuit.id || pdmIsBus(o) !== bus) return;
pdmCells(o).forEach(({ c, r }) => taken.add(`${c},${r}`));
});
const probe = { ...circuit, col, row, rot };
return pdmCells(probe).every(({ c, r }) => !taken.has(`${c},${r}`));
}
// Clamp a placement so the whole footprint sits inside the grid. Overlap is
// deliberately allowed: an overlapping component is a state you must be able to
// drag your way out of, so the editor permits it and validation flags it in red
// rather than refusing the move and trapping you.
function pdmClamp(props, circuit, col, row, rot) {
const mod = PDM_MODULES[props.moduleId] || PDM_MODULES["gep-frh-a24"];
const comp = PDM_COMPONENTS[circuit.type];
if (!comp) return { col: 0, row: 0 };
const { w, h } = pdmSize(comp, rot ?? circuit.rot ?? 0);
return {
col: Math.max(0, Math.min(col, mod.cols - w)),
row: Math.max(0, Math.min(row, mod.rows - h)),
};
}
// Pull every component back inside the grid — used after a module change, so
// switching to a smaller or differently-shaped box never strands a tile off
// the edge where it cannot be clicked.
function pdmClampAll(props) {
(props.circuits || []).forEach((ci) => {
const p = pdmClamp(props, ci, ci.col, ci.row, ci.rot || 0);
ci.col = p.col;
ci.row = p.row;
});
}
// First free slot, scanning row-major. Returns null if the box is full.
function pdmAutoPlace(props, circuit) {
const mod = PDM_MODULES[props.moduleId] || PDM_MODULES["gep-frh-a24"];
for (let r = 0; r < mod.rows; r++) {
for (let c = 0; c < mod.cols; c++) {
for (const rot of [0, 90]) {
if (pdmFits(props, circuit, c, r, rot)) return { col: c, row: r, rot };
}
}
}
return null;
}
// ── Canvas pins ──────────────────────────────────────────────────────────────
// One pin per terminal, named by cavity, carrying the wire standard. Coils go
// left, switched outputs right, so power reads across the device.
// Friendly suffixes so a named circuit reads as plain English on the pin.
// Relay terminals keep their standard numbers — "Main contactor 87" is what is
// printed on the relay, so renaming it would help nobody.
const PDM_TERM_LABEL = { IN: "in", OUT: "out", FEED: "feed" };
// The pin name a terminal ends up with. Naming a fuse "VCU" turns its pins into
// "VCU in" and "VCU out"; unnamed components fall back to the cavity number so
// they are still identifiable on the board.
function pdmPinName(circuit, comp, t) {
const suffix = PDM_TERM_LABEL[t.t] || t.t;
const name = (circuit.name || "").trim();
return name ? `${name} ${suffix}` : `${t.cavity}·${t.t}`;
}
function pdmPins(props, w, h) {
const mod = PDM_MODULES[props.moduleId] || PDM_MODULES["gep-frh-a24"];
const left = [{ id: "FEED", name: "FEED", fn: "KL30", note: "Module bus input" }];
const right = [];
(props.circuits || []).forEach((ci) => {
const comp = PDM_COMPONENTS[ci.type];
if (!comp) return;
const tag = ci.name || `${comp.short}${comp.isBus ? "" : ` ${ci.rating}`}`;
pdmTerminals(ci, mod).forEach((t) => {
// A component input sitting on a bus bar is fed by the bar, so it needs
// no wire of its own — the bar's single FEED pin covers the whole run.
if (!comp.isBus && t.fn === "KL30") {
const bar = pdmBusAt(props, t.c, t.r);
if (bar) return;
}
const pin = {
id: `${ci.id}_${t.t}`,
name: pdmPinName(ci, comp, t),
fn: t.fn,
// The cavity stays in the note, so renaming a circuit never loses where
// it physically sits in the box.
note: comp.isBus
? `${tag} — cavity ${t.cavity}, feeds ${pdmCells(ci).map((x) => x.r * mod.cols + x.c + 1).join(", ")}`
: `${tag} (${ci.rating}) — cavity ${t.cavity}`,
};
(t.fn === "COIL" ? left : right).push(pin);
});
});
const place = (arr, side) =>
arr.map((p, i) => ({
id: p.id,
name: p.name,
side,
x_offset: side === "left" ? 0 : w,
y_offset: ((i + 1) / (arr.length + 1)) * h,
wire_fn: p.fn,
wire_oem: null,
note: p.note,
}));
return [...place(left, "left"), ...place(right, "right")];
}
function pdmDefaultProps() {
const m = PDM_MODULES["gep-frh-a24"];
return {
moduleId: m.id,
circuits: [],
partNumber: m.partNumber,
manufacturer: m.manufacturer,
};
}
if (typeof DEVICE_TYPES !== "undefined") {
DEVICE_TYPES.pdm = {
label: "Fuse / Relay Box",
description: "GEP power distribution module — arrange the cavity grid in the fuse box tool",
icon: "▦",
defaultProps: pdmDefaultProps(),
defaultSize: (p) => {
const n = Math.max((p.circuits || []).length, 3);
return { w: 190, h: Math.max(90, n * 24 + 40) };
},
getPins: (p, w, h) => pdmPins(p, w, h),
};
}
+188
View File
@@ -0,0 +1,188 @@
// ─────────────────────────────────────────────────────────────────────────────
// Theme — light / dark plus user overrides for the colours that make wires hard
// to read: canvas background, grid dots, device fill and device outline.
//
// Loaded FIRST, before canvas.js, so the canvas can ask for its colours as it
// draws. App chrome is themed by CSS custom properties on <html data-theme>;
// Konva cannot read CSS variables, so device colours come from here instead.
// ─────────────────────────────────────────────────────────────────────────────
const THEME_KEY = "wiredraw.theme";
const THEME_PRESETS = {
dark: {
canvasBg: "#131320",
gridDot: "#2e2e50",
deviceStroke: "#5a5a8a",
deviceText: "#dde0f5",
deviceSubtext: "#8890b8",
deviceRef: "#99aaee", // reference designator on the device body
pinFill: "#0a0f1a",
pinStroke: "#5566aa",
notch: "#333355", // connector key notch
rowBg: "#0e0e1a", // cable conductor rows
wireLabel: "#aabbcc", // wire tag, drawn on the canvas background
harnessBg: "#12122a",
harnessEdge: "#4466cc",
harnessText: "#99bbff",
loomShadow: "#0a0a18",
// Device fills are tinted per type so you can tell a relay from a fuse at a
// glance. Light mode derives pastel equivalents from these same hues rather
// than keeping a second hand-tuned table in sync.
lightness: null,
},
light: {
canvasBg: "#f7f8fa",
gridDot: "#c3c9d8",
deviceStroke: "#8089a8",
deviceText: "#1b2030",
deviceSubtext: "#5d657e",
deviceRef: "#2c4b96",
pinFill: "#ffffff",
pinStroke: "#5a6ba8",
notch: "#b9bed4",
rowBg: "#eceef5",
wireLabel: "#41506b",
harnessBg: "#dfe4f3",
harnessEdge: "#4466cc",
harnessText: "#26418f",
loomShadow: "#b9c0d6",
lightness: 0.88,
},
};
const DEVICE_HUES = {
connector: "#12253a",
terminal_block: "#122a1a",
component: "#1e1230",
splice: "#2a1e10",
label: "#22220e",
fuse: "#2a1c08",
relay: "#0a1628",
switch: "#0a2218",
bulb: "#24220a",
motor: "#1a0a28",
diode: "#28081a",
resistor: "#1a1a08",
capacitor: "#081a1a",
ground: "#0e140e",
power: "#1a0808",
cable: "#1a1a1a",
pdm: "#2a1408",
group: "rgba(40,40,80,0.35)",
};
// ── colour maths ─────────────────────────────────────────────────────────────
function _hexToRgb(hex) {
const h = hex.replace("#", "");
const s = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
return {
r: parseInt(s.slice(0, 2), 16),
g: parseInt(s.slice(2, 4), 16),
b: parseInt(s.slice(4, 6), 16),
};
}
function _rgbToHsl({ r, g, b }) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0, s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
}
return { h, s, l };
}
// Re-light a colour to a target lightness, keeping its hue so the per-type
// distinction survives the theme switch.
function _relight(hex, targetL, satScale = 0.55) {
if (!hex || hex.startsWith("rgba")) return hex;
const { h, s } = _rgbToHsl(_hexToRgb(hex));
return `hsl(${Math.round(h * 360)}, ${Math.round(Math.min(1, s * satScale) * 100)}%, ${Math.round(targetL * 100)}%)`;
}
// ── state ────────────────────────────────────────────────────────────────────
const Theme = {
_state: {
mode: "dark",
canvasBg: null, // null = follow the mode preset
gridDot: null,
deviceStroke: null,
deviceFill: null, // null = tint per device type
},
_listeners: [],
load() {
try {
const raw = localStorage.getItem(THEME_KEY);
if (raw) Object.assign(this._state, JSON.parse(raw));
} catch { /* corrupt or unavailable storage — fall back to defaults */ }
if (!THEME_PRESETS[this._state.mode]) this._state.mode = "dark";
return this._state;
},
save() {
try { localStorage.setItem(THEME_KEY, JSON.stringify(this._state)); }
catch { /* private mode — theme just will not persist */ }
},
get() { return { ...this._state }; },
preset() { return THEME_PRESETS[this._state.mode]; },
set(patch) {
Object.assign(this._state, patch);
this.save();
this.apply();
this._listeners.forEach((fn) => fn(this._state));
},
reset() {
this._state = { mode: this._state.mode, canvasBg: null, gridDot: null, deviceStroke: null, deviceFill: null };
this.save();
this.apply();
this._listeners.forEach((fn) => fn(this._state));
},
onChange(fn) { this._listeners.push(fn); },
// Push the theme into CSS. The app chrome reads data-theme; the canvas
// background and grid are plain custom properties so a user override is a
// one-line change with no repaint logic.
apply() {
const p = this.preset();
const root = document.documentElement;
root.setAttribute("data-theme", this._state.mode);
root.style.setProperty("--bg-canvas", this._state.canvasBg || p.canvasBg);
root.style.setProperty("--grid-dot", this._state.gridDot || p.gridDot);
},
// ── canvas colours (Konva cannot read CSS variables) ──
deviceFill(type) {
if (this._state.deviceFill) return this._state.deviceFill;
const base = DEVICE_HUES[type] || "#1e1e2e";
const p = this.preset();
return p.lightness == null ? base : _relight(base, p.lightness);
},
deviceStroke() { return this._state.deviceStroke || this.preset().deviceStroke; },
deviceText() { return this.preset().deviceText; },
deviceSubtext() { return this.preset().deviceSubtext; },
deviceRef() { return this.preset().deviceRef; },
pinFill() { return this.preset().pinFill; },
pinStroke() { return this.preset().pinStroke; },
notch() { return this.preset().notch; },
rowBg() { return this.preset().rowBg; },
wireLabel() { return this.preset().wireLabel; },
harnessBg() { return this.preset().harnessBg; },
harnessEdge() { return this.preset().harnessEdge; },
harnessText() { return this.preset().harnessText; },
loomShadow() { return this.preset().loomShadow; },
canvasBg() { return this._state.canvasBg || this.preset().canvasBg; },
};
Theme.load();
Theme.apply();