master
noah Publish 1.0.0 a09b9ee 2mo ago
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
  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')} &middot; ${c.pins}P</span>
          <button class="item-del" data-action="del-conn" data-index="${i}" data-tip="Remove this connector">&times;</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) || '?')} &rarr; ${esc(connLabel(toC) || '?')}</span>
          <button class="item-del" data-action="del-wire" data-index="${i}" data-tip="Remove this wire">&times;</button>
        </div>
        <div class="field"><label>From &mdash; connector &amp; 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 &mdash; connector &amp; 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 ? '&middot; ' + esc(connLabel(fromC)) + '&harr;' + 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">&harr;</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">&times;</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">&#9660;</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) || '?')} &rarr; ${esc(connLabel(toC) || '?')}</span><button class="pp-close" data-pp="close" data-tip="Close">&times;</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))}&harr;${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">&times;</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();
})();