app
Wire Harness Designer
Public Made by Adomby adom
Design wire harnesses in the browser and export a professional, print-ready PDF assembly drawing. Pick connectors, wire gauge, colour and stripe, sleeving, and connector-to-connector lengths; get a live fishbone diagram with a wire schedule and title block. Click any wire or connector in the drawing to edit it. Save and reload the whole harness as JSON.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
/* app.js — Wire Harness Designer UI: state, editors, live preview, save/load, PDF. */
(function () {
'use strict';
var HL = window.HarnessLayout, RSVG = window.RenderSVG, RPDF = window.RenderPDF;
var LS_KEY = 'whd.harness.v1';
/* ---------- state ---------- */
var state = null;
var pages = [];
var curPage = 0;
var zoomFactor = 1; // 1 == fit
var baseFitWidth = 0;
var lastWarnings = [];
function today() {
try { return new Date().toISOString().slice(0, 10); } catch (e) { return ''; }
}
function emptyModel() {
return {
schema: 'adom.wire-harness/v1',
meta: { name: 'Untitled Harness', partNumber: '', revision: 'A', drawnBy: 'noah', date: today(), notes: '' },
options: { page: 'tabloid', units: 'mm' },
connectors: [], wires: [], wraps: [], segments: [],
};
}
function clone(o) { return JSON.parse(JSON.stringify(o)); }
/* ---------- connector-to-connector segment lengths ---------- */
function segKey(a, b) { return a < b ? a + ' ' + b : b + ' ' + a; }
function getSegment(a, b) {
if (!a || !b) return null;
var k = segKey(a, b);
for (var i = 0; i < state.segments.length; i++) {
if (segKey(state.segments[i].a, state.segments[i].b) === k) return state.segments[i];
}
return null;
}
function ensureSegment(a, b, dflt) {
if (!a || !b || a === b) return null;
var s = getSegment(a, b);
if (!s) { s = { a: a, b: b, length: (dflt != null ? dflt : 300) }; state.segments.push(s); }
return s;
}
// Backfill segments for every wired pair (e.g. after loading an older file).
function ensureSegments() {
if (!Array.isArray(state.segments)) state.segments = [];
state.wires.forEach(function (w) {
if (w.from && w.to && w.from.conn && w.to.conn && w.from.conn !== w.to.conn) {
// seed from a legacy per-wire length if the segment is new
ensureSegment(w.from.conn, w.to.conn, w.length != null && w.length !== '' ? w.length : 300);
}
});
}
// pairs that currently have wires, with their segment + wire count
function activePairs() {
var seen = {}, out = [];
state.wires.forEach(function (w) {
if (!(w.from && w.to && w.from.conn && w.to.conn && w.from.conn !== w.to.conn)) return;
var k = segKey(w.from.conn, w.to.conn);
if (!seen[k]) { seen[k] = { a: w.from.conn, b: w.to.conn, count: 0 }; out.push(seen[k]); }
seen[k].count++;
});
out.forEach(function (p) { p.seg = ensureSegment(p.a, p.b); });
return out;
}
function connLabel(id) { var c = state.connectors.find(function (x) { return x.id === id; }); return c ? (c.name || c.id) : id; }
function cleanModel() {
// strip any transient _ keys defensively
return JSON.parse(JSON.stringify(state, function (k, v) { return k[0] === '_' ? undefined : v; }));
}
/* ---------- id helpers ---------- */
function nextConnId() {
var n = 1; var used = {};
state.connectors.forEach(function (c) { used[c.id] = 1; });
while (used['J' + n]) n++;
return 'J' + n;
}
function nextWireId() {
var n = state.wires.length + 1;
var used = {}; state.wires.forEach(function (w) { used[w.id] = 1; });
while (used['W' + n]) n++;
return 'W' + n;
}
function nextWrapId() {
var n = state.wraps.length + 1;
var used = {}; state.wraps.forEach(function (w) { used[w.id] = 1; });
while (used['S' + n]) n++;
return 'S' + n;
}
/* ---------- tiny DOM helpers ---------- */
function h(html) { var d = document.createElement('div'); d.innerHTML = html.trim(); return d.firstChild; }
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
function optionList(list, cur, valFn, labFn) {
return list.map(function (it) {
var v = valFn ? valFn(it) : it, l = labFn ? labFn(it) : it;
return '<option value="' + esc(v) + '"' + (String(v) === String(cur) ? ' selected' : '') + '>' + esc(l) + '</option>';
}).join('');
}
function connOptions(cur) {
if (!state.connectors.length) return '<option value="">(no connectors)</option>';
return state.connectors.map(function (c) {
return '<option value="' + esc(c.id) + '"' + (c.id === cur ? ' selected' : '') + '>' + esc(c.name || c.id) + '</option>';
}).join('');
}
function typeOptions(cur) {
var list = HL.CONNECTOR_TYPES.slice();
if (cur && list.indexOf(cur) < 0) list = [cur].concat(list); // keep custom/loaded type visible
return optionList(list, cur);
}
function colorOptions(cur, includeNone) {
var s = includeNone ? '<option value="">-- none --</option>' : '';
s += HL.COLOR_ORDER.map(function (code) {
return '<option value="' + code + '"' + (code === cur ? ' selected' : '') + '>' + code + ' ' + HL.PALETTE[code].name + '</option>';
}).join('');
return s;
}
/* ============================================================
* EDITOR
* ========================================================== */
var editor = document.getElementById('editor');
function renderEditor() {
var m = state;
var html = '';
// --- Harness info ---
html += section('info', 'Harness Info', '', `
<div class="grid2">
<div class="field"><label>Name</label><input type="text" data-scope="meta" data-key="name" value="${esc(m.meta.name)}"></div>
<div class="field"><label>Part Number</label><input type="text" data-scope="meta" data-key="partNumber" value="${esc(m.meta.partNumber)}"></div>
</div>
<div class="grid3">
<div class="field"><label>Revision</label><input type="text" data-scope="meta" data-key="revision" value="${esc(m.meta.revision)}"></div>
<div class="field"><label>Drawn By</label><input type="text" data-scope="meta" data-key="drawnBy" value="${esc(m.meta.drawnBy)}"></div>
<div class="field"><label>Date</label><input type="text" data-scope="meta" data-key="date" value="${esc(m.meta.date)}"></div>
</div>
<div class="field"><label>Notes</label><textarea data-scope="meta" data-key="notes">${esc(m.meta.notes)}</textarea></div>
<div class="grid2">
<div class="field"><label data-tip="Sheet size of the exported PDF">Page Size</label>
<select data-scope="opts" data-key="page" data-struct="1">${optionList(Object.keys(HL.PAGES), m.options.page, null, function (k) { return HL.PAGES[k].label; })}</select></div>
<div class="field"><label>Units</label>
<select data-scope="opts" data-key="units">${optionList(['mm', 'in'], m.options.units)}</select></div>
</div>
`);
// --- Connectors ---
var connBody = '';
m.connectors.forEach(function (c, i) {
connBody += `
<div class="item" data-conn="${i}">
<div class="item-head">
<span class="item-tag">${esc(c.id)}</span>
<span class="item-title">${esc(c.type || 'Connector')} · ${c.pins}P</span>
<button class="item-del" data-action="del-conn" data-index="${i}" data-tip="Remove this connector">×</button>
</div>
<div class="grid2">
<div class="field"><label data-tip="Reference designator, e.g. J1. Wires refer to it.">ID</label>
<input type="text" data-scope="conn" data-index="${i}" data-key="id" data-struct="1" value="${esc(c.id)}"></div>
<div class="field"><label>Label</label>
<input type="text" data-scope="conn" data-index="${i}" data-key="name" value="${esc(c.name || '')}"></div>
</div>
<div class="field"><label>Type</label>
<select data-scope="conn" data-index="${i}" data-key="type">${typeOptions(c.type)}</select></div>
<div class="grid3">
<div class="field"><label>Pins</label>
<input type="number" min="1" max="64" data-scope="conn" data-index="${i}" data-key="pins" data-type="num" value="${c.pins}"></div>
<div class="field"><label data-tip="Where the connector sits: auto lays it out, or pin it to an end / branch">Side</label>
<select data-scope="conn" data-index="${i}" data-key="side" data-struct="1">${optionList([['', 'Auto'], ['left', 'Left end'], ['right', 'Right end'], ['up', 'Branch up'], ['down', 'Branch down']], c.side || '', function (x) { return x[0]; }, function (x) { return x[1]; })}</select></div>
<div class="field"><label data-tip="Distance in mm along the trunk. Sets sleeve alignment.">Pos mm</label>
<input type="number" min="0" data-scope="conn" data-index="${i}" data-key="pos" data-type="num" value="${c.pos != null ? c.pos : ''}"></div>
</div>
</div>`;
});
connBody += '<button class="addbtn" data-action="add-conn">+ Add connector</button>';
html += section('conn', 'Connectors', m.connectors.length, connBody);
// --- Wires ---
var wireBody = '';
m.wires.forEach(function (w, i) {
var fromC = (w.from && w.from.conn) || '', toC = (w.to && w.to.conn) || '';
var col = HL.PALETTE[w.color] ? HL.PALETTE[w.color].hex : '#666';
var stripeHex = w.stripe && HL.PALETTE[w.stripe] ? HL.PALETTE[w.stripe].hex : 'transparent';
var paired = fromC && toC && fromC !== toC;
var seg = paired ? getSegment(fromC, toC) : null;
var segLen = seg && seg.length != null ? seg.length : '';
var lenField = paired
? `<input type="number" min="0" data-scope="seg" data-a="${esc(fromC)}" data-b="${esc(toC)}" data-tip="Distance between ${esc(connLabel(fromC))} and ${esc(connLabel(toC))}. Shared by every wire on this pair." value="${segLen}">`
: `<input type="number" disabled placeholder="set both ends" data-tip="Pick a From and To connector to set the length">`;
wireBody += `
<div class="item" data-wire="${i}">
<div class="item-head">
<span class="item-tag">${esc(w.id)}</span>
<span class="item-title">${esc(connLabel(fromC) || '?')} → ${esc(connLabel(toC) || '?')}</span>
<button class="item-del" data-action="del-wire" data-index="${i}" data-tip="Remove this wire">×</button>
</div>
<div class="field"><label>From — connector & pin</label>
<div class="row-inline">
<select class="grow" data-scope="wire" data-index="${i}" data-ref="from" data-part="conn" data-struct="1">${connOptions(fromC)}</select>
<input class="pin-input" type="number" min="1" data-scope="wire" data-index="${i}" data-ref="from" data-part="pin" data-tip="Pin number on the From connector" value="${w.from && w.from.pin != null ? w.from.pin : ''}">
</div></div>
<div class="field"><label>To — connector & pin</label>
<div class="row-inline">
<select class="grow" data-scope="wire" data-index="${i}" data-ref="to" data-part="conn" data-struct="1">${connOptions(toC)}</select>
<input class="pin-input" type="number" min="1" data-scope="wire" data-index="${i}" data-ref="to" data-part="pin" data-tip="Pin number on the To connector" value="${w.to && w.to.pin != null ? w.to.pin : ''}">
</div></div>
<div class="grid2">
<div class="field"><label>Gauge</label>
<select data-scope="wire" data-index="${i}" data-key="gauge">${optionList(HL.GAUGES, w.gauge, null, function (g) { return g + ' AWG'; })}</select></div>
<div class="field"><label data-tip="Connector-to-connector distance. Shared by all wires on this pair.">Length mm ${paired ? '· ' + esc(connLabel(fromC)) + '↔' + esc(connLabel(toC)) : ''}</label>
${lenField}</div>
</div>
<div class="grid2">
<div class="field"><label>Color</label>
<div class="color-wrap"><span class="swatch" data-swatch="${i}" style="background:${col}"></span>
<select class="grow" data-scope="wire" data-index="${i}" data-key="color" data-color="1">${colorOptions(w.color)}</select></div></div>
<div class="field"><label data-tip="Optional second colour (stripe)">Stripe</label>
<div class="color-wrap"><span class="swatch" data-stripe="${i}" style="background:${stripeHex}"></span>
<select class="grow" data-scope="wire" data-index="${i}" data-key="stripe" data-stripe-sel="1">${colorOptions(w.stripe || '', true)}</select></div></div>
</div>
<div class="field"><label>Signal / Notes</label>
<input type="text" data-scope="wire" data-index="${i}" data-key="signal" value="${esc(w.signal || '')}"></div>
</div>`;
});
wireBody += '<button class="addbtn" data-action="add-wire">+ Add wire</button>';
html += section('wire', 'Wires', m.wires.length, wireBody);
// --- Segment lengths (connector-to-connector distances) ---
var pairs = activePairs();
var segBody = '';
if (!pairs.length) {
segBody = '<div class="mini" style="padding:8px 2px">Add wires between two connectors and their run length appears here.</div>';
} else {
segBody = '<div class="mini" style="text-align:left;margin:2px 0 6px">Distance between connectors. Every wire on a pair uses this length.</div>';
pairs.forEach(function (p) {
segBody += `
<div class="seg-row">
<span class="seg-pair">${esc(connLabel(p.a))} <span class="seg-arrow">↔</span> ${esc(connLabel(p.b))}</span>
<span class="seg-count" data-tip="${p.count} wire(s) share this length">${p.count}w</span>
<input class="seg-len" type="number" min="0" data-scope="seg" data-a="${esc(p.a)}" data-b="${esc(p.b)}" value="${p.seg && p.seg.length != null ? p.seg.length : ''}">
<span class="seg-unit">mm</span>
</div>`;
});
}
html += section('seg', 'Segment Lengths', pairs.length, segBody);
// --- Wraps ---
var wrapBody = '';
m.wraps.forEach(function (s, i) {
wrapBody += `
<div class="item" data-wrap="${i}">
<div class="item-head">
<span class="item-tag">${esc(s.id)}</span>
<span class="item-title">${esc(s.type)}</span>
<button class="item-del" data-action="del-wrap" data-index="${i}" data-tip="Remove this sleeving">×</button>
</div>
<div class="grid2">
<div class="field"><label>Type</label>
<select data-scope="wrap" data-index="${i}" data-key="type">${optionList(HL.WRAP_ORDER, s.type)}</select></div>
<div class="field"><label>Color</label>
<input type="text" data-scope="wrap" data-index="${i}" data-key="color" value="${esc(s.color || '')}"></div>
</div>
<div class="grid2">
<div class="field"><label data-tip="Start position in mm along the trunk">Start mm</label>
<input type="number" min="0" data-scope="wrap" data-index="${i}" data-key="start" data-type="num" value="${s.start != null ? s.start : ''}"></div>
<div class="field"><label data-tip="End position in mm along the trunk">End mm</label>
<input type="number" min="0" data-scope="wrap" data-index="${i}" data-key="end" data-type="num" value="${s.end != null ? s.end : ''}"></div>
</div>
</div>`;
});
wrapBody += '<button class="addbtn" data-action="add-wrap">+ Add sleeving</button>';
html += section('wrap', 'Wire Wrap / Sleeving', m.wraps.length, wrapBody);
editor.innerHTML = html;
}
function section(id, title, count, bodyHtml) {
var collapsed = collapsedSections[id] ? ' collapsed' : '';
return `
<section class="section${collapsed}" data-section="${id}">
<div class="section-head" data-toggle="${id}">
<span class="section-title">${title}</span>
${count !== '' ? '<span class="section-count">' + count + '</span>' : ''}
<span class="section-caret">▼</span>
</div>
<div class="section-body">${bodyHtml}</div>
</section>`;
}
var collapsedSections = {};
/* ---------- editor event delegation ---------- */
editor.addEventListener('input', onEditorInput);
editor.addEventListener('change', onEditorChange);
editor.addEventListener('click', onEditorClick);
function readVal(el) {
if (el.dataset.type === 'num') {
if (el.value === '') return undefined;
var n = parseFloat(el.value); return isNaN(n) ? undefined : n;
}
return el.value;
}
function applyField(el) {
var scope = el.dataset.scope; if (!scope) return;
var v = readVal(el);
if (scope === 'meta') state.meta[el.dataset.key] = v;
else if (scope === 'opts') state.options[el.dataset.key] = v;
else if (scope === 'conn') {
var c = state.connectors[+el.dataset.index]; if (!c) return;
if (el.dataset.key === 'side' && v === '') delete c.side;
else if (el.dataset.key === 'pos' && v === undefined) delete c.pos;
else c[el.dataset.key] = v;
} else if (scope === 'seg') {
var sv = el.value === '' ? undefined : parseFloat(el.value);
var s = ensureSegment(el.dataset.a, el.dataset.b);
if (s) s.length = (sv == null || isNaN(sv)) ? undefined : sv;
} else if (scope === 'wrap') {
state.wraps[+el.dataset.index][el.dataset.key] = v;
} else if (scope === 'wire') {
var w = state.wires[+el.dataset.index]; if (!w) return;
if (el.dataset.ref) {
var ref = el.dataset.ref === 'from' ? (w.from = w.from || {}) : (w.to = w.to || {});
if (el.dataset.part === 'pin') ref.pin = (v === undefined ? undefined : Math.max(1, Math.round(v)));
else ref.conn = v;
} else if (el.dataset.key === 'stripe' && v === '') delete w.stripe;
else w[el.dataset.key] = v;
}
}
function onEditorInput(e) {
var el = e.target; if (!el.dataset || !el.dataset.scope) return;
applyField(el);
// keep every length input for the same pair in sync live
if (el.dataset.scope === 'seg') {
var k = segKey(el.dataset.a, el.dataset.b);
editor.querySelectorAll('input[data-scope="seg"]').forEach(function (o) {
if (o !== el && segKey(o.dataset.a, o.dataset.b) === k) o.value = el.value;
});
}
schedulePreview();
}
function onEditorChange(e) {
var el = e.target; if (!el.dataset || !el.dataset.scope) return;
// structural: id rename cascade, page size, connector side
if (el.dataset.struct && el.dataset.scope === 'conn' && el.dataset.key === 'id') {
var idx = +el.dataset.index; var oldId = state.connectors[idx].id; var newId = el.value.trim();
if (!newId) { el.value = oldId; return; }
if (state.connectors.some(function (c, j) { return j !== idx && c.id === newId; })) {
toast('Connector ID "' + newId + '" is already used', 'error'); el.value = oldId; return;
}
state.connectors[idx].id = newId;
state.wires.forEach(function (w) {
if (w.from && w.from.conn === oldId) w.from.conn = newId;
if (w.to && w.to.conn === oldId) w.to.conn = newId;
});
renderEditor(); schedulePreview(); return;
}
// changing a wire's connector re-pairs it: refresh derived length + segment list
if (el.dataset.scope === 'wire' && el.dataset.ref && el.dataset.part === 'conn') {
applyField(el); ensureSegments(); renderEditor(); schedulePreview(); return;
}
applyField(el);
// live swatch update
if (el.dataset.color) { var sw = editor.querySelector('[data-swatch="' + el.dataset.index + '"]'); if (sw) sw.style.background = HL.PALETTE[el.value] ? HL.PALETTE[el.value].hex : '#666'; }
if (el.dataset.stripeSel) { var sw2 = editor.querySelector('[data-stripe="' + el.dataset.index + '"]'); if (sw2) sw2.style.background = HL.PALETTE[el.value] ? HL.PALETTE[el.value].hex : 'transparent'; }
if (el.dataset.struct && el.dataset.scope === 'opts') { renderEditor(); }
schedulePreview();
}
function onEditorClick(e) {
var tog = e.target.closest('[data-toggle]');
if (tog) {
var id = tog.dataset.toggle; collapsedSections[id] = !collapsedSections[id];
tog.parentElement.classList.toggle('collapsed'); return;
}
var act = e.target.closest('[data-action]');
if (!act) return;
var a = act.dataset.action, idx = +act.dataset.index;
if (a === 'add-conn') { state.connectors.push({ id: nextConnId(), name: '', type: 'Header 2.54mm', pins: 2 }); renderEditor(); schedulePreview(); toast('Connector added', 'success'); }
else if (a === 'del-conn') { var rm = state.connectors.splice(idx, 1)[0]; state.wires.forEach(function (w) { if (w.from && w.from.conn === rm.id) w.from.conn = ''; if (w.to && w.to.conn === rm.id) w.to.conn = ''; }); renderEditor(); schedulePreview(); }
else if (a === 'add-wire') {
var c0 = state.connectors[0], c1 = state.connectors[1] || state.connectors[0];
state.wires.push({ id: nextWireId(), from: { conn: c0 ? c0.id : '', pin: 1 }, to: { conn: c1 ? c1.id : '', pin: 1 }, gauge: '20', color: 'BK', signal: '' });
ensureSegments();
renderEditor(); schedulePreview(); toast('Wire added', 'success');
}
else if (a === 'del-wire') { state.wires.splice(idx, 1); renderEditor(); schedulePreview(); }
else if (a === 'add-wrap') { state.wraps.push({ id: nextWrapId(), type: 'Braided sleeve', color: 'Black', start: 0, end: 100 }); renderEditor(); schedulePreview(); toast('Sleeving added', 'success'); }
else if (a === 'del-wrap') { state.wraps.splice(idx, 1); renderEditor(); schedulePreview(); }
}
/* ============================================================
* PREVIEW
* ========================================================== */
var sheetWrap = document.getElementById('sheetWrap');
var sheetScroll = document.getElementById('sheetScroll');
var pvPage = document.getElementById('pvPage');
var previewTimer = null;
function schedulePreview() {
if (previewTimer) clearTimeout(previewTimer);
previewTimer = setTimeout(function () { renderPreview(); saveLocal(); }, 90);
}
function renderPreview() {
try {
var res = HL.build(cleanModel());
pages = res.pages; lastWarnings = res.warnings;
if (curPage >= pages.length) curPage = pages.length - 1;
if (curPage < 0) curPage = 0;
sheetWrap.innerHTML = RSVG.renderPage(pages[curPage]);
pvPage.textContent = 'Sheet ' + (curPage + 1) + ' / ' + pages.length;
computeFit(); applyZoom();
updateHeader(); updateStatus();
} catch (e) {
console.error('preview error', e);
toast('Preview error: ' + e.message, 'error');
}
}
function computeFit() {
if (!pages.length) return;
var p = pages[curPage];
var availW = sheetScroll.clientWidth - 40;
var availH = sheetScroll.clientHeight - 40;
var aspect = p.w / p.h;
var wFromH = availH * aspect;
baseFitWidth = Math.max(120, Math.min(availW, wFromH));
}
function applyZoom() {
if (!pages.length) return;
sheetWrap.style.width = (baseFitWidth * zoomFactor) + 'px';
}
/* ---------- header / status ---------- */
function updateHeader() {
document.getElementById('subjectName').textContent = state.meta.name || 'Untitled Harness';
var meta = [];
if (state.meta.partNumber) meta.push(state.meta.partNumber);
if (state.meta.revision) meta.push('Rev ' + state.meta.revision);
meta.push(HL.PAGES[state.options.page].label.split(' ')[0]);
document.getElementById('subjectMeta').textContent = meta.join(' · ');
}
function updateStatus() {
document.getElementById('statCounts').textContent =
state.connectors.length + ' connectors ' + state.wires.length + ' wires ' + state.wraps.length + ' sleeves';
var hint = lastWarnings.length ? (lastWarnings.length + ' warning: ' + lastWarnings[0]) : 'Autosaved';
document.getElementById('statHint').textContent = hint;
document.getElementById('statHint').style.color = lastWarnings.length ? 'var(--yellow)' : 'var(--text-3)';
}
/* ============================================================
* ACTIONS
* ========================================================== */
function fileBase() {
var n = (state.meta.name || 'harness').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
return n || 'harness';
}
function saveLocal() { try { localStorage.setItem(LS_KEY, JSON.stringify(cleanModel())); } catch (e) {} }
function downloadBlob(blob, name) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a'); a.href = url; a.download = name;
document.body.appendChild(a); a.click();
setTimeout(function () { document.body.removeChild(a); URL.revokeObjectURL(url); }, 200);
}
function saveJSON() {
var data = JSON.stringify(cleanModel(), null, 2);
downloadBlob(new Blob([data], { type: 'application/json' }), fileBase() + '.json');
toast('Saved ' + fileBase() + '.json', 'success');
}
function exportPDF() {
try {
var res = RPDF.buildFromModel(cleanModel());
res.doc.save(fileBase() + '.pdf');
toast('Exported ' + fileBase() + '.pdf', 'success');
if (res.warnings.length) toast(res.warnings.length + ' warning(s), e.g. ' + res.warnings[0], 'info');
} catch (e) { console.error(e); toast('PDF export failed: ' + e.message, 'error'); }
}
function loadFromText(txt, name) {
var obj;
try { obj = JSON.parse(txt); } catch (e) { toast('Not valid JSON: ' + e.message, 'error'); return; }
if (!obj || !Array.isArray(obj.connectors) || !Array.isArray(obj.wires)) { toast('That file is not a harness (missing connectors/wires)', 'error'); return; }
obj.meta = obj.meta || {}; obj.options = obj.options || { page: 'tabloid', units: 'mm' };
obj.wraps = obj.wraps || [];
obj.segments = Array.isArray(obj.segments) ? obj.segments : [];
if (!HL.PAGES[obj.options.page]) obj.options.page = 'tabloid';
state = obj; curPage = 0; zoomFactor = 1;
ensureSegments(); // backfill segment lengths for older files
renderEditor(); renderPreview();
toast('Loaded ' + (name || 'harness') + ' · ' + obj.connectors.length + ' connectors, ' + obj.wires.length + ' wires', 'success');
}
document.getElementById('btnSave').addEventListener('click', saveJSON);
document.getElementById('btnPdf').addEventListener('click', exportPDF);
document.getElementById('btnNew').addEventListener('click', function () { state = emptyModel(); curPage = 0; zoomFactor = 1; renderEditor(); renderPreview(); toast('New harness', 'info'); });
document.getElementById('btnSample').addEventListener('click', function () { state = clone(HL.defaultModel); curPage = 0; zoomFactor = 1; renderEditor(); renderPreview(); toast('Sample harness loaded', 'info'); });
var fileInput = document.getElementById('fileInput');
document.getElementById('btnLoad').addEventListener('click', function () { fileInput.value = ''; fileInput.click(); });
fileInput.addEventListener('change', function () {
var f = fileInput.files[0]; if (!f) return;
var r = new FileReader(); r.onload = function () { loadFromText(r.result, f.name); }; r.readAsText(f);
});
// preview toolbar
document.getElementById('pvPrev').addEventListener('click', function () { if (curPage > 0) { curPage--; renderPreview(); } });
document.getElementById('pvNext').addEventListener('click', function () { if (curPage < pages.length - 1) { curPage++; renderPreview(); } });
document.getElementById('zoomIn').addEventListener('click', function () { zoomFactor = Math.min(4, zoomFactor * 1.2); applyZoom(); });
document.getElementById('zoomOut').addEventListener('click', function () { zoomFactor = Math.max(0.25, zoomFactor / 1.2); applyZoom(); });
document.getElementById('zoomFit').addEventListener('click', function () { zoomFactor = 1; computeFit(); applyZoom(); });
// mobile edit/preview toggle
var app = document.getElementById('app');
document.getElementById('btnMobileEdit').addEventListener('click', function () {
app.classList.toggle('show-preview');
this.textContent = app.classList.contains('show-preview') ? 'Edit' : 'Drawing';
if (app.classList.contains('show-preview')) renderPreview();
});
window.addEventListener('resize', function () { if (zoomFactor === 1) { computeFit(); applyZoom(); } });
/* ============================================================
* TOASTS
* ========================================================== */
var toastHost = document.getElementById('toast-host');
var ICONS = {
success: '<svg class="t-ico" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
error: '<svg class="t-ico" viewBox="0 0 24 24" fill="none" stroke="#f85149" stroke-width="2.2" stroke-linecap="round"><path d="M12 8v5"/><circle cx="12" cy="16.5" r=".6"/><circle cx="12" cy="12" r="9"/></svg>',
info: '<svg class="t-ico" viewBox="0 0 24 24" fill="none" stroke="#00e6dc" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><circle cx="12" cy="7.5" r=".6"/></svg>',
};
function toast(msg, type) {
type = type || 'info';
var el = h('<div class="toast ' + type + '">' + (ICONS[type] || ICONS.info) + '<span>' + esc(msg) + '</span></div>');
toastHost.appendChild(el);
var life = type === 'error' ? 6000 : 3600;
var t = setTimeout(remove, life);
el.addEventListener('mouseenter', function () { clearTimeout(t); });
el.addEventListener('mouseleave', function () { t = setTimeout(remove, 1200); });
function remove() { el.classList.add('hide'); setTimeout(function () { el.remove(); }, 200); }
}
window.whdToast = toast;
/* ============================================================
* TOOLTIPS (body-appended, viewport-clamped)
* ========================================================== */
var tip = document.getElementById('tooltip');
var tipTimer = null, tipTarget = null;
document.addEventListener('mouseover', function (e) {
var t = e.target.closest('[data-tip]'); if (!t || t === tipTarget) return;
tipTarget = t;
if (tipTimer) clearTimeout(tipTimer);
tipTimer = setTimeout(function () { showTip(t); }, 600);
});
document.addEventListener('mouseout', function (e) {
var t = e.target.closest('[data-tip]'); if (!t) return;
if (tipTimer) clearTimeout(tipTimer);
tipTarget = null; hideTip();
});
document.addEventListener('mousemove', function (e) { if (tip.classList.contains('show')) placeTip(e.clientX, e.clientY); });
function showTip(t) {
tip.textContent = t.getAttribute('data-tip');
tip.classList.add('show');
var r = t.getBoundingClientRect();
placeTip(r.left + r.width / 2, r.top + r.height / 2);
}
function placeTip(cx, cy) {
var M = 8, ew = tip.offsetWidth, eh = tip.offsetHeight;
var x = cx + 14, y = cy + 16;
if (x + ew + M > window.innerWidth) x = cx - 14 - ew; // flip left
if (y + eh + M > window.innerHeight) y = cy - 16 - eh; // flip up
x = Math.max(M, Math.min(x, window.innerWidth - ew - M));
y = Math.max(M, Math.min(y, window.innerHeight - eh - M));
tip.style.left = x + 'px'; tip.style.top = y + 'px';
}
function hideTip() { tip.classList.remove('show'); }
/* ---------- brand info popover ---------- */
var brandPop = null, brandHideTimer = null;
var brandZone = document.getElementById('brandZone');
brandZone.addEventListener('mouseenter', function () {
if (brandHideTimer) clearTimeout(brandHideTimer);
if (!brandPop) {
brandPop = h('<div class="brand-pop"><h4>Wire Harness Designer</h4>' +
'<dl><dt>Version</dt><dd>0.1.0</dd><dt>Engine</dt><dd>jsPDF vector</dd>' +
'<dt>Author</dt><dd>Adom</dd><dt>Format</dt><dd>.json + .pdf</dd></dl></div>');
document.body.appendChild(brandPop);
brandPop.addEventListener('mouseenter', function () { if (brandHideTimer) clearTimeout(brandHideTimer); });
brandPop.addEventListener('mouseleave', hideBrand);
}
requestAnimationFrame(function () { brandPop.classList.add('show'); });
});
brandZone.addEventListener('mouseleave', hideBrand);
function hideBrand() { brandHideTimer = setTimeout(function () { if (brandPop) brandPop.classList.remove('show'); }, 220); }
/* ============================================================
* CLICK-TO-EDIT: click a wire or connector in the drawing
* ========================================================== */
var pickPop = null, editorDirty = false;
sheetWrap.addEventListener('click', function (e) {
var t = e.target.closest('[data-pick-type]');
if (!t) return;
openPickPop(t.getAttribute('data-pick-type'), t.getAttribute('data-pick-id'), e.clientX, e.clientY);
});
document.addEventListener('mousedown', function (e) {
if (pickPop && !pickPop.contains(e.target) && !e.target.closest('[data-pick-type]')) closePickPop();
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closePickPop(); });
function openPickPop(type, id, cx, cy) {
closePickPop();
if (type === 'wire') buildWirePop(id);
else if (type === 'conn') buildConnPop(id);
if (!pickPop) return;
document.body.appendChild(pickPop);
positionPop(cx, cy);
requestAnimationFrame(function () { pickPop.classList.add('show'); });
}
function closePickPop() {
if (!pickPop) return;
var p = pickPop; pickPop = null;
p.classList.remove('show');
setTimeout(function () { p.remove(); }, 150);
if (editorDirty) { editorDirty = false; renderEditor(); }
}
function positionPop(cx, cy) {
var M = 10, w = pickPop.offsetWidth, hh = pickPop.offsetHeight;
var x = cx + 14, y = cy + 14;
if (x + w + M > window.innerWidth) x = cx - 14 - w;
if (y + hh + M > window.innerHeight) y = cy - 14 - hh;
x = Math.max(M, Math.min(x, window.innerWidth - w - M));
y = Math.max(M, Math.min(y, window.innerHeight - hh - M));
pickPop.style.left = x + 'px'; pickPop.style.top = y + 'px';
}
function buildWirePop(id) {
var w = state.wires.find(function (x) { return x.id === id; }); if (!w) return;
var fromC = w.from && w.from.conn, toC = w.to && w.to.conn;
var paired = fromC && toC && fromC !== toC;
var seg = paired ? getSegment(fromC, toC) : null;
var col = HL.PALETTE[w.color] ? HL.PALETTE[w.color].hex : '#666';
var stripeHex = w.stripe && HL.PALETTE[w.stripe] ? HL.PALETTE[w.stripe].hex : 'transparent';
pickPop = h(`<div class="pick-pop" data-wire="${esc(id)}">
<div class="pp-head"><span class="item-tag">${esc(w.id)}</span><span class="pp-sub">${esc(connLabel(fromC) || '?')} → ${esc(connLabel(toC) || '?')}</span><button class="pp-close" data-pp="close" data-tip="Close">×</button></div>
<div class="pp-body">
<label class="pp-l">Color</label>
<div class="color-wrap"><span class="swatch" data-pp-sw="color" style="background:${col}"></span><select class="grow" data-pp="color">${colorOptions(w.color)}</select></div>
<div class="grid2">
<div><label class="pp-l">Gauge</label><select data-pp="gauge">${optionList(HL.GAUGES, w.gauge, null, function (g) { return g + ' AWG'; })}</select></div>
<div><label class="pp-l">Length mm</label>${paired ? `<input type="number" min="0" data-pp="seglen" data-tip="Shared by all ${esc(connLabel(fromC))}↔${esc(connLabel(toC))} wires" value="${seg && seg.length != null ? seg.length : ''}">` : `<input type="number" disabled placeholder="set both ends">`}</div>
</div>
<label class="pp-l">Stripe</label>
<div class="color-wrap"><span class="swatch" data-pp-sw="stripe" style="background:${stripeHex}"></span><select class="grow" data-pp="stripe">${colorOptions(w.stripe || '', true)}</select></div>
<label class="pp-l">Signal / Notes</label>
<input type="text" data-pp="signal" value="${esc(w.signal || '')}">
</div></div>`);
wirePop();
}
function buildConnPop(id) {
var c = state.connectors.find(function (x) { return x.id === id; }); if (!c) return;
pickPop = h(`<div class="pick-pop" data-conn="${esc(id)}">
<div class="pp-head"><span class="item-tag">${esc(c.id)}</span><span class="pp-sub">connector</span><button class="pp-close" data-pp="close" data-tip="Close">×</button></div>
<div class="pp-body">
<label class="pp-l">Label</label><input type="text" data-pp="name" value="${esc(c.name || '')}">
<label class="pp-l">Type</label><select data-pp="type">${typeOptions(c.type)}</select>
<div class="grid2">
<div><label class="pp-l">Pins</label><input type="number" min="1" max="64" data-pp="pins" value="${c.pins}"></div>
<div><label class="pp-l">Side</label><select data-pp="side">${optionList([['', 'Auto'], ['left', 'Left end'], ['right', 'Right end'], ['up', 'Branch up'], ['down', 'Branch down']], c.side || '', function (x) { return x[0]; }, function (x) { return x[1]; })}</select></div>
</div>
</div></div>`);
wirePop();
}
function wirePop() { // shared listeners
pickPop.addEventListener('input', onPopInput);
pickPop.addEventListener('change', onPopInput);
pickPop.addEventListener('click', function (e) { if (e.target.closest('[data-pp="close"]')) closePickPop(); });
}
function onPopInput(e) {
var el = e.target, f = el.dataset.pp; if (!f || f === 'close') return;
if (pickPop.dataset.wire) {
var w = state.wires.find(function (x) { return x.id === pickPop.dataset.wire; }); if (!w) return;
if (f === 'color') { w.color = el.value; setSw('color', el.value, '#666'); }
else if (f === 'stripe') { if (el.value === '') delete w.stripe; else w.stripe = el.value; setSw('stripe', el.value, 'transparent'); }
else if (f === 'gauge') w.gauge = el.value;
else if (f === 'signal') w.signal = el.value;
else if (f === 'seglen') { var s = ensureSegment(w.from.conn, w.to.conn); if (s) { var v = el.value === '' ? undefined : parseFloat(el.value); s.length = (v == null || isNaN(v)) ? undefined : v; } }
} else if (pickPop.dataset.conn) {
var c = state.connectors.find(function (x) { return x.id === pickPop.dataset.conn; }); if (!c) return;
if (f === 'name') c.name = el.value;
else if (f === 'type') c.type = el.value;
else if (f === 'pins') { var p = parseInt(el.value, 10); if (!isNaN(p)) c.pins = Math.max(1, p); }
else if (f === 'side') { if (el.value === '') delete c.side; else c.side = el.value; }
}
editorDirty = true; schedulePreview();
}
function setSw(which, code, fallback) {
var sw = pickPop.querySelector('[data-pp-sw="' + which + '"]');
if (sw) sw.style.background = HL.PALETTE[code] ? HL.PALETTE[code].hex : fallback;
}
/* ============================================================
* small drive API (for CLI/AI testing)
* ========================================================== */
window.harnessApp = {
getModel: function () { return cleanModel(); },
setModel: function (m) { loadFromText(typeof m === 'string' ? m : JSON.stringify(m), 'model'); },
exportPDF: exportPDF, saveJSON: saveJSON,
pdfDataUrl: function () { var r = RPDF.buildFromModel(cleanModel()); return r.doc.output('datauristring'); },
toast: toast,
};
/* ============================================================
* BOOT
* ========================================================== */
function boot() {
var saved = null;
try { saved = JSON.parse(localStorage.getItem(LS_KEY)); } catch (e) {}
if (saved && Array.isArray(saved.connectors)) state = saved;
else state = clone(HL.defaultModel);
if (!Array.isArray(state.segments)) state.segments = [];
ensureSegments();
renderEditor();
renderPreview();
}
boot();
})();