Sign in to continue
Checking your session…
Simulator
Simulation
Output Waveform
Transfer Characteristics
U
User
R1

Project File

Simulation Settings

Checking…

Edit Value

function selectSectionPage(event, idx) { // Prevent the click from bubbling back to #sectionSelector, whose onclick // would otherwise toggle the dropdown open again after goToPage(). if (event) { event.preventDefault(); event.stopPropagation(); } const dropdown = document.getElementById("sectionDropdown"); if (dropdown) dropdown.classList.remove("show"); goToPage(idx); } function applyTheme(theme) { if (theme === "dark") document.documentElement.setAttribute("data-theme", "dark"); else document.documentElement.removeAttribute("data-theme"); document.getElementById("themeToggle").innerHTML = theme === "dark" ? MOON_SVG : SUN_SVG; } function toggleTheme() { const isDark = document.documentElement.getAttribute("data-theme") === "dark"; const next = isDark ? "light" : "dark"; try { localStorage.setItem("oblivionlabs-theme", next); } catch (e) {} applyTheme(next); } const GRID = 32; let cfg = { backend: "https://circuitsim-z9lz.onrender.com", endTimeMs: 1, stepTimeUs: 1 }; let components = []; let wires = []; let selectedWire = null; let texts = []; let idCounters = { R: 0, C: 0, L: 0, D: 0, Q: 0, V: 0, G: 0, P: 0, T: 0 }; let tool = "select"; let placeType = null; let selectedId = null; let wireDraft = null; let currentPage = 0; let lastTapInfo = null; let pendingTextPoint = null; const svg = document.getElementById("canvas"); const worldG = document.getElementById("world"); const wiresLayer = document.getElementById("wiresLayer"); const compsLayer = document.getElementById("compsLayer"); const textsLayer = document.getElementById("textsLayer"); let panX = -4000 + 400, panY = -4000 + 200, viewScale = 1; function applyWorldTransform() { worldG.setAttribute("transform", "translate(" + panX + "," + panY + ") scale(" + viewScale + ")"); } applyWorldTransform(); const DEFS = { R: { label: "Resistor", prefix: "R", pins: 2, defaultValue: 1000, unit: "\u03A9" }, C: { label: "Capacitor", prefix: "C", pins: 2, defaultValue: 0.000001, unit: "F" }, L: { label: "Inductor", prefix: "L", pins: 2, defaultValue: 0.001, unit: "H" }, D: { label: "Diode", prefix: "D", pins: 2, defaultValue: null, unit: "" }, Q: { label: "Transistor",prefix: "Q", pins: 3, defaultValue: null, unit: "" }, V: { label: "Voltage Source", prefix: "V", pins: 2, defaultValue: 5, unit: "V" }, G: { label: "Ground", prefix: "GND", pins: 1, defaultValue: null, unit: "" }, P: { label: "Probe", prefix: "P", pins: 1, defaultValue: null, unit: "" } }; function formatValue(type, value) { if (type === "R") return value >= 1000 ? (value/1000) + "k\u03A9" : value + "\u03A9"; if (type === "C") return (value * 1e6) + "\u00B5F"; if (type === "L") return value >= 1 ? value + "H" : (value*1000) + "mH"; if (type === "V") return value + "V"; return ""; } function sourceIndexAmong(comp) { const sources = components.filter(c => c.type === "V"); return sources.indexOf(comp); } function labelForComponent(comp) { if (comp.type === "G") return comp.prefix; if (comp.type === "P") return comp.customLabel || comp.prefix; if (comp.type === "V") { const sources = components.filter(c => c.type === "V"); const name = sources.length > 1 ? "Vin" + (sourceIndexAmong(comp)+1) : "Vin"; if (comp.waveform === "sin") return name + " SIN " + comp.amplitude + "V/" + comp.frequency + "Hz"; if (comp.waveform === "square") return name + " SQR " + comp.lowValue + "-" + comp.highValue + "V/" + comp.frequency + "Hz"; return name + " " + comp.value + "V"; } const formatted = formatValue(comp.type, comp.value); return comp.prefix + (formatted ? " " + formatted : ""); } const paletteIcons = { R: '', C: '', L: '', D: '', Q: '', V: '+', G: '', P: 'V' }; const moreDotsIcon = ''; const chevronIcon = ''; const sidebar = document.getElementById("sidebar"); // Show every component directly; no separate “More” row. const mainPaletteOrder = ["R", "C", "L", "D", "Q", "V", "G", "P"]; function buildSidebarItem(type) { const item = document.createElement("div"); item.className = "sidebar-item"; item.id = "pal-" + type; item.innerHTML = paletteIcons[type] + "" + DEFS[type].label + ""; item.onclick = function() { armPlacement(type); }; return item; } mainPaletteOrder.forEach(type => sidebar.appendChild(buildSidebarItem(type))); function armPlacement(type) { placeType = type; tool = "place"; document.querySelectorAll(".sidebar-item").forEach(el => el.classList.remove("active")); document.getElementById("pal-" + type).classList.add("active"); document.querySelectorAll("#drawToolsGroup .toolbtn").forEach(b => b.classList.remove("active")); setStatus("Tap the grid to place " + DEFS[type].label); } function armProbePlacement() { armPlacement("P"); const btn = document.getElementById("tool-probe"); if (btn) btn.classList.add("active"); setStatus("Tap a wire or node to place a voltage probe (measured to GND)"); } function setTool(t) { tool = t; placeType = null; wireDraft = null; document.querySelectorAll(".sidebar-item").forEach(el => el.classList.remove("active")); document.querySelectorAll("#drawToolsGroup .toolbtn").forEach(b => b.classList.remove("active")); const btn = document.getElementById("tool-" + t); if (btn) btn.classList.add("active"); if (t === "wire") setStatus("Tap a pin, then tap bend points, then tap the target pin"); else if (t === "erase") setStatus("Tap a component, wire, or text to remove it"); else if (t === "text") setStatus("Tap the canvas to add text"); else setStatus(""); selectComponent(null); renderStructure(); } function setStatus(msg) { document.getElementById("status").textContent = msg; } const pageNames = ["Simulation", "Output Waveform", "Transfer Characteristics"]; const pillLabels = ["Simulator", "Output Waveform", "Transfer Characteristics"]; function syncPageChrome() { document.body.classList.toggle("simulation-page", currentPage === 0); } function toggleComponentsMenu() { if (currentPage !== 0) return; document.getElementById("page-simulation").classList.toggle("components-collapsed"); } function toggleSectionDropdown() { document.getElementById("sectionDropdown").classList.toggle("show"); } function goToPage(idx) { currentPage = idx; document.getElementById("pageTrack").style.transform = "translateX(-" + (idx * 33.333) + "%)"; const labels = ["Simulator", "Output Waveform", "Transfer Characteristics"]; document.getElementById("sectionLabel").textContent = labels[idx]; document.querySelectorAll("#sectionDropdown .opt").forEach((el, i) => el.classList.toggle("active", i === idx)); document.getElementById("sectionDropdown").classList.remove("show"); document.body.classList.toggle("simulation-page", idx === 0); document.body.classList.toggle("waveform-page", idx === 1); document.body.classList.toggle("transfer-page", idx === 2); const isSimulation = idx === 0; const isWaveform = idx === 1; // Editing controls only belong on Simulator. document.getElementById("drawToolsGroup").style.display = isSimulation ? "flex" : "none"; document.getElementById("undoRedoGroup").style.display = isSimulation ? "flex" : "none"; document.getElementById("topDividerA").style.display = isSimulation ? "block" : "none"; document.getElementById("topDividerB").style.display = isSimulation ? "block" : "none"; // Simulate is only shown on Output Waveform. document.getElementById("wfTopSimulateBtn").style.display = isWaveform ? "flex" : "none"; // The same Graph download control is used on Output Waveform and Transient Analysis. document.getElementById("wfTopDownloadGraphBtn").style.display = (isWaveform || idx === 2) ? "flex" : "none"; // Settings is hidden on Transient Analysis. document.getElementById("settingsBtn").style.display = (isSimulation || isWaveform) ? "flex" : "none"; document.getElementById("themeToggle").style.display = (isSimulation || isWaveform || idx === 2) ? "flex" : "none"; if (isWaveform && waveformChart) { setTimeout(() => waveformChart.resize(), 50); } if (idx === 2) { refreshTransferControls(); setTimeout(() => { if (window.tcChartInstance) window.tcChartInstance.resize(); }, 50); } } syncPageChrome(); document.addEventListener("pointerdown", evt => { const sel = document.getElementById("sectionSelector"); if (!sel.contains(evt.target)) document.getElementById("sectionDropdown").classList.remove("show"); }); function pinPoints(comp) { const def = DEFS[comp.type]; const ox = comp.gx * GRID, oy = comp.gy * GRID; let raw; if (def.pins === 1) raw = [{ x: 0, y: 0 }]; else if (def.pins === 2) raw = [{ x: 0, y: 0 }, { x: 2 * GRID, y: 0 }]; else if (comp.type === "Q") raw = [{ x: -8, y: 28 }, { x: 35.2, y: -8 }, { x: 35.2, y: 64 }]; else raw = [{ x: 0, y: GRID }, { x: 2 * GRID, y: 0 }, { x: 2 * GRID, y: 2 * GRID }]; const rad = (comp.rot || 0) * Math.PI / 180; return raw.map(p => { const rx = p.x * Math.cos(rad) - p.y * Math.sin(rad); const ry = p.x * Math.sin(rad) + p.y * Math.cos(rad); if (comp.type === "Q") return { x: ox + rx, y: oy + ry }; return { x: Math.round((ox + rx) / (GRID/2)) * (GRID/2), y: Math.round((oy + ry) / (GRID/2)) * (GRID/2) }; }); } function pointKey(p) { return Math.round(p.x) + "," + Math.round(p.y); } function resolveEndpoint(ep) { if (ep.pin) { const comp = components.find(c => c.id === ep.pin.compId); if (!comp) return { x: 0, y: 0 }; return pinPoints(comp)[ep.pin.pinIndex]; } return { x: ep.x, y: ep.y }; } function wirePolylinePoints(wire) { return wire.points.map(resolveEndpoint).map(p => p.x + "," + p.y).join(" "); } function distToSegment(p, v, w) { const l2 = Math.pow(w.x-v.x,2) + Math.pow(w.y-v.y,2); if (l2 === 0) return { dist: Math.hypot(p.x-v.x, p.y-v.y), point: v }; let t = ((p.x-v.x)*(w.x-v.x) + (p.y-v.y)*(w.y-v.y)) / l2; t = Math.max(0, Math.min(1, t)); return { dist: Math.hypot(p.x-(v.x+t*(w.x-v.x)), p.y-(v.y+t*(w.y-v.y))), point: { x: v.x+t*(w.x-v.x), y: v.y+t*(w.y-v.y) } }; } function nearestPointOnWire(wire, p) { const resolved = wire.points.map(resolveEndpoint); let best = null; for (let i = 0; i < resolved.length - 1; i++) { const s = distToSegment(p, resolved[i], resolved[i+1]); if (!best || s.dist < best.dist) best = s; } return best; } function renderStructure() { wiresLayer.innerHTML = ""; compsLayer.innerHTML = ""; textsLayer.innerHTML = ""; wires.forEach((wire) => { const pts = wirePolylinePoints(wire); const vis = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); vis.setAttribute("points", pts); vis.setAttribute("class", "wire-line"); wiresLayer.appendChild(vis); const hit = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); hit.setAttribute("points", pts); hit.setAttribute("class", "wire-hit"); hit.addEventListener("pointerdown", (evt) => onWirePointerDown(evt, wire)); wiresLayer.appendChild(hit); wire.visEl = vis; wire.hitEl = hit; // Bend handles are shown only for the currently selected wire. if (wire === selectedWire) { wire.points.forEach((point, pointIndex) => { if (pointIndex === 0 || pointIndex === wire.points.length - 1 || point.pin) return; const rp = resolveEndpoint(point); const handle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); handle.setAttribute("cx", rp.x); handle.setAttribute("cy", rp.y); handle.setAttribute("r", 6); handle.setAttribute("class", "wire-bend-handle"); handle.addEventListener("pointerdown", evt => onWireBendPointerDown(evt, wire, pointIndex)); wiresLayer.appendChild(handle); }); } }); if (wireDraft) { const resolved = wireDraft.points.map(resolveEndpoint); if (wireDraft.previewEnd) resolved.push(wireDraft.previewEnd); if (resolved.length > 1) { const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); line.setAttribute("points", resolved.map(p => p.x+","+p.y).join(" ")); line.setAttribute("class", "wire-preview"); wiresLayer.appendChild(line); } } components.forEach(comp => buildComponentDom(comp)); texts.forEach(t => buildTextDom(t)); updateSelectionBar(); } function buildComponentDom(comp) { const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); g.dataset.compId = comp.id; if (comp.id === selectedId) g.classList.add("is-selected"); const def = DEFS[comp.type]; const bodyClass = comp.type === "P" ? "probe-body" : "comp-body"; function svgEl(tag, attrs) { const el = document.createElementNS("http://www.w3.org/2000/svg", tag); for (const k in attrs) el.setAttribute(k, attrs[k]); return el; } if (comp.type === "R") { g.appendChild(svgEl("path", { class: bodyClass, d: "M0 0h10 M10 0l5 -8 10 16 10 -16 10 16 5 -8 H"+(2*GRID) })); } else if (comp.type === "C") { const m = GRID; g.appendChild(svgEl("path", { class: bodyClass, d: "M0 0h"+(m-8)+" M"+(m-8)+" -14v28 M"+(m+8)+" -14v28 M"+(m+8)+" 0h"+(GRID-8) })); } else if (comp.type === "L") { g.appendChild(svgEl("path", { class: bodyClass, d: "M0 0h8 M8 0a8 8 0 0 1 16 0 M24 0a8 8 0 0 1 16 0 M40 0a8 8 0 0 1 16 0 M56 0h"+(2*GRID-56) })); } else if (comp.type === "D") { const m = GRID; g.appendChild(svgEl("path", { class: bodyClass, d: "M0 0h"+(m-10)+" M"+(m-10)+" -12l0 24 20 -12z M"+(m+10)+" -12v24 M"+(m+10)+" 0h"+(GRID-10) })); } else if (comp.type === "Q") { // Exact NPN symbol supplied by the user, uniformly scaled for the canvas. const q = svgEl("g", { transform: "translate(-3.2,-4) scale(0.16)", fill: "none", stroke: "var(--text)", "stroke-width": 9, "stroke-linecap": "round", "stroke-linejoin": "round" }); q.appendChild(svgEl("circle", { cx: 200, cy: 200, r: 125 })); q.appendChild(svgEl("line", { x1: 20, y1: 200, x2: 140, y2: 200 })); q.appendChild(svgEl("line", { x1: 140, y1: 140, x2: 140, y2: 260 })); q.appendChild(svgEl("line", { x1: 140, y1: 180, x2: 240, y2: 105 })); q.appendChild(svgEl("line", { x1: 240, y1: 105, x2: 240, y2: 25 })); q.appendChild(svgEl("line", { x1: 140, y1: 220, x2: 240, y2: 295 })); q.appendChild(svgEl("line", { x1: 240, y1: 295, x2: 240, y2: 375 })); g.appendChild(q); g.appendChild(svgEl("polygon", { class: "transistor-arrow", points: "30.4,39.52 24.64,38.56 28.16,33.92" })); } else if (comp.type === "V") { g.appendChild(svgEl("circle", { class: bodyClass, cx: GRID, cy: 0, r: 16 })); g.appendChild(svgEl("path", { class: bodyClass, d: "M0 0h"+(GRID-16)+" M"+(GRID+16)+" 0h"+(GRID-16) })); const plus = svgEl("text", { x: GRID-11, y: 4, "text-anchor": "middle", "dominant-baseline": "middle", class: "comp-label", "font-size": 13, "font-weight": 700 }); plus.textContent = "+"; g.appendChild(plus); } else if (comp.type === "G") { g.appendChild(svgEl("path", { class: "gnd-symbol", d: "M0 0v10 M-12 10h24 M-8 16h16 M-4 22h8" })); } else if (comp.type === "P") { // One-terminal voltage probe. Its pin is at (0,0); the marker sits above it. const probeColor = comp.color || "#e53935"; g.appendChild(svgEl("path", { class: bodyClass, d: "M0 0v-14", style: "stroke:"+probeColor })); g.appendChild(svgEl("circle", { class: bodyClass, cx: 0, cy: -22, r: 8, style: "stroke:"+probeColor })); const vtext = svgEl("text", { x: 0, y: -19, "text-anchor": "middle", class: "comp-label", "font-size": 9, style: "fill:"+probeColor }); vtext.textContent = "V"; g.appendChild(vtext); } compsLayer.appendChild(g); const hit = document.createElementNS("http://www.w3.org/2000/svg", "rect"); hit.setAttribute("fill", "transparent"); hit.addEventListener("pointerdown", (evt) => onBodyPointerDown(evt, comp)); compsLayer.appendChild(hit); const pinEls = pinPoints(comp).map((abs, idx) => { const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle"); dot.setAttribute("r", 8); dot.setAttribute("class", "pin"); if (comp.type === "P") dot.setAttribute("style", "stroke:"+(comp.color || "#e53935")); dot.addEventListener("pointerdown", (evt) => onPinPointerDown(evt, comp, idx)); compsLayer.appendChild(dot); return dot; }); const label = document.createElementNS("http://www.w3.org/2000/svg", "text"); label.setAttribute("text-anchor", "middle"); label.setAttribute("class", "comp-label"); if (comp.type === "P") label.setAttribute("style", "fill:"+(comp.color || "#e53935")); compsLayer.appendChild(label); comp.el = g; comp.hitEl = hit; comp.pinEls = pinEls; comp.labelEl = label; positionComponentDom(comp); } function positionComponentDom(comp) { if (!comp.el) return; const def = DEFS[comp.type]; comp.el.setAttribute("transform", "translate("+(comp.gx*GRID)+","+(comp.gy*GRID)+") rotate("+(comp.rot||0)+")"); comp.hitEl.setAttribute("x", comp.gx*GRID - 18); comp.hitEl.setAttribute("y", comp.gy*GRID - 18); comp.hitEl.setAttribute("width", 2*GRID + 18); comp.hitEl.setAttribute("height", 36 + (def.pins === 3 ? 2*GRID-36 : 0)); const pins = pinPoints(comp); pins.forEach((abs, i) => { if (comp.pinEls[i]) { comp.pinEls[i].setAttribute("cx", abs.x); comp.pinEls[i].setAttribute("cy", abs.y); } }); // Keep each value/name label attached to its component when the component rotates. // The label starts just above the component in local coordinates, then follows // that same local point through the component rotation. // One-pin components use a centered name above the top of their symbol. // Other component value/name labels keep their existing attached position. let labelLocalX = GRID; let labelLocalY = -14; if (comp.type === "G") { labelLocalX = 0; labelLocalY = -12; } else if (comp.type === "P") { labelLocalX = 0; labelLocalY = -36; } const angle = ((comp.rot || 0) * Math.PI) / 180; const labelX = comp.gx*GRID + labelLocalX*Math.cos(angle) - labelLocalY*Math.sin(angle); const labelY = comp.gy*GRID + labelLocalX*Math.sin(angle) + labelLocalY*Math.cos(angle); comp.labelEl.setAttribute("x", labelX); comp.labelEl.setAttribute("y", labelY); comp.labelEl.textContent = labelForComponent(comp); wires.forEach(w => { const touches = w.points.some(p => p.pin && p.pin.compId === comp.id); if (touches && w.visEl) { const pts = wirePolylinePoints(w); w.visEl.setAttribute("points", pts); w.hitEl.setAttribute("points", pts); } }); } function buildTextDom(t) { const el = document.createElementNS("http://www.w3.org/2000/svg", "text"); el.setAttribute("class", "canvas-text"); el.textContent = t.content; textsLayer.appendChild(el); const hit = document.createElementNS("http://www.w3.org/2000/svg", "rect"); hit.setAttribute("fill", "transparent"); hit.addEventListener("pointerdown", (evt) => onTextPointerDown(evt, t)); textsLayer.appendChild(hit); t.el = el; t.hitEl = hit; positionTextDom(t); } function positionTextDom(t) { if (!t.el) return; t.el.setAttribute("x", t.x); t.el.setAttribute("y", t.y); t.el.classList.toggle("is-selected", t.id === selectedId); let bbox; try { bbox = t.el.getBBox(); } catch (e) { bbox = { x: t.x, y: t.y-14, width: 60, height: 18 }; } t.hitEl.setAttribute("x", bbox.x-6); t.hitEl.setAttribute("y", bbox.y-6); t.hitEl.setAttribute("width", bbox.width+12); t.hitEl.setAttribute("height", bbox.height+12); } function setSelectedVisual(id) { compsLayer.querySelectorAll(".is-selected").forEach(el => el.classList.remove("is-selected")); if (id) { const comp = components.find(c => c.id === id); if (comp && comp.el) comp.el.classList.add("is-selected"); } texts.forEach(t => { if (t.el) t.el.classList.toggle("is-selected", t.id === id); }); } const canvasWrap = document.getElementById("canvas-wrap"); let activePointers = new Map(); let pinchStartDist = null, pinchStartScale = 1, pinchStartWorld = null; let panState = null; // Batches transform writes to one per animation frame instead of one per // pointermove event, so fast-firing input doesn't cause jank/stutter. let transformRafScheduled = false; function scheduleWorldTransform() { if (transformRafScheduled) return; transformRafScheduled = true; requestAnimationFrame(() => { transformRafScheduled = false; applyWorldTransform(); }); } // Client (x,y) -> raw pixel offset within the SVG's own bounding rect, // corrected for the forced-landscape rotation. This is the same space // panX/panY/viewScale operate in, but *before* pan/scale are applied. function rawLocalPointFromClient(clientX, clientY) { const rect = svg.getBoundingClientRect(); let ox = clientX - rect.left, oy = clientY - rect.top; if (isForcedLandscape()) { const localX = rect.height - oy, localY = ox; ox = localX; oy = localY; } return { x: ox, y: oy }; } canvasWrap.addEventListener("pointerdown", evt => { // Accidental zoom protection: pinch zoom is available only while Select is active. // While placing components or wiring, one-finger taps/slides can never start zooming. if (tool !== "select" || evt.pointerType !== "touch") return; // Pinch zoom may begin only from the blank canvas. A one-finger touch on a // component/pin/wire must remain available for selecting and dragging it. if (evt.target !== svg && evt.target.id !== "bgRect") return; activePointers.set(evt.pointerId, { x: evt.clientX, y: evt.clientY }); if (activePointers.size === 2) { const pts = Array.from(activePointers.values()); pinchStartDist = Math.hypot(pts[0].x-pts[1].x, pts[0].y-pts[1].y); pinchStartScale = viewScale; // Freeze the world/circuit point currently under the pinch midpoint so we // can keep it stationary on screen as the scale changes. const midRaw = rawLocalPointFromClient((pts[0].x+pts[1].x)/2, (pts[0].y+pts[1].y)/2); pinchStartWorld = { x: (midRaw.x - panX) / viewScale, y: (midRaw.y - panY) / viewScale }; panState = null; // A two-finger pinch owns the gesture completely. Do not let an already // selected component continue dragging underneath the zoom gesture. dragState = null; wireBendDrag = null; } }, true); canvasWrap.addEventListener("pointermove", evt => { if (tool !== "select" || evt.pointerType !== "touch") return; if (!activePointers.has(evt.pointerId)) return; activePointers.set(evt.pointerId, { x: evt.clientX, y: evt.clientY }); if (activePointers.size === 2 && pinchStartDist) { const pts = Array.from(activePointers.values()); const dist = Math.hypot(pts[0].x-pts[1].x, pts[0].y-pts[1].y); const newScale = Math.max(0.35, Math.min(4, pinchStartScale * (dist / pinchStartDist))); const midRaw = rawLocalPointFromClient((pts[0].x+pts[1].x)/2, (pts[0].y+pts[1].y)/2); // Solve pan so pinchStartWorld stays under the current pinch midpoint: // midRaw = pinchStartWorld * newScale + pan viewScale = newScale; panX = midRaw.x - pinchStartWorld.x * newScale; panY = midRaw.y - pinchStartWorld.y * newScale; scheduleWorldTransform(); } }, true); function endPinch(evt) { activePointers.delete(evt.pointerId); if (activePointers.size < 2) { pinchStartDist = null; pinchStartWorld = null; } } canvasWrap.addEventListener("pointerup", endPinch, true); canvasWrap.addEventListener("pointercancel", endPinch, true); svg.addEventListener("pointerdown", evt => { if (evt.target !== svg && evt.target.id !== "bgRect") return; if (activePointers.size >= 2) return; // a pinch gesture owns this - ignore stray taps if (tool === "select") { panState = { startClientX: evt.clientX, startClientY: evt.clientY, startPanX: panX, startPanY: panY, moved: false }; try { svg.setPointerCapture(evt.pointerId); } catch (e) {} } else { // Place / Wire / Text / Erase tools: no pan-drag ambiguity to resolve, act immediately. onEmptyTap(evt); } }); svg.addEventListener("pointermove", evt => { if (panState && activePointers.size < 2) { const rawDx = evt.clientX - panState.startClientX, rawDy = evt.clientY - panState.startClientY; const { dx, dy } = correctScreenDelta(rawDx, rawDy); if (Math.abs(dx) > 4 || Math.abs(dy) > 4) panState.moved = true; if (panState.moved) { panX = panState.startPanX + dx; panY = panState.startPanY + dy; scheduleWorldTransform(); } } }); svg.addEventListener("pointerup", evt => { if (panState) { if (!panState.moved) onEmptyTap(evt); panState = null; try { svg.releasePointerCapture(evt.pointerId); } catch (e) {} } }); svg.addEventListener("pointercancel", evt => { panState = null; try { svg.releasePointerCapture(evt.pointerId); } catch (e) {} }); // True when the "force landscape" CSS hack (rotate(-90deg) on ) is active. // Must mirror the media query above exactly. function isForcedLandscape() { return window.matchMedia("(max-aspect-ratio: 99/100)").matches; } // A -90deg rotation swaps and flips the visual axes relative to the SVG's own // (pre-rotation) local coordinate space: a rightward screen movement is a +y // local movement, and a downward screen movement is a -x local movement. // Used to correct raw clientX/clientY deltas (pan-drag, component-drag) so they // land in the same local space the world transform (panX/panY/viewScale) uses. function correctScreenDelta(rawDx, rawDy) { if (!isForcedLandscape()) return { dx: rawDx, dy: rawDy }; return { dx: -rawDy, dy: rawDx }; } function svgPointFromEvent(evt) { const { x: ox, y: oy } = rawLocalPointFromClient(evt.clientX, evt.clientY); return { x: (ox - panX) / viewScale, y: (oy - panY) / viewScale }; } function snapToGrid(p) { return { x: Math.round(p.x/(GRID/2))*(GRID/2), y: Math.round(p.y/(GRID/2))*(GRID/2) }; } function onEmptyTap(evt) { const p = snapToGrid(svgPointFromEvent(evt)); if (tool === "place" && placeType) { placeComponent(placeType, p); return; } if (tool === "wire") { addWireDraftPoint({ x: p.x, y: p.y }); return; } if (tool === "text") { pendingTextPoint = svgPointFromEvent(evt); openTextEditor(null); return; } selectComponent(null); } function placeComponent(type, gridPointPx) { const gx = gridPointPx.x/GRID, gy = gridPointPx.y/GRID; idCounters[type]++; const def = DEFS[type]; const comp = { id: type+idCounters[type], type, gx, gy, rot: 0, value: def.defaultValue, prefix: def.prefix+idCounters[type] }; if (type === "V") { comp.waveform="DC"; comp.amplitude=5; comp.frequency=1000; comp.offset=0; comp.lowValue=0; comp.highValue=5; } if (type === "P") { comp.customLabel = comp.prefix; comp.color = "#e53935"; comp.lineStyle = "solid"; } components.push(comp); renderStructure(); selectComponent(comp.id); pushHistory(); } function isDoubleTap(key) { const now = Date.now(); const isDouble = lastTapInfo && lastTapInfo.key === key && (now - lastTapInfo.time) < 350; lastTapInfo = { key, time: now }; return isDouble; } function onPinPointerDown(evt, comp, pinIndex) { evt.stopPropagation(); if (tool === "place" && placeType === "P") { const pt = pinPoints(comp)[pinIndex]; placeComponent("P", pt); return; } if (tool === "wire") { addWireDraftPoint({ pin: { compId: comp.id, pinIndex } }); return; } if (tool === "erase") { removeComponent(comp.id); return; } onSelectableTap(comp, evt); } let dragState = null; function onBodyPointerDown(evt, comp) { evt.stopPropagation(); if (tool === "erase") { removeComponent(comp.id); return; } if (tool !== "select") return; onSelectableTap(comp, evt); } function onSelectableTap(comp, evt) { if (selectedId !== comp.id) { if (isDoubleTap("comp:" + comp.id)) { selectComponent(comp.id); editSelectedValue(); } else selectComponent(comp.id); return; } dragState = { kind: "comp", compId: comp.id, startClientX: evt.clientX, startClientY: evt.clientY, startGx: comp.gx, startGy: comp.gy, moved: false }; try { svg.setPointerCapture(evt.pointerId); } catch (e) {} } function onTextPointerDown(evt, t) { evt.stopPropagation(); if (tool === "erase") { removeText(t.id); return; } if (tool !== "select") return; if (selectedId !== t.id) { if (isDoubleTap("text:" + t.id)) { selectComponent(t.id); openTextEditor(t); } else selectComponent(t.id); return; } dragState = { kind: "text", compId: t.id, startClientX: evt.clientX, startClientY: evt.clientY, startGx: t.x, startGy: t.y, moved: false }; try { svg.setPointerCapture(evt.pointerId); } catch (e) {} } svg.addEventListener("pointermove", evt => { if (!dragState || activePointers.size >= 2) return; const rawDx = evt.clientX - dragState.startClientX, rawDy = evt.clientY - dragState.startClientY; const corrected = correctScreenDelta(rawDx, rawDy); const dx = corrected.dx / viewScale; const dy = corrected.dy / viewScale; if (Math.abs(dx) > 4 || Math.abs(dy) > 4) dragState.moved = true; if (dragState.moved) { if (dragState.kind === "text") { const t = texts.find(x => x.id === dragState.compId); if (t) { t.x = dragState.startGx + dx; t.y = dragState.startGy + dy; positionTextDom(t); } } else { const comp = components.find(c => c.id === dragState.compId); if (comp) { comp.gx = Math.round((dragState.startGx + dx/GRID) / 0.5) * 0.5; comp.gy = Math.round((dragState.startGy + dy/GRID) / 0.5) * 0.5; positionComponentDom(comp); } } } }); svg.addEventListener("pointerup", evt => { if (!dragState) return; const id = dragState.compId, kind = dragState.kind; if (!dragState.moved) { if (isDoubleTap((kind === "text" ? "text:" : "comp:") + id)) { if (kind === "text") openTextEditor(texts.find(t => t.id === id)); else editSelectedValue(); } else selectComponent(id); } else { pushHistory(); } try { svg.releasePointerCapture(evt.pointerId); } catch (e) {} dragState = null; }); svg.addEventListener("pointercancel", evt => { if (!dragState) return; // Keep the current visual position but end the interrupted gesture cleanly. // If it actually moved, preserve that state in undo history. if (dragState.moved) pushHistory(); dragState = null; try { svg.releasePointerCapture(evt.pointerId); } catch (e) {} }); let wireBendDrag = null; function onWireBendPointerDown(evt, wire, pointIndex) { if (tool !== "select") return; evt.stopPropagation(); evt.preventDefault(); wireBendDrag = { wire, pointIndex, pointerId: evt.pointerId, moved: false, handleEl: evt.currentTarget || evt.target }; try { svg.setPointerCapture(evt.pointerId); } catch (e) {} } let wireBendRaf = 0; svg.addEventListener("pointermove", evt => { if (!wireBendDrag || evt.pointerId !== wireBendDrag.pointerId) return; const p = snapToGrid(svgPointFromEvent(evt)); const point = wireBendDrag.wire.points[wireBendDrag.pointIndex]; if (!point || point.pin || (point.x === p.x && point.y === p.y)) return; point.x = p.x; point.y = p.y; wireBendDrag.moved = true; if (wireBendRaf) return; wireBendRaf = requestAnimationFrame(() => { wireBendRaf = 0; if (!wireBendDrag) return; const w = wireBendDrag.wire; const pts = wirePolylinePoints(w); if (w.visEl) w.visEl.setAttribute("points", pts); if (w.hitEl) w.hitEl.setAttribute("points", pts); const rp = resolveEndpoint(w.points[wireBendDrag.pointIndex]); if (wireBendDrag.handleEl) { wireBendDrag.handleEl.setAttribute("cx", rp.x); wireBendDrag.handleEl.setAttribute("cy", rp.y); } }); }); function finishWireBendDrag(evt) { if (!wireBendDrag || evt.pointerId !== wireBendDrag.pointerId) return; if (wireBendDrag.moved) pushHistory(); try { svg.releasePointerCapture(evt.pointerId); } catch (e) {} wireBendDrag = null; } svg.addEventListener("pointerup", finishWireBendDrag); svg.addEventListener("pointercancel", finishWireBendDrag); function onWirePointerDown(evt, wire) { evt.stopPropagation(); if (tool === "place" && placeType === "P") { const nearest = nearestPointOnWire(wire, svgPointFromEvent(evt)); if (nearest) placeComponent("P", snapToGrid(nearest.point)); return; } if (tool === "erase") { wires = wires.filter(w => w !== wire); renderStructure(); pushHistory(); return; } if (tool === "wire") { const tapPt = svgPointFromEvent(evt); const nearest = nearestPointOnWire(wire, tapPt); if (nearest) addWireDraftPoint(snapToGrid(nearest.point)); return; } if (tool === "select") { if (isDoubleTap("wire:" + wires.indexOf(wire))) { wires = wires.filter(w => w !== wire); if (selectedWire === wire) selectedWire = null; renderStructure(); pushHistory(); } else { selectedId = null; selectedWire = wire; renderStructure(); } } } function addWireDraftPoint(pointOrPinRef) { if (!wireDraft) wireDraft = { points: [] }; wireDraft.points.push(pointOrPinRef); const isPin = !!pointOrPinRef.pin; if (isPin && wireDraft.points.length >= 2) { wires.push({ points: wireDraft.points }); wireDraft = null; tool = "wire"; placeType = null; document.querySelectorAll("#drawToolsGroup .toolbtn").forEach(b => b.classList.remove("active")); document.getElementById("tool-wire")?.classList.add("active"); setStatus("Tap a pin, then tap bend points, then tap the target pin");; setStatus("Wire added."); renderStructure(); pushHistory(); return; } setStatus(wireDraft.points.length === 1 ? "Tap a bend point or the target pin" : "Tap another bend point or the target pin"); renderStructure(); } let wirePreviewRaf = 0; svg.addEventListener("pointermove", evt => { if (tool !== "wire" || !wireDraft) return; wireDraft.previewEnd = snapToGrid(svgPointFromEvent(evt)); if (wirePreviewRaf) return; wirePreviewRaf = requestAnimationFrame(() => { wirePreviewRaf = 0; const preview = wiresLayer.querySelector(".wire-preview"); if (!preview || !wireDraft) return; const resolved = wireDraft.points.map(resolveEndpoint); if (wireDraft.previewEnd) resolved.push(wireDraft.previewEnd); preview.setAttribute("points", resolved.map(p => p.x + "," + p.y).join(" ")); }); }); function removeComponent(compId) { components = components.filter(c => c.id !== compId); wires = wires.filter(w => !w.points.some(p => p.pin && p.pin.compId === compId)); if (selectedId === compId) selectedId = null; renderStructure(); pushHistory(); } function removeText(textId) { texts = texts.filter(t => t.id !== textId); if (selectedId === textId) selectedId = null; renderStructure(); pushHistory(); } function selectComponent(id) { selectedId = id; selectedWire = null; setSelectedVisual(id); updateSelectionBar(); renderStructure(); } function updateSelectionBar() { const bar = document.getElementById("selection-bar"); if (!selectedId) { bar.classList.remove("show"); return; } const comp = components.find(c => c.id === selectedId); const text = !comp ? texts.find(t => t.id === selectedId) : null; if (!comp && !text) { bar.classList.remove("show"); return; } bar.classList.add("show"); document.getElementById("selLabel").textContent = comp ? (labelForComponent(comp) || comp.prefix) : text.content; const rotateBtn = document.getElementById("rotateBtn"); const valueBtn = document.getElementById("valueBtn"); rotateBtn.style.display = comp ? "" : "none"; valueBtn.textContent = comp ? "Value" : "Edit"; valueBtn.style.display = (comp && (comp.type === "D" || comp.type === "Q" || comp.type === "G")) ? "none" : ""; } function rotateSelected() { const comp = components.find(c => c.id === selectedId); if (!comp) return; comp.rot = ((comp.rot||0)+90) % 360; positionComponentDom(comp); pushHistory(); } function deleteSelected() { if (!selectedId) return; if (components.find(c => c.id === selectedId)) removeComponent(selectedId); else if (texts.find(t => t.id === selectedId)) removeText(selectedId); } function editSelectedValue() { const comp = components.find(c => c.id === selectedId); if (comp) { if (comp.type === "D" || comp.type === "Q" || comp.type === "G") return; openValueEditor(comp); return; } const text = texts.find(t => t.id === selectedId); if (text) openTextEditor(text); } function escapeHtml(s) { return String(s).replace(/&/g,"&").replace(/"/g,""").replace(//g,">"); } function openValueEditor(comp) { const fields = document.getElementById("valueFields"); document.getElementById("valuePanel").dataset.mode = "comp"; document.getElementById("valueTitle").textContent = "Edit " + comp.prefix; if (comp.type === "R" || comp.type === "L") { const unitLabel = comp.type === "R" ? "Resistance (Ω)" : "Inductance (H)"; fields.innerHTML = ''; } else if (comp.type === "C") { fields.innerHTML = '' + ''; } else if (comp.type === "V") { fields.innerHTML = '' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
'; } else if (comp.type === "P") { const probeColor = comp.color || "#e53935"; const colorOptions = [ ["#e53935","Red"], ["#1e88e5","Blue"], ["#43a047","Green"], ["#f9a825","Yellow"], ["#8e24aa","Purple"], ["#00acc1","Cyan"], ["#fb8c00","Orange"], ["#ec407a","Pink"], ["#6d4c41","Brown"], ["#546e7a","Slate"] ]; fields.innerHTML = '' + '' + ''; } else return; document.getElementById("valuePanel").classList.add("show"); document.getElementById("valuePanel").dataset.compId = comp.id; if (comp.type === "V") toggleWaveformFields(); } function openTextEditor(text) { const panel = document.getElementById("valuePanel"); panel.dataset.mode = "text"; panel.dataset.textId = text ? text.id : ""; document.getElementById("valueTitle").textContent = text ? "Edit Text" : "Add Text"; document.getElementById("valueFields").innerHTML = ''; panel.classList.add("show"); } function toggleWaveformFields() { const wf = document.getElementById("vf-waveform").value; document.getElementById("wf-dc").style.display = wf==="DC"?"block":"none"; document.getElementById("wf-sin").style.display = wf==="sin"?"block":"none"; document.getElementById("wf-square").style.display = wf==="square"?"block":"none"; } function closeValueEditor() { const panel = document.getElementById("valuePanel"); panel.classList.remove("show"); panel.dataset.mode = ""; } function saveValueEditor() { const panel = document.getElementById("valuePanel"); if (panel.dataset.mode === "text") { const val = document.getElementById("vf-text-content").value.trim(); const textId = panel.dataset.textId; if (textId) { const t = texts.find(x => x.id === textId); if (t) { if (val) t.content = val; else texts = texts.filter(x => x.id !== textId); } } else if (val && pendingTextPoint) { idCounters.T = (idCounters.T||0) + 1; texts.push({ id: "T"+idCounters.T, x: pendingTextPoint.x, y: pendingTextPoint.y, content: val }); } closeValueEditor(); renderStructure(); pushHistory(); return; } const compId = panel.dataset.compId; const comp = components.find(c => c.id === compId); if (!comp) { closeValueEditor(); return; } if (comp.type === "R" || comp.type === "C" || comp.type === "L") { const v = parseFloat(document.getElementById("vf-value").value); if (!isNaN(v)) comp.value = v; if (comp.type === "C") { const ic = parseFloat(document.getElementById("vf-ic").value); comp.initialCondition = isNaN(ic)?0:ic; } } else if (comp.type === "V") { comp.waveform = document.getElementById("vf-waveform").value; if (comp.waveform === "DC") comp.value = parseFloat(document.getElementById("vf-dc-value").value) || 0; else if (comp.waveform === "sin") { comp.amplitude = parseFloat(document.getElementById("vf-sin-amp").value) || 0; comp.frequency = parseFloat(document.getElementById("vf-sin-freq").value) || 1000; comp.offset = parseFloat(document.getElementById("vf-sin-offset").value) || 0; } else if (comp.waveform === "square") { comp.lowValue = parseFloat(document.getElementById("vf-sq-low").value) || 0; comp.highValue = parseFloat(document.getElementById("vf-sq-high").value) || 0; comp.frequency = parseFloat(document.getElementById("vf-sq-freq").value) || 1000; } } else if (comp.type === "P") { comp.customLabel = document.getElementById("vf-label").value.trim() || comp.prefix; comp.color = document.getElementById("vf-probe-color").value || "#e53935"; comp.lineStyle = document.getElementById("vf-probe-line-style").value || "solid"; } closeValueEditor(); positionComponentDom(comp); updateSelectionBar(); pushHistory(); } /* ---------- Undo / Redo ---------- */ let history = []; let historyIndex = -1; let restoringHistory = false; const HISTORY_LIMIT = 100; function cloneComp(c) { const copy = {}; for (const k in c) { if (k==="el"||k==="hitEl"||k==="pinEls"||k==="labelEl") continue; copy[k]=c[k]; } return copy; } function cloneText(t) { return { id: t.id, x: t.x, y: t.y, content: t.content }; } function snapshotState() { return JSON.stringify({ components: components.map(cloneComp), wires: wires.map(w => ({ points: w.points })), texts: texts.map(cloneText), idCounters: Object.assign({}, idCounters) }); } function pushHistory() { if (restoringHistory) return; const snap = snapshotState(); history = history.slice(0, historyIndex+1); history.push(snap); if (history.length > HISTORY_LIMIT) history.shift(); else historyIndex++; historyIndex = history.length - 1; updateUndoRedoButtons(); } function restoreState(json) { restoringHistory = true; const data = JSON.parse(json); components = data.components; wires = data.wires; texts = data.texts; idCounters = data.idCounters; selectedId = null; selectedWire = null; wireDraft = null; dragState = null; wireBendDrag = null; panState = null; renderStructure(); restoringHistory = false; } function undo() { if (historyIndex <= 0) return; historyIndex--; restoreState(history[historyIndex]); updateUndoRedoButtons(); } function redo() { if (historyIndex >= history.length - 1) return; historyIndex++; restoreState(history[historyIndex]); updateUndoRedoButtons(); } function updateUndoRedoButtons() { const undoBtn = document.getElementById("undoBtn"); const redoBtn = document.getElementById("redoBtn"); if (undoBtn) undoBtn.disabled = historyIndex <= 0; if (redoBtn) redoBtn.disabled = historyIndex >= history.length - 1; } class UnionFind { constructor() { this.parent = {}; } find(x) { if (!(x in this.parent)) this.parent[x]=x; if (this.parent[x]!==x) this.parent[x]=this.find(this.parent[x]); return this.parent[x]; } union(a,b) { const ra=this.find(a), rb=this.find(b); if (ra!==rb) this.parent[ra]=rb; } } function buildNetlist() { const uf = new UnionFind(); wires.forEach(w => { const resolved = w.points.map(resolveEndpoint); for (let i=0; i { const resolved = w.points.map(resolveEndpoint); resolved.forEach(pt => { wires.forEach((other, oi) => { if (wi === oi) return; const otherResolved = other.points.map(resolveEndpoint); for (let i=0; i c.type === "P").forEach(probe => { const pp = pinPoints(probe)[0]; wires.forEach(w => { const resolved = w.points.map(resolveEndpoint); for (let i=0; i c.type === "G").forEach(g => { groundRoot = uf.find(pointKey(pinPoints(g)[0])); }); const rootToNode = {}; let nextNode = 1; function nodeFor(pt) { const root = uf.find(pointKey(pt)); if (groundRoot !== null && root === groundRoot) return "0"; if (!(root in rootToNode)) rootToNode[root] = String(nextNode++); return rootToNode[root]; } const netComponents = []; const probes = []; const sources = []; let hasSource = false; for (const comp of components) { if (comp.type === "G") continue; const pins = pinPoints(comp); if (comp.type === "R") netComponents.push({ id: comp.prefix, type: "resistor", nodeA: nodeFor(pins[0]), nodeB: nodeFor(pins[1]), value: comp.value }); else if (comp.type === "C") netComponents.push({ id: comp.prefix, type: "capacitor", nodeA: nodeFor(pins[0]), nodeB: nodeFor(pins[1]), value: comp.value, initial_condition: comp.initialCondition||0 }); else if (comp.type === "L") netComponents.push({ id: comp.prefix, type: "inductor", nodeA: nodeFor(pins[0]), nodeB: nodeFor(pins[1]), value: comp.value }); else if (comp.type === "D") netComponents.push({ id: comp.prefix, type: "diode", nodeA: nodeFor(pins[0]), nodeB: nodeFor(pins[1]) }); else if (comp.type === "V") { hasSource = true; const nA = nodeFor(pins[0]), nB = nodeFor(pins[1]); const name = labelForComponent(comp).split(" ")[0]; sources.push({ node: nA, label: name }); if (comp.waveform === "sin") netComponents.push({ id: comp.prefix, type: "voltage_source_sin", nodeA: nA, nodeB: nB, amplitude: comp.amplitude, frequency: comp.frequency, offset: comp.offset }); else if (comp.waveform === "square") netComponents.push({ id: comp.prefix, type: "voltage_source_square", nodeA: nA, nodeB: nB, low_value: comp.lowValue, high_value: comp.highValue, frequency: comp.frequency }); else netComponents.push({ id: comp.prefix, type: "voltage_source", nodeA: nA, nodeB: nB, value: comp.value }); } else if (comp.type === "Q") { netComponents.push({ id: comp.prefix, type: "bjt_npn", nodeA: nodeFor(pins[1]), nodeB: nodeFor(pins[0]), nodeC: nodeFor(pins[2]) }); } else if (comp.type === "P") { probes.push({ id: comp.prefix, label: comp.customLabel || comp.prefix, color: comp.color || "#e53935", lineStyle: comp.lineStyle || "solid", nodeA: nodeFor(pins[0]), nodeB: "0" }); } } return { components: netComponents, probes, sources, hasGround: groundRoot!==null, hasSource, analysis: { type: "transient", step_time: cfg.stepTimeUs*1e-6, end_time: cfg.endTimeMs*1e-3 } }; } let lastResultData = null, lastTraces = null; let simulationRunning = false; async function runSimulation() { if (simulationRunning) return; const netlist = buildNetlist(); if (!netlist.hasGround) { alert("Add a Ground component and wire it in."); return; } if (!netlist.hasSource) { alert("Add a Voltage Source."); return; } if (netlist.components.length === 0) { alert("Place some components first."); return; } document.getElementById("wfError").style.display = "none"; document.getElementById("wfStatus").textContent = "Simulating... (cold start can take ~1 min)"; simulationRunning = true; const simButtons = [document.getElementById("wfTopSimulateBtn"), document.getElementById("wfSimulateBtn")].filter(Boolean); simButtons.forEach(btn => btn.disabled = true); try { const res = await fetch(cfg.backend + "/simulate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ components: netlist.components, analysis: netlist.analysis }) }); if (!res.ok) throw new Error("Simulation server returned HTTP " + res.status); const data = await res.json(); document.getElementById("wfStatus").textContent = ""; if (data.error) { showWfError(data.error); return; } const traces = []; netlist.sources.forEach(s => { if (data[s.node]) traces.push({ key: "src_"+s.node, label: s.label, values: data[s.node] }); }); netlist.probes.forEach(p => { const a = data[p.nodeA] || data.time.map(() => 0); const b = data[p.nodeB] || data.time.map(() => 0); traces.push({ key: "probe_"+p.id, label: p.label, color: p.color, lineStyle: p.lineStyle || "solid", values: a.map((v,i) => v - b[i]) }); }); lastResultData = data; lastTraces = { time: data.time, traces }; drawResultChart(lastTraces); } catch (err) { document.getElementById("wfStatus").textContent = ""; showWfError("Request failed: " + err.message); } finally { simulationRunning = false; simButtons.forEach(btn => btn.disabled = false); } } function showWfError(msg) { const el = document.getElementById("wfError"); el.style.display = "block"; el.textContent = msg; } let resultChart = null; let activeTraceKey = null; const whiteBgPlugin = { id: "whiteBg", beforeDraw(chart) { const ctx = chart.ctx; ctx.save(); ctx.globalCompositeOperation = "destination-over"; ctx.fillStyle = "#ffffff"; ctx.fillRect(0,0,chart.width,chart.height); ctx.restore(); }}; const zeroAxisPlugin = { id: "zeroAxis", afterDraw(chart) { const ctx = chart.ctx, area = chart.chartArea, scales = chart.scales; const xZero = scales.x.getPixelForValue(0), yZero = scales.y.getPixelForValue(0); ctx.save(); ctx.strokeStyle = "#222"; ctx.lineWidth = 1.4; if (yZero >= area.top && yZero <= area.bottom) { ctx.beginPath(); ctx.moveTo(area.left,yZero); ctx.lineTo(area.right,yZero); ctx.stroke(); } if (xZero >= area.left && xZero <= area.right) { ctx.beginPath(); ctx.moveTo(xZero,area.top); ctx.lineTo(xZero,area.bottom); ctx.stroke(); } ctx.restore(); }}; function makeTouchCrosshairPlugin(pluginId, getLabel) { const state = { active: false, index: -1, datasetIndex: 0, chart: null }; function clientToCanvas(chart, clientX, clientY) { const rect = chart.canvas.getBoundingClientRect(); return { x: (clientX - rect.left) * (chart.width / rect.width), y: (clientY - rect.top) * (chart.height / rect.height) }; } function nearestPoint(chart, canvasX, canvasY) { let best = null; let bestScore = Infinity; chart.data.datasets.forEach((ds, di) => { if (!ds || ds.hidden || !Array.isArray(ds.data)) return; ds.data.forEach((p, i) => { if (!p || typeof p !== "object") return; const xv = Number(p.x), yv = Number(p.y); if (!Number.isFinite(xv) || !Number.isFinite(yv)) return; const px = chart.scales.x.getPixelForValue(xv); const py = chart.scales.y.getPixelForValue(yv); // X dominates dragging direction; Y chooses the closest trace when // several graphs/traces share the same X position. const dx = Math.abs(px - canvasX); const dy = Math.abs(py - canvasY); const score = dx * 4 + dy; if (score < bestScore) { bestScore = score; best = { datasetIndex: di, index: i }; } }); }); return best; } function update(chart, clientX, clientY) { const pos = clientToCanvas(chart, clientX, clientY); const area = chart.chartArea; pos.x = Math.max(area.left, Math.min(pos.x, area.right)); pos.y = Math.max(area.top, Math.min(pos.y, area.bottom)); const hit = nearestPoint(chart, pos.x, pos.y); if (!hit) return; state.active = true; state.chart = chart; state.datasetIndex = hit.datasetIndex; state.index = hit.index; chart.draw(); } function bind(chart) { if (chart.$touchCrosshairBound) return; chart.$touchCrosshairBound = true; const canvas = chart.canvas; canvas.style.touchAction = "none"; const coords = (e) => { const t = e.touches?.[0] || e.changedTouches?.[0] || e; return { x: t.clientX, y: t.clientY }; }; const startDrag = (e) => { if (e.cancelable) e.preventDefault(); const p = coords(e); state.active = true; state.chart = chart; try { if (e.pointerId != null) canvas.setPointerCapture(e.pointerId); } catch (_) {} update(chart, p.x, p.y); }; const drag = (e) => { if (!state.active || state.chart !== chart) return; if (e.cancelable) e.preventDefault(); const p = coords(e); update(chart, p.x, p.y); }; if (window.PointerEvent) { canvas.addEventListener("pointerdown", startDrag, { passive:false }); canvas.addEventListener("pointermove", drag, { passive:false }); } else { canvas.addEventListener("touchstart", startDrag, { passive:false }); canvas.addEventListener("touchmove", drag, { passive:false }); canvas.addEventListener("mousedown", startDrag); window.addEventListener("mousemove", drag); } } return { id: pluginId, afterInit(chart) { bind(chart); }, afterDraw(chart) { if (!state.active || state.chart !== chart || state.index < 0) return; const ds = chart.data.datasets[state.datasetIndex]; const p = ds?.data?.[state.index]; if (!p) return; const x = chart.scales.x.getPixelForValue(Number(p.x)); const y = chart.scales.y.getPixelForValue(Number(p.y)); const area = chart.chartArea; if (!Number.isFinite(x) || !Number.isFinite(y)) return; // Marker/crosshair always inherits the ACTIVE graph/trace colour. const accent = ds.borderColor || ds.backgroundColor || "#a76527"; const ctx = chart.ctx; ctx.save(); ctx.strokeStyle = accent; ctx.globalAlpha = 0.72; ctx.lineWidth = 1.15; ctx.setLineDash([4,4]); ctx.beginPath(); ctx.moveTo(x, area.top); ctx.lineTo(x, area.bottom); ctx.moveTo(area.left, y); ctx.lineTo(area.right, y); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1; // Exactly one visible marker. ctx.fillStyle = "#fff"; ctx.strokeStyle = accent; ctx.lineWidth = 2.7; ctx.beginPath(); ctx.arc(x, y, 5.5, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); const lines = getLabel(chart, state.datasetIndex, state.index, p) || []; if (lines.length) { ctx.font = "600 11px Arial, sans-serif"; const px = 9, py = 7, lh = 16; let w = 0; lines.forEach(s => w = Math.max(w, ctx.measureText(s).width)); w += px * 2; const h = lines.length * lh + py * 2; let bx = x + 13; if (bx + w > area.right - 4) bx = x - w - 13; bx = Math.max(area.left + 4, Math.min(bx, area.right - w - 4)); let by = y - h - 13; if (by < area.top + 4) by = y + 13; by = Math.max(area.top + 4, Math.min(by, area.bottom - h - 4)); ctx.fillStyle = "rgba(25,25,25,.90)"; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(bx, by, w, h, 7); else ctx.rect(bx, by, w, h); ctx.fill(); // Small trace-colour indicator inside tooltip. ctx.fillStyle = accent; ctx.fillRect(bx + px, by + py + 2, 7, 7); ctx.fillStyle = "#fff"; ctx.textAlign = "left"; ctx.textBaseline = "top"; lines.forEach((s,i) => ctx.fillText(s, bx + px + (i === 0 ? 12 : 0), by + py + i*lh)); } ctx.restore(); } }; } const waveformTouchCrosshairPlugin = makeTouchCrosshairPlugin( "waveformTouchCrosshair", (chart, datasetIndex, index, point) => { const ds = chart.data.datasets[datasetIndex]; return [ String(ds.label || `Trace ${datasetIndex + 1}`), `Time: ${Number(point.x).toFixed(3)} ms`, `Voltage: ${Number(point.y).toFixed(3)} V` ]; } ); function drawResultChart(result) { const timeMs = result.time.map(t => t*1000); const colors = ["#c1752e","#9c6a35","#8a5a2b","#d98c4a","#b5651d","#e2a06b"]; let dataMin = 0, dataMax = 0; result.traces.forEach(tr => { for (let i = 0; i < tr.values.length; i++) { const v = tr.values[i]; if (!Number.isFinite(v)) continue; if (v < dataMin) dataMin = v; if (v > dataMax) dataMax = v; } }); const range = (dataMax - dataMin) || 1; let vMax = dataMax + range*0.2, vMin = dataMin - range*0.2; if (vMin > 0) vMin = -range*0.2; if (vMax < 0) vMax = range*0.2; if (!activeTraceKey || !result.traces.find(t => t.key === activeTraceKey)) { activeTraceKey = result.traces.length ? result.traces[0].key : null; } const datasets = result.traces.map((tr, i) => { const style = tr.lineStyle || (tr.key && tr.key.startsWith("probe_") ? "solid" : (i===0 ? "solid" : "dashed")); const isThick = style.startsWith("thick"); let dash = []; if (style === "dashed" || style === "thick-dashed") dash = [8,5]; else if (style === "dotted" || style === "thick-dotted") dash = [2,5]; const baseWidth = isThick ? 4 : 1.8; return { label: tr.label, data: Array.from({length: Math.min(timeMs.length, tr.values.length)}, (_, idx) => ({x:timeMs[idx], y:tr.values[idx]})), borderColor: tr.color || colors[i % colors.length], borderDash: dash, borderCapStyle: (style === "dotted" || style === "thick-dotted") ? "round" : "butt", borderWidth: tr.key === activeTraceKey ? Math.max(baseWidth, 3) : baseWidth, pointRadius: 0, pointHoverRadius: 6, pointHitRadius: 20, pointHoverBorderWidth: 2.5, pointHoverBorderColor: tr.color || colors[i % colors.length], pointHoverBackgroundColor: "#ffffff", tension: 0.05 }; }); if (resultChart) resultChart.destroy(); resultChart = new Chart(document.getElementById("resultChart"), { type: "line", data: { datasets }, plugins: [whiteBgPlugin, zeroAxisPlugin], options: { responsive: true, maintainAspectRatio: false, aspectRatio: 1, animation: false, scales: { x: { type: "linear", min: 0, title: { display: true, text: "Time (ms)", color: "#000" }, ticks: { color: "#333" }, grid: { color: "#ddd" } }, y: { min: vMin, max: vMax, title: { display: true, text: "Voltage (V)", color: "#000" }, ticks: { color: "#333" }, grid: { color: "#ddd" } } }, interaction: { mode: "nearest", intersect: false, axis: "xy" }, hover: { mode: "nearest", intersect: false }, plugins: { legend: { display: false }, tooltip: { enabled: true, mode: "nearest", intersect: false, displayColors: true } } } }); const select = document.getElementById("wfTraceSelect"); select.innerHTML = ""; result.traces.forEach(tr => { const opt = document.createElement("option"); opt.value = tr.key; opt.textContent = tr.label; if (tr.key === activeTraceKey) opt.selected = true; select.appendChild(opt); }); function setActiveWaveformTrace(key) { activeTraceKey = key; select.value = key; // Update line emphasis without destroying/recreating the chart. resultChart.data.datasets.forEach((ds, i) => { const tr = result.traces[i]; const style = tr.style || "solid"; const isThick = style.indexOf("thick") === 0; const baseWidth = isThick ? 4 : 1.8; ds.borderWidth = tr.key === activeTraceKey ? Math.max(baseWidth, 3) : baseWidth; }); resultChart.update("none"); legend.querySelectorAll(".legend-chip").forEach((el, i) => { el.classList.toggle("active", result.traces[i] && result.traces[i].key === activeTraceKey); }); const activeTr = result.traces.find(t => t.key === activeTraceKey); if (activeTr) showTraceStats(activeTr); } select.onchange = () => setActiveWaveformTrace(select.value); const swatchClasses = ["solid","dashed","dotted"]; const legend = document.getElementById("wfLegend"); legend.innerHTML = ""; result.traces.forEach((tr, i) => { const chip = document.createElement("div"); chip.className = "legend-chip" + (tr.key === activeTraceKey ? " active" : ""); chip.innerHTML = ''+tr.label+''; const swatch = chip.querySelector(".legend-swatch"); const traceColor = tr.color || colors[i % colors.length]; swatch.style.borderColor = traceColor; if (swatch.classList.contains("solid")) swatch.style.background = traceColor; else if (swatch.classList.contains("dashed")) swatch.style.background = "repeating-linear-gradient(45deg,"+traceColor+" 0 2px,transparent 2px 4px)"; else swatch.style.backgroundImage = "radial-gradient("+traceColor+" 35%,transparent 36%)"; chip.onclick = () => setActiveWaveformTrace(tr.key); legend.appendChild(chip); }); const activeTr = result.traces.find(t => t.key === activeTraceKey); if (activeTr) showTraceStats(activeTr); } function showTraceStats(tr) { let min = Infinity, max = -Infinity; for (let i = 0; i < tr.values.length; i++) { const v = tr.values[i]; if (!Number.isFinite(v)) continue; if (v < min) min = v; if (v > max) max = v; } if (!Number.isFinite(min) || !Number.isFinite(max)) { min = 0; max = 0; } const vpp = max - min; document.getElementById("wfMax").textContent = max.toFixed(3) + " V"; document.getElementById("wfMin").textContent = min.toFixed(3) + " V"; document.getElementById("wfVpp").textContent = vpp.toFixed(3) + " V"; document.getElementById("wfHigh").textContent = max.toFixed(3) + " V"; document.getElementById("wfLow").textContent = min.toFixed(3) + " V"; } function exportCurrent() { if (currentPage === 1) downloadPNG(); else downloadDiagramPNG(); } function downloadPNG() { if (!resultChart) { alert("Run a simulation first."); return; } const a = document.createElement("a"); a.href = resultChart.toBase64Image("image/png", 1); a.download = "waveform.png"; a.click(); } function downloadCSV() { if (!lastTraces) { alert("Run a simulation first."); return; } let csv = "time_s," + lastTraces.traces.map(t => t.label).join(",") + "\n"; lastTraces.time.forEach((t, i) => { csv += t + "," + lastTraces.traces.map(tr => tr.values[i]).join(",") + "\n"; }); const blob = new Blob([csv], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "simulation_data.csv"; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function downloadDiagramPNG() { if (components.length === 0 && texts.length === 0) { alert("Place some components first."); return; } let minX=1e9,minY=1e9,maxX=-1e9,maxY=-1e9; components.forEach(c => { pinPoints(c).forEach(p => { minX=Math.min(minX,p.x); minY=Math.min(minY,p.y); maxX=Math.max(maxX,p.x); maxY=Math.max(maxY,p.y); }); }); wires.forEach(w => { w.points.map(resolveEndpoint).forEach(p => { minX=Math.min(minX,p.x); minY=Math.min(minY,p.y); maxX=Math.max(maxX,p.x); maxY=Math.max(maxY,p.y); }); }); texts.forEach(t => { minX=Math.min(minX,t.x); minY=Math.min(minY,t.y-14); maxX=Math.max(maxX,t.x+80); maxY=Math.max(maxY,t.y+6); }); const pad = 60; minX-=pad; minY-=pad; maxX+=pad; maxY+=pad; const w = maxX-minX, h = maxY-minY; const clone = document.createElementNS("http://www.w3.org/2000/svg","svg"); clone.setAttribute("xmlns","http://www.w3.org/2000/svg"); clone.setAttribute("width", w); clone.setAttribute("height", h); clone.setAttribute("viewBox", minX+" "+minY+" "+w+" "+h); const bgRectC = document.createElementNS("http://www.w3.org/2000/svg","rect"); bgRectC.setAttribute("x",minX); bgRectC.setAttribute("y",minY); bgRectC.setAttribute("width",w); bgRectC.setAttribute("height",h); bgRectC.setAttribute("fill","#ffffff"); clone.appendChild(bgRectC); const styleEl = document.createElementNS("http://www.w3.org/2000/svg","style"); styleEl.textContent = ".comp-body,.gnd-symbol{stroke:#000;stroke-width:2.2;fill:none;} .probe-body{stroke:#b45309;stroke-width:2.2;fill:none;stroke-dasharray:3 2;} .wire-line{stroke:#c1752e;stroke-width:2.5;fill:none;} .comp-label{fill:#111;font-size:11px;font-family:monospace;} .pin{fill:#fff;stroke:#c1752e;stroke-width:2;}"; clone.appendChild(styleEl); wires.forEach(w => { const pl = document.createElementNS("http://www.w3.org/2000/svg","polyline"); pl.setAttribute("points", wirePolylinePoints(w)); pl.setAttribute("class","wire-line"); clone.appendChild(pl); }); components.forEach(comp => { const g = comp.el.cloneNode(true); g.removeAttribute("class"); // The live transistor uses CSS var(--text) for its nested SVG stroke. // CSS custom properties are not available in the standalone SVG used for // PNG export, so resolve only the transistor's export colours explicitly. if (comp.type === "Q") { const transistorGroup = g.querySelector("g"); if (transistorGroup) transistorGroup.setAttribute("stroke", "#000000"); const transistorArrow = g.querySelector(".transistor-arrow"); if (transistorArrow) transistorArrow.setAttribute("fill", "#000000"); } clone.appendChild(g); const label = comp.labelEl.cloneNode(true); clone.appendChild(label); pinPoints(comp).forEach(p => { const dot = document.createElementNS("http://www.w3.org/2000/svg","circle"); dot.setAttribute("cx",p.x); dot.setAttribute("cy",p.y); dot.setAttribute("r",5); dot.setAttribute("class","pin"); clone.appendChild(dot); }); }); texts.forEach(t => { const el = document.createElementNS("http://www.w3.org/2000/svg","text"); el.setAttribute("x", t.x); el.setAttribute("y", t.y); el.setAttribute("style", "font-size:14px;fill:#111;font-family:'Segoe UI',system-ui,sans-serif;"); el.textContent = t.content; clone.appendChild(el); }); const xml = new XMLSerializer().serializeToString(clone); const svgBlob = new Blob([xml], { type: "image/svg+xml;charset=utf-8" }); const url = URL.createObjectURL(svgBlob); const img = new Image(); img.onload = () => { const scaleFactor = 2; const canvasEl = document.createElement("canvas"); canvasEl.width = w*scaleFactor; canvasEl.height = h*scaleFactor; const ctx = canvasEl.getContext("2d"); ctx.fillStyle = "#fff"; ctx.fillRect(0,0,canvasEl.width,canvasEl.height); ctx.drawImage(img, 0, 0, canvasEl.width, canvasEl.height); URL.revokeObjectURL(url); const a = document.createElement("a"); a.href = canvasEl.toDataURL("image/png"); a.download = safeProjectName() + "_diagram.png"; a.click(); }; img.src = url; } async function updateBackendStatus() { const box = document.getElementById("backendStatus"); const text = document.getElementById("backendStatusText"); if (!box || !text) return; box.className = "backend-status checking"; text.textContent = "Checking…"; try { const response = await fetch(cfg.backend + "/health", { cache: "no-store" }); if (!response.ok) throw new Error("Health check failed"); box.className = "backend-status connected"; text.textContent = "Connected"; } catch (e) { box.className = "backend-status unavailable"; text.textContent = "Unavailable"; } } function openSettings() { document.getElementById("cfgEndTime").value = cfg.endTimeMs; document.getElementById("cfgStepTime").value = cfg.stepTimeUs; document.getElementById("settingsPanel").classList.add("show"); updateBackendStatus(); } function closeSettings() { document.getElementById("settingsPanel").classList.remove("show"); } function saveSettings() { cfg.endTimeMs = parseFloat(document.getElementById("cfgEndTime").value) || 1; cfg.stepTimeUs = parseFloat(document.getElementById("cfgStepTime").value) || 1; closeSettings(); } function clearAll() { if (!confirm("Clear the whole canvas?")) return; components = []; wires = []; texts = []; selectedId = null; selectedWire = null; wireDraft = null; renderStructure(); pushHistory(); } try { applyTheme(localStorage.getItem("oblivionlabs-theme") || "light"); } catch (e) { applyTheme("light"); } fetch(cfg.backend + "/health").catch(() => {}); setTool("select"); goToPage(0); renderStructure(); pushHistory();