The `spice` field on a digital part is a power and loading macromodel — it draws the right quiescent current and cannot shift a bit. Showing it under a single "SPICE model" heading implies the part is simulatable, which for the digital parts it was not. `spice_mixed` is a model that performs the part's actual function, verified by simulation, and it is now shown as its own section with what was verified and what is not modelled. The download bundles both, so a deck gets the functional model and keeps the supply behaviour.
139 lines
11 KiB
JavaScript
139 lines
11 KiB
JavaScript
// part.js — part detail. Left: a tabbed visualization (real 3D model / schematic symbol / footprint),
|
||
// all built from the curated data. Right: the full record (specs, pins, SPICE, downloads).
|
||
import { mountViewer } from '/assets/viewer.js';
|
||
const esc = (s) => (s == null ? '' : String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])));
|
||
const id = new URLSearchParams(location.search).get('id');
|
||
|
||
function dl(name, text, mime = 'text/plain') { const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], { type: mime })); a.download = name; a.click(); }
|
||
function spendCredit() { let c = parseInt(localStorage.getItem('ppcb_credits') || '5', 10); if (c <= 0) { alert('Out of download credits — contribute a curated part to earn 5 more.'); return false; } localStorage.setItem('ppcb_credits', --c); document.getElementById('cred-n').textContent = c; return true; }
|
||
|
||
// ---- footprint SVG from real pad geometry (top-down, copper pads, pad numbers, outline) ----
|
||
function footprintSVG(p) {
|
||
const pads = p.footprint_pads || []; if (!pads.length) return '<div class="hint">no footprint data</div>';
|
||
let minx = 1e9, miny = 1e9, maxx = -1e9, maxy = -1e9;
|
||
for (const d of pads) { minx = Math.min(minx, d.x - d.w / 2); maxx = Math.max(maxx, d.x + d.w / 2); miny = Math.min(miny, d.y - d.h / 2); maxy = Math.max(maxy, d.y + d.h / 2); }
|
||
const m = Math.max(maxx - minx, maxy - miny) * 0.14 + 0.4;
|
||
minx -= m; miny -= m; maxx += m; maxy += m;
|
||
const vb = `${minx} ${miny} ${maxx - minx} ${maxy - miny}`;
|
||
const fs = Math.max((maxx - minx), (maxy - miny)) * 0.035;
|
||
let s = `<svg viewBox="${vb}" preserveAspectRatio="xMidYMid meet" font-family="monospace">`;
|
||
s += `<rect x="${minx + m * 0.4}" y="${miny + m * 0.4}" width="${maxx - minx - m * 0.8}" height="${maxy - miny - m * 0.8}" fill="none" stroke="#bcbcb6" stroke-width="${fs * 0.12}" stroke-dasharray="${fs * 0.4} ${fs * 0.3}"/>`;
|
||
for (const d of pads) {
|
||
// EasyEDA names a round pad ELLIPSE; OVAL/ROUND are the KiCad-side spellings.
|
||
const round = /OVAL|ROUND|ELLIPSE|CIRCLE/.test((d.shape || '').toUpperCase());
|
||
s += `<rect x="${d.x - d.w / 2}" y="${d.y - d.h / 2}" width="${d.w}" height="${d.h}" rx="${round ? Math.min(d.w, d.h) / 2 : fs * 0.06}" fill="#c47a3d" stroke="#8a531f" stroke-width="${fs * 0.05}"/>`;
|
||
// Fit the label to its pad. A fixed size turns multi-character names on fine-pitch
|
||
// pads (USB-C's A1/B12) into overlapping mush, so narrow pads get smaller text.
|
||
if (d.n != null) {
|
||
const lab = String(d.n);
|
||
const pf = Math.min(fs, (d.w * 1.7) / Math.max(lab.length, 1), d.h * 0.95);
|
||
s += `<text x="${d.x}" y="${d.y + pf * 0.35}" font-size="${pf}" fill="#2a1a0c" text-anchor="middle" font-weight="600">${esc(lab)}</text>`;
|
||
}
|
||
}
|
||
if (p.class === 'connector') { // board-edge marker on the mating side
|
||
s += `<line x1="${minx + m * 0.4}" y1="${maxy - m * 0.5}" x2="${maxx - m * 0.4}" y2="${maxy - m * 0.5}" stroke="#2f8f2f" stroke-width="${fs * 0.18}"/>`;
|
||
s += `<text x="${(minx + maxx) / 2}" y="${maxy - m * 0.5 - fs * 0.4}" font-size="${fs * 0.9}" fill="#2f8f2f" text-anchor="middle">BOARD EDGE</text>`;
|
||
}
|
||
return s + '</svg>';
|
||
}
|
||
|
||
// ---- schematic symbol from pins (labelled box; pins split L/R) ----
|
||
function schematicSVG(p) {
|
||
const pins = p.pins || []; if (!pins.length) return '<div class="hint">no pin data</div>';
|
||
const L = pins.filter((_, i) => i % 2 === 0), R = pins.filter((_, i) => i % 2 === 1);
|
||
const rows = Math.max(L.length, R.length), rh = 26, bw = 150, bx = 120, by = 40, bh = rows * rh + 20;
|
||
const W = bx + bw + 120, H = by + bh + 40;
|
||
let s = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" font-family="monospace">`;
|
||
s += `<rect x="${bx}" y="${by}" width="${bw}" height="${bh}" fill="#fdfdfb" stroke="#2a2a2a" stroke-width="2"/>`;
|
||
s += `<text x="${bx + bw / 2}" y="${by - 14}" font-size="15" text-anchor="middle" fill="#111" font-weight="700">${esc(p.mpn || p.id)}</text>`;
|
||
const draw = (arr, side) => arr.forEach((pin, i) => {
|
||
const y = by + 20 + i * rh, x0 = side === 'L' ? bx : bx + bw, x1 = side === 'L' ? bx - 34 : bx + bw + 34;
|
||
s += `<line x1="${x0}" y1="${y}" x2="${x1}" y2="${y}" stroke="#2a2a2a" stroke-width="2"/>`;
|
||
s += `<text x="${side === 'L' ? x1 - 5 : x1 + 5}" y="${y - 5}" font-size="11" fill="#666" text-anchor="${side === 'L' ? 'end' : 'start'}">${esc(pin.pin ?? '')}</text>`;
|
||
s += `<text x="${side === 'L' ? x0 + 8 : x0 - 8}" y="${y + 4}" font-size="12" fill="#111" text-anchor="${side === 'L' ? 'start' : 'end'}" font-weight="600">${esc(pin.name || '')}</text>`;
|
||
});
|
||
draw(L, 'L'); draw(R, 'R');
|
||
return s + '</svg>';
|
||
}
|
||
|
||
window.ppcbReady(async () => {
|
||
const parts = await window.getCatalog();
|
||
const p = parts.find((x) => x.id === id);
|
||
const root = document.getElementById('root');
|
||
if (!p) { root.innerHTML = '<div class="empty">Part not found. <a href="/">Back to library</a></div>'; return; }
|
||
document.title = `${p.mpn || p.id} — PromptPCB Parts`;
|
||
|
||
const params = (p.params || []).map((x) => `<tr><td>${esc(x.name)}</td><td class="v">${esc(x.value)} ${esc(x.unit || '')}${x.note ? `<span class="cond">${esc(x.note)}</span>` : ''}</td></tr>`).join('');
|
||
const pinsList = (p.pins || []).map((x) => `<div class="pin"><span class="n">${esc(x.pin ?? '')}</span>${esc(x.name || '')}<span class="t">${esc(x.type || '')}</span></div>`).join('');
|
||
const ucArr = p.use_cases || [];
|
||
const ucLi = (a) => a.map((u) => `<li>${esc(u)}</li>`).join('');
|
||
const ucs = ucArr.length ? `<ul class="uc">${ucLi(ucArr.slice(0, 5))}</ul>` +
|
||
(ucArr.length > 5 ? `<details class="more"><summary>+${ucArr.length - 5} more uses</summary><ul class="uc">${ucLi(ucArr.slice(5))}</ul></details>` : '') : '';
|
||
const tagList = Object.entries(p.tags || {}).filter(([, v]) => v != null && v !== '' && !Array.isArray(v) && typeof v !== 'boolean').map(([k, v]) => `<span class="tag"><b>${esc(k)}</b> ${esc(v)}</span>`).join('');
|
||
const kr = ((p.tags || {}).key_ratings || []).map((r) => `<span class="tag">${esc(r)}</span>`).join('');
|
||
const ser = p.series ? `<div class="tags" style="margin-top:8px">${Object.entries(p.series).filter(([k]) => k !== 'mesh').map(([k, v]) => `<span class="tag"><b>${esc(k)}</b> ${esc(v)}</span>`).join('')}</div>` : '';
|
||
const edge = p.board_edge ? `<div class="viznote"><b>Board edge:</b> ${esc(p.board_edge)}.</div>` : '';
|
||
|
||
root.innerHTML = `<div class="detail">
|
||
<div><div class="viz">
|
||
<div class="viztabs"><button data-tab="d3" class="on">3D model</button><button data-tab="sch">Schematic</button><button data-tab="fp">Footprint</button></div>
|
||
<div class="vizpanel" data-v="d3"><canvas id="cv"></canvas><span class="hint">real manufacturer model · drag to orbit</span></div>
|
||
<div class="vizpanel" data-v="sch" hidden>${schematicSVG(p)}</div>
|
||
<div class="vizpanel" data-v="fp" hidden>${footprintSVG(p)}<span class="hint">${esc(p.package || '')}</span></div>
|
||
${edge}
|
||
</div></div>
|
||
<div>
|
||
<div class="dclass">${esc(p.class)}${p.type === 'series' ? ' · series' : ''}</div>
|
||
<h1 class="dh1">${esc((p.role || p.summary || '').split('—')[0])}</h1>
|
||
<div class="dmpn">${esc(p.mpn || p.id)} · ${esc(p.manufacturer || '')} · ${esc(p.lcsc || '')}</div>
|
||
<p style="color:var(--dim);line-height:1.6;margin:0 0 8px">${esc(p.summary)}</p>
|
||
<div class="dactions">
|
||
<button class="btn" id="dl-all">↓ Download files</button>
|
||
<a class="btn ghost" href="${esc(p.datasheet_url)}" target="_blank" rel="noopener">Datasheet ↗</a>
|
||
<button class="btn ghost" id="lib">+ My library</button>
|
||
</div>
|
||
<div class="assets">${[
|
||
['3D model', !!p.model_3d],
|
||
['Schematic', !!(p.pins || []).length], // schematicSVG() draws from pins
|
||
['Footprint', !!(p.footprint_pads || []).length], // footprintSVG() draws from footprint_pads
|
||
['SPICE', !!p.spice],
|
||
[`${(p.pins || []).length} pins`, !!(p.pins || []).length],
|
||
].map(([label, have]) => `<span><span class="${have ? 'ok' : 'hint'}">${have ? '✓' : '—'}</span> ${label}</span>`).join('')}</div>
|
||
${p.when_to_use ? `<div class="section"><h3>When to use</h3><p style="color:var(--text);line-height:1.6;margin:0">${esc(p.when_to_use)}</p></div>` : ''}
|
||
${ucs ? `<div class="section"><h3>What it's used for</h3>${ucs}</div>` : ''}
|
||
${params ? `<div class="section"><h3>Specifications</h3><table class="ptable">${params}</table></div>` : ''}
|
||
${ser}
|
||
${pinsList ? `<div class="section"><h3>Pins & pads</h3><div class="pins">${pinsList}</div></div>` : ''}
|
||
${(tagList || kr) ? `<div class="section"><h3>Requirement tags</h3><div class="tags">${kr}${tagList}</div></div>` : ''}
|
||
${p.spice_mixed ? `<div class="section"><h3>Mixed-signal model <span class="ok">✓ simulatable</span></h3>
|
||
<p style="color:var(--text);line-height:1.6;margin:0 0 8px">${esc(p.spice_mixed_note || '')}</p>
|
||
${p.spice_mixed_verified ? `<p class="hint" style="margin:0 0 8px">Verified — ${esc(p.spice_mixed_verified)}</p>` : ''}
|
||
${(p.spice_mixed_ports || []).length ? `<p class="hint" style="margin:0 0 8px">.SUBCKT ${esc(p.spice_mixed_subckt || '')} ${esc((p.spice_mixed_ports || []).join(' '))}</p>` : ''}
|
||
<details class="more"><summary>show model</summary><pre class="spice">${esc(p.spice_mixed)}</pre></details></div>` : ''}
|
||
${p.spice ? `<div class="section"><h3>Supply / loading model</h3><details class="more"><summary>show model</summary><pre class="spice">${esc(p.spice)}</pre></details></div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
|
||
// tab switching (lazy-mount the 3D viewer on first show)
|
||
let mounted = false;
|
||
const show = (tab) => {
|
||
root.querySelectorAll('.viztabs button').forEach((b) => b.classList.toggle('on', b.dataset.tab === tab));
|
||
root.querySelectorAll('.vizpanel').forEach((v) => (v.hidden = v.dataset.v !== tab));
|
||
if (tab === 'd3' && !mounted) { mounted = true; mountViewer(document.getElementById('cv'), p.model_3d); }
|
||
};
|
||
root.querySelectorAll('.viztabs button').forEach((b) => (b.onclick = () => show(b.dataset.tab)));
|
||
show('d3');
|
||
|
||
document.getElementById('dl-all').onclick = async () => {
|
||
try { const wrl = await (await fetch(p.model_3d)).text(); dl(`${p.id}.wrl`, wrl); } catch {}
|
||
dl(`${p.id}.cir`, `* ${p.mpn} — PromptPCB Parts\n`
|
||
+ (p.spice_mixed ? `* functional mixed-signal model (${p.spice_mixed_subckt})\n${p.spice_mixed}\n\n` : '')
|
||
+ (p.spice ? `* supply / loading macromodel\n${p.spice}\n` : ''));
|
||
dl(`${p.id}.json`, JSON.stringify(p, null, 2), 'application/json');
|
||
};
|
||
document.getElementById('lib').onclick = () => {
|
||
const lib = JSON.parse(localStorage.getItem('ppcb_lib') || '[]');
|
||
if (!lib.includes(p.id)) { lib.push(p.id); localStorage.setItem('ppcb_lib', JSON.stringify(lib)); }
|
||
document.getElementById('lib').textContent = '✓ In library';
|
||
};
|
||
});
|