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:
+521
-2
@@ -54,6 +54,8 @@ class WiringApp {
|
||||
this._bindPinoutModal();
|
||||
this._bindLibrarySearch();
|
||||
this._bindFormboard();
|
||||
this._bindPdm();
|
||||
this._bindTheme();
|
||||
|
||||
this.loadDiagramList()
|
||||
.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); });
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
@@ -996,9 +1012,20 @@ class WiringApp {
|
||||
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() {
|
||||
document.addEventListener("keydown", (e) => {
|
||||
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 === "Escape") { this.setMode("select"); this._closeCtx(); }
|
||||
if (e.key === "w") this.setMode("wire");
|
||||
@@ -1725,17 +1752,507 @@ class WiringApp {
|
||||
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) {
|
||||
if (!this.diagramId) return;
|
||||
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)
|
||||
|| "#CC0000";
|
||||
const wire = await api.wires.create({
|
||||
diagram_id: this.diagramId,
|
||||
from_device_id: fromDev, from_pin: fromPin,
|
||||
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.setMode("select");
|
||||
@@ -2169,6 +2686,8 @@ class WiringApp {
|
||||
document.getElementById("pin-table-body").closest(".prop-row").style.display = isGroup ? "none" : "";
|
||||
document.getElementById("prop-pinout-btn").style.display =
|
||||
(!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
|
||||
const res = document.getElementById("octopart-results");
|
||||
const msg = document.getElementById("octopart-status-msg");
|
||||
|
||||
+61
-53
@@ -173,7 +173,7 @@ class DiagramCanvas {
|
||||
// Loom background
|
||||
this.harnessLayer.add(new Konva.Line({
|
||||
points: [cx, cy, ex, ey],
|
||||
stroke: '#0a0a18', strokeWidth: trunkW + 4,
|
||||
stroke: Theme.loomShadow(), strokeWidth: trunkW + 4,
|
||||
lineCap: 'round', listening: false,
|
||||
}));
|
||||
// Colored wire stripes
|
||||
@@ -192,7 +192,7 @@ class DiagramCanvas {
|
||||
// Split-off circle at loom end
|
||||
this.harnessLayer.add(new Konva.Circle({
|
||||
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
|
||||
this.harnessLayer.add(new Konva.Line({
|
||||
points: [ax, ay, bx, by],
|
||||
stroke: '#0a0a18', strokeWidth: trunkW + 4,
|
||||
stroke: Theme.loomShadow(), strokeWidth: trunkW + 4,
|
||||
lineCap: 'round', lineJoin: 'round', listening: false,
|
||||
}));
|
||||
// Colored wire stripes
|
||||
@@ -260,12 +260,12 @@ class DiagramCanvas {
|
||||
this.harnessLayer.add(new Konva.Rect({
|
||||
x: mx - 22, y: my - 9,
|
||||
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({
|
||||
x: mx - 22, y: my - 6,
|
||||
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;
|
||||
const d = this.deviceData.get(id);
|
||||
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);
|
||||
(isGroup ? this.groupLayer : this.deviceLayer).batchDraw();
|
||||
}
|
||||
@@ -638,6 +638,13 @@ class DiagramCanvas {
|
||||
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() {
|
||||
this._netHighlightIds.forEach(id => this._wireHighlight(id, false));
|
||||
this._netHighlightIds.clear();
|
||||
@@ -1026,26 +1033,10 @@ class DiagramCanvas {
|
||||
|
||||
// ── 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) {
|
||||
return {
|
||||
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";
|
||||
return Theme.deviceFill(type);
|
||||
}
|
||||
|
||||
_renderDevice(device) {
|
||||
@@ -1072,7 +1063,7 @@ class DiagramCanvas {
|
||||
group.add(new Konva.Text({
|
||||
name: "device-label",
|
||||
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,
|
||||
}));
|
||||
} else {
|
||||
@@ -1080,7 +1071,7 @@ class DiagramCanvas {
|
||||
const rect = new Konva.Rect({
|
||||
name: "device-rect",
|
||||
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);
|
||||
|
||||
@@ -1089,7 +1080,7 @@ class DiagramCanvas {
|
||||
group.add(new Konva.Text({
|
||||
name: "device-label",
|
||||
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",
|
||||
}));
|
||||
} else if (device.device_type === "cable") {
|
||||
@@ -1160,25 +1151,25 @@ class DiagramCanvas {
|
||||
// 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] }));
|
||||
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({
|
||||
name: "device-label",
|
||||
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
|
||||
conductors.forEach((cond, i) => {
|
||||
const rowY = 26 + i * 24;
|
||||
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({
|
||||
points: [14, rowY + 12, device.width - 14, rowY + 12],
|
||||
stroke: color, strokeWidth: 5, lineCap: "round", listening: false,
|
||||
}));
|
||||
group.add(new Konva.Text({
|
||||
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
|
||||
@@ -1186,7 +1177,7 @@ class DiagramCanvas {
|
||||
group.add(new Konva.Text({
|
||||
name: "device-type",
|
||||
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") {
|
||||
rect.visible(false);
|
||||
@@ -1197,32 +1188,32 @@ class DiagramCanvas {
|
||||
group.add(new Konva.Circle({
|
||||
name: "device-body",
|
||||
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
|
||||
group.add(new Konva.Rect({
|
||||
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({
|
||||
name: "device-label",
|
||||
x: 4, y: cy - 6, width: device.width - 8,
|
||||
text: device.label, fontSize: Math.min(10, fontSize),
|
||||
fontFamily: "monospace", fill: "#dde0f5", align: "center",
|
||||
fontFamily: "monospace", fill: Theme.deviceText(), align: "center",
|
||||
}));
|
||||
} else {
|
||||
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({
|
||||
name: "device-label",
|
||||
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({
|
||||
name: "device-type",
|
||||
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
|
||||
@@ -1491,7 +1482,7 @@ class DiagramCanvas {
|
||||
_addPin(group, device, pin) {
|
||||
const circle = new Konva.Circle({
|
||||
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({
|
||||
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 };
|
||||
|
||||
// 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;
|
||||
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) {
|
||||
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":
|
||||
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":
|
||||
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
|
||||
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({
|
||||
x: centred ? 0 : lx,
|
||||
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(new Konva.Text({
|
||||
x: lx, y: ly, width: lw, align: la,
|
||||
text: pin.name, fontSize: 8, fontFamily: "monospace", fill: "#556688",
|
||||
}));
|
||||
group.add(circle);
|
||||
group.add(hit);
|
||||
|
||||
const highlightPin = (active) => {
|
||||
circle.fill(active ? "#003300" : "#0a0f1a");
|
||||
circle.stroke(active ? "#00dd00" : "#5566aa");
|
||||
circle.fill(active ? "#003300" : Theme.pinFill());
|
||||
circle.stroke(active ? "#00dd00" : Theme.pinStroke());
|
||||
circle.radius(active ? 7 : 5);
|
||||
this.deviceLayer.batchDraw();
|
||||
};
|
||||
@@ -1685,7 +1693,7 @@ class DiagramCanvas {
|
||||
lbl = new Konva.Text({
|
||||
x: mx + 3, y: my - 10,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -213,16 +213,77 @@ function connectorToDevice(connId, diagramId) {
|
||||
};
|
||||
}
|
||||
|
||||
// Standard rectangular connector
|
||||
const w = 120;
|
||||
const h = Math.max(60, pinCount * 18 + 20);
|
||||
const pins = Array.from({ length: pinCount }, (_, i) => ({
|
||||
id: `pin_${i + 1}`,
|
||||
name: conn.pinLabels ? (conn.pinLabels[i] || String(i + 1)) : String(i + 1),
|
||||
side: "right",
|
||||
x_offset: w,
|
||||
y_offset: ((i + 1) / (pinCount + 1)) * h,
|
||||
}));
|
||||
// Standard rectangular connector.
|
||||
// Parts carrying pinSpecs (see partsLibrary.js) get their standard wire
|
||||
// function stamped onto each pin, so wires drawn off them always come out the
|
||||
// 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}`,
|
||||
name: spec ? spec.name : (conn.pinLabels ? (conn.pinLabels[i] || String(i + 1)) : String(i + 1)),
|
||||
side: onRight ? "right" : "left",
|
||||
x_offset: onRight ? w : 0,
|
||||
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 {
|
||||
diagram_id: diagramId,
|
||||
@@ -237,6 +298,8 @@ function connectorToDevice(connId, diagramId) {
|
||||
partNumber: conn.partNumber || "",
|
||||
manufacturer: conn.manufacturer || "",
|
||||
connectorLibraryId: connId,
|
||||
standardPart: !!specs,
|
||||
verifyPinout: !!conn.verify,
|
||||
},
|
||||
pins,
|
||||
};
|
||||
|
||||
@@ -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", "0–12 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 1–4. " +
|
||||
"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 9–13. 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 7–12 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 1–6 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 })),
|
||||
];
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user