12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664
/**
 * Branded Symbol Viewer Generator
 *
 * Generates a complete interactive HTML viewer for KiCad symbols,
 * themed with the Adom dark design system. Includes:
 *
 *   - Dark canvas with themed SVG (via svg-theme.js)
 *   - Pin hover highlighting (wire + dot + name + number → teal)
 *   - Sticky click-to-pin tooltips with text selection
 *   - Pin source attribution (datasheet link in tooltip footer)
 *   - Wiki edit button (top-right, links to Adom Wiki)
 *   - Group label hover with purple highlight
 *   - Discrete component support (hit zones from pin coordinates)
 *   - Zoom, pan, grid toggle, keyboard shortcuts
 *   - Bottom info bar with metadata
 */

import { readFile } from 'fs/promises';
import { THEME } from '../../viewer/adom-theme.js';
import { symExportSvg } from '../../viewer/kicad-api-client.js';
import { applyDarkTheme, colorGroupLabels, stripRefValueText, colorPinNumbers, THEME_COLORS } from './svg-theme.js';

// ── Parse .kicad_sym for metadata and pins ───────────────────────────

function parseKicadSym(content, symbolName) {
  const meta = {
    name: symbolName || 'Unknown',
    reference: '',
    value: '',
    footprint: '',
    datasheet: '',
    description: '',
    manufacturer: '',
    pins: [],
    groupLabels: [],
    hidePinNames: false,
    hidePinNumbers: false,
  };

  // Check if pin names/numbers are hidden (discrete components)
  // Format: (pin_names (offset 0) (hide yes)) — may have (offset N) before (hide yes)
  // Use [^\n]* to match within same line (avoids matching hidden properties on other lines)
  if (/\(pin_names[^\n]*\(hide\s+yes\)/.test(content)) {
    meta.hidePinNames = true;
  }
  if (/\(pin_numbers[^\n]*\(hide\s+yes\)/.test(content)) {
    meta.hidePinNumbers = true;
  }

  // Match the ENTIRE top-level symbol block including sub-symbols.
  // Our generated files always have exactly one symbol, so match from
  // the symbol name to the end of the file content.
  const symRegex = new RegExp(`\\(symbol "${symbolName || '[^"]*'}"[\\s\\S]*`);
  const symMatch = content.match(symRegex);
  if (!symMatch) return meta;
  let symBlock = symMatch[0];

  // Extract properties from the child symbol block first (correct variant metadata)
  const propRegex = /\(property "(\w+)" "([^"]*)"/g;
  let m;
  while ((m = propRegex.exec(symBlock)) !== null) {
    const key = m[1].toLowerCase();
    const val = m[2];
    if (key === 'reference') meta.reference = val;
    else if (key === 'value') meta.value = val;
    else if (key === 'footprint') meta.footprint = val;
    else if (key === 'datasheet') meta.datasheet = val;
    else if (key === 'description') meta.description = val;
    else if (key === 'manufacturer') meta.manufacturer = val;
  }

  if (!meta.name || meta.name === 'Unknown') {
    meta.name = meta.value || symbolName || 'Unknown';
  }

  // Resolve extends: if this symbol extends a parent, switch to the parent block
  // for pin/geometry data (child only overrides properties, not pin layout)
  const extendsMatch = symBlock.match(/\(extends\s+"([^"]+)"\)/);
  if (extendsMatch) {
    const parentName = extendsMatch[1];
    const parentRegex = new RegExp(`\\(symbol "${parentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"[\\s\\S]*`);
    const parentMatch = content.match(parentRegex);
    if (parentMatch) {
      symBlock = parentMatch[0];
      // Re-check pin visibility from parent
      if (/\(pin_names[^\n]*\(hide\s+yes\)/.test(symBlock)) meta.hidePinNames = true;
      if (/\(pin_numbers[^\n]*\(hide\s+yes\)/.test(symBlock)) meta.hidePinNumbers = true;
    }
  }

  // Parse pins — handle BOTH compact single-line and multi-line KiCad formats
  // Compact: (pin TYPE STYLE (at X Y A) (length L) (name "N") (number "N"))
  // Multi-line: (pin TYPE STYLE (at X Y A) (length L) (name "N" (effects ...)) (number "N" (effects ...)))
  // Strategy: match the fixed structure up to name/number strings, ignore nested effects
  const pinRegex = /\(pin\s+(\w+)\s+(\w+)\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+(\d+)\)\s+\(length\s+([-\d.]+)\)\s+\(name\s+"([^"]*)"[\s\S]*?\(number\s+"([^"]*)"/g;
  while ((m = pinRegex.exec(symBlock)) !== null) {
    meta.pins.push({
      electricalType: m[1],
      graphicStyle: m[2],
      x: parseFloat(m[3]),
      y: parseFloat(m[4]),
      angle: parseInt(m[5]),
      length: parseFloat(m[6]),
      name: m[7] === '~' ? m[8] : m[7],  // Use number when name is hidden tilde
      number: m[8],
    });
  }

  // Parse group labels — (text "LABEL" ...) elements with dark red color
  const groupLabelRegex = /\(text "([^"]+)"\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+\d+\)\s*\n\s*\(effects\s+\(font\s+\(size\s+0\.762\s+0\.762\)\s+\(color\s+132\s+0\s+0\s+1\)\)/g;
  while ((m = groupLabelRegex.exec(symBlock)) !== null) {
    meta.groupLabels.push({ name: m[1] });
  }

  // Parse body rectangle — (rectangle (start X1 Y1) (end X2 Y2) ...)
  const rectMatch = symBlock.match(/\(rectangle\s+\(start\s+([-\d.]+)\s+([-\d.]+)\)\s+\(end\s+([-\d.]+)\s+([-\d.]+)\)/);
  if (rectMatch) {
    meta.bodyRect = {
      x1: parseFloat(rectMatch[1]), y1: parseFloat(rectMatch[2]),
      x2: parseFloat(rectMatch[3]), y2: parseFloat(rectMatch[4]),
    };
  }

  return meta;
}

function electricalTypeLabel(type) {
  const labels = {
    power_in: 'Power Input', power_out: 'Power Output', input: 'Input',
    output: 'Output', bidirectional: 'Bidirectional', passive: 'Passive',
    tri_state: 'Tri-State', unspecified: 'Unspecified',
    open_collector: 'Open Collector', open_emitter: 'Open Emitter',
    unconnected: 'Unconnected', free: 'Free',
  };
  return labels[type] || type;
}

function electricalTypeColor(type) {
  return {
    power_in: THEME.pinPower, power_out: THEME.pinPower,
    input: THEME.pinInput, output: THEME.pinOutput,
    bidirectional: THEME.pinBidir, passive: THEME.pinPassive,
  }[type] || THEME.pinDefault;
}

function esc(s) {
  if (!s) return '';
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

/**
 * Compute the KiCad symbol origin → SVG coordinate offset by matching
 * pin wire paths in the exported SVG against known pin coordinates.
 *
 * KiCad SVG places the symbol so all content fits in the viewBox, but the
 * origin is NOT necessarily at the viewBox center (stripped ref/value text
 * shifts the layout). We find the actual offset by identifying pin wire
 * paths (simple 2-point lines matching pin length and orientation).
 *
 * Mapping: svgX = offset.x + kicadX,  svgY = offset.y - kicadY
 *
 * @returns {{ x: number, y: number }} or null if no match found
 */
function computeKicadToSvgOffset(svgContent, pins) {
  if (!pins || pins.length === 0) return null;

  // Find simple 2-point straight-line paths: d="M x1 y1\nL x2 y2\n"
  // Use [-\d.]+ to handle negative coordinates (can occur when viewBox origin is negative)
  const lineRegex = /d="M(-?[\d.]+)\s+(-?[\d.]+)\s+L(-?[\d.]+)\s+(-?[\d.]+)\s*"/g;
  const lines = [];
  let m;
  while ((m = lineRegex.exec(svgContent)) !== null) {
    lines.push({
      x1: parseFloat(m[1]), y1: parseFloat(m[2]),
      x2: parseFloat(m[3]), y2: parseFloat(m[4]),
    });
  }

  // For each pin, find the SVG wire that matches its length and orientation
  for (const pin of pins) {
    const expectedLen = pin.length || 2.54;
    const isHoriz = (pin.angle === 0 || pin.angle === 180);

    for (const line of lines) {
      const dx = Math.abs(line.x2 - line.x1);
      const dy = Math.abs(line.y2 - line.y1);
      const len = Math.sqrt(dx * dx + dy * dy);
      if (Math.abs(len - expectedLen) > 0.05) continue;
      if (isHoriz && dy > 0.01) continue;
      if (!isHoriz && dx > 0.01) continue;

      // Found matching wire — determine which end is the tip
      let tipSvgX, tipSvgY;
      switch (pin.angle) {
        case 0:   // wire extends right toward body; tip is LEFT end
          tipSvgX = Math.min(line.x1, line.x2);
          tipSvgY = (line.y1 + line.y2) / 2;
          break;
        case 180: // wire extends left toward body; tip is RIGHT end
          tipSvgX = Math.max(line.x1, line.x2);
          tipSvgY = (line.y1 + line.y2) / 2;
          break;
        case 90:  // wire extends down in SVG (up in KiCad); tip is BOTTOM
          tipSvgX = (line.x1 + line.x2) / 2;
          tipSvgY = Math.max(line.y1, line.y2);
          break;
        case 270: // wire extends up in SVG (down in KiCad); tip is TOP
          tipSvgX = (line.x1 + line.x2) / 2;
          tipSvgY = Math.min(line.y1, line.y2);
          break;
      }

      // offset: svgX = ox + kicadX,  svgY = oy - kicadY
      return { x: tipSvgX - pin.x, y: tipSvgY + pin.y };
    }
  }

  return null; // no match found
}

/**
 * Compute content bounds from pin positions and body rectangle using the KiCad→SVG offset.
 * Falls back to viewBox center if no offset available.
 *
 * @param {Array} pins - Pin data from parseKicadSym
 * @param {number} vbW - SVG viewBox width
 * @param {number} vbH - SVG viewBox height
 * @param {object|null} offset - KiCad→SVG offset { x, y }
 * @param {object|null} bodyRect - Body rectangle { x1, y1, x2, y2 } in KiCad coords
 */
function computeContentBoundsFromPins(pins, vbW, vbH, offset, bodyRect) {
  if (!pins || pins.length === 0) return null;

  const ox = offset ? offset.x : vbW / 2;
  const oy = offset ? offset.y : vbH / 2;
  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;

  for (const pin of pins) {
    // Pin tip in SVG coords
    const tipX = ox + pin.x;
    const tipY = oy - pin.y;

    // Pin body end (opposite end of wire from tip)
    let bodyX = tipX, bodyY = tipY;
    const len = pin.length || 2.54;
    switch (pin.angle) {
      case 0:   bodyX += len; break;
      case 180: bodyX -= len; break;
      case 90:  bodyY -= len; break;
      case 270: bodyY += len; break;
    }

    minX = Math.min(minX, tipX, bodyX);
    maxX = Math.max(maxX, tipX, bodyX);
    minY = Math.min(minY, tipY, bodyY);
    maxY = Math.max(maxY, tipY, bodyY);
  }

  // Include body rectangle if present (IC symbols)
  // Mapping: svgX = ox + kicadX, svgY = oy - kicadY
  if (bodyRect) {
    const bx1 = ox + bodyRect.x1, bx2 = ox + bodyRect.x2;
    const by1 = oy - bodyRect.y1, by2 = oy - bodyRect.y2;
    minX = Math.min(minX, bx1, bx2);
    maxX = Math.max(maxX, bx1, bx2);
    minY = Math.min(minY, by1, by2);
    maxY = Math.max(maxY, by1, by2);
  }

  // Padding: enough for pin number text beyond wire tips and symbol body graphics
  const pad = 5;
  return { x: minX - pad, y: minY - pad, w: (maxX - minX) + pad * 2, h: (maxY - minY) + pad * 2 };
}

// ── Main entry point ────────────────────────────────────────────────

/**
 * Generate a complete branded interactive viewer HTML.
 *
 * @param {string} symPath - Path to .kicad_sym file
 * @param {string} symbolName - Symbol name in the file
 * @param {object} options
 * @param {object} [options.pinDescriptions] - Map of pin name → description
 * @param {object} [options.groupDescriptions] - Map of group name → description
 * @param {string} [options.datasheetUrl] - Datasheet URL for source attribution
 * @param {string} [options.manufacturer] - Manufacturer name for source attribution
 * @returns {Promise<string>} Complete HTML string
 */
export async function generateBrandedViewer(symPath, symbolName, options = {}) {
  const { pinDescriptions = {}, groupDescriptions = {}, datasheetUrl, manufacturer, svgContent: preRenderedSvg } = options;

  const content = await readFile(symPath, 'utf-8');
  if (!symbolName) {
    const nameMatch = content.match(/\(symbol "([^"]+)"/);
    symbolName = nameMatch ? nameMatch[1] : 'Unknown';
  }

  const meta = parseKicadSym(content, symbolName);

  // Export SVG via KiCad service (or use pre-rendered SVG if provided)
  let svgContent = preRenderedSvg || await symExportSvg(symPath, symbolName);

  // Extract viewBox dimensions before theming
  const vbMatch = svgContent.match(/viewBox="([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)"/);
  const svgWidth = vbMatch ? parseFloat(vbMatch[3]) : 100;
  const svgHeight = vbMatch ? parseFloat(vbMatch[4]) : 100;

  // Compute KiCad → SVG coordinate offset BEFORE theming (need raw paths)
  // The offset accounts for the fact that the SVG viewBox is NOT centered on the symbol origin
  const svgOffset = computeKicadToSvgOffset(svgContent, meta.pins);

  // Apply Adom dark theme
  svgContent = applyDarkTheme(svgContent);
  svgContent = colorGroupLabels(svgContent, meta.groupLabels.map(g => g.name));
  svgContent = stripRefValueText(svgContent, meta.reference, meta.value);

  // Remove <title> tags
  svgContent = svgContent.replace(/<title>[^<]*<\/title>/g, '');

  // For ICs (visible pin names), color pin numbers distinctly from pin names.
  // KiCad renders both as the same color — we re-color pin numbers to muted
  // gray so they're visually subordinate to the white pin names inside the body.
  if (!meta.hidePinNames && meta.pins.length > 0) {
    const pinNumbers = meta.pins.map(p => p.number);
    const pinNames = meta.pins.map(p => p.name);
    const groupNames = meta.groupLabels.map(g => g.name);
    svgContent = colorPinNumbers(svgContent, pinNumbers, pinNames, groupNames);
  }

  // For discrete symbols: strip KiCad-rendered pin numbers (stroked-text) since
  // we inject our own styled labels below. Prevents visual duplication.
  if (meta.hidePinNames && meta.pins.length > 0) {
    for (const pin of meta.pins) {
      const esc = pin.number.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
      svgContent = svgContent.replace(
        new RegExp('<g class="stroked-text"><desc>' + esc + '</desc>[\\s\\S]*?</g>', 'g'), ''
      );
      svgContent = svgContent.replace(
        new RegExp('<text[^>]*opacity="0"[^>]*>' + esc + '</text>', 'g'), ''
      );
    }
  }

  // Inject pin name/number labels for discrete components (where KiCad hides them)
  // Font sizes here are for the viewer preview only — the .kicad_sym file uses the
  // standard 1.27mm sizes enforced by kicad-validate.js.
  const VIEWER_NAME_FONT = 1.1;   // pin name (viewer-only, larger for readability)
  const VIEWER_NUM_FONT = 0.75;   // pin number (viewer-only, smaller secondary info)
  if (meta.hidePinNames && meta.pins.length > 0) {
    // Use computed offset (or fall back to viewBox center)
    const ox = svgOffset ? svgOffset.x : svgWidth / 2;
    const oy = svgOffset ? svgOffset.y : svgHeight / 2;
    let labelSvg = '';
    for (const pin of meta.pins) {
      // Pin tip in SVG coords using real offset
      const tipX = ox + pin.x;
      const tipY = oy - pin.y;

      // Place name and number near the tip:
      //   - Name: offset outward from the tip along the pin direction
      //   - Number: always to the right of the name, same baseline, smaller gray text
      let nameX, nameY, numX, numY;
      let nameAnchor = 'middle', numAnchor = 'start';
      const tipGap = 1.0; // mm from tip to name baseline
      const numGap = 0.3; // mm gap between name and number

      // Name: outward from tip along the wire direction
      // Number: perpendicular to wire — below for horizontal, right for vertical
      const perpOff = 0.5; // perpendicular offset from wire center
      switch (pin.angle) {
        case 0: // tip on LEFT — name further left, number below wire at tip
          nameX = tipX - tipGap;
          nameY = tipY + VIEWER_NAME_FONT * 0.35;
          nameAnchor = 'end';
          numX = tipX;
          numY = tipY + perpOff + VIEWER_NUM_FONT;
          numAnchor = 'middle';
          break;
        case 180: // tip on RIGHT — name further right, number below wire at tip
          nameX = tipX + tipGap;
          nameY = tipY + VIEWER_NAME_FONT * 0.35;
          nameAnchor = 'start';
          numX = tipX;
          numY = tipY + perpOff + VIEWER_NUM_FONT;
          numAnchor = 'middle';
          break;
        case 270: // tip on TOP — name above tip, number to the right of wire
          nameX = tipX;
          nameY = tipY - tipGap;
          nameAnchor = 'middle';
          numX = tipX + perpOff + 0.2;
          numY = tipY + VIEWER_NUM_FONT * 0.35;
          numAnchor = 'start';
          break;
        case 90: // tip on BOTTOM — name below tip, number to the right of wire
          nameX = tipX;
          nameY = tipY + tipGap + VIEWER_NAME_FONT;
          nameAnchor = 'middle';
          numX = tipX + perpOff + 0.2;
          numY = tipY + VIEWER_NUM_FONT * 0.35;
          numAnchor = 'start';
          break;
      }

      // Skip duplicate name label when name === number (e.g., passive R/C/L with hidden "~" names)
      if (pin.name !== pin.number) {
        labelSvg += `<text x="${nameX.toFixed(3)}" y="${nameY.toFixed(3)}" ` +
          `font-size="${VIEWER_NAME_FONT}" font-family="sans-serif" fill="${THEME_COLORS.pinName}" ` +
          `text-anchor="${nameAnchor}" class="pin-label">${esc(pin.name)}</text>\n`;
      }
      // Always inject pin number (use name position when name was skipped)
      const fnX = (pin.name === pin.number) ? nameX : numX;
      const fnY = (pin.name === pin.number) ? nameY : numY;
      const fnAnchor = (pin.name === pin.number) ? nameAnchor : numAnchor;
      const fnFont = (pin.name === pin.number) ? VIEWER_NAME_FONT : VIEWER_NUM_FONT;
      const fnFill = (pin.name === pin.number) ? THEME_COLORS.pinName : THEME_COLORS.pinNumber;
      labelSvg += `<text x="${fnX.toFixed(3)}" y="${fnY.toFixed(3)}" ` +
        `font-size="${fnFont}" font-family="sans-serif" fill="${fnFill}" ` +
        `text-anchor="${fnAnchor}" class="pin-num-label">${esc(pin.number)}</text>\n`;
    }
    svgContent = svgContent.replace('</svg>', labelSvg + '</svg>');

    // Expand viewBox if labels extend past current bounds
    const allY = [];
    const allX = [];
    const _tg = 1.0; // must match tipGap above
    for (const pin of meta.pins) {
      const tipX = ox + pin.x, tipY = oy - pin.y;
      switch (pin.angle) {
        case 0:   allX.push(tipX - _tg - 2); allY.push(tipY + 2); break;
        case 180: allX.push(tipX + _tg + 2); allY.push(tipY + 2); break;
        case 270: allY.push(tipY - _tg - VIEWER_NAME_FONT - 0.5); allX.push(tipX + 2); break;
        case 90:  allY.push(tipY + _tg + VIEWER_NAME_FONT + 1); allX.push(tipX + 2); break;
      }
    }
    const labelMaxX = Math.max(...allX, svgWidth);
    const labelMaxY = Math.max(...allY, svgHeight);
    const labelMinX = Math.min(...allX, 0);
    const labelMinY = Math.min(...allY, 0);
    if (labelMaxY > svgHeight || labelMaxX > svgWidth || labelMinX < 0 || labelMinY < 0) {
      const newVbX = Math.min(0, labelMinX);
      const newVbY = Math.min(0, labelMinY);
      const newVbW = Math.max(svgWidth, labelMaxX) - newVbX;
      const newVbH = Math.max(svgHeight, labelMaxY) - newVbY;
      svgContent = svgContent.replace(
        /viewBox="[\d.\-\s]+"/,
        `viewBox="${newVbX.toFixed(4)} ${newVbY.toFixed(4)} ${newVbW.toFixed(4)} ${newVbH.toFixed(4)}"`
      );
    }
  }

  // Compute content bounds using the real offset + body rectangle
  const contentBounds = computeContentBoundsFromPins(meta.pins, svgWidth, svgHeight, svgOffset, meta.bodyRect);

  // Expand viewBox to encompass the BBox rect (which has generous padding)
  if (contentBounds) {
    const vbCur = svgContent.match(/viewBox="([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)"/);
    if (vbCur) {
      const curX = parseFloat(vbCur[1]), curY = parseFloat(vbCur[2]);
      const curW = parseFloat(vbCur[3]), curH = parseFloat(vbCur[4]);
      const bboxRight = contentBounds.x + contentBounds.w;
      const bboxBottom = contentBounds.y + contentBounds.h;
      const strokeMargin = 0.3; // extra margin for BBox stroke + visual breathing room
      const newX = Math.min(curX, contentBounds.x - strokeMargin);
      const newY = Math.min(curY, contentBounds.y - strokeMargin);
      const newW = Math.max(curX + curW, bboxRight + strokeMargin) - newX;
      const newH = Math.max(curY + curH, bboxBottom + strokeMargin) - newY;
      if (newX < curX || newY < curY || newW > curW || newH > curH) {
        svgContent = svgContent.replace(
          /viewBox="[\d.\-\s]+"/,
          `viewBox="${newX.toFixed(4)} ${newY.toFixed(4)} ${newW.toFixed(4)} ${newH.toFixed(4)}"`
        );
      }
    }
  }

  // Inject a debug bounding box rect (light dashed outline) — toggled by BBox button
  if (contentBounds) {
    svgContent = svgContent.replace(
      /<\/svg>/,
      `<rect x="${contentBounds.x}" y="${contentBounds.y}" width="${contentBounds.w}" height="${contentBounds.h}" ` +
      `fill="none" stroke="#00e6dc" stroke-width="0.1" stroke-dasharray="0.3,0.3" opacity="0.4" class="debug-bbox"/></svg>`
    );
  }

  // Re-extract final viewBox dimensions after all expansions (label injection + content bounds)
  // The original svgWidth/svgHeight may be stale if the viewBox was expanded
  const finalVbMatch = svgContent.match(/viewBox="([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)"/);
  const finalSvgWidth = finalVbMatch ? parseFloat(finalVbMatch[3]) : svgWidth;
  const finalSvgHeight = finalVbMatch ? parseFloat(finalVbMatch[4]) : svgHeight;

  // Build pin data for JS
  const pinData = JSON.stringify(meta.pins.map(p => ({
    name: p.name, number: p.number,
    type: electricalTypeLabel(p.electricalType),
    electricalType: p.electricalType,
    color: electricalTypeColor(p.electricalType),
    desc: pinDescriptions[p.name] || pinDescriptions[p.number] || '',
    x: p.x, y: p.y, angle: p.angle, length: p.length,
  })));

  const groupData = JSON.stringify(meta.groupLabels.map(g => ({
    name: g.name,
    desc: groupDescriptions[g.name] || '',
  })));

  // Build info row
  const srcDatasheet = datasheetUrl || meta.datasheet;
  const srcManufacturer = manufacturer || meta.manufacturer;
  const infoItems = [];
  if (srcManufacturer) infoItems.push({ label: 'Mfr', value: srcManufacturer });
  if (meta.footprint) infoItems.push({ label: 'Pkg', value: meta.footprint.split(':').pop() || meta.footprint });
  infoItems.push({ label: 'Pins', value: String(meta.pins.length) });
  if (srcDatasheet) infoItems.push({ label: 'Datasheet', value: 'PDF', href: srcDatasheet });
  if (srcDatasheet && srcManufacturer) {
    infoItems.push({ label: 'Pin Source', value: `${srcManufacturer} Datasheet`, href: srcDatasheet });
  }
  if (meta.description) infoItems.push({ label: 'Desc', value: meta.description });

  const infoRowHtml = infoItems.map(item =>
    `<div class="info-item"><span class="info-label">${esc(item.label)}</span><span class="info-value">${
      item.href ? `<a href="${esc(item.href)}" target="_blank">${esc(item.value)}</a>` : esc(item.value)
    }</span></div>`
  ).join('');

  // Wiki edit URL
  const wikiSlug = symbolName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
  const wikiEditUrl = `https://coder.john-service-wiki-a054078430ce38e4.containers.adom.inc/proxy/8785/wiki/components/${wikiSlug}/edit?field=content`;

  const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${esc(meta.name)}</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    background: ${THEME.bg};
    font-family: ${THEME.fontStack};
    overflow: hidden;
    height: 100vh;
    display: flex;
    flex-direction: column;
    color: ${THEME.text};
  }

  /* ── Top toolbar ── */
  .toolbar {
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 8px 14px;
    background: ${THEME.bgSurface};
    border-bottom: 1px solid ${THEME.borderMuted};
    flex-shrink: 0;
    font-size: 13px;
  }
  .toolbar .title {
    font-weight: 600;
    color: ${THEME.accent};
    font-size: 15px;
    letter-spacing: 0.02em;
  }
  .toolbar .spacer { flex: 1; }
  .toolbar .sep {
    width: 1px;
    height: 22px;
    background: ${THEME.borderMuted};
    margin: 0 4px;
    flex-shrink: 0;
  }
  .toolbar .btn-group {
    display: flex;
    align-items: center;
    gap: 2px;
    flex-shrink: 0;
  }
  .toolbar button {
    background: ${THEME.accentMuted};
    border: 1px solid ${THEME.accentBorder};
    border-radius: ${THEME.radiusSm};
    padding: 4px 10px;
    cursor: pointer;
    font-size: 12px;
    color: ${THEME.textSecondary};
    transition: all 0.15s;
  }
  .toolbar button:hover { background: rgba(0,184,176,0.18); color: ${THEME.text}; border-color: rgba(0,184,176,0.4); }
  .toolbar button:active { background: rgba(0,184,176,0.28); }
  .toolbar button.active { background: rgba(0,184,176,0.2); color: ${THEME.accent}; border-color: rgba(0,184,176,0.4); }
  .toolbar .zoom-level {
    font-size: 11px;
    color: ${THEME.textSecondary};
    min-width: 44px;
    text-align: center;
    font-variant-numeric: tabular-nums;
  }

  /* ── Canvas (dark background) ── */
  .canvas-wrap {
    flex: 1;
    overflow: hidden;
    position: relative;
    cursor: grab;
    background-color: ${THEME_COLORS.canvasBg};
    background-image:
      radial-gradient(ellipse at 50% 50%, rgba(0,184,176,0.03) 0%, transparent 70%),
      linear-gradient(rgba(255,255,255,0.015) 1px, transparent 1px),
      linear-gradient(90deg, rgba(255,255,255,0.015) 1px, transparent 1px);
    background-size: 100% 100%, 20px 20px, 20px 20px;
  }
  .canvas-wrap.dragging { cursor: grabbing; }
  .canvas-wrap.no-grid {
    background-image: radial-gradient(ellipse at 50% 50%, rgba(0,184,176,0.03) 0%, transparent 70%);
    background-size: 100% 100%;
  }

  .canvas {
    position: absolute;
    transform-origin: 0 0;
  }
  .canvas svg { display: block; }

  /* ── Tooltip ── */
  .tooltip {
    display: none;
    position: fixed;
    background: ${THEME.bgOverlay};
    backdrop-filter: blur(16px);
    -webkit-backdrop-filter: blur(16px);
    color: ${THEME.text};
    padding: 10px 14px;
    border-radius: 10px;
    border: 1px solid ${THEME.accentBorder};
    font-size: 12px;
    line-height: 1.6;
    pointer-events: none;
    user-select: none;
    z-index: 100;
    max-width: 300px;
    box-shadow: 0 8px 24px rgba(0,0,0,0.4);
    transition: border-color 0.15s;
  }
  .tooltip.visible { display: block; }
  .tooltip.sticky {
    pointer-events: auto;
    user-select: text;
    border-color: ${THEME.accentBright};
    box-shadow: 0 8px 32px rgba(0,184,176,0.15), 0 8px 24px rgba(0,0,0,0.4);
  }
  .tooltip .tt-name { font-weight: 700; font-size: 15px; color: ${THEME.accentBright}; }
  .tooltip .tt-pin { color: ${THEME.textSecondary}; font-size: 11px; margin-left: 4px; }
  .tooltip .tt-type {
    display: inline-block;
    margin-top: 4px;
    padding: 2px 8px;
    border-radius: ${THEME.radiusSm};
    font-size: 11px;
    font-weight: 500;
  }
  .tooltip .tt-desc {
    margin-top: 6px;
    padding-top: 6px;
    border-top: 1px solid ${THEME.borderMuted};
    color: ${THEME.textSecondary};
    font-size: 11px;
    line-height: 1.5;
  }
  .tooltip .tt-source {
    margin-top: 6px;
    padding-top: 6px;
    border-top: 1px solid ${THEME.borderMuted};
    font-size: 10px;
    color: ${THEME.textMuted};
  }
  .tooltip .tt-source a { color: ${THEME.accent}; text-decoration: none; }
  .tooltip .tt-source a:hover { text-decoration: underline; }
  .tooltip .tt-source-label { color: ${THEME_COLORS.groupLabel}; font-weight: 600; }
  .tooltip .tt-hint {
    margin-top: 6px;
    font-size: 9px;
    color: ${THEME.textMuted};
    font-style: italic;
  }
  .tooltip .tt-group-name { font-weight: 700; font-size: 14px; color: ${THEME_COLORS.groupLabel}; }
  .tooltip .tt-group-badge {
    display: inline-block;
    margin-top: 4px;
    padding: 2px 8px;
    border-radius: ${THEME.radiusSm};
    font-size: 11px;
    font-weight: 500;
    background: rgba(230,180,80,0.15);
    color: ${THEME_COLORS.groupLabelHover};
    border: 1px solid rgba(230,180,80,0.3);
  }

  /* ── Wiki edit button ── */
  .edit-btn {
    position: absolute; top: 10px; right: 10px; z-index: 100;
    display: flex; align-items: center; gap: 6px;
    padding: 5px 10px;
    background: rgba(22,27,34,0.75);
    backdrop-filter: blur(8px);
    border: 1px solid rgba(230,180,80,0.2);
    border-radius: 6px;
    color: #8b949e; font-size: 11px;
    cursor: pointer; text-decoration: none;
    transition: all 0.2s; opacity: 0.7;
  }
  .edit-btn:hover {
    opacity: 1;
    background: rgba(230,180,80,0.12);
    border-color: rgba(230,180,80,0.4);
    color: ${THEME_COLORS.groupLabelHover};
  }
  .edit-btn svg { width: 12px; height: 12px; }

  /* Regen prompt modal */
  .regen-overlay {
    display: none;
    position: fixed; inset: 0;
    background: rgba(0,0,0,0.6);
    z-index: 2000;
    align-items: center; justify-content: center;
  }
  .regen-overlay.visible { display: flex; }
  .regen-modal {
    background: ${THEME_COLORS.bgSurface};
    border: 1px solid ${THEME_COLORS.borderMuted};
    border-radius: 10px;
    padding: 20px 24px;
    max-width: 520px;
    width: 90%;
    box-shadow: 0 12px 40px rgba(0,0,0,0.5);
  }
  .regen-modal h3 {
    margin: 0 0 12px;
    font-size: 14px;
    font-weight: 600;
    color: ${THEME_COLORS.accent};
  }
  .regen-modal .prompt-text {
    background: ${THEME_COLORS.bgMain};
    border: 1px solid ${THEME_COLORS.borderMuted};
    border-radius: 6px;
    padding: 12px 14px;
    font-size: 13px;
    line-height: 1.5;
    color: ${THEME_COLORS.textPrimary};
    white-space: pre-wrap;
    word-break: break-word;
    user-select: all;
  }
  .regen-modal .modal-footer {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-top: 14px;
  }
  .regen-modal .copy-status {
    font-size: 11px;
    color: ${THEME_COLORS.textMuted};
  }
  .regen-modal .copy-status.copied { color: ${THEME_COLORS.accent}; }
  .regen-modal .modal-btns { display: flex; gap: 8px; }
  .regen-modal .modal-btns button {
    padding: 6px 14px;
    border-radius: 6px;
    border: 1px solid ${THEME_COLORS.borderMuted};
    background: ${THEME_COLORS.bgSurface};
    color: ${THEME_COLORS.textSecondary};
    font-size: 12px;
    cursor: pointer;
  }
  .regen-modal .modal-btns button:hover { background: rgba(255,255,255,0.06); }
  .regen-modal .modal-btns .copy-btn {
    background: rgba(0,230,220,0.12);
    border-color: rgba(0,230,220,0.3);
    color: ${THEME_COLORS.accent};
  }
  .regen-modal .modal-btns .copy-btn:hover { background: rgba(0,230,220,0.22); }

  /* Pin highlight on hover */
  .canvas svg .pin-highlight path, .canvas svg path.pin-highlight { stroke: ${THEME_COLORS.pinWireHover} !important; }
  .canvas svg .group-highlight path { stroke: ${THEME_COLORS.groupLabelHover} !important; }

  /* Discrete pin label highlight (injected <text> elements) */
  .canvas svg text.pin-label-highlight { fill: ${THEME_COLORS.pinNameHover} !important; }
  .canvas svg text.pin-num-highlight { fill: ${THEME_COLORS.pinNameHover} !important; }

  /* Discrete pin hit zone outline (visible on hover) */
  .canvas svg rect.pin-hit-zone { fill: rgba(0,230,220,0.06); stroke: ${THEME_COLORS.pinWireHover}; stroke-width: 0.12; stroke-dasharray: 0.3,0.2; stroke-opacity: 0.5; pointer-events: all; cursor: pointer; rx: 0.3; }
  .canvas svg rect.pin-hit-zone:hover { fill: rgba(0,230,220,0.12); stroke-opacity: 0.8; stroke-width: 0.15; }

  /* ── Bottom panel ── */
  .bottom-panel {
    background: ${THEME.bgSurface};
    border-top: 1px solid ${THEME.borderMuted};
    flex-shrink: 0;
  }
  .info-row {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: 1px 0;
    padding: 5px 8px;
    font-size: 10px;
  }
  .info-item {
    display: inline-flex;
    align-items: baseline;
    gap: 5px;
    padding: 1px 0;
    margin-right: 8px;
  }
  .info-item::after {
    content: '';
    display: inline-block;
    width: 1px;
    height: 10px;
    background: ${THEME.borderMuted};
    margin-left: 8px;
    vertical-align: middle;
  }
  .info-item:last-child::after { display: none; }
  .info-item:last-child { margin-right: 0; }
  .info-label {
    color: ${THEME.textMuted};
    text-transform: uppercase;
    letter-spacing: 0.04em;
    font-size: 9px;
  }
  .info-value { color: ${THEME.textSecondary}; }
  .info-value a { color: ${THEME.accent}; text-decoration: none; }
  .info-value a:hover { text-decoration: underline; }
  .info-divider { height: 1px; background: ${THEME.borderMuted}; margin: 0 8px; }
  .coord-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 4px 8px;
    font-size: 10px;
    color: ${THEME.textSecondary};
    font-variant-numeric: tabular-nums;
  }
</style>
</head>
<body>

<div class="toolbar">
  <span class="title">${esc(meta.name)}</span>
  <div class="spacer"></div>
  <div class="btn-group">
    <button onclick="zoomOut()">&#x2212;</button>
    <span class="zoom-level" id="zoom-level">100%</span>
    <button onclick="zoomIn()">&#x2b;</button>
  </div>
  <div class="sep"></div>
  <button onclick="zoomFit()">Fit</button>
  <button onclick="zoomReset()">1:1</button>
  <button onclick="toggleGrid()" id="grid-btn" class="active">Grid</button>
  <button onclick="toggleBBox()" id="bbox-btn" class="active">BBox</button>
  <button onclick="toggleHits()" id="hits-btn">Hits</button>
  <div class="sep"></div>
  <button onclick="showRegenPrompt()" title="Show regeneration prompt to paste into Claude Code">Regen</button>
</div>

<div class="regen-overlay" id="regen-overlay" onclick="if(event.target===this)closeRegenPrompt()">
  <div class="regen-modal">
    <h3>Regeneration Prompt</h3>
    <div class="prompt-text" id="regen-prompt-text"></div>
    <div class="modal-footer">
      <span class="copy-status" id="regen-copy-status">Copied to clipboard</span>
      <div class="modal-btns">
        <button onclick="closeRegenPrompt()">Close</button>
        <button class="copy-btn" onclick="copyRegenText()">Copy</button>
      </div>
    </div>
  </div>
</div>

<div class="canvas-wrap" id="canvas-wrap">
  <a class="edit-btn" href="${esc(wikiEditUrl)}" target="_blank" title="Suggest corrections to this symbol on the Adom Wiki">
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
      <path d="M11.5 1.5l3 3L5 14H2v-3L11.5 1.5z"/>
      <path d="M9.5 3.5l3 3"/>
    </svg>
    Suggest edit
  </a>
  <div class="canvas" id="canvas">
    ${svgContent}
  </div>
</div>

<div class="tooltip" id="tooltip"></div>

<div class="bottom-panel">
  <div class="info-row">
    ${infoRowHtml}
  </div>
  <div class="info-divider"></div>
  <div class="coord-row">
    <span id="cursor-pos">X: 0.00 &nbsp; Y: 0.00 mm</span>
    <span style="margin-left:auto;color:${THEME.accent};font-weight:600;font-size:12px">SymView</span>
  </div>
</div>

<script>
  const pins = ${pinData};
  const groups = ${groupData};
  const svgW = ${finalSvgWidth};
  const svgH = ${finalSvgHeight};
  const contentBounds = ${contentBounds ? JSON.stringify(contentBounds) : 'null'};
  const svgOffset = ${svgOffset ? JSON.stringify(svgOffset) : 'null'};
  const hidePinNames = ${meta.hidePinNames};
  const hidePinNumbers = ${meta.hidePinNumbers};
  const datasheetUrl = ${JSON.stringify(srcDatasheet || '')};
  const mfrName = ${JSON.stringify(srcManufacturer || '')};
  const _symbolName = ${JSON.stringify(symbolName)};
  const _symPath = ${JSON.stringify(symPath)};

  const wrap = document.getElementById('canvas-wrap');
  const canvas = document.getElementById('canvas');
  const tooltip = document.getElementById('tooltip');
  const zoomLabel = document.getElementById('zoom-level');
  const cursorPos = document.getElementById('cursor-pos');

  let scale = 1, panX = 0, panY = 0;
  let isDragging = false;
  let dragStartX, dragStartY, panStartX, panStartY;
  let gridVisible = true;
  let stickyPin = null; // currently pinned tooltip

  const MM_TO_PX = 3.78;
  const svgPxW = svgW * MM_TO_PX;
  const svgPxH = svgH * MM_TO_PX;

  const svgEl = canvas.querySelector('svg');
  if (svgEl) {
    svgEl.setAttribute('width', svgPxW + 'px');
    svgEl.setAttribute('height', svgPxH + 'px');
  }

  function updateTransform() {
    canvas.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + scale + ')';
    zoomLabel.textContent = Math.round(scale * 100) + '%';
  }

  function zoomFit() {
    const ww = wrap.clientWidth, wh = wrap.clientHeight;
    const pad = 40;
    // Read the bbox rect directly from the SVG DOM — it's already positioned correctly
    const bboxRect = svgEl ? svgEl.querySelector('.debug-bbox') : null;
    if (bboxRect) {
      const bb = bboxRect.getBBox();
      const vb = svgEl.viewBox.baseVal;
      // Convert SVG user-space coords to pixel coords within the SVG element
      const pxPerUnitX = svgPxW / vb.width;
      const pxPerUnitY = svgPxH / vb.height;
      const pxX = (bb.x - vb.x) * pxPerUnitX;
      const pxY = (bb.y - vb.y) * pxPerUnitY;
      const pxW = bb.width * pxPerUnitX;
      const pxH = bb.height * pxPerUnitY;
      scale = Math.min((ww - pad * 2) / pxW, (wh - pad * 2) / pxH, 10);
      if (!isFinite(scale) || scale <= 0) scale = 1;
      panX = (ww - pxW * scale) / 2 - pxX * scale;
      panY = (wh - pxH * scale) / 2 - pxY * scale;
    } else {
      scale = Math.min((ww - pad * 2) / svgPxW, (wh - pad * 2) / svgPxH, 10);
      if (!isFinite(scale) || scale <= 0) scale = 1;
      panX = (ww - svgPxW * scale) / 2;
      panY = (wh - svgPxH * scale) / 2;
    }
    updateTransform();
  }

  function zoomReset() {
    scale = 1;
    panX = (wrap.clientWidth - svgPxW) / 2;
    panY = (wrap.clientHeight - svgPxH) / 2;
    updateTransform();
  }

  function zoomIn() { zoomAt(wrap.clientWidth / 2, wrap.clientHeight / 2, 1.25); }
  function zoomOut() { zoomAt(wrap.clientWidth / 2, wrap.clientHeight / 2, 0.8); }

  function zoomAt(cx, cy, factor) {
    const ns = Math.max(0.1, Math.min(scale * factor, 20));
    const r = ns / scale;
    panX = cx - (cx - panX) * r;
    panY = cy - (cy - panY) * r;
    scale = ns;
    updateTransform();
  }

  function toggleGrid() {
    gridVisible = !gridVisible;
    wrap.classList.toggle('no-grid', !gridVisible);
    document.getElementById('grid-btn').classList.toggle('active', gridVisible);
  }

  let bboxVisible = true;
  function toggleBBox() {
    bboxVisible = !bboxVisible;
    const el = svgEl && svgEl.querySelector('.debug-bbox');
    if (el) el.setAttribute('opacity', bboxVisible ? '0.4' : '0');
    document.getElementById('bbox-btn').classList.toggle('active', bboxVisible);
  }

  let hitsVisible = false;
  function toggleHits() {
    hitsVisible = !hitsVisible;
    if (svgEl) {
      svgEl.querySelectorAll('.pin-hit-zone').forEach(r => {
        r.style.visibility = hitsVisible ? '' : 'hidden';
      });
    }
    document.getElementById('hits-btn').classList.toggle('active', hitsVisible);
  }

  // ── Regen prompt modal ──
  let _regenPrompt = '';
  function buildRegenPrompt() {
    const folder = _symPath.substring(0, _symPath.lastIndexOf('/'));
    return 'Regenerate the SymView for ' + _symbolName + '.\\n\\n'
      + 'Symbol: ' + _symPath + '\\n'
      + 'Metadata: ' + folder + '/' + _symbolName + '-metadata.json\\n\\n'
      + 'Re-export the SVG via the KiCad service, regenerate the SymView HTML at the same path, '
      + 'and re-display it in the Adom app.';
  }
  function showRegenPrompt() {
    _regenPrompt = buildRegenPrompt();
    document.getElementById('regen-prompt-text').textContent = _regenPrompt;
    document.getElementById('regen-overlay').classList.add('visible');
    const status = document.getElementById('regen-copy-status');
    navigator.clipboard.writeText(_regenPrompt).then(() => {
      status.textContent = 'Copied to clipboard';
      status.classList.add('copied');
    }).catch(() => {
      status.textContent = 'Click Copy to copy';
      status.classList.remove('copied');
    });
  }
  function closeRegenPrompt() {
    document.getElementById('regen-overlay').classList.remove('visible');
  }
  function copyRegenText() {
    navigator.clipboard.writeText(_regenPrompt).then(() => {
      const status = document.getElementById('regen-copy-status');
      status.textContent = 'Copied to clipboard';
      status.classList.add('copied');
    });
  }

  // Mouse wheel zoom
  wrap.addEventListener('wheel', (e) => {
    e.preventDefault();
    const r = wrap.getBoundingClientRect();
    zoomAt(e.clientX - r.left, e.clientY - r.top, e.deltaY < 0 ? 1.12 : 0.89);
  }, { passive: false });

  // Pan
  wrap.addEventListener('mousedown', (e) => {
    if (e.button !== 0) return;
    isDragging = true;
    dragStartX = e.clientX; dragStartY = e.clientY;
    panStartX = panX; panStartY = panY;
    wrap.classList.add('dragging');
  });
  window.addEventListener('mousemove', (e) => {
    const r = wrap.getBoundingClientRect();
    const mx = (e.clientX - r.left - panX) / scale / MM_TO_PX;
    const my = (e.clientY - r.top - panY) / scale / MM_TO_PX;
    cursorPos.innerHTML = 'X: ' + mx.toFixed(2) + ' &nbsp; Y: ' + my.toFixed(2) + ' mm';
    if (!isDragging) return;
    panX = panStartX + (e.clientX - dragStartX);
    panY = panStartY + (e.clientY - dragStartY);
    updateTransform();
  });
  window.addEventListener('mouseup', () => { isDragging = false; wrap.classList.remove('dragging'); });

  // ── Pin highlighting ──
  const pinTextGroups = new Map();
  const pinWireGroups = new Map();
  if (svgEl) {
    // Build text group lookup
    svgEl.querySelectorAll('g.stroked-text').forEach(g => {
      const desc = g.querySelector('desc');
      if (desc) pinTextGroups.set(desc.textContent.trim(), g);
    });

    // Build wire group lookup using coordinate matching (not sibling navigation)
    if (svgOffset) {
      const ox = svgOffset.x, oy = svgOffset.y;
      // Collect all simple 2-point line paths in the SVG
      const allPaths = svgEl.querySelectorAll('path');
      const wirePaths = [];
      allPaths.forEach(p => {
        const d = p.getAttribute('d');
        if (!d) return;
        const m = d.match(/^M([\\d.]+)\\s+([\\d.]+)\\s+L([\\d.]+)\\s+([\\d.]+)\\s*$/);
        if (m) wirePaths.push({ el: p, x1: parseFloat(m[1]), y1: parseFloat(m[2]), x2: parseFloat(m[3]), y2: parseFloat(m[4]) });
      });

      for (const pin of pins) {
        const tipX = ox + pin.x;
        const tipY = oy - pin.y;
        const wireLen = pin.length || 2.54;
        const isHoriz = (pin.angle === 0 || pin.angle === 180);

        for (const w of wirePaths) {
          const dx = Math.abs(w.x2 - w.x1), dy = Math.abs(w.y2 - w.y1);
          const len = Math.sqrt(dx * dx + dy * dy);
          if (Math.abs(len - wireLen) > 0.05) continue;
          if (isHoriz && dy > 0.01) continue;
          if (!isHoriz && dx > 0.01) continue;
          // Check if one endpoint is near the pin tip
          const d1 = Math.hypot(w.x1 - tipX, w.y1 - tipY);
          const d2 = Math.hypot(w.x2 - tipX, w.y2 - tipY);
          if (Math.min(d1, d2) < 0.1) {
            // Found the wire path element directly
            pinWireGroups.set(pin.name, w.el);
            break;
          }
        }
      }
    }
  }

  function highlightPin(pin) {
    clearHighlight();
    // IC symbols: stroked-text groups
    [pin.name, pin.number].forEach(label => {
      const g = pinTextGroups.get(label);
      if (g) g.classList.add('pin-highlight');
    });
    const wireG = pinWireGroups.get(pin.name);
    if (wireG) wireG.classList.add('pin-highlight');
    // Discrete symbols: injected <text> labels
    if (svgEl && hidePinNames) {
      svgEl.querySelectorAll('text.pin-label').forEach(t => {
        if (t.textContent.trim() === pin.name) t.classList.add('pin-label-highlight');
      });
      svgEl.querySelectorAll('text.pin-num-label').forEach(t => {
        if (t.textContent.trim() === pin.number) t.classList.add('pin-num-highlight');
      });
    }
  }
  function clearHighlight() {
    if (svgEl) {
      svgEl.querySelectorAll('.pin-highlight').forEach(g => g.classList.remove('pin-highlight'));
      svgEl.querySelectorAll('.group-highlight').forEach(g => g.classList.remove('group-highlight'));
      svgEl.querySelectorAll('.pin-label-highlight').forEach(t => t.classList.remove('pin-label-highlight'));
      svgEl.querySelectorAll('.pin-num-highlight').forEach(t => t.classList.remove('pin-num-highlight'));
    }
  }

  // ── Tooltip positioning ──
  function positionTooltip(x, y) {
    const tw = tooltip.offsetWidth || 280;
    const th = tooltip.offsetHeight || 120;
    let tx = x + 14;
    let ty = y + 14;
    if (tx + tw > window.innerWidth - 14) tx = x - tw - 14;
    if (ty + th > window.innerHeight - 14) ty = y - th - 14;
    tx = Math.max(14, tx);
    ty = Math.max(14, ty);
    tooltip.style.left = tx + 'px';
    tooltip.style.top = ty + 'px';
  }

  // ── Source attribution HTML ──
  function sourceHtml() {
    if (!datasheetUrl || !mfrName) return '';
    return '<div class="tt-source"><span class="tt-source-label">Source:</span> <a href="' + datasheetUrl + '" target="_blank">' + mfrName + ' Datasheet</a></div>';
  }

  function hintHtml() {
    return '<div class="tt-hint">click to pin &middot; select text</div>';
  }

  function showTooltip(x, y, pin) {
    if (stickyPin) return; // don't override sticky
    highlightPin(pin);
    let html = '<div><span class="tt-name">' + pin.name + '</span><span class="tt-pin">Pin ' + pin.number + '</span></div>' +
      '<div><span class="tt-type" style="background:' + pin.color + '22; color:' + pin.color + '; border:1px solid ' + pin.color + '44">' + pin.type + '</span></div>';
    if (pin.desc) html += '<div class="tt-desc">' + pin.desc + '</div>';
    html += sourceHtml();
    html += hintHtml();
    tooltip.innerHTML = html;
    tooltip.classList.add('visible');
    tooltip.classList.remove('sticky');
    positionTooltip(x, y);
  }

  function showGroupTooltip(x, y, group) {
    if (stickyPin) return;
    clearHighlight();
    const g = pinTextGroups.get(group.name);
    if (g) g.classList.add('group-highlight');
    let html = '<div><span class="tt-group-name">' + group.name + '</span></div>' +
      '<div><span class="tt-group-badge">Function Group</span></div>';
    if (group.desc) html += '<div class="tt-desc">' + group.desc + '</div>';
    tooltip.innerHTML = html;
    tooltip.classList.add('visible');
    tooltip.classList.remove('sticky');
    positionTooltip(x, y);
  }

  function hideTooltip() {
    if (stickyPin) return;
    tooltip.classList.remove('visible');
    clearHighlight();
  }

  // ── Sticky tooltip (click-to-pin) ──
  function toggleSticky(pin, x, y) {
    if (stickyPin === pin) {
      // Unpin
      stickyPin = null;
      tooltip.classList.remove('sticky');
      hideTooltip();
    } else {
      // Pin
      stickyPin = pin;
      highlightPin(pin);
      let html = '<div><span class="tt-name">' + pin.name + '</span><span class="tt-pin">Pin ' + pin.number + '</span></div>' +
        '<div><span class="tt-type" style="background:' + pin.color + '22; color:' + pin.color + '; border:1px solid ' + pin.color + '44">' + pin.type + '</span></div>';
      if (pin.desc) html += '<div class="tt-desc">' + pin.desc + '</div>';
      html += sourceHtml();
      tooltip.innerHTML = html;
      tooltip.classList.add('visible', 'sticky');
      positionTooltip(x, y);
    }
  }

  // Dismiss sticky on click outside
  document.addEventListener('click', (e) => {
    if (!stickyPin) return;
    if (tooltip.contains(e.target)) return; // click inside tooltip
    if (e.target.closest('[data-pin]') || e.target.closest('[data-pin-num]') || e.target.closest('[data-pin-wire]')) return;
    stickyPin = null;
    tooltip.classList.remove('sticky', 'visible');
    clearHighlight();
  });

  wrap.addEventListener('mouseleave', hideTooltip);

  // ── Pin hit zones ──
  function setupPinHitZones() {
    if (!svgEl) return;

    if (hidePinNames) {
      // Discrete components (hidden pin names): compute hit zones from injected labels
      // Covers MOSFETs, resistors, etc. — standard KiCad discrete symbols hide pin names
      // but may leave pin numbers visible, so we check hidePinNames only
      setupDiscreteHitZones();
    } else {
      // Standard IC: use hidden text elements for hit detection
      setupTextHitZones();
    }
  }

  function setupTextHitZones() {
    // IC symbols: two hit zones per pin, derived from actual rendered text bounds (getBBox):
    //   1. INSIDE body — covers pin name text (bbox from stroked-text group)
    //   2. OUTSIDE body — covers pin wire + number area (bbox or coordinate-based)
    if (!svgOffset) return;
    const ox = svgOffset.x, oy = svgOffset.y;
    const pitch = 2.54;
    const zoneH = pitch * 0.95;
    const wireExt = 1.5;
    const pad = 0.3; // padding around text bbox

    // Build lookup: text content → stroked-text group bbox
    const textBBoxMap = {};
    svgEl.querySelectorAll('g.stroked-text').forEach(g => {
      const desc = g.querySelector('desc');
      if (desc) {
        try {
          const bbox = g.getBBox();
          if (bbox.width > 0 && bbox.height > 0) {
            const key = desc.textContent.trim();
            if (!textBBoxMap[key]) textBBoxMap[key] = [];
            textBBoxMap[key].push(bbox);
          }
        } catch (e) {}
      }
    });

    function addHitZone(hx, hy, hw, hh, pin) {
      const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
      rect.setAttribute('x', hx);
      rect.setAttribute('y', hy);
      rect.setAttribute('width', hw);
      rect.setAttribute('height', hh);
      rect.setAttribute('class', 'pin-hit-zone');
      rect.setAttribute('data-pin', pin.name);
      rect.setAttribute('rx', '0.3');
      svgEl.appendChild(rect);
      rect.addEventListener('mouseenter', (ev) => showTooltip(ev.clientX, ev.clientY, pin));
      rect.addEventListener('mousemove', (ev) => { if (!stickyPin) positionTooltip(ev.clientX, ev.clientY); });
      rect.addEventListener('mouseleave', hideTooltip);
      rect.addEventListener('click', (ev) => { ev.stopPropagation(); toggleSticky(pin, ev.clientX, ev.clientY); });
    }

    for (const pin of pins) {
      const tipSvgX = ox + pin.x;
      const tipSvgY = oy - pin.y;
      const wireLen = pin.length || 2.54;
      const isHoriz = (pin.angle === 0 || pin.angle === 180);

      // Pin name hitbox: use actual text bbox if available
      const nameBBoxes = textBBoxMap[pin.name];
      if (nameBBoxes && nameBBoxes.length > 0) {
        // Pick the bbox closest to this pin's body-edge position
        const bodyEdgeX = isHoriz
          ? (pin.angle === 0 ? tipSvgX + wireLen : tipSvgX - wireLen)
          : tipSvgX;
        const bodyEdgeY = isHoriz
          ? tipSvgY
          : (pin.angle === 90 ? tipSvgY - wireLen : tipSvgY + wireLen);
        let best = nameBBoxes[0];
        let bestDist = Infinity;
        for (const bb of nameBBoxes) {
          const cx = bb.x + bb.width / 2, cy = bb.y + bb.height / 2;
          const d = Math.hypot(cx - bodyEdgeX, cy - bodyEdgeY);
          if (d < bestDist) { bestDist = d; best = bb; }
        }
        addHitZone(best.x - pad, best.y - pad, best.width + pad * 2, best.height + pad * 2, pin);
      } else {
        // Fallback: fixed-width zone inside body
        const bodyDepth = 8;
        if (isHoriz) {
          const cy = tipSvgY - zoneH / 2;
          if (pin.angle === 0) addHitZone(tipSvgX + wireLen, cy, bodyDepth, zoneH, pin);
          else addHitZone(tipSvgX - wireLen - bodyDepth, cy, bodyDepth, zoneH, pin);
        } else {
          const cx = tipSvgX - zoneH / 2;
          if (pin.angle === 90) addHitZone(cx, tipSvgY - wireLen - bodyDepth, zoneH, bodyDepth, pin);
          else addHitZone(cx, tipSvgY + wireLen, zoneH, bodyDepth, pin);
        }
      }

      // Pin number hitbox: use actual text bbox if available
      const numBBoxes = textBBoxMap[pin.number];
      if (numBBoxes && numBBoxes.length > 0) {
        const tipDist = (bb) => Math.hypot(bb.x + bb.width / 2 - tipSvgX, bb.y + bb.height / 2 - tipSvgY);
        let best = numBBoxes[0], bestD = tipDist(best);
        for (const bb of numBBoxes) { const d = tipDist(bb); if (d < bestD) { bestD = d; best = bb; } }
        addHitZone(best.x - pad, best.y - pad, best.width + pad * 2, best.height + pad * 2, pin);
      } else {
        // Fallback: wire area
        if (isHoriz) {
          const cy = tipSvgY - zoneH / 2;
          if (pin.angle === 0) addHitZone(tipSvgX - wireExt, cy, wireLen + wireExt, zoneH, pin);
          else addHitZone(tipSvgX, cy, wireLen + wireExt, zoneH, pin);
        } else {
          const cx = tipSvgX - zoneH / 2;
          if (pin.angle === 90) addHitZone(cx, tipSvgY, zoneH, wireLen + wireExt, pin);
          else addHitZone(cx, tipSvgY - wireExt, zoneH, wireLen + wireExt, pin);
        }
      }
    }
  }

  function setupDiscreteHitZones() {
    // For discrete components (hidden pin names/numbers), create hit zones
    // using getBBox() on injected text labels for precise alignment.
    // Falls back to coordinate-based zones if text elements aren't found.
    if (!svgOffset) return;
    const ox = svgOffset.x, oy = svgOffset.y;
    const pad = 0.5; // generous padding for easy hover on small labels

    // Build lookup: text content → getBBox for injected labels
    const labelBBoxMap = {};
    const allTextEls = svgEl.querySelectorAll('text.pin-label, text.pin-num-label');
    allTextEls.forEach(t => {
      try {
        const bbox = t.getBBox();
        if (bbox.width > 0 && bbox.height > 0) {
          const key = t.textContent.trim();
          if (!labelBBoxMap[key]) labelBBoxMap[key] = [];
          labelBBoxMap[key].push(bbox);
        }
      } catch (e) {}
    });

    for (const pin of pins) {
      const tipSvgX = ox + pin.x;
      const tipSvgY = oy - pin.y;
      const wireLen = pin.length || 2.54;

      // Collect bounding boxes of this pin's name and number labels
      const bboxes = [];

      // Find name label bbox (closest to pin tip)
      const nameBBs = labelBBoxMap[pin.name];
      if (nameBBs) {
        let best = nameBBs[0], bestD = Infinity;
        for (const bb of nameBBs) {
          const d = Math.hypot(bb.x + bb.width / 2 - tipSvgX, bb.y + bb.height / 2 - tipSvgY);
          if (d < bestD) { bestD = d; best = bb; }
        }
        bboxes.push(best);
      }

      // Find number label bbox (closest to pin tip)
      const numBBs = labelBBoxMap[pin.number];
      if (numBBs) {
        let best = numBBs[0], bestD = Infinity;
        for (const bb of numBBs) {
          const d = Math.hypot(bb.x + bb.width / 2 - tipSvgX, bb.y + bb.height / 2 - tipSvgY);
          if (d < bestD) { bestD = d; best = bb; }
        }
        bboxes.push(best);
      }

      if (bboxes.length > 0) {
        // Union text bboxes into a single hit zone (just covers labels near the tip)
        let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
        for (const bb of bboxes) {
          minX = Math.min(minX, bb.x);
          minY = Math.min(minY, bb.y);
          maxX = Math.max(maxX, bb.x + bb.width);
          maxY = Math.max(maxY, bb.y + bb.height);
        }

        const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
        rect.setAttribute('x', minX - pad);
        rect.setAttribute('y', minY - pad);
        rect.setAttribute('width', maxX - minX + pad * 2);
        rect.setAttribute('height', maxY - minY + pad * 2);
        rect.setAttribute('class', 'pin-hit-zone');
        rect.setAttribute('data-pin-wire', pin.name);
        rect.setAttribute('rx', '0.3');
        svgEl.appendChild(rect);

        rect.addEventListener('mouseenter', (ev) => showTooltip(ev.clientX, ev.clientY, pin));
        rect.addEventListener('mousemove', (ev) => { if (!stickyPin) positionTooltip(ev.clientX, ev.clientY); });
        rect.addEventListener('mouseleave', hideTooltip);
        rect.addEventListener('click', (ev) => { ev.stopPropagation(); toggleSticky(pin, ev.clientX, ev.clientY); });
      } else {
        // Fallback: coordinate-based zone around wire area
        const perpHalf = 1.5;
        let hitX, hitY, hitW, hitH;
        switch (pin.angle) {
          case 0:   hitX = tipSvgX - 1; hitY = tipSvgY - perpHalf; hitW = wireLen + 1; hitH = perpHalf * 2; break;
          case 180: hitX = tipSvgX - wireLen; hitY = tipSvgY - perpHalf; hitW = wireLen + 1; hitH = perpHalf * 2; break;
          case 270: hitX = tipSvgX - perpHalf; hitY = tipSvgY; hitW = perpHalf * 2; hitH = wireLen + 1; break;
          case 90:  hitX = tipSvgX - perpHalf; hitY = tipSvgY - wireLen; hitW = perpHalf * 2; hitH = wireLen + 1; break;
          default:  hitX = tipSvgX - 2; hitY = tipSvgY - 2; hitW = 4; hitH = 4;
        }
        const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
        rect.setAttribute('x', hitX);
        rect.setAttribute('y', hitY);
        rect.setAttribute('width', hitW);
        rect.setAttribute('height', hitH);
        rect.setAttribute('class', 'pin-hit-zone');
        rect.setAttribute('data-pin-wire', pin.name);
        rect.setAttribute('rx', '0.3');
        svgEl.appendChild(rect);
        rect.addEventListener('mouseenter', (ev) => showTooltip(ev.clientX, ev.clientY, pin));
        rect.addEventListener('mousemove', (ev) => { if (!stickyPin) positionTooltip(ev.clientX, ev.clientY); });
        rect.addEventListener('mouseleave', hideTooltip);
        rect.addEventListener('click', (ev) => { ev.stopPropagation(); toggleSticky(pin, ev.clientX, ev.clientY); });
      }
    }
  }

  // ── Group label hit zones ──
  function setupGroupHitZones() {
    if (!svgEl) return;
    const texts = svgEl.querySelectorAll('text');
    texts.forEach(textEl => {
      const label = textEl.textContent.trim();
      const group = groups.find(g => g.name === label);
      if (!group) return;
      const fSize = parseFloat(textEl.getAttribute('font-size')) || 1;
      if (Math.abs(fSize - 1.016) > 0.1) return;

      const x = parseFloat(textEl.getAttribute('x')) || 0;
      const y = parseFloat(textEl.getAttribute('y')) || 0;
      const tLen = parseFloat(textEl.getAttribute('textLength')) || 6;
      const anchor = textEl.getAttribute('text-anchor') || 'start';

      const padX = fSize * 0.4;
      const padY = fSize * 0.15; // tight vertical padding to avoid spilling into pin zones
      let rx, rw = tLen + padX * 2, rh = fSize + padY * 2;
      if (anchor === 'end') rx = x - tLen - padX;
      else if (anchor === 'middle') rx = x - tLen / 2 - padX;
      else rx = x - padX;
      const ry = y - fSize - padY;

      const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
      rect.setAttribute('x', rx);
      rect.setAttribute('y', ry);
      rect.setAttribute('width', rw);
      rect.setAttribute('height', rh);
      rect.setAttribute('class', 'pin-hit-zone');
      rect.setAttribute('data-group', group.name);
      rect.setAttribute('rx', '0.3');
      svgEl.appendChild(rect);

      rect.addEventListener('mouseenter', (ev) => showGroupTooltip(ev.clientX, ev.clientY, group));
      rect.addEventListener('mousemove', (ev) => { if (!stickyPin) positionTooltip(ev.clientX, ev.clientY); });
      rect.addEventListener('mouseleave', hideTooltip);
    });
  }

  // Keyboard shortcuts
  window.addEventListener('keydown', (e) => {
    if (e.key === 'f' || e.key === 'F') zoomFit();
    else if (e.key === '1') zoomReset();
    else if (e.key === '+' || e.key === '=') zoomIn();
    else if (e.key === '-') zoomOut();
    else if (e.key === 'g' || e.key === 'G') toggleGrid();
    else if (e.key === 'Escape') {
      closeRegenPrompt();
      stickyPin = null;
      tooltip.classList.remove('sticky', 'visible');
      clearHighlight();
    }
  });

  // Auto-fit on window/container resize (e.g. iframe becoming visible)
  new ResizeObserver(() => zoomFit()).observe(wrap);

  // Init
  requestAnimationFrame(() => {
    zoomFit();
    setupPinHitZones();
    setupGroupHitZones();
    // Hide hit zones by default (user can toggle with Hits button)
    if (!hitsVisible && svgEl) {
      svgEl.querySelectorAll('.pin-hit-zone').forEach(r => { r.style.visibility = 'hidden'; });
    }
  });

  // Zoom to a named region of the symbol (for AI screenshot verification)
  // Regions: 'top', 'bottom', 'left', 'right', 'fit' (full symbol)
  function zoomToRegion(region) {
    if (!contentBounds) { zoomFit(); return; }
    const ww = wrap.clientWidth, wh = wrap.clientHeight;
    const svgEl = document.querySelector('#canvas svg');
    if (!svgEl) return;
    const vb = svgEl.viewBox.baseVal;
    const svgPxW = svgEl.clientWidth || svgEl.getBoundingClientRect().width;
    const svgPxH = svgEl.clientHeight || svgEl.getBoundingClientRect().height;
    const pxPerUnitX = svgPxW / vb.width;
    const pxPerUnitY = svgPxH / vb.height;

    // Content bounds in SVG user-space
    const cb = contentBounds;
    const cx = cb.x, cy = cb.y, cw = cb.w || cb.width, ch = cb.h || cb.height;
    // Region crop (SVG user-space coordinates)
    let rx, ry, rw, rh;
    const margin = 2; // mm padding
    if (region === 'top') {
      rx = cx - margin; ry = cy - margin; rw = cw + margin * 2; rh = Math.min(ch * 0.12, 50) + margin;
    } else if (region === 'bottom') {
      const cropH = Math.min(ch * 0.12, 50);
      ry = cy + ch - cropH - margin; rx = cx - margin; rw = cw + margin * 2; rh = cropH + margin * 2;
    } else if (region === 'top-left') {
      rx = cx - margin; ry = cy - margin; rw = cw * 0.4 + margin; rh = Math.min(ch * 0.12, 50) + margin;
    } else if (region === 'top-right') {
      rx = cx + cw * 0.6 - margin; ry = cy - margin; rw = cw * 0.4 + margin * 2; rh = Math.min(ch * 0.12, 50) + margin;
    } else if (region === 'bottom-left') {
      const cropH = Math.min(ch * 0.12, 50);
      ry = cy + ch - cropH - margin; rx = cx - margin; rw = cw * 0.4 + margin; rh = cropH + margin * 2;
    } else if (region === 'bottom-right') {
      const cropH = Math.min(ch * 0.12, 50);
      ry = cy + ch - cropH - margin; rx = cx + cw * 0.6 - margin; rw = cw * 0.4 + margin * 2; rh = cropH + margin * 2;
    } else if (region === 'left') {
      rx = cx - margin; ry = cy - margin; rw = cw * 0.35 + margin; rh = ch + margin * 2;
    } else if (region === 'right') {
      rx = cx + cw * 0.65 - margin; ry = cy - margin; rw = cw * 0.35 + margin * 2; rh = ch + margin * 2;
    } else {
      zoomFit(); return;
    }
    // Convert to pixel coords
    const pxX = (rx - vb.x) * pxPerUnitX;
    const pxY = (ry - vb.y) * pxPerUnitY;
    const pxW = rw * pxPerUnitX;
    const pxH = rh * pxPerUnitY;
    // Compute scale to fit region in viewport
    const ns = Math.min(ww / pxW, wh / pxH) * 0.92;
    scale = Math.max(0.1, Math.min(ns, 20));
    panX = ww / 2 - (pxX + pxW / 2) * scale;
    panY = wh / 2 - (pxY + pxH / 2) * scale;
    updateTransform();
  }

  // Screenshot capture handler for Adom Viewer mgmt relay
  // Send SVG data back to parent for rendering (avoids sandbox restrictions on Image loading)
  window.addEventListener('message', (e) => {
    // Zoom-to-region command (from AI via mgmt relay)
    if (e.data?.type === 'sym_zoom_region') {
      zoomToRegion(e.data.region || 'fit');
      return;
    }
    if (e.data?.type === 'mgmt_capture_request') {
      const reqId = e.data._reqId;
      try {
        const svg = document.querySelector('#canvas svg');
        if (!svg) { window.parent.postMessage({ type: 'mgmt_canvas_capture', _reqId: reqId, data: null }, '*'); return; }
        const clone = svg.cloneNode(true);
        // Inline pin-hit-zone styles so they're visible in standalone SVG
        clone.querySelectorAll('.pin-hit-zone').forEach(el => {
          el.setAttribute('fill', 'rgba(0,230,220,0.06)');
          el.setAttribute('stroke', '${THEME_COLORS.pinWireHover}');
          el.setAttribute('stroke-width', '0.12');
          el.setAttribute('stroke-dasharray', '0.3,0.2');
          el.setAttribute('stroke-opacity', '0.5');
          el.setAttribute('rx', '0.3');
        });
        const vb = svg.getAttribute('viewBox');
        const parts = vb ? vb.split(/\\s+/).map(Number) : [0, 0, 100, 100];
        let vbX = parts[0], vbY = parts[1], vbW = parts[2] || 100, vbH = parts[3] || 100;

        // Crop to visible viewport when zoomed/panned
        const svgPxW = svg.clientWidth || svg.getBoundingClientRect().width;
        const svgPxH = svg.clientHeight || svg.getBoundingClientRect().height;
        const ww = wrap.clientWidth, wh = wrap.clientHeight;
        if (scale !== 1 || panX !== (ww - svgPxW) / 2 || panY !== (wh - svgPxH) / 2) {
          const pxPerUnitX = svgPxW / vbW;
          const pxPerUnitY = svgPxH / vbH;
          // Visible viewport in pixel space of the SVG element
          const visLeft = -panX / scale;
          const visTop = -panY / scale;
          const visW = ww / scale;
          const visH = wh / scale;
          // Convert to SVG user-space
          vbX = vbX + visLeft / pxPerUnitX;
          vbY = vbY + visTop / pxPerUnitY;
          vbW = visW / pxPerUnitX;
          vbH = visH / pxPerUnitY;
        }

        clone.setAttribute('viewBox', vbX + ' ' + vbY + ' ' + vbW + ' ' + vbH);
        clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
        const xml = new XMLSerializer().serializeToString(clone);
        // Send SVG XML + dimensions to parent for canvas rendering
        window.parent.postMessage({
          type: 'mgmt_canvas_capture',
          _reqId: reqId,
          svgXml: xml,
          svgWidth: vbW,
          svgHeight: vbH,
          bgColor: '${THEME_COLORS.canvasBg}'
        }, '*');
      } catch (err) {
        window.parent.postMessage({ type: 'mgmt_canvas_capture', _reqId: reqId, data: null }, '*');
      }
    }
  });
</script>
</body>
</html>`;

  return html;
}