app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
/**
* 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()">−</button>
<span class="zoom-level" id="zoom-level">100%</span>
<button onclick="zoomIn()">+</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 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) + ' 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 · 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;
}