diff --git a/public/static/physics/widgets/Dynamic Circuit/UserExFeedback.md b/public/static/physics/widgets/Dynamic Circuit/UserExFeedback.md
new file mode 100644
index 0000000..e69de29
diff --git a/public/static/physics/widgets/Dynamic Circuit/circuit-renderer.js b/public/static/physics/widgets/Dynamic Circuit/circuit-renderer.js
new file mode 100644
index 0000000..b7eb731
--- /dev/null
+++ b/public/static/physics/widgets/Dynamic Circuit/circuit-renderer.js
@@ -0,0 +1,481 @@
+/**
+ * Circuit Renderer - SVG Drawing and Visualization
+ * Handles rendering the circuit diagram with proper layout
+ */
+
+class CircuitRenderer {
+ constructor(svgElement, circuit) {
+ this.svg = svgElement;
+ this.circuit = circuit;
+ this.group = document.getElementById('circuit-group');
+
+ // Layout constants
+ this.margin = { top: 50, right: 100, bottom: 50, left: 100 };
+ this.componentSpacing = 150; // Horizontal spacing between series components
+ this.parallelSpacing = 100; // Vertical spacing for parallel components
+ this.wireLength = 60; // Length of connecting wires
+
+ // Power supply position
+ this.batteryX = this.margin.left;
+ this.batteryY = 300;
+
+ this.selectedComponent = null;
+ }
+
+ render() {
+ // Clear existing circuit
+ this.group.innerHTML = '';
+
+ const topology = this.circuit.getTopology();
+
+ // Calculate positions for all components (stacked vertically on right side)
+ const positions = this.calculatePositions(topology);
+
+ // Draw power supply
+ this.drawBattery(this.batteryX, this.batteryY);
+
+ // Circuit dimensions
+ const rightSideX = 700; // Fixed X position for right side components
+ const topY = this.batteryY - 150;
+ const bottomY = this.batteryY + 150;
+
+ // Top wire from battery positive terminal
+ this.drawWire(this.batteryX, this.batteryY - 40, this.batteryX, topY);
+ this.drawWire(this.batteryX, topY, rightSideX, topY);
+
+ // Track current Y position as we stack components vertically
+ let currentY = topY;
+
+ // If no components, still draw a complete loop and a friendly message
+ if (topology.length === 0) {
+ // Vertical backbone with no components
+ this.drawWire(rightSideX, currentY, rightSideX, bottomY);
+ // Bottom rail back to battery
+ this.drawWire(rightSideX, bottomY, this.batteryX, bottomY);
+ this.drawWire(this.batteryX, bottomY, this.batteryX, this.batteryY + 40);
+ this.renderEmptyCircuit();
+ return;
+ }
+
+ // Draw all components stacked vertically on the right side
+ topology.forEach((parallelGroup, seriesIndex) => {
+ if (parallelGroup.length === 1) {
+ // Single component in series
+ const component = parallelGroup[0];
+ const pos = positions[seriesIndex][0];
+
+ // Determine connector thickness along the vertical backbone
+ const connectorHalfHeight = component.isBulb() ? 20 : 7.5; // bulb radius or half resistor height
+
+ // Wire down from current position to just touch the component edge
+ this.drawWire(rightSideX, currentY, rightSideX, pos.y - connectorHalfHeight);
+
+ // Component
+ this.drawComponent(component, rightSideX, pos.y);
+
+ // Continue backbone from the component's lower edge
+ currentY = pos.y + connectorHalfHeight;
+ } else {
+ // Multiple components in parallel - branch out horizontally
+ const firstY = positions[seriesIndex][0].y;
+ const lastY = positions[seriesIndex][positions[seriesIndex].length - 1].y;
+
+ // Draw vertical wire from current position to first parallel component
+ this.drawWire(rightSideX, currentY, rightSideX, firstY);
+
+ // Draw vertical wire connecting all parallel branches (the main backbone)
+ this.drawWire(rightSideX, firstY, rightSideX, lastY);
+
+ // Draw each parallel branch
+ parallelGroup.forEach((component, parallelIndex) => {
+ const pos = positions[seriesIndex][parallelIndex];
+
+ // Determine connector offset to meet component edge
+ const connectorOffset = component.isBulb() ? 20 : 25; // bulb radius or half resistor width
+
+ // Horizontal wire out to component edge
+ this.drawWire(rightSideX, pos.y, pos.x - connectorOffset, pos.y);
+
+ // Component
+ this.drawComponent(component, pos.x, pos.y);
+
+ // Horizontal wire back from component edge
+ this.drawWire(pos.x + connectorOffset, pos.y, rightSideX, pos.y);
+ });
+
+ // Continue down after parallel section
+ currentY = lastY;
+ }
+ });
+
+ // Wire down to bottom rail
+ this.drawWire(rightSideX, currentY, rightSideX, bottomY);
+
+ // Bottom rail back to battery
+ this.drawWire(rightSideX, bottomY, this.batteryX, bottomY);
+ this.drawWire(this.batteryX, bottomY, this.batteryX, this.batteryY + 40);
+ }
+
+ calculatePositions(topology) {
+ const positions = [];
+ const rightSideX = 700; // X position for series components
+ const parallelOffsetX = 600; // X position for parallel components (to the left)
+ const verticalSpacing = 70; // Spacing between series components
+ const parallelVerticalSpacing = 60; // Spacing for parallel components
+
+ let currentY = this.batteryY - 120; // Start position from top
+
+ topology.forEach((parallelGroup, seriesIndex) => {
+ const groupPositions = [];
+ const numParallel = parallelGroup.length;
+
+ if (numParallel === 1) {
+ // Single component in series - stack vertically on right side
+ groupPositions.push({ x: rightSideX, y: currentY });
+ currentY += verticalSpacing;
+ } else {
+ // Multiple parallel components - branch out to the left
+ const totalHeight = (numParallel - 1) * parallelVerticalSpacing;
+ const startY = currentY - totalHeight / 2;
+
+ parallelGroup.forEach((component, parallelIndex) => {
+ groupPositions.push({
+ x: parallelOffsetX,
+ y: startY + parallelIndex * parallelVerticalSpacing
+ });
+ });
+
+ // Advance past the parallel group
+ currentY += totalHeight / 2 + verticalSpacing;
+ }
+
+ positions.push(groupPositions);
+ });
+
+ return positions;
+ }
+
+ drawBattery(x, y) {
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
+ g.setAttribute('class', 'battery');
+
+ // Positive terminal (longer line) - top
+ const positiveLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
+ positiveLine.setAttribute('x1', x - 20);
+ positiveLine.setAttribute('y1', y - 40);
+ positiveLine.setAttribute('x2', x + 20);
+ positiveLine.setAttribute('y2', y - 40);
+ positiveLine.setAttribute('class', 'battery-positive');
+ positiveLine.setAttribute('stroke-width', '4');
+ g.appendChild(positiveLine);
+
+ // Negative terminal (shorter line) - bottom
+ const negativeLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
+ negativeLine.setAttribute('x1', x - 15);
+ negativeLine.setAttribute('y1', y + 40);
+ negativeLine.setAttribute('x2', x + 15);
+ negativeLine.setAttribute('y2', y + 40);
+ negativeLine.setAttribute('class', 'battery-negative');
+ negativeLine.setAttribute('stroke-width', '4');
+ g.appendChild(negativeLine);
+
+ // Middle lines
+ for (let i = 0; i < 2; i++) {
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
+ const offset = i === 0 ? -15 : 15;
+ line.setAttribute('x1', x - 12);
+ line.setAttribute('y1', y + offset);
+ line.setAttribute('x2', x + 12);
+ line.setAttribute('y2', y + offset);
+ line.setAttribute('stroke', '#34495e');
+ line.setAttribute('stroke-width', '3');
+ g.appendChild(line);
+ }
+
+ // Plus and minus symbols
+ const plusText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
+ plusText.setAttribute('x', x + 30);
+ plusText.setAttribute('y', y - 35);
+ plusText.setAttribute('font-size', '18');
+ plusText.setAttribute('fill', '#e74c3c');
+ plusText.setAttribute('font-weight', 'bold');
+ plusText.textContent = '+';
+ g.appendChild(plusText);
+
+ const minusText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
+ minusText.setAttribute('x', x + 30);
+ minusText.setAttribute('y', y + 45);
+ minusText.setAttribute('font-size', '18');
+ minusText.setAttribute('fill', '#34495e');
+ minusText.setAttribute('font-weight', 'bold');
+ minusText.textContent = '−';
+ g.appendChild(minusText);
+
+ // Voltage label
+ const voltageText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
+ voltageText.setAttribute('x', x - 60);
+ voltageText.setAttribute('y', y + 5);
+ voltageText.setAttribute('class', 'component-label');
+ voltageText.textContent = `${this.circuit.voltage.toFixed(1)}V`;
+ g.appendChild(voltageText);
+
+ this.group.appendChild(g);
+ }
+
+ drawWire(x1, y1, x2, y2) {
+ const wire = document.createElementNS('http://www.w3.org/2000/svg', 'line');
+ wire.setAttribute('x1', x1);
+ wire.setAttribute('y1', y1);
+ wire.setAttribute('x2', x2);
+ wire.setAttribute('y2', y2);
+ wire.setAttribute('class', 'wire');
+ this.group.appendChild(wire);
+ }
+
+ drawJunction(x, y) {
+ const junction = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+ junction.setAttribute('cx', x);
+ junction.setAttribute('cy', y);
+ junction.setAttribute('r', 4);
+ junction.setAttribute('fill', '#2c3e50');
+ this.group.appendChild(junction);
+ }
+
+ drawComponent(component, x, y) {
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
+ g.setAttribute('class', 'component');
+ g.setAttribute('data-component-id', component.id);
+ g.setAttribute('data-x', x);
+ g.setAttribute('data-y', y);
+ g.style.cursor = 'pointer';
+
+ if (component.isBulb()) {
+ this.drawLightbulb(g, component, x, y);
+ } else {
+ this.drawResistor(g, component, x, y);
+ }
+
+ // Event listeners will be added by CircuitInteractions
+ this.group.appendChild(g);
+ }
+
+ drawResistor(g, component, x, y) {
+ const width = 50;
+ const height = 15;
+
+ // Resistor body (rectangle)
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
+ rect.setAttribute('x', x - width / 2);
+ rect.setAttribute('y', y - height / 2);
+ rect.setAttribute('width', width);
+ rect.setAttribute('height', height);
+ rect.setAttribute('class', 'resistor-body');
+ g.appendChild(rect);
+
+ // Labels
+ this.addComponentLabels(g, component, x, y);
+ }
+
+ drawLightbulb(g, component, x, y) {
+ const radius = 20;
+ const power = component.power;
+ const maxPower = Math.max(this.circuit.getMaxBulbPower(), 0.1);
+ const brightness = Math.min(power / maxPower, 1);
+
+ // Bulb circle
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+ circle.setAttribute('cx', x);
+ circle.setAttribute('cy', y);
+ circle.setAttribute('r', radius);
+
+ if (power > 0.01) {
+ // Bulb is on - calculate brightness
+ circle.setAttribute('class', 'lightbulb-glow');
+
+ // Adjust fill opacity based on power
+ const opacity = 0.3 + brightness * 0.7;
+ circle.style.fillOpacity = opacity;
+
+ // Add glow filter based on brightness
+ if (brightness > 0.7) {
+ circle.setAttribute('filter', 'url(#glow-high)');
+ } else if (brightness > 0.3) {
+ circle.setAttribute('filter', 'url(#glow-medium)');
+ } else {
+ circle.setAttribute('filter', 'url(#glow-low)');
+ }
+ } else {
+ // Bulb is off
+ circle.setAttribute('class', 'lightbulb-off');
+ }
+
+ g.appendChild(circle);
+
+ // Filament
+ const filament = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+ const filamentPath = `M ${x - 8} ${y - 5} Q ${x} ${y - 10} ${x + 8} ${y - 5}
+ M ${x - 8} ${y + 5} Q ${x} ${y} ${x + 8} ${y + 5}`;
+ filament.setAttribute('d', filamentPath);
+ filament.setAttribute('class', 'filament');
+ if (power > 0.01) {
+ filament.style.stroke = '#ff6b6b';
+ filament.style.strokeWidth = 1 + brightness * 1.5;
+ }
+ g.appendChild(filament);
+
+ // Labels
+ this.addComponentLabels(g, component, x, y);
+ }
+
+ addComponentLabels(g, component, x, y) {
+ // Only show resistance value - positioned to the right side
+ const nameText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
+ nameText.setAttribute('x', x + 35);
+ nameText.setAttribute('y', y + 5);
+ nameText.setAttribute('class', 'component-label');
+ nameText.setAttribute('text-anchor', 'start');
+ nameText.textContent = `${component.resistance}Ω`;
+ g.appendChild(nameText);
+
+ // Note: V, I, P stats will be shown in popup when selected
+ }
+
+ renderEmptyCircuit() {
+ const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
+ text.setAttribute('x', 500);
+ text.setAttribute('y', 300);
+ text.setAttribute('text-anchor', 'middle');
+ text.setAttribute('class', 'component-label');
+ text.setAttribute('font-size', '24');
+ text.textContent = 'Add components to start building your circuit';
+ this.group.appendChild(text);
+ }
+
+ showStatsPopup(componentId) {
+ // Remove any existing popup
+ this.hideStatsPopup();
+
+ const stats = this.circuit.getComponentStats(componentId);
+ if (!stats) {
+ console.log('No stats found for component', componentId);
+ return;
+ }
+
+ const component = this.circuit.getComponentById(componentId);
+ if (!component) {
+ console.log('Component not found', componentId);
+ return;
+ }
+
+ // Find component position from stored data attributes
+ const componentEl = document.querySelector(`[data-component-id="${componentId}"]`);
+ if (!componentEl) {
+ console.log('Component element not found', componentId);
+ return;
+ }
+
+ const x = parseFloat(componentEl.getAttribute('data-x'));
+ const y = parseFloat(componentEl.getAttribute('data-y'));
+
+ console.log('Creating popup at', x, y, 'for component', componentId);
+
+ // Create popup group - position next to the component
+ const popup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
+ popup.setAttribute('id', 'stats-popup');
+ popup.setAttribute('class', 'stats-popup');
+
+ // Position popup to the right of the component
+ const popupX = x + 80;
+ const popupY = y - 30;
+
+ // Background rectangle
+ const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
+ bg.setAttribute('x', popupX);
+ bg.setAttribute('y', popupY);
+ bg.setAttribute('width', 120);
+ bg.setAttribute('height', 60);
+ bg.setAttribute('rx', 5);
+ bg.setAttribute('class', 'popup-bg');
+ popup.appendChild(bg);
+
+ // Voltage text
+ const vText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
+ vText.setAttribute('x', popupX + 10);
+ vText.setAttribute('y', popupY + 20);
+ vText.setAttribute('class', 'popup-text');
+ vText.setAttribute('text-anchor', 'start');
+ vText.textContent = `V = ${stats.voltage.toFixed(2)} V`;
+ popup.appendChild(vText);
+
+ // Current text
+ const iText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
+ iText.setAttribute('x', popupX + 10);
+ iText.setAttribute('y', popupY + 35);
+ iText.setAttribute('class', 'popup-text');
+ iText.setAttribute('text-anchor', 'start');
+ iText.textContent = `I = ${stats.current.toFixed(3)} A`;
+ popup.appendChild(iText);
+
+ // Power text
+ const pText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
+ pText.setAttribute('x', popupX + 10);
+ pText.setAttribute('y', popupY + 50);
+ pText.setAttribute('class', 'popup-text');
+ pText.setAttribute('text-anchor', 'start');
+ pText.textContent = `P = ${stats.power.toFixed(2)} W`;
+ popup.appendChild(pText);
+
+ this.group.appendChild(popup);
+ console.log('Popup created and appended');
+ }
+
+ hideStatsPopup() {
+ const existingPopup = document.getElementById('stats-popup');
+ if (existingPopup) {
+ existingPopup.remove();
+ }
+ }
+
+ showComponentDetails(componentId) {
+ const stats = this.circuit.getComponentStats(componentId);
+ if (!stats) return;
+
+ const detailsPanel = document.getElementById('component-details');
+ const infoDiv = document.getElementById('component-info');
+
+ infoDiv.innerHTML = `
+
Type: ${stats.name}
+ Resistance: ${stats.resistance.toFixed(1)} Ω
+ Voltage Drop: ${stats.voltage.toFixed(2)} V
+ Current: ${stats.current.toFixed(3)} A
+ Power: ${stats.power.toFixed(2)} W
+ Position: Series ${stats.position.seriesIndex + 1},
+ ${stats.position.parallelIndex > 0 ? `Parallel ${stats.position.parallelIndex + 1}` : 'Single'}
+ `;
+
+ detailsPanel.style.display = 'block';
+
+ // Set up delete button
+ const deleteBtn = document.getElementById('delete-component-btn');
+ deleteBtn.onclick = () => {
+ this.circuit.removeComponent(componentId);
+ this.render();
+ this.updateStats();
+ detailsPanel.style.display = 'none';
+ this.selectedComponent = null;
+ };
+ }
+
+ updateStats() {
+ const stats = this.circuit.getStats();
+
+ document.getElementById('total-voltage').textContent = `${stats.voltage.toFixed(1)} V`;
+ document.getElementById('total-current').textContent = `${stats.totalCurrent.toFixed(3)} A`;
+ document.getElementById('total-resistance').textContent = `${stats.totalResistance.toFixed(2)} Ω`;
+ document.getElementById('total-power').textContent = `${stats.totalPower.toFixed(2)} W`;
+ }
+}
+
+// Global renderer instance (will be initialized after DOM loads)
+let renderer = null;
+
diff --git a/public/static/physics/widgets/Dynamic Circuit/circuit.html b/public/static/physics/widgets/Dynamic Circuit/circuit.html
new file mode 100644
index 0000000..eecaa3d
--- /dev/null
+++ b/public/static/physics/widgets/Dynamic Circuit/circuit.html
@@ -0,0 +1,104 @@
+
+
+
+
+
+ Dynamic Resistor Circuit
+
+
+
+
+
+
+
+
+
+
+
Dynamic Resistor Circuit
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Adds to the current configuration (series or parallel)
+
+
+
+
+
+
12.0 V
+
+
+
removes all selected resistors in the circuit at once
+
+
+
+
+
+
+
+
User Guide
+
+ - Use Add Component to add a resistor or bulb.
+ - Click any resistor to see resistance, power, current, and voltage drop.
+ - Right-click a resistor to set its resistance or remove it.
+ - Select resistors to add new ones in series or parallel.
+ - Lightbulb brightness reflects individual power output.
+
+
+
+
+
+
+
+
+
+
+
+
Selection
+
Click a resistor or bulb to see its values here.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/static/physics/widgets/Dynamic Circuit/circuit.js b/public/static/physics/widgets/Dynamic Circuit/circuit.js
new file mode 100644
index 0000000..62a1bd5
--- /dev/null
+++ b/public/static/physics/widgets/Dynamic Circuit/circuit.js
@@ -0,0 +1,2035 @@
+(function () {
+ "use strict";
+
+ // DOM
+ const elV = document.getElementById("voltage");
+ const elVVal = document.getElementById("voltageVal");
+ const elMode = Array.from(document.querySelectorAll('input[name="mode"]'));
+ const elAddMode = Array.from(document.querySelectorAll('input[name="addmode"]'));
+ const svg = document.getElementById("circuit");
+ if (svg) svg.setAttribute("preserveAspectRatio", "xMinYMin meet");
+ const elNewR = document.getElementById("newR");
+ const elAdd = document.getElementById("addRes");
+ const elNewType = document.getElementById("newType");
+ const elAddResistor = document.getElementById("addResistor");
+ const elAddBulb = document.getElementById("addBulb");
+ const elRemove = document.getElementById("removeSel");
+ const elReset = document.getElementById("resetAll");
+ const elAddComponentBtn = document.getElementById("addComponentBtn");
+ let currentType = "resistor";
+
+ const elTotalR = document.getElementById("totalR");
+ const elTotalI = document.getElementById("totalI");
+ const elTotalP = document.getElementById("totalP");
+ // Removed R1/R2 specific inputs and readouts
+
+ // New Add-Mode buttons (toggle between Series/Parallel)
+ const elSeriesBtn = document.getElementById("addAsSeriesBtn");
+ const elParallelBtn = document.getElementById("addAsParallelBtn");
+ const elAddModeSeries = document.querySelector('input[name="addmode"][value="series"]');
+ const elAddModeParallel = document.querySelector('input[name="addmode"][value="parallel"]');
+ function setAddModeBtn(mode) {
+ if (!elAddModeSeries || !elAddModeParallel || !elSeriesBtn || !elParallelBtn) return;
+ if (mode === "series") {
+ elAddModeSeries.checked = true;
+ elSeriesBtn.classList.add("btn--selected");
+ elParallelBtn.classList.remove("btn--selected");
+ } else {
+ elAddModeParallel.checked = true;
+ elParallelBtn.classList.add("btn--selected");
+ elSeriesBtn.classList.remove("btn--selected");
+ }
+ }
+ if (elSeriesBtn) elSeriesBtn.addEventListener("click", () => setAddModeBtn("series"));
+ if (elParallelBtn) elParallelBtn.addEventListener("click", () => setAddModeBtn("parallel"));
+ // Initialize default state
+ setAddModeBtn("series");
+ // Default type selection: Basic Resistor
+ if (elAddResistor) {
+ elAddResistor.classList.add("btn--selected");
+ }
+
+ // Geometry (dynamic height)
+ let W = 900, H = 478; // base canvas size
+ const BASE_H = 478;
+ const margin = 110; // more white space around the circuit
+ let leftX, rightX, topY, bottomY, centerX, centerY;
+ function recalcLayout() {
+ leftX = margin; rightX = W - margin;
+ topY = margin; bottomY = H - margin;
+ centerX = (leftX + rightX) / 2;
+ centerY = (topY + bottomY) / 2;
+ }
+ recalcLayout();
+
+ // Selection state (multi-select)
+ const selectedIds = new Set(); // values: 'r1', 'r2'
+ // Use a centralized selection panel instead of inline SVG popups
+ const USE_EXTERNAL_PANEL = true;
+
+ // Dynamic resistor list
+ const resistors = [];
+ let nextId = 1;
+ // Toast helper
+ let toastTimer = null;
+ // Centered alert modal for errors / unsuccessful adds
+ let alertOverlay = null;
+ let alertMsg = null;
+ let alertOkBtn = null;
+ function ensureAlertOverlay() {
+ if (alertOverlay) return alertOverlay;
+ // Overlay
+ alertOverlay = document.createElement("div");
+ alertOverlay.id = "alertOverlay";
+ Object.assign(alertOverlay.style, {
+ position: "fixed",
+ inset: "0",
+ display: "none",
+ alignItems: "center",
+ justifyContent: "center",
+ background: "rgba(17,24,39,0.45)",
+ zIndex: 9998
+ });
+ // Card (reuse ohm-card styling)
+ const card = document.createElement("div");
+ card.className = "ohm-card";
+ const title = document.createElement("div");
+ title.className = "ohm-title";
+ title.textContent = "Notice";
+ const body = document.createElement("div");
+ body.className = "ohm-body";
+ alertMsg = document.createElement("div");
+ alertMsg.className = "label";
+ alertMsg.style.fontWeight = "500";
+ const actions = document.createElement("div");
+ actions.className = "ohm-actions";
+ alertOkBtn = document.createElement("button");
+ alertOkBtn.type = "button";
+ alertOkBtn.className = "btn";
+ alertOkBtn.textContent = "OK";
+ actions.appendChild(alertOkBtn);
+ body.appendChild(alertMsg);
+ card.appendChild(title);
+ card.appendChild(body);
+ card.appendChild(actions);
+ alertOverlay.appendChild(card);
+ document.body.appendChild(alertOverlay);
+ // Events
+ alertOkBtn.addEventListener("click", () => { alertOverlay.style.display = "none"; });
+ alertOverlay.addEventListener("click", (e) => {
+ if (e.target === alertOverlay) alertOverlay.style.display = "none";
+ });
+ document.addEventListener("keydown", (e) => {
+ if (alertOverlay && alertOverlay.style.display === "flex" && e.key === "Escape") {
+ e.preventDefault();
+ alertOverlay.style.display = "none";
+ }
+ });
+ return alertOverlay;
+ }
+ function showToast(message) {
+ ensureAlertOverlay();
+ alertMsg.textContent = message;
+ alertOverlay.style.display = "flex";
+ }
+ // Simple HTML context menu overlay
+ let ctxMenu = null;
+ let ohmModal = null;
+ let ohmInput = null;
+ let ohmSaveBtn = null;
+ let ohmCancelBtn = null;
+ let pendingAdjustId = null;
+ function ensureContextMenu() {
+ if (ctxMenu) return ctxMenu;
+ ctxMenu = document.createElement("div");
+ ctxMenu.id = "ctxMenu";
+ Object.assign(ctxMenu.style, {
+ position: "absolute",
+ display: "none",
+ background: "#fff",
+ border: "1px solid #c9d2e3",
+ borderRadius: "8px",
+ boxShadow: "0 6px 18px rgba(16,24,40,0.18)",
+ padding: "6px",
+ zIndex: "9999",
+ fontFamily: "ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
+ fontSize: "14px",
+ color: "#111",
+ minWidth: "160px"
+ });
+ const btn = (text) => {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.textContent = text;
+ Object.assign(b.style, {
+ width: "100%",
+ textAlign: "left",
+ background: "transparent",
+ border: "0",
+ padding: "8px 10px",
+ cursor: "pointer",
+ borderRadius: "6px"
+ });
+ b.onmouseenter = () => b.style.background = "#f2f4f7";
+ b.onmouseleave = () => b.style.background = "transparent";
+ return b;
+ };
+ const bAdjust = btn("Adjust ohmage");
+ const bRemove = btn("Remove");
+ ctxMenu.appendChild(bAdjust);
+ ctxMenu.appendChild(bRemove);
+ document.body.appendChild(ctxMenu);
+ // handlers set at show-time
+ return ctxMenu;
+ }
+ function ensureOhmModal() {
+ if (ohmModal) return ohmModal;
+ // Overlay
+ ohmModal = document.createElement("div");
+ ohmModal.id = "ohmModal";
+ // Card
+ const card = document.createElement("div");
+ card.className = "ohm-card";
+ const title = document.createElement("div");
+ title.className = "ohm-title";
+ title.textContent = "Adjust Resistance";
+ const body = document.createElement("div");
+ body.className = "ohm-body";
+ const label = document.createElement("label");
+ label.className = "label";
+ label.htmlFor = "ohmInput";
+ label.textContent = "Resistance (Ω)";
+ ohmInput = document.createElement("input");
+ ohmInput.id = "ohmInput";
+ ohmInput.type = "number";
+ ohmInput.min = "0.01";
+ ohmInput.step = "0.01";
+ ohmInput.className = "input";
+ const actions = document.createElement("div");
+ actions.className = "ohm-actions";
+ ohmCancelBtn = document.createElement("button");
+ ohmCancelBtn.type = "button";
+ ohmCancelBtn.className = "btn btn--outline";
+ ohmCancelBtn.textContent = "Cancel";
+ ohmSaveBtn = document.createElement("button");
+ ohmSaveBtn.type = "button";
+ ohmSaveBtn.className = "btn";
+ ohmSaveBtn.textContent = "Save";
+ actions.appendChild(ohmCancelBtn);
+ actions.appendChild(ohmSaveBtn);
+ body.appendChild(label);
+ body.appendChild(ohmInput);
+ card.appendChild(title);
+ card.appendChild(body);
+ card.appendChild(actions);
+ ohmModal.appendChild(card);
+ document.body.appendChild(ohmModal);
+ // Events
+ ohmCancelBtn.addEventListener("click", () => closeOhmModal());
+ ohmSaveBtn.addEventListener("click", () => {
+ const comp = pendingAdjustId ? findComponentRef(pendingAdjustId) : null;
+ if (!comp) return closeOhmModal();
+ const val = Math.max(0.01, parseFloat(ohmInput.value || "0"));
+ if (isFinite(val)) { comp.R = val; update(); }
+ closeOhmModal();
+ });
+ ohmModal.addEventListener("click", (e) => {
+ if (e.target === ohmModal) closeOhmModal();
+ });
+ document.addEventListener("keydown", (e) => {
+ if (ohmModal && ohmModal.classList.contains("is-open")) {
+ if (e.key === "Escape") { e.preventDefault(); closeOhmModal(); }
+ if (e.key === "Enter") { e.preventDefault(); ohmSaveBtn.click(); }
+ }
+ });
+ return ohmModal;
+ }
+ function openOhmModal(resistorId, currentR) {
+ ensureOhmModal();
+ pendingAdjustId = resistorId;
+ ohmInput.value = String(currentR ?? 10);
+ ohmModal.classList.add("is-open");
+ setTimeout(() => { try { ohmInput.focus(); ohmInput.select(); } catch (_) {} }, 0);
+ }
+ function closeOhmModal() {
+ if (!ohmModal) return;
+ ohmModal.classList.remove("is-open");
+ pendingAdjustId = null;
+ }
+ function showContextMenu(clientX, clientY, resistorId) {
+ const m = ensureContextMenu();
+ m.dataset.resistorId = resistorId;
+ m.style.left = `${clientX + window.scrollX + 6}px`;
+ m.style.top = `${clientY + window.scrollY + 6}px`;
+ m.style.display = "block";
+ // wire buttons
+ const [bAdjust, bRemove] = m.querySelectorAll("button");
+ bAdjust.onclick = (e) => {
+ e.stopPropagation();
+ const comp = findComponentRef(resistorId);
+ if (!comp) { hideContextMenu(); return; }
+ hideContextMenu();
+ openOhmModal(resistorId, comp.R);
+ };
+ bRemove.onclick = (e) => {
+ e.stopPropagation();
+ removeById(resistorId);
+ hideContextMenu();
+ update();
+ };
+ }
+ function hideContextMenu() {
+ if (ctxMenu) ctxMenu.style.display = "none";
+ }
+ document.addEventListener("click", () => hideContextMenu());
+ window.addEventListener("resize", () => hideContextMenu());
+
+ // Helpers
+ function findComponentRef(id) {
+ for (const it of resistors) {
+ if (it && it.kind === "comp" && it.id === id) return it;
+ if (it && it.kind === "parallel" && Array.isArray(it.children)) {
+ for (const ch of it.children) {
+ if (ch.kind === "comp" && ch.id === id) return ch;
+ if (ch.kind === "series" && Array.isArray(ch.children)) {
+ const hit = ch.children.find(cc => cc.id === id);
+ if (hit) return hit;
+ }
+ }
+ }
+ }
+ return null;
+ }
+ function findSelectionInfo(selId) {
+ for (let i = 0; i < resistors.length; i++) {
+ const it = resistors[i];
+ if (it.kind === "comp" && it.id === selId) {
+ return { level: "top", idx: i };
+ }
+ if (it.kind === "parallel") {
+ for (let j = 0; j < it.children.length; j++) {
+ const ch = it.children[j];
+ if (ch.kind === "comp" && ch.id === selId) {
+ return { level: "parallel", idx: i, childIndex: j, child: ch };
+ }
+ if (ch.kind === "series") {
+ const si = ch.children.findIndex(cc => cc.id === selId);
+ if (si !== -1) {
+ return { level: "parallel-series", idx: i, childIndex: j, seriesIndex: si, child: ch };
+ }
+ }
+ }
+ }
+ }
+ return null;
+ }
+ function clampPos(value, min, max) { return Math.min(max, Math.max(min, value)); }
+ function fmt(n, digits = 3) {
+ if (!isFinite(n)) return "—";
+ const v = Math.abs(n) < 1e-6 ? 0 : n;
+ return Number(v.toFixed(digits)).toString();
+ }
+ function fmtR(r) { return `${fmt(r, 3)} Ω`; }
+ function fmtI(i) { return `${fmt(i, 3)} A`; }
+ function fmtV(v) { return `${fmt(v, 3)} V`; }
+ function fmtP(p) { return `${fmt(p, 3)} W`; }
+
+ // Stable selection ordering and index mapping (r1, r2, r3 …)
+ function sortIds(ids) {
+ return ids.slice().sort((a, b) => {
+ const na = parseInt(String(a).replace(/^\D+/, ""), 10);
+ const nb = parseInt(String(b).replace(/^\D+/, ""), 10);
+ if (!isFinite(na) || !isFinite(nb)) return String(a).localeCompare(String(b));
+ return na - nb;
+ });
+ }
+ function getSelectionIndexMap() {
+ const arr = sortIds(Array.from(selectedIds));
+ const map = {};
+ arr.forEach((id, i) => { map[id] = i + 1; });
+ return map;
+ }
+
+ // Centralized selection panel renderer
+ function updateSelectionPanel(res) {
+ if (!USE_EXTERNAL_PANEL) return;
+ const panel = document.getElementById("selectionPanel");
+ if (!panel) return;
+ const header = 'Selection
';
+ if (selectedIds.size === 0) {
+ panel.innerHTML = `${header}Click a resistor or bulb to see its values here.
`;
+ return;
+ }
+ const idxMap = getSelectionIndexMap();
+ const ids = sortIds(Array.from(selectedIds));
+ if (ids.length === 1) {
+ const id = ids[0];
+ const comp = findComponentRef(id);
+ const p = (res && res.per && res.per[id]) ? res.per[id] : { V: 0, I: 0, P: 0 };
+ const rVal = comp && typeof comp.R === "number" ? comp.R : 0;
+ panel.innerHTML =
+ header +
+ `${idxMap[id] || 1} Resistance${fmtR(rVal)}
` +
+ `Voltage${fmtV(p.V || 0)}
` +
+ `Current${fmtI(p.I || 0)}
` +
+ `Power${fmtP(p.P || 0)}
`;
+ return;
+ }
+ // Multiple selection: show up to first 4 items plus count
+ const lines = [];
+ const maxShow = 4;
+ for (let i = 0; i < Math.min(ids.length, maxShow); i++) {
+ const id = ids[i];
+ const comp = findComponentRef(id);
+ const p = (res && res.per && res.per[id]) ? res.per[id] : { V: 0, I: 0, P: 0 };
+ const rVal = comp && typeof comp.R === "number" ? comp.R : 0;
+ lines.push(
+ `${idxMap[id] || (i+1)} ${id}R: ${fmtR(rVal)}, V: ${fmtV(p.V || 0)}, I: ${fmtI(p.I || 0)}, P: ${fmtP(p.P || 0)}
`
+ );
+ }
+ const more = ids.length > maxShow ? `${ids.length - maxShow} more selected…
` : "";
+ panel.innerHTML = header + lines.join("") + more;
+ }
+
+ // Physics for N resistors
+ function computeSeriesN(V, list) {
+ // Support components and parallel groups at the top level
+ if (list.length === 0) {
+ return { Rtot: Infinity, Itot: 0, Ptot: 0, per: {} };
+ }
+ const per = {};
+ function eqRNode(node) {
+ if (node && node.kind === "parallel" && Array.isArray(node.children) && node.children.length > 0) {
+ let sumG = 0;
+ for (const ch of node.children) {
+ const Rc = eqRNode(ch);
+ if (Rc > 0) sumG += 1 / Rc;
+ }
+ return sumG > 0 ? 1 / sumG : Infinity;
+ }
+ if (node && node.kind === "series" && Array.isArray(node.children) && node.children.length > 0) {
+ return node.children.reduce((acc, ch) => acc + eqRNode(ch), 0);
+ }
+ // default: component
+ return Math.max(0.01, node.R || 0); // guard
+ }
+ const itemReq = list.map(eqRNode);
+ const Rtot = itemReq.reduce((a, b) => a + b, 0);
+ const Itot = Rtot > 0 && isFinite(Rtot) ? V / Rtot : 0;
+ function fillSeries(Vdrop, node) {
+ if (node && node.kind === "parallel" && Array.isArray(node.children) && node.children.length > 0) {
+ for (const ch of node.children) {
+ const Rc = eqRNode(ch);
+ const I = Rc > 0 && isFinite(Rc) ? Vdrop / Rc : 0;
+ fillSeries(Vdrop, ch);
+ }
+ return;
+ }
+ if (node && node.kind === "series" && Array.isArray(node.children) && node.children.length > 0) {
+ const Rseries = node.children.reduce((acc, ch) => acc + eqRNode(ch), 0);
+ const Ibranch = Rseries > 0 && isFinite(Rseries) ? Vdrop / Rseries : 0;
+ for (const ch of node.children) {
+ const Rc = eqRNode(ch);
+ const Vc = Ibranch * Rc;
+ fillSeries(Vc, ch);
+ }
+ return;
+ }
+ // component
+ const R = Math.max(0.01, node.R || 0);
+ const I = Vdrop / R;
+ per[node.id] = { V: Vdrop, I, P: Vdrop * I };
+ }
+ // Distribute along top-level series
+ for (let i = 0; i < list.length; i++) {
+ const Vdrop = Itot * itemReq[i];
+ fillSeries(Vdrop, list[i]);
+ }
+ return { Rtot, Itot, Ptot: V * Itot, per };
+ }
+
+ // ===== Multi-select grouping helpers =====
+ function findPathToComponent(id) {
+ for (let topIndex = 0; topIndex < resistors.length; topIndex++) {
+ const node = resistors[topIndex];
+ if (node && node.kind === "comp" && node.id === id) {
+ return { topIndex, parallelNode: null, branchIndex: null, seriesNode: null, seriesIndex: 0 };
+ }
+ if (node && node.kind === "parallel" && Array.isArray(node.children)) {
+ for (let branchIndex = 0; branchIndex < node.children.length; branchIndex++) {
+ const ch = node.children[branchIndex];
+ if (ch.kind === "comp" && ch.id === id) {
+ return { topIndex, parallelNode: node, branchIndex, seriesNode: null, seriesIndex: 0 };
+ }
+ if (ch.kind === "series" && Array.isArray(ch.children)) {
+ const si = ch.children.findIndex(cc => cc.id === id);
+ if (si !== -1) {
+ return { topIndex, parallelNode: node, branchIndex, seriesNode: ch, seriesIndex: si };
+ }
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ function computeSelectionGroup(selIds) {
+ const paths = selIds.map(findPathToComponent).filter(Boolean);
+ if (paths.length === 0) return null;
+ if (paths.length === 1) {
+ const p = paths[0];
+ if (p.parallelNode) {
+ // Single in a branch
+ return {
+ kind: "branch-series",
+ topIndex: p.topIndex,
+ parallelNode: p.parallelNode,
+ branchIndex: p.branchIndex,
+ seriesNode: p.seriesNode,
+ seriesSpan: [p.seriesIndex, p.seriesIndex]
+ };
+ }
+ // Single at top level
+ return { kind: "top-series", topSpan: [p.topIndex, p.topIndex] };
+ }
+ // Check if all share the same nearest parallel ancestor
+ const allHaveParallel = paths.every(p => !!p.parallelNode);
+ if (allHaveParallel) {
+ const ref = paths[0].parallelNode;
+ const sameParallel = paths.every(p => p.parallelNode === ref);
+ if (sameParallel) {
+ // How many distinct branches touched?
+ const branchSet = new Set(paths.map(p => p.branchIndex));
+ if (branchSet.size >= 2) {
+ return { kind: "parallel-group", topIndex: paths[0].topIndex, parallelNode: ref };
+ }
+ // Same branch -> branch-series span
+ const branchIndex = paths[0].branchIndex;
+ const indices = paths.map(p => (p.seriesNode ? p.seriesIndex : 0));
+ const lo = Math.min(...indices);
+ const hi = Math.max(...indices);
+ const seriesNode = paths.find(p => p.seriesNode)?.seriesNode || null;
+ return {
+ kind: "branch-series",
+ topIndex: paths[0].topIndex,
+ parallelNode: ref,
+ branchIndex,
+ seriesNode,
+ seriesSpan: [lo, hi]
+ };
+ }
+ }
+ // Fall back to top-level series segment
+ const topIdx = paths.map(p => p.topIndex);
+ const lo = Math.min(...topIdx);
+ const hi = Math.max(...topIdx);
+ return { kind: "top-series", topSpan: [lo, hi] };
+ }
+
+ function insertSeriesAfterTopIndex(comp, idx) {
+ resistors.splice(idx + 1, 0, comp);
+ }
+ function ensureBranchSeriesNode(parallelNode, branchIndex) {
+ const child = parallelNode.children[branchIndex];
+ if (child && child.kind === "series") return child;
+ // Convert single component (or unexpected) into series node
+ const asComp = child.kind === "comp" ? child : { kind: "comp", id: child.id, R: child.R, type: child.type };
+ const seriesNode = { kind: "series", children: [asComp] };
+ parallelNode.children[branchIndex] = seriesNode;
+ return seriesNode;
+ }
+ function insertIntoBranchSeries(comp, parallelNode, branchIndex, seriesNode, insertAfterIndex) {
+ const ser = seriesNode || ensureBranchSeriesNode(parallelNode, branchIndex);
+ const pos = Math.max(0, Math.min(insertAfterIndex + 1, ser.children.length));
+ ser.children.splice(pos, 0, comp);
+ }
+ function addBranchToParallel(comp, parallelNode) {
+ parallelNode.children.push(comp);
+ }
+ function wrapTopSeriesSegmentAsParallel(lo, hi, newBranchComp) {
+ const count = hi - lo + 1;
+ if (count <= 0) return;
+ const segment = resistors.slice(lo, hi + 1);
+ let branchA = null;
+ if (segment.length === 1) {
+ branchA = segment[0];
+ } else {
+ branchA = { kind: "series", children: segment };
+ }
+ const parallelNode = { kind: "parallel", children: [branchA, newBranchComp] };
+ // Replace the segment with the new parallel node
+ resistors.splice(lo, count, parallelNode);
+ }
+
+ // Localized parallel group renderers on top/bottom and left/right edges
+ function drawLocalParallelGroupTop(cx, yMain, children, pMax, res, brightnessMap, selIndexMap) {
+ // Filter out empty branches to avoid blank lanes
+ const filtered = (children || []).filter(ch =>
+ ch && (
+ (ch.kind === "comp") ||
+ (ch.kind === "series" && Array.isArray(ch.children) && ch.children.length > 0)
+ )
+ );
+ const BASE_WIDTH_FOR_TWO = (2 * RES_RENDER_LEN) + 14; // minimal room for two series elements
+ const MIN_SERIES_GAP = 14; // minimum spacing between series elements
+
+ // Longest series run across branches
+ let maxSeries = 0;
+ for (const ch of filtered) {
+ if (ch && ch.kind === "series" && Array.isArray(ch.children)) {
+ maxSeries = Math.max(maxSeries, ch.children.length);
+ }
+ }
+
+ // Desired group width with clamp to rails:
+ // - Minimal width sized just enough for two elements or the exact series span if >2
+ // width_for_m = m*RES_RENDER_LEN + (m-1)*MIN_SERIES_GAP
+ let desiredWidth = BASE_WIDTH_FOR_TWO;
+ if (maxSeries > 2) desiredWidth = Math.max(BASE_WIDTH_FOR_TWO, (maxSeries * RES_RENDER_LEN) + ((maxSeries - 1) * MIN_SERIES_GAP));
+ const railPad = 28;
+ const minX = leftX + railPad;
+ const maxX = rightX - railPad;
+ const maxWidth = Math.max(0, maxX - minX);
+ const groupWidth = Math.min(desiredWidth, maxWidth);
+ let xL = cx - groupWidth / 2;
+ xL = Math.max(minX, Math.min(xL, maxX - groupWidth)); // shift away from corner if needed
+ const xR = xL + groupWidth;
+ // Mask the main horizontal wire between bus taps to avoid a short across the group
+ const hwMask = line(xL, yMain, xR, yMain);
+ hwMask.setAttribute("stroke", "#fff");
+ hwMask.setAttribute("stroke-width", "6");
+ hwMask.setAttribute("pointer-events", "none");
+ svg.appendChild(hwMask);
+
+ // Lanes above/below
+ const n = filtered.length;
+ const nAbove = Math.floor(n / 2);
+ const nBelow = n - nAbove;
+ const laneGap = 22;
+ const aboveYs = Array.from({ length: nAbove }, (_, i) => yMain - laneGap * (i + 1));
+ const belowYs = Array.from({ length: nBelow }, (_, i) => yMain + laneGap * (i + 1));
+ // bus bars
+ if (aboveYs.length) { svg.appendChild(line(xL, yMain, xL, aboveYs[aboveYs.length - 1])); svg.appendChild(line(xR, yMain, xR, aboveYs[aboveYs.length - 1])); }
+ if (belowYs.length) { svg.appendChild(line(xL, yMain, xL, belowYs[belowYs.length - 1])); svg.appendChild(line(xR, yMain, xR, belowYs[belowYs.length - 1])); }
+ // render children
+ let idx = 0;
+ const lanes = [];
+ for (let i = 0; i < nAbove && idx < n; i++, idx++) lanes.push({ comp: filtered[idx], y: aboveYs[i], pos: "above" });
+ for (let i = 0; i < nBelow && idx < n; i++, idx++) lanes.push({ comp: filtered[idx], y: belowYs[i], pos: "below" });
+ const groupLen = xR - xL;
+ lanes.forEach(l => {
+ const c = l.comp;
+ if (c.kind === "series" && Array.isArray(c.children)) {
+ const m = c.children.length;
+ const halfLen = RES_RENDER_LEN / 2;
+ // Total span we would like (bodies + minimal gaps)
+ const baseSpan = m * RES_RENDER_LEN + (m - 1) * MIN_SERIES_GAP;
+ // Clamp span to fit within groupLen
+ const span = Math.min(baseSpan, groupLen);
+ const availableForGaps = Math.max(span - m * RES_RENDER_LEN, 0);
+ const gap = m > 1 ? availableForGaps / (m - 1) : 0;
+ const centersStart = xL + (groupLen - span) / 2 + halfLen;
+ const centers = Array.from({ length: m }, (_, k) => centersStart + k * (RES_RENDER_LEN + gap));
+
+ // Left bus to first element
+ if (m > 0) {
+ const firstLeft = centers[0] - halfLen;
+ svg.appendChild(line(xL, l.y, firstLeft, l.y));
+ }
+ // Between consecutive elements
+ for (let k = 0; k < m - 1; k++) {
+ const rightPrev = centers[k] + halfLen;
+ const leftNext = centers[k + 1] - halfLen;
+ svg.appendChild(line(rightPrev, l.y, leftNext, l.y));
+ }
+ // Last element to right bus
+ if (m > 0) {
+ const lastRight = centers[m - 1] + halfLen;
+ svg.appendChild(line(lastRight, l.y, xR, l.y));
+ }
+ c.children.forEach((cc, k) => {
+ const px = centers[k];
+ const sel = selectedIds.has(cc.id);
+ const powRaw = Math.max(0, (res.per[cc.id]?.P || 0));
+ const pow = brightnessMap ? brightnessMap(powRaw) : powRaw;
+ const g = componentHorizontal(cc.type || "resistor", px, l.y, RES_RENDER_LEN, pow, sel);
+ g.dataset.resistorId = cc.id;
+ g.addEventListener("click", (e) => { e.stopPropagation(); if (sel) selectedIds.delete(cc.id); else selectedIds.add(cc.id); update(); });
+ g.addEventListener("contextmenu", (e) => { e.preventDefault(); showContextMenu(e.clientX, e.clientY, cc.id); });
+ svg.appendChild(g);
+ if (sel && USE_EXTERNAL_PANEL && selIndexMap && selIndexMap[cc.id] != null) {
+ svg.appendChild(selectionBadge(px, l.y, selIndexMap[cc.id], l.pos === "above" ? "above" : "below"));
+ }
+ if (sel && !USE_EXTERNAL_PANEL) {
+ svg.appendChild(popupBox(px, l.y, [
+ `R = ${fmtR(cc.R)}`,
+ `V = ${fmtV(res.per[cc.id]?.V || 0)}`,
+ `I = ${fmtI(res.per[cc.id]?.I || 0)}`,
+ `P = ${fmtP(res.per[cc.id]?.P || 0)}`
+ ], l.pos === "above" ? "above" : "below", () => { selectedIds.delete(cc.id); update(); }, cc.id));
+ }
+ });
+ } else {
+ const sel = selectedIds.has(c.id);
+ const powRaw = Math.max(0, (res.per[c.id]?.P || 0));
+ const pow = brightnessMap ? brightnessMap(powRaw) : powRaw;
+ // Single element: span full group to connect bus bars
+ const g = componentHorizontal(c.type || "resistor", (xL + xR) / 2, l.y, groupLen, pow, sel);
+ g.dataset.resistorId = c.id;
+ g.addEventListener("click", (e) => { e.stopPropagation(); if (sel) selectedIds.delete(c.id); else selectedIds.add(c.id); update(); });
+ g.addEventListener("contextmenu", (e) => { e.preventDefault(); showContextMenu(e.clientX, e.clientY, c.id); });
+ svg.appendChild(g);
+ if (sel && USE_EXTERNAL_PANEL && selIndexMap && selIndexMap[c.id] != null) {
+ svg.appendChild(selectionBadge((xL + xR) / 2, l.y, selIndexMap[c.id], l.pos === "above" ? "above" : "below"));
+ }
+ if (sel && !USE_EXTERNAL_PANEL) {
+ svg.appendChild(popupBox((xL + xR) / 2, l.y, [
+ `R = ${fmtR(c.R)}`,
+ `V = ${fmtV(res.per[c.id]?.V || 0)}`,
+ `I = ${fmtI(res.per[c.id]?.I || 0)}`,
+ `P = ${fmtP(res.per[c.id]?.P || 0)}`
+ ], l.pos === "above" ? "above" : "below", () => { selectedIds.delete(c.id); update(); }, c.id));
+ }
+ }
+ });
+ }
+
+ function drawLocalParallelGroupLeft(xMain, cy, children, pMax, res, brightnessMap, selIndexMap) {
+ // Filter out empty branches
+ const filtered = (children || []).filter(ch =>
+ ch && (
+ (ch.kind === "comp") ||
+ (ch.kind === "series" && Array.isArray(ch.children) && ch.children.length > 0)
+ )
+ );
+ const BASE_HEIGHT_FOR_TWO = (2 * RES_RENDER_LEN) + 14;
+ const MIN_SERIES_GAP = 14;
+
+ // Longest series run across branches
+ let maxSeries = 0;
+ for (const ch of filtered) {
+ if (ch && ch.kind === "series" && Array.isArray(ch.children)) {
+ maxSeries = Math.max(maxSeries, ch.children.length);
+ }
+ }
+
+ // Desired group height with clamp to rails; shift away from corners if needed
+ let desiredHeight = BASE_HEIGHT_FOR_TWO;
+ if (maxSeries > 2) desiredHeight = Math.max(BASE_HEIGHT_FOR_TWO, (maxSeries * RES_RENDER_LEN) + ((maxSeries - 1) * MIN_SERIES_GAP));
+ const railPad = 28;
+ const minY = topY + railPad;
+ const maxY = bottomY - railPad;
+ const maxHeight = Math.max(0, maxY - minY);
+ const groupHeight = Math.min(desiredHeight, maxHeight);
+ let yT = cy - groupHeight / 2;
+ yT = Math.max(minY, Math.min(yT, maxY - groupHeight)); // shift away from corner if needed
+ const yB = yT + groupHeight;
+ // Mask the main vertical wire between bus taps to avoid a short across the group
+ const vwMask = line(xMain, yT, xMain, yB);
+ vwMask.setAttribute("stroke", "#fff");
+ vwMask.setAttribute("stroke-width", "6");
+ vwMask.setAttribute("pointer-events", "none");
+ svg.appendChild(vwMask);
+
+ // Lanes left/right
+ const n = filtered.length;
+ const nLeft = Math.floor(n / 2);
+ const nRight = n - nLeft;
+ const laneGap = 22;
+ const leftXs = Array.from({ length: nLeft }, (_, i) => xMain - laneGap * (i + 1));
+ const rightXs = Array.from({ length: nRight }, (_, i) => xMain + laneGap * (i + 1));
+ // bus bars
+ if (leftXs.length) { svg.appendChild(line(xMain, yT, leftXs[leftXs.length - 1], yT)); svg.appendChild(line(xMain, yB, leftXs[leftXs.length - 1], yB)); }
+ if (rightXs.length) { svg.appendChild(line(xMain, yT, rightXs[rightXs.length - 1], yT)); svg.appendChild(line(xMain, yB, rightXs[rightXs.length - 1], yB)); }
+ // render children
+ let idx = 0;
+ const lanes = [];
+ for (let i = 0; i < nLeft && idx < n; i++, idx++) lanes.push({ comp: filtered[idx], x: leftXs[i], pos: "left" });
+ for (let i = 0; i < nRight && idx < n; i++, idx++) lanes.push({ comp: filtered[idx], x: rightXs[i], pos: "right" });
+ const groupLen = yB - yT;
+ lanes.forEach(l => {
+ const c = l.comp;
+ if (c.kind === "series" && Array.isArray(c.children)) {
+ const m = c.children.length;
+ const halfLen = RES_RENDER_LEN / 2;
+ const baseSpan = m * RES_RENDER_LEN + (m - 1) * MIN_SERIES_GAP;
+ const span = Math.min(baseSpan, groupLen);
+ const availableForGaps = Math.max(span - m * RES_RENDER_LEN, 0);
+ const gap = m > 1 ? availableForGaps / (m - 1) : 0;
+ const centersStart = yT + (groupLen - span) / 2 + halfLen;
+ const centers = Array.from({ length: m }, (_, k) => centersStart + k * (RES_RENDER_LEN + gap));
+
+ // Top bus to first element
+ if (m > 0) {
+ const firstTop = centers[0] - halfLen;
+ svg.appendChild(line(l.x, yT, l.x, firstTop));
+ }
+ // Between consecutive elements
+ for (let k = 0; k < m - 1; k++) {
+ const bottomPrev = centers[k] + halfLen;
+ const topNext = centers[k + 1] - halfLen;
+ svg.appendChild(line(l.x, bottomPrev, l.x, topNext));
+ }
+ // Last element to bottom bus
+ if (m > 0) {
+ const lastBottom = centers[m - 1] + halfLen;
+ svg.appendChild(line(l.x, lastBottom, l.x, yB));
+ }
+ c.children.forEach((cc, k) => {
+ const py = centers[k];
+ const sel = selectedIds.has(cc.id);
+ const powRaw = Math.max(0, (res.per[cc.id]?.P || 0));
+ const pow = brightnessMap ? brightnessMap(powRaw) : powRaw;
+ const g = componentVertical(cc.type || "resistor", l.x, py, RES_RENDER_LEN, pow, sel);
+ g.dataset.resistorId = cc.id;
+ g.addEventListener("click", (e) => { e.stopPropagation(); if (sel) selectedIds.delete(cc.id); else selectedIds.add(cc.id); update(); });
+ g.addEventListener("contextmenu", (e) => { e.preventDefault(); showContextMenu(e.clientX, e.clientY, cc.id); });
+ svg.appendChild(g);
+ if (sel && USE_EXTERNAL_PANEL && selIndexMap && selIndexMap[cc.id] != null) {
+ svg.appendChild(selectionBadge(l.x, py, selIndexMap[cc.id], l.pos === "left" ? "left" : "right"));
+ }
+ if (sel && !USE_EXTERNAL_PANEL) {
+ svg.appendChild(popupBox(l.x, py, [
+ `R = ${fmtR(cc.R)}`,
+ `V = ${fmtV(res.per[cc.id]?.V || 0)}`,
+ `I = ${fmtI(res.per[cc.id]?.I || 0)}`,
+ `P = ${fmtP(res.per[cc.id]?.P || 0)}`
+ ], l.pos === "left" ? "left" : "right", () => { selectedIds.delete(cc.id); update(); }, cc.id));
+ }
+ });
+ } else {
+ const sel = selectedIds.has(c.id);
+ const powRaw = Math.max(0, (res.per[c.id]?.P || 0));
+ const pow = brightnessMap ? brightnessMap(powRaw) : powRaw;
+ // Single element: span full group to connect bus bars
+ const g = componentVertical(c.type || "resistor", l.x, (yT + yB) / 2, groupLen, pow, sel);
+ g.dataset.resistorId = c.id;
+ g.addEventListener("click", (e) => { e.stopPropagation(); if (sel) selectedIds.delete(c.id); else selectedIds.add(c.id); update(); });
+ g.addEventListener("contextmenu", (e) => { e.preventDefault(); showContextMenu(e.clientX, e.clientY, c.id); });
+ svg.appendChild(g);
+ if (sel && USE_EXTERNAL_PANEL && selIndexMap && selIndexMap[c.id] != null) {
+ svg.appendChild(selectionBadge(l.x, (yT + yB) / 2, selIndexMap[c.id], l.pos === "left" ? "left" : "right"));
+ }
+ if (sel && !USE_EXTERNAL_PANEL) {
+ svg.appendChild(popupBox(l.x, (yT + yB) / 2, [
+ `R = ${fmtR(c.R)}`,
+ `V = ${fmtV(res.per[c.id]?.V || 0)}`,
+ `I = ${fmtI(res.per[c.id]?.I || 0)}`,
+ `P = ${fmtP(res.per[c.id]?.P || 0)}`
+ ], l.pos === "left" ? "left" : "right", () => { selectedIds.delete(c.id); update(); }, c.id));
+ }
+ }
+ });
+ }
+ function computeParallelN(V, list) {
+ if (list.length === 0) {
+ return { Rtot: Infinity, Itot: 0, Ptot: 0, per: {} };
+ }
+ const G = list.reduce((s, r) => s + 1 / r.R, 0);
+ const Rtot = 1 / G;
+ const per = {};
+ let Itot = 0;
+ for (const r of list) {
+ const Ir = V / r.R;
+ const Pr = V * Ir;
+ per[r.id] = { V, I: Ir, P: Pr };
+ Itot += Ir;
+ }
+ return { Rtot, Itot, Ptot: V * Itot, per };
+ }
+
+ // SVG helpers
+ function clear(svg) {
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
+ }
+ function line(x1, y1, x2, y2, cls = "wire") {
+ const e = document.createElementNS("http://www.w3.org/2000/svg", "line");
+ e.setAttribute("x1", x1); e.setAttribute("y1", y1);
+ e.setAttribute("x2", x2); e.setAttribute("y2", y2);
+ e.setAttribute("stroke", "#333"); e.setAttribute("stroke-width", "4");
+ e.setAttribute("stroke-linecap", "round");
+ if (cls) e.setAttribute("class", cls);
+ return e;
+ }
+ function dot(x, y) {
+ // Dots disabled for cleaner appearance
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
+ return g;
+ }
+ const RES_BODY = 60; // constant symbol length for zig-zag body
+ const RES_AMP = 8;
+ const RES_SEGS = 6;
+ const BULB_D = 24;
+ const RES_LEAD = 12; // desired minimum lead length on each side
+ const RES_RENDER_LEN = RES_BODY + 2 * RES_LEAD; // standard total symbol span
+
+ function drawZigZagHorizontal(cx, cy, length, selected = false) {
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
+ const left = cx - length / 2, right = cx + length / 2;
+ const bodyLen = Math.min(RES_BODY, Math.max(16, length - 16));
+ const lead = (length - bodyLen) / 2;
+ const x1 = left + lead, x2 = right - lead;
+
+ const lead1 = line(left, cy, x1, cy);
+ const lead2 = line(x2, cy, right, cy);
+ // leads keep default style; only body highlights
+ g.appendChild(lead1); g.appendChild(lead2);
+
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
+ const step = bodyLen / RES_SEGS;
+ const points = [];
+ for (let i = 0; i <= RES_SEGS; i++) {
+ const x = x1 + i * step;
+ const y = cy + (i % 2 === 0 ? -RES_AMP : RES_AMP);
+ points.push(`${x},${y}`);
+ }
+ path.setAttribute("points", points.join(" "));
+ path.setAttribute("fill", "none");
+ path.setAttribute("stroke", selected ? "#c8102e" : "#111");
+ path.setAttribute("stroke-width", selected ? "3.5" : "2.5");
+ g.appendChild(path);
+ // Larger, invisible hit area for easy selection
+ const hit = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+ hit.setAttribute("x", left - 8);
+ hit.setAttribute("y", cy - 22);
+ hit.setAttribute("width", length + 16);
+ hit.setAttribute("height", 44);
+ hit.setAttribute("fill", "#000");
+ hit.setAttribute("opacity", "0");
+ hit.setAttribute("pointer-events", "all");
+ g.appendChild(hit);
+ g.style.cursor = "pointer";
+ // Attach dataset for hit forwarding convenience (optional)
+ g.dataset.hit = "1";
+ return g;
+ }
+
+ function drawZigZagVertical(cx, cy, length, selected = false) {
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
+ const top = cy - length / 2, bottom = cy + length / 2;
+ const bodyLen = Math.min(RES_BODY, Math.max(16, length - 16));
+ const lead = (length - bodyLen) / 2;
+ const y1 = top + lead, y2 = bottom - lead;
+
+ const lead1 = line(cx, top, cx, y1);
+ const lead2 = line(cx, y2, cx, bottom);
+ // leads keep default style; only body highlights
+ g.appendChild(lead1); g.appendChild(lead2);
+
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
+ const step = bodyLen / RES_SEGS;
+ const points = [];
+ for (let i = 0; i <= RES_SEGS; i++) {
+ const y = y1 + i * step;
+ const x = cx + (i % 2 === 0 ? -RES_AMP : RES_AMP);
+ points.push(`${x},${y}`);
+ }
+ path.setAttribute("points", points.join(" "));
+ path.setAttribute("fill", "none");
+ path.setAttribute("stroke", selected ? "#c8102e" : "#111");
+ path.setAttribute("stroke-width", selected ? "3.5" : "2.5");
+ g.appendChild(path);
+ // Larger, invisible hit area for easy selection
+ const hit = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+ hit.setAttribute("x", cx - 22);
+ hit.setAttribute("y", top - 8);
+ hit.setAttribute("width", 44);
+ hit.setAttribute("height", length + 16);
+ hit.setAttribute("fill", "#000");
+ hit.setAttribute("opacity", "0");
+ hit.setAttribute("pointer-events", "all");
+ g.appendChild(hit);
+ g.style.cursor = "pointer";
+ g.dataset.hit = "1";
+ return g;
+ }
+
+ function drawBulbHorizontal(cx, cy, length, powerFrac, selected = false) {
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
+ const left = cx - length / 2, right = cx + length / 2;
+ const lead = Math.max(8, (length - BULB_D) / 2);
+ const x1 = left + lead, x2 = right - lead;
+ const lead1 = line(left, cy, x1, cy);
+ const lead2 = line(x2, cy, right, cy);
+ // leads keep default style; only body highlights
+ g.appendChild(lead1); g.appendChild(lead2);
+ // Brightness mapping (power in watts):
+ // - 0–0.05W: hold at lightest shade (intensity 0)
+ // - 0.05–4.5W: linear ramp to brightest shade (intensity 1 at 4.5W)
+ // - ≥4.5W: clamp to brightest
+ let intensity = 0;
+ if (powerFrac >= 4.5) intensity = 1;
+ else if (powerFrac > 0.05) intensity = (powerFrac - 0.05) / 4.45;
+ intensity = clampPos(intensity, 0, 1);
+ // Glow halo (subtle outer light) scales with intensity
+ const halo = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ halo.setAttribute("cx", cx); halo.setAttribute("cy", cy);
+ halo.setAttribute("r", (BULB_D / 2) + 10 + 16 * intensity);
+ halo.setAttribute("fill", `rgba(255, 200, 0, ${0.06 + 0.22 * intensity})`);
+ halo.setAttribute("stroke", "none");
+ g.appendChild(halo);
+ // Bulb body
+ const c = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ c.setAttribute("cx", cx); c.setAttribute("cy", cy); c.setAttribute("r", BULB_D / 2);
+ // Color ramp: slightly deeper gold at low power so it stands out on white
+ // low: #FFD45A (richer gold)
+ // high: #FF8C00 (bright orange)
+ const r0 = 255, g0 = 212, b0 = 90;
+ const r1 = 255, g1 = 140, b1 = 0;
+ const cr = Math.round(r0 + (r1 - r0) * intensity);
+ const cg = Math.round(g0 + (g1 - g0) * intensity);
+ const cb = Math.round(b0 + (b1 - b0) * intensity);
+ c.setAttribute("fill", `rgb(${cr}, ${cg}, ${cb})`);
+ c.setAttribute("stroke", selected ? "#c8102e" : "#111");
+ c.setAttribute("stroke-width", selected ? "3" : "2");
+ g.appendChild(c);
+ // Larger, invisible hit area for easy selection
+ const hit = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+ hit.setAttribute("x", left - 8);
+ hit.setAttribute("y", cy - (BULB_D / 2) - 16);
+ hit.setAttribute("width", length + 16);
+ hit.setAttribute("height", BULB_D + 32);
+ hit.setAttribute("fill", "#000");
+ hit.setAttribute("opacity", "0");
+ hit.setAttribute("pointer-events", "all");
+ g.appendChild(hit);
+ g.style.cursor = "pointer";
+ g.dataset.hit = "1";
+ return g;
+ }
+
+ function drawBulbVertical(cx, cy, length, powerFrac, selected = false) {
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
+ const top = cy - length / 2, bottom = cy + length / 2;
+ const lead = Math.max(8, (length - BULB_D) / 2);
+ const y1 = top + lead, y2 = bottom - lead;
+ const lead1 = line(cx, top, cx, y1);
+ const lead2 = line(cx, y2, cx, bottom);
+ g.appendChild(lead1); g.appendChild(lead2);
+ // Brightness mapping (power in watts):
+ // - 0–0.05W: hold at lightest shade (intensity 0)
+ // - 0.05–4.5W: linear ramp to brightest shade (intensity 1 at 4.5W)
+ // - ≥4.5W: clamp to brightest
+ let intensity = 0;
+ if (powerFrac >= 4.5) intensity = 1;
+ else if (powerFrac > 0.05) intensity = (powerFrac - 0.05) / 4.45;
+ intensity = clampPos(intensity, 0, 1);
+ // Glow halo scales with intensity
+ const halo = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ halo.setAttribute("cx", cx); halo.setAttribute("cy", cy);
+ halo.setAttribute("r", (BULB_D / 2) + 10 + 16 * intensity);
+ halo.setAttribute("fill", `rgba(255, 200, 0, ${0.06 + 0.22 * intensity})`);
+ halo.setAttribute("stroke", "none");
+ g.appendChild(halo);
+ // Bulb body
+ const c = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ c.setAttribute("cx", cx); c.setAttribute("cy", cy); c.setAttribute("r", BULB_D / 2);
+ // Color ramp: slightly deeper gold at low power so it stands out on white
+ // low: #FFD45A
+ // high: #FF8C00
+ const r0 = 255, g0 = 212, b0 = 90;
+ const r1 = 255, g1 = 140, b1 = 0;
+ const cr = Math.round(r0 + (r1 - r0) * intensity);
+ const cg = Math.round(g0 + (g1 - g0) * intensity);
+ const cb = Math.round(b0 + (b1 - b0) * intensity);
+ c.setAttribute("fill", `rgb(${cr}, ${cg}, ${cb})`);
+ c.setAttribute("stroke", selected ? "#c8102e" : "#111");
+ c.setAttribute("stroke-width", selected ? "3" : "2");
+ g.appendChild(c);
+ // Larger, invisible hit area for easy selection
+ const hit = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+ hit.setAttribute("x", cx - (BULB_D / 2) - 16);
+ hit.setAttribute("y", top - 8);
+ hit.setAttribute("width", BULB_D + 32);
+ hit.setAttribute("height", length + 16);
+ hit.setAttribute("fill", "#000");
+ hit.setAttribute("opacity", "0");
+ hit.setAttribute("pointer-events", "all");
+ g.appendChild(hit);
+ g.style.cursor = "pointer";
+ g.dataset.hit = "1";
+ return g;
+ }
+
+ function componentHorizontal(kind, cx, cy, length, powerFrac, selected) {
+ if (kind === "bulb") return drawBulbHorizontal(cx, cy, length, powerFrac, selected);
+ return drawZigZagHorizontal(cx, cy, length, selected);
+ }
+ function componentVertical(kind, cx, cy, length, powerFrac, selected) {
+ if (kind === "bulb") return drawBulbVertical(cx, cy, length, powerFrac, selected);
+ return drawZigZagVertical(cx, cy, length, selected);
+ }
+ function label(text, x, y, anchor = "middle") {
+ const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ t.setAttribute("x", x); t.setAttribute("y", y);
+ t.setAttribute("text-anchor", anchor);
+ t.setAttribute("font-size", "14");
+ t.setAttribute("font-family", "ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif");
+ t.setAttribute("fill", "#222");
+ t.textContent = text;
+ return t;
+ }
+
+ // Small SVG popup showing lines of text near a resistor
+ function popupBox(x, y, lines, position = "above", onToggle = null, resistorId = null) {
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
+ // Allow clicks on popup to toggle selection off
+ g.style.cursor = onToggle ? "pointer" : "default";
+ const maxChars = Math.max(0, ...lines.map(s => s.length));
+ const width = Math.max(100, Math.min(220, maxChars * 7 + 16));
+ const height = lines.length * 16 + 12;
+ let px = x, py = y;
+ if (position === "above") { px = x - width / 2; py = y - height - 8; }
+ else if (position === "below") { px = x - width / 2; py = y + 8; }
+ else if (position === "left") { px = x - width - 8; py = y - height / 2; }
+ else if (position === "right") { px = x + 8; py = y - height / 2; }
+
+ // Clamp within SVG viewport
+ px = Math.max(8, Math.min(px, W - width - 8));
+ py = Math.max(8, Math.min(py, H - height - 8));
+
+ const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+ rect.setAttribute("x", px); rect.setAttribute("y", py);
+ rect.setAttribute("width", width); rect.setAttribute("height", height);
+ rect.setAttribute("rx", "6"); rect.setAttribute("ry", "6");
+ rect.setAttribute("fill", "#ffffff");
+ rect.setAttribute("stroke", "#c8102e");
+ rect.setAttribute("stroke-width", "1.5");
+ g.appendChild(rect);
+
+ lines.forEach((text, i) => {
+ const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ t.setAttribute("x", px + 8); t.setAttribute("y", py + 18 + i * 16);
+ t.setAttribute("text-anchor", "start");
+ t.setAttribute("font-size", "14");
+ t.setAttribute("font-family", "ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif");
+ t.setAttribute("fill", "#111");
+ t.textContent = text;
+ g.appendChild(t);
+ });
+ if (onToggle) {
+ g.addEventListener("click", (e) => {
+ e.stopPropagation();
+ onToggle();
+ });
+ }
+ if (resistorId) {
+ g.addEventListener("contextmenu", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ showContextMenu(e.clientX, e.clientY, resistorId);
+ });
+ }
+ return g;
+ }
+
+ // Small numeric badge to link selected item to the panel list
+ function selectionBadge(x, y, label, position = "above") {
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
+ const padX = 6, padY = 3;
+ const fontSize = 12;
+ let px = x, py = y;
+ const dx = 0, dy = 0;
+ const offset = 16;
+ if (position === "above") { py = y - offset; }
+ else if (position === "below") { py = y + offset; }
+ else if (position === "left") { px = x - offset; }
+ else if (position === "right") { px = x + offset; }
+ // Background rect sized to text: approximate width for 2 chars
+ const w = 16, h = 16;
+ const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+ rect.setAttribute("x", px - w / 2); rect.setAttribute("y", py - h / 2);
+ rect.setAttribute("width", w); rect.setAttribute("height", h);
+ rect.setAttribute("rx", "8"); rect.setAttribute("ry", "8");
+ rect.setAttribute("fill", "#c8102e");
+ g.appendChild(rect);
+ const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ t.setAttribute("x", px); t.setAttribute("y", py + 4); // baseline tweak
+ t.setAttribute("text-anchor", "middle");
+ t.setAttribute("font-size", String(fontSize));
+ t.setAttribute("font-family", "ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif");
+ t.setAttribute("fill", "#fff");
+ t.textContent = String(label);
+ g.appendChild(t);
+ g.setAttribute("pointer-events", "none");
+ return g;
+ }
+ function drawRectangleLoop(opts = {}) {
+ const topGap = opts.topGap || null; // [x1, x2] to skip drawing top segment
+ // Top
+ if (topGap && Array.isArray(topGap) && topGap.length === 2) {
+ const [gx1, gx2] = topGap;
+ if (gx1 > leftX) svg.appendChild(line(leftX, topY, gx1, topY));
+ if (gx2 < rightX) svg.appendChild(line(gx2, topY, rightX, topY));
+ } else {
+ svg.appendChild(line(leftX, topY, rightX, topY)); // full top
+ }
+ // Right, Bottom
+ svg.appendChild(line(rightX, topY, rightX, bottomY));
+ svg.appendChild(line(rightX, bottomY, leftX, bottomY));
+ // Battery symbol centered on left wire: two perpendicular lines centered on the rail,
+ // and the left rail has a small gap between the two terminals.
+ const plateLong = 46;
+ const plateShort = 26;
+ const plateGap = 26; // vertical distance between plates
+ const yPlus = centerY - plateGap / 2;
+ const yMinus = centerY + plateGap / 2;
+ // Left rail segments with gap between terminals
+ svg.appendChild(line(leftX, topY, leftX, yPlus));
+ svg.appendChild(line(leftX, yMinus, leftX, bottomY));
+ // Plates (centered on the wire)
+ const plateP = line(leftX - plateLong / 2, yPlus, leftX + plateLong / 2, yPlus);
+ const plateN = line(leftX - plateShort / 2, yMinus, leftX + plateShort / 2, yMinus);
+ svg.appendChild(plateP);
+ svg.appendChild(plateN);
+ // Labels near the right ends of plates
+ const tPlus = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ tPlus.setAttribute("x", leftX + plateLong / 2 + 8); tPlus.setAttribute("y", yPlus + 5);
+ tPlus.setAttribute("text-anchor", "start");
+ tPlus.setAttribute("font-size", "18");
+ tPlus.setAttribute("font-family", "ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif");
+ tPlus.setAttribute("fill", "#d00");
+ tPlus.textContent = "+";
+ svg.appendChild(tPlus);
+ const tMinus = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ tMinus.setAttribute("x", leftX + plateShort / 2 + 8); tMinus.setAttribute("y", yMinus + 5);
+ tMinus.setAttribute("text-anchor", "start");
+ tMinus.setAttribute("font-size", "18");
+ tMinus.setAttribute("font-family", "ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif");
+ tMinus.setAttribute("fill", "#111");
+ tMinus.textContent = "-";
+ svg.appendChild(tMinus);
+ }
+
+ function render(mode, V, list, res) {
+ clear(svg);
+
+ // Normalize power for brightness across all components in 'per'
+ let pMax = 1e-9;
+ for (const k in res.per) {
+ if (Object.prototype.hasOwnProperty.call(res.per, k)) {
+ pMax = Math.max(pMax, Math.max(0, res.per[k].P));
+ }
+ }
+
+ // Use a consistent, monotonic brightness transform so intensity increases
+ // smoothly with power as voltage rises.
+ let brightnessMap = (p) => p;
+ const selIndexMap = getSelectionIndexMap();
+
+ if (mode === "series") {
+ drawRectangleLoop();
+ // Series: distribute evenly around all four edges, colinear with the local edge
+ const n = list.length;
+ const width = rightX - leftX;
+ const height = bottomY - topY;
+ const perimeter = 2 * (width + height);
+ const arcGap = perimeter / (n + 1);
+ const cornerPad = 18; // keep clear of corners so wires stay visually connected
+
+ function positionAlongPerimeter(s) {
+ // s: distance from left-top corner along top→right→bottom→left
+ let d = s % perimeter;
+ if (d < width) {
+ // top edge (left→right)
+ return { x: leftX + d, y: topY, edge: "top" };
+ }
+ d -= width;
+ if (d < height) {
+ // right edge (top→bottom)
+ return { x: rightX, y: topY + d, edge: "right" };
+ }
+ d -= height;
+ if (d < width) {
+ // bottom edge (right→left)
+ return { x: rightX - d, y: bottomY, edge: "bottom" };
+ }
+ d -= width;
+ // left edge (bottom→top)
+ return { x: leftX, y: bottomY - d, edge: "left" };
+ }
+
+ list.forEach((item, i) => {
+ const s = arcGap * (i + 1);
+ const pos = positionAlongPerimeter(s);
+ // Nudge away from corners to avoid too-short bodies
+ let px = pos.x, py = pos.y;
+ const guard = 28;
+ if (pos.edge === "top" || pos.edge === "bottom") {
+ if (px - leftX < guard) px = leftX + guard;
+ else if (rightX - px < guard) px = rightX - guard;
+ } else {
+ if (py - topY < guard) py = topY + guard;
+ else if (bottomY - py < guard) py = bottomY - guard;
+ }
+
+ // Use a standard symbol span everywhere
+ const rLen = RES_RENDER_LEN;
+
+ if (pos.edge === "top" || pos.edge === "bottom") {
+ const y = py;
+ // Keep away from corners: clamp center so the full symbol fits with margin
+ const half = rLen / 2;
+ px = clampPos(px, leftX + cornerPad + half, rightX - cornerPad - half);
+ const x1 = px - half;
+ const x2 = px + half;
+ if (item && item.kind === "parallel" && Array.isArray(item.children)) {
+ // Localized parallel group on top/bottom edge
+ drawLocalParallelGroupTop(px, y, item.children, pMax, res, brightnessMap, selIndexMap);
+ } else {
+ const r = item;
+ const sel = selectedIds.has(r.id);
+ const fRaw = Math.max(0, (res.per[r.id]?.P || 0));
+ const f = brightnessMap(fRaw);
+ svg.appendChild(dot(x1, y));
+ svg.appendChild(dot(x2, y));
+ const g = componentHorizontal(r.type || "resistor", px, y, rLen, f, sel);
+ g.dataset.resistorId = r.id;
+ g.addEventListener("click", (e) => {
+ e.stopPropagation();
+ if (selectedIds.has(r.id)) selectedIds.delete(r.id); else selectedIds.add(r.id);
+ update();
+ });
+ g.addEventListener("contextmenu", (e) => {
+ e.preventDefault();
+ showContextMenu(e.clientX, e.clientY, r.id);
+ });
+ svg.appendChild(g);
+ if (sel && USE_EXTERNAL_PANEL && selIndexMap && selIndexMap[r.id] != null) {
+ const badgePos = pos.edge === "top" ? "below" : "above";
+ svg.appendChild(selectionBadge(px, y, selIndexMap[r.id], badgePos));
+ }
+ if (sel && !USE_EXTERNAL_PANEL) {
+ const popupPos = pos.edge === "top" ? "below" : "above";
+ svg.appendChild(popupBox(px, y, [
+ `R = ${fmtR(r.R)}`,
+ `V = ${fmtV(res.per[r.id].V)}`,
+ `I = ${fmtI(res.per[r.id].I)}`,
+ `P = ${fmtP(res.per[r.id].P)}`
+ ], popupPos, () => { selectedIds.delete(r.id); update(); }, r.id));
+ }
+ }
+ } else {
+ const x = px;
+ // Keep away from corners: clamp center so the full symbol fits with margin
+ let half = rLen / 2;
+ py = clampPos(py, topY + cornerPad + half, bottomY - cornerPad - half);
+ // Avoid overlapping the left-rail battery: reserve a vertical band
+ const plateGap = 26;
+ const reserveMargin = 18;
+ const clearTop = centerY - plateGap / 2 - reserveMargin;
+ const clearBottom = centerY + plateGap / 2 + reserveMargin;
+ if (x === leftX) {
+ let y1 = py - half;
+ let y2 = py + half;
+ if (!(y2 < clearTop || y1 > clearBottom)) {
+ // Nudge above or below the reserved band
+ if (py <= centerY) py = clearTop - half - 2;
+ else py = clearBottom + half + 2;
+ // Clamp within bounds after nudge
+ py = clampPos(py, topY + cornerPad + half, bottomY - cornerPad - half);
+ }
+ }
+ const y1 = py - half;
+ const y2 = py + half;
+ if (item && item.kind === "parallel" && Array.isArray(item.children)) {
+ drawLocalParallelGroupLeft(x, py, item.children, pMax, res, brightnessMap, selIndexMap);
+ } else {
+ const r = item;
+ const sel = selectedIds.has(r.id);
+ const fRaw = Math.max(0, (res.per[r.id]?.P || 0));
+ const f = brightnessMap(fRaw);
+ svg.appendChild(dot(x, y1));
+ svg.appendChild(dot(x, y2));
+ const g = componentVertical(r.type || "resistor", x, py, rLen, f, sel);
+ g.dataset.resistorId = r.id;
+ g.addEventListener("click", (e) => {
+ e.stopPropagation();
+ if (selectedIds.has(r.id)) selectedIds.delete(r.id); else selectedIds.add(r.id);
+ update();
+ });
+ g.addEventListener("contextmenu", (e) => {
+ e.preventDefault();
+ showContextMenu(e.clientX, e.clientY, r.id);
+ });
+ svg.appendChild(g);
+ if (sel && USE_EXTERNAL_PANEL && selIndexMap && selIndexMap[r.id] != null) {
+ const badgePos = pos.edge === "right" ? "right" : "left";
+ svg.appendChild(selectionBadge(x, py, selIndexMap[r.id], badgePos));
+ }
+ if (sel && !USE_EXTERNAL_PANEL) {
+ const popupPos = pos.edge === "right" ? "right" : "left";
+ svg.appendChild(popupBox(x, py, [
+ `R = ${fmtR(r.R)}`,
+ `V = ${fmtV(res.per[r.id].V)}`,
+ `I = ${fmtI(res.per[r.id].I)}`,
+ `P = ${fmtP(res.per[r.id].P)}`
+ ], popupPos, () => { selectedIds.delete(r.id); update(); }, r.id));
+ }
+ }
+ }
+ });
+ } else {
+ // Parallel (legacy global view) - not used; kept for reference
+ const n = list.length;
+ if (n === 0) {
+ // With no branches, render intact rectangle (no fork gap)
+ drawRectangleLoop();
+ return;
+ }
+ const busInset = Math.min(320, (rightX - leftX) * 0.42); // bring buses further inward to shorten branches
+ const xL = leftX + busInset;
+ const xR = rightX - busInset;
+ const yMain = topY;
+ // Break the top wire between fork and rejoin to avoid bypass
+ drawRectangleLoop({ topGap: [xL, xR] });
+
+ // Fork/rejoin markers on the main wire
+ svg.appendChild(dot(xL, yMain));
+ svg.appendChild(dot(xR, yMain));
+
+ // Determine lanes above and below
+ const nAbove = Math.floor(n / 2);
+ const nBelow = n - nAbove;
+ const laneGap = 24; // even shorter vertical runs
+ const aboveYs = Array.from({ length: nAbove }, (_, i) => yMain - laneGap * (i + 1));
+ const belowYs = Array.from({ length: nBelow }, (_, i) => yMain + laneGap * (i + 1));
+
+ const yTopMost = aboveYs.length ? aboveYs[aboveYs.length - 1] : yMain;
+ const yBottomMost = belowYs.length ? belowYs[belowYs.length - 1] : yMain;
+
+ // Draw compact bus bars up and down from the main wire to the extreme lanes
+ if (yTopMost !== yMain) {
+ svg.appendChild(line(xL, yMain, xL, yTopMost));
+ svg.appendChild(line(xR, yMain, xR, yTopMost));
+ }
+ if (yBottomMost !== yMain) {
+ svg.appendChild(line(xL, yMain, xL, yBottomMost));
+ svg.appendChild(line(xR, yMain, xR, yBottomMost));
+ }
+
+ // Assign resistors to lanes: fill above first, then below
+ let idx = 0;
+ const lanes = [];
+ for (let i = 0; i < nAbove && idx < n; i++, idx++) lanes.push({ id: list[idx].id, y: aboveYs[i], pos: "above" });
+ for (let i = 0; i < nBelow && idx < n; i++, idx++) lanes.push({ id: list[idx].id, y: belowYs[i], pos: "below" });
+
+ // Render each branch as a horizontal resistor between bus bars
+ const length = xR - xL;
+ lanes.forEach(l => {
+ const rId = l.id;
+ const comp = list.find(x => x.id === rId);
+ const sel = selectedIds.has(rId);
+ const f = brightnessMap(Math.max(0, res.per[rId].P));
+ const g = componentHorizontal(comp?.type || "resistor", (xL + xR) / 2, l.y, length, f, sel);
+ g.dataset.resistorId = rId;
+ g.addEventListener("click", (e) => {
+ e.stopPropagation();
+ if (selectedIds.has(rId)) selectedIds.delete(rId); else selectedIds.add(rId);
+ update();
+ });
+ g.addEventListener("contextmenu", (e) => {
+ e.preventDefault();
+ showContextMenu(e.clientX, e.clientY, rId);
+ });
+ svg.appendChild(g);
+
+ if (sel && !USE_EXTERNAL_PANEL) {
+ svg.appendChild(popupBox((xL + xR) / 2, l.y, [
+ `R = ${fmtR(comp?.R ?? 0)}`,
+ `V = ${fmtV(res.per[rId].V)}`,
+ `I = ${fmtI(res.per[rId].I)}`,
+ `P = ${fmtP(res.per[rId].P)}`
+ ], l.pos === "above" ? "above" : "below", () => { selectedIds.delete(rId); update(); }, rId));
+ }
+ });
+ }
+ }
+
+ function update() {
+ const V = parseFloat(elV.value);
+ // Always render with localized-parallel series layout; add-mode is separate
+ const mode = "series";
+
+ elVVal.textContent = fmt(V, 1);
+
+ let res = computeSeriesN(V, resistors);
+
+ // Dynamically expand vertical size based on effective series width
+ // Units: comp=1; series=sum; parallel=1+max(branch)
+ function unitsOf(node) {
+ if (!node) return 0;
+ if (node.kind === "comp") return 1;
+ if (node.kind === "series" && Array.isArray(node.children)) {
+ return node.children.reduce((s, ch) => s + unitsOf(ch), 0);
+ }
+ if (node.kind === "parallel" && Array.isArray(node.children)) {
+ const branchUnits = node.children.map(ch => unitsOf(ch));
+ const longest = branchUnits.length ? Math.max(...branchUnits) : 0;
+ return 1 + longest;
+ }
+ return 0;
+ }
+ function totalUnitsTopLevel(list) {
+ return (list || []).reduce((s, n) => s + unitsOf(n), 0);
+ }
+ const totalUnits = totalUnitsTopLevel(resistors);
+ const extraRows = Math.max(0, totalUnits - 13); // start expanding at 14
+ // After threshold, expand downward by 0.5 × resistor length per extra unit
+ const extraPx = extraRows * (RES_RENDER_LEN / 2);
+ H = BASE_H + extraPx;
+ // Update SVG viewBox and height only when threshold reached (avoid initial shift)
+ if (svg && extraRows > 0) {
+ svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
+ // Use a stable base ratio so height growth is linear (no compounding)
+ const baseViewH = BASE_H;
+ let baseCssH = parseFloat(svg.dataset.baseCssH || svg.getAttribute("height") || "436");
+ if (!svg.dataset.baseCssH) {
+ svg.dataset.baseCssH = String(baseCssH);
+ }
+ const ratio = baseCssH / baseViewH;
+ const newCssH = Math.max(baseCssH, Math.round(H * ratio));
+ svg.setAttribute("height", String(newCssH));
+ }
+ recalcLayout();
+
+ elTotalR.textContent = fmtR(res.Rtot);
+ elTotalI.textContent = fmtI(res.Itot);
+ elTotalP.textContent = fmtP(res.Ptot);
+
+ // R1/R2 side cards removed
+
+ render(mode, V, resistors, res);
+ updateSelectionPanel(res);
+ // Toggle remove button availability
+ if (elRemove) {
+ elRemove.disabled = selectedIds.size === 0;
+ }
+ }
+
+ [elV, ...elMode].forEach(input => {
+ input.addEventListener("input", update);
+ input.addEventListener("change", update);
+ });
+
+ // Removed R1/R2 input bindings
+
+ function handleAdd(type) {
+ const R = Math.max(0.01, parseFloat(elNewR.value || "0"));
+ const addMode = (elAddMode.find(r => r.checked)?.value) || "series";
+ const sel = Array.from(selectedIds);
+ const id = `r${nextId++}`;
+ const comp = { kind: "comp", id, R, type };
+ if (sel.length === 0) {
+ // Fallback behavior when nothing selected
+ if (addMode === "series") {
+ resistors.push(comp);
+ update();
+ } else {
+ showToast("Select components within the same series row or the same parallel group to add in parallel.");
+ }
+ return;
+ }
+ const group = computeSelectionGroup(sel);
+ if (!group) { showToast("Couldn't resolve selection."); return; }
+ if (addMode === "series") {
+ if (group.kind === "parallel-group") {
+ insertSeriesAfterTopIndex(comp, group.topIndex);
+ } else if (group.kind === "branch-series") {
+ const insertAfter = group.seriesSpan ? group.seriesSpan[1] : 0;
+ insertIntoBranchSeries(comp, group.parallelNode, group.branchIndex, group.seriesNode, insertAfter);
+ } else if (group.kind === "top-series") {
+ insertSeriesAfterTopIndex(comp, group.topSpan[1]);
+ }
+ update();
+ return;
+ }
+ // addMode === 'parallel'
+ if (group.kind === "parallel-group") {
+ addBranchToParallel(comp, group.parallelNode);
+ update();
+ return;
+ }
+ if (group.kind === "branch-series") {
+ addBranchToParallel(comp, group.parallelNode);
+ update();
+ return;
+ }
+ // top-series: allow wrapping a single top-level component into a new parallel group
+ if (group.kind === "top-series") {
+ if (sel.length === 1) {
+ const path = findPathToComponent(sel[0]);
+ if (!path || typeof path.topIndex !== "number") {
+ showToast("Couldn't resolve selection.");
+ return;
+ }
+ const target = resistors[path.topIndex];
+ if (!target) {
+ showToast("Couldn't locate the selected component.");
+ return;
+ }
+ if (target.kind === "parallel") {
+ // Selected a top-level parallel group (edge case): add a new branch
+ addBranchToParallel(comp, target);
+ } else {
+ // Wrap top-level component with a new parallel group
+ const oldComp = target.kind === "comp" ? target : { kind: "comp", id: target.id, R: target.R, type: target.type };
+ resistors.splice(path.topIndex, 1, { kind: "parallel", children: [oldComp, comp] });
+ }
+ update();
+ return;
+ }
+ // Multiple top-level items selected: allow wrapping a contiguous series segment
+ const paths = sel.map(findPathToComponent).filter(Boolean);
+ if (paths.length !== sel.length) {
+ showToast("Couldn't resolve selection.");
+ return;
+ }
+ // All must be top-level comps (no parallel ancestor)
+ if (!paths.every(p => p.parallelNode === null)) {
+ showToast("Select components within the same series row or the same parallel group to add in parallel.");
+ return;
+ }
+ const topIdx = paths.map(p => p.topIndex).sort((a,b)=>a-b);
+ const lo = topIdx[0];
+ const hi = topIdx[topIdx.length - 1];
+ // Check contiguity and that all items in [lo..hi] are selected and are comps
+ const selectedTopIndexSet = new Set(topIdx);
+ for (let k = lo; k <= hi; k++) {
+ const node = resistors[k];
+ if (!node || node.kind !== "comp" || !selectedTopIndexSet.has(k)) {
+ showToast("Select a contiguous set of resistors in one series row.");
+ return;
+ }
+ }
+ wrapTopSeriesSegmentAsParallel(lo, hi, comp);
+ update();
+ return;
+ }
+ // Fallback
+ showToast("Select components within the same series row or the same parallel group to add in parallel.");
+ }
+ // Type selection toggles (persistent)
+ if (elAddResistor) {
+ elAddResistor.addEventListener("click", () => {
+ currentType = "resistor";
+ elAddResistor.classList.add("btn--selected");
+ if (elAddBulb) elAddBulb.classList.remove("btn--selected");
+ if (elAddComponentBtn) elAddComponentBtn.textContent = "Add Resistor";
+ });
+ }
+ if (elAddBulb) {
+ elAddBulb.addEventListener("click", () => {
+ currentType = "bulb";
+ elAddBulb.classList.add("btn--selected");
+ if (elAddResistor) elAddResistor.classList.remove("btn--selected");
+ if (elAddComponentBtn) elAddComponentBtn.textContent = "Add Bulb";
+ });
+ }
+ // Add component action
+ if (elAddComponentBtn) {
+ elAddComponentBtn.textContent = "Add Resistor"; // default
+ elAddComponentBtn.addEventListener("click", () => handleAdd(currentType));
+ }
+ if (elRemove) {
+ elRemove.addEventListener("click", () => {
+ if (selectedIds.size === 0) return;
+ const ids = Array.from(selectedIds);
+ ids.forEach(removeById);
+ selectedIds.clear();
+ update();
+ });
+ }
+ if (elReset) {
+ elReset.addEventListener("click", () => {
+ resistors.length = 0;
+ selectedIds.clear();
+ nextId = 1;
+ update();
+ });
+ }
+
+ function removeById(id) {
+ const idx = resistors.findIndex(r => r.id === id);
+ if (idx >= 0) {
+ resistors.splice(idx, 1);
+ selectedIds.delete(id);
+ return;
+ }
+ // search inside parallel groups
+ for (let i = 0; i < resistors.length; i++) {
+ const it = resistors[i];
+ if (it.kind === "parallel") {
+ for (let j = 0; j < it.children.length; j++) {
+ const ch = it.children[j];
+ if (ch.kind === "comp" && ch.id === id) {
+ it.children.splice(j, 1);
+ selectedIds.delete(id);
+ if (it.children.length === 1) {
+ // collapse to single component
+ resistors.splice(i, 1, it.children[0]);
+ }
+ return;
+ }
+ if (ch.kind === "series") {
+ const si = ch.children.findIndex(cc => cc.id === id);
+ if (si !== -1) {
+ ch.children.splice(si, 1);
+ selectedIds.delete(id);
+ if (ch.children.length === 1) {
+ it.children[j] = ch.children[0];
+ }
+ if (it.children.length === 1) {
+ resistors.splice(i, 1, it.children[0]);
+ }
+ return;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ update();
+})();
+
+/**
+ * Circuit Simulator - Core Logic and Physics Calculations
+ * Handles circuit model, component management, and electrical calculations
+ */
+
+class Component {
+ constructor(id, type, resistance, position = null) {
+ this.id = id;
+ this.type = type; // 'resistor', 'bulb-10', 'bulb-15', 'bulb-20'
+ this.resistance = resistance; // in Ohms
+ this.current = 0; // in Amperes
+ this.voltage = 0; // in Volts
+ this.power = 0; // in Watts
+ this.position = position; // {seriesIndex, parallelIndex}
+ }
+
+ isBulb() {
+ return this.type.startsWith('bulb');
+ }
+
+ getDisplayName() {
+ if (this.type === 'resistor') {
+ return `Resistor (${this.resistance}Ω)`;
+ } else if (this.type.startsWith('bulb')) {
+ return `Light Bulb (${this.resistance}Ω)`;
+ }
+ return 'Component';
+ }
+}
+
+class CircuitSimulator {
+ constructor() {
+ this.voltage = 12; // Default 12V
+ this.components = [];
+ this.nextId = 1;
+
+ // Circuit topology: array of series positions, each can have parallel components
+ // Structure: [ [comp1], [comp2, comp3], [comp4] ]
+ // Represents: comp1 --- (comp2 || comp3) --- comp4
+ this.topology = [];
+
+ // Overall circuit values
+ this.totalResistance = 0;
+ this.totalCurrent = 0;
+ this.totalPower = 0;
+
+ // Initialize with default circuit (12V, 10Ω resistor)
+ this.initializeDefaultCircuit();
+ }
+
+ initializeDefaultCircuit() {
+ const defaultResistor = new Component(
+ this.nextId++,
+ 'resistor',
+ 10,
+ { seriesIndex: 0, parallelIndex: 0 }
+ );
+ this.components.push(defaultResistor);
+ this.topology = [[defaultResistor]];
+ this.calculateCircuit();
+ }
+
+ setVoltage(voltage) {
+ this.voltage = parseFloat(voltage);
+ this.calculateCircuit();
+ }
+
+ addComponent(type, resistance, placementMode = 'series', targetPosition = null) {
+ const component = new Component(this.nextId++, type, resistance);
+ this.components.push(component);
+
+ if (placementMode === 'series') {
+ // Insert as a new series position after target (if provided), else at end
+ let insertIndex = this.topology.length;
+ if (targetPosition && typeof targetPosition.seriesIndex === 'number') {
+ insertIndex = Math.min(targetPosition.seriesIndex + 1, this.topology.length);
+ }
+ this.topology.splice(insertIndex, 0, [component]);
+ component.position = { seriesIndex: insertIndex, parallelIndex: 0 };
+ // Reindex positions after insertion
+ this.updatePositions();
+ } else {
+ // Add in parallel to a target series position if provided, else to the last
+ if (this.topology.length === 0) {
+ // No components yet, add as first series position
+ component.position = { seriesIndex: 0, parallelIndex: 0 };
+ this.topology.push([component]);
+ } else {
+ const seriesIndex = (targetPosition && typeof targetPosition.seriesIndex === 'number')
+ ? Math.max(0, Math.min(targetPosition.seriesIndex, this.topology.length - 1))
+ : this.topology.length - 1;
+ // Insert after the target parallelIndex if provided, else at end
+ const afterIndex = (targetPosition && typeof targetPosition.parallelIndex === 'number')
+ ? Math.max(0, Math.min(targetPosition.parallelIndex + 1, this.topology[seriesIndex].length))
+ : this.topology[seriesIndex].length;
+ this.topology[seriesIndex].splice(afterIndex, 0, component);
+ component.position = { seriesIndex, parallelIndex: afterIndex };
+ // Reindex positions after insertion
+ this.updatePositions();
+ }
+ }
+
+ this.calculateCircuit();
+ return component;
+ }
+
+ addComponentToSelected(type, resistance, placementMode, selectedComponentIds) {
+ if (selectedComponentIds.length === 0) {
+ // Fallback to old behavior
+ return this.addComponent(type, resistance, placementMode);
+ }
+
+ const newComponent = new Component(this.nextId++, type, resistance);
+ this.components.push(newComponent);
+
+ // Get the first selected component to determine position
+ const selectedComponent = this.getComponentById(selectedComponentIds[0]);
+ if (!selectedComponent) {
+ // Fallback
+ this.topology.push([newComponent]);
+ newComponent.position = { seriesIndex: this.topology.length - 1, parallelIndex: 0 };
+ this.calculateCircuit();
+ return newComponent;
+ }
+
+ const { seriesIndex, parallelIndex } = selectedComponent.position;
+
+ if (placementMode === 'series') {
+ // Insert new series position after the selected component
+ const newSeriesIndex = seriesIndex + 1;
+ this.topology.splice(newSeriesIndex, 0, [newComponent]);
+ newComponent.position = { seriesIndex: newSeriesIndex, parallelIndex: 0 };
+
+ // Update positions of all components after the insertion
+ this.updatePositions();
+ } else {
+ // Add in parallel to the selected component(s) directly adjacent to selection
+ // Insert right after the selected component within the same series position
+ const insertIndex = parallelIndex + 1;
+ this.topology[seriesIndex].splice(insertIndex, 0, newComponent);
+ newComponent.position = { seriesIndex, parallelIndex: insertIndex };
+ // Update positions for all components in that series group
+ this.updatePositions();
+ }
+
+ this.calculateCircuit();
+ return newComponent;
+ }
+
+ removeComponent(componentId) {
+ const component = this.components.find(c => c.id === componentId);
+ if (!component) return false;
+
+ const { seriesIndex, parallelIndex } = component.position;
+
+ // Remove from topology
+ this.topology[seriesIndex].splice(parallelIndex, 1);
+
+ // If series position is now empty, remove it
+ if (this.topology[seriesIndex].length === 0) {
+ this.topology.splice(seriesIndex, 1);
+ }
+
+ // Remove from components array
+ const index = this.components.findIndex(c => c.id === componentId);
+ this.components.splice(index, 1);
+
+ // Update positions for all remaining components
+ this.updatePositions();
+
+ // Recalculate if there are still components
+ if (this.components.length > 0) {
+ this.calculateCircuit();
+ } else {
+ this.resetCircuitValues();
+ }
+
+ return true;
+ }
+
+ moveComponent(componentId, newSeriesIndex, newParallelIndex) {
+ const component = this.components.find(c => c.id === componentId);
+ if (!component) return false;
+
+ const { seriesIndex, parallelIndex } = component.position;
+
+ // Remove from old position
+ this.topology[seriesIndex].splice(parallelIndex, 1);
+ if (this.topology[seriesIndex].length === 0) {
+ this.topology.splice(seriesIndex, 1);
+ }
+
+ // Adjust indices if necessary
+ if (newSeriesIndex > seriesIndex) {
+ newSeriesIndex--;
+ }
+
+ // Ensure series position exists
+ while (this.topology.length <= newSeriesIndex) {
+ this.topology.push([]);
+ }
+
+ // Add to new position
+ if (newParallelIndex >= this.topology[newSeriesIndex].length) {
+ this.topology[newSeriesIndex].push(component);
+ } else {
+ this.topology[newSeriesIndex].splice(newParallelIndex, 0, component);
+ }
+
+ // Update all positions
+ this.updatePositions();
+ this.calculateCircuit();
+
+ return true;
+ }
+
+ updatePositions() {
+ this.topology.forEach((seriesGroup, seriesIndex) => {
+ seriesGroup.forEach((component, parallelIndex) => {
+ component.position = { seriesIndex, parallelIndex };
+ });
+ });
+ }
+
+ calculateCircuit() {
+ if (this.components.length === 0) {
+ this.resetCircuitValues();
+ return;
+ }
+
+ // Step 1: Calculate equivalent resistance for each series position
+ const seriesResistances = [];
+ this.topology.forEach(parallelGroup => {
+ if (parallelGroup.length === 1) {
+ // Single component, use its resistance
+ seriesResistances.push(parallelGroup[0].resistance);
+ } else {
+ // Multiple components in parallel: 1/R_eq = 1/R1 + 1/R2 + ...
+ const reciprocalSum = parallelGroup.reduce(
+ (sum, comp) => sum + (1 / comp.resistance),
+ 0
+ );
+ seriesResistances.push(1 / reciprocalSum);
+ }
+ });
+
+ // Step 2: Calculate total resistance (sum of series resistances)
+ this.totalResistance = seriesResistances.reduce((sum, r) => sum + r, 0);
+
+ // Step 3: Calculate total current using Ohm's Law: I = V / R
+ this.totalCurrent = this.voltage / this.totalResistance;
+
+ // Step 4: Calculate total power: P = V * I
+ this.totalPower = this.voltage * this.totalCurrent;
+
+ // Step 5: Calculate voltage and current for each component
+ this.topology.forEach((parallelGroup, seriesIndex) => {
+ // Voltage drop across this series position
+ const seriesVoltage = this.totalCurrent * seriesResistances[seriesIndex];
+
+ parallelGroup.forEach(component => {
+ // In parallel, voltage is the same across all components
+ component.voltage = seriesVoltage;
+
+ // Current through each component: I = V / R
+ component.current = component.voltage / component.resistance;
+
+ // Power dissipated: P = I^2 * R (or V * I)
+ component.power = component.current * component.current * component.resistance;
+ });
+ });
+ }
+
+ resetCircuitValues() {
+ this.totalResistance = 0;
+ this.totalCurrent = 0;
+ this.totalPower = 0;
+ this.components.forEach(comp => {
+ comp.current = 0;
+ comp.voltage = 0;
+ comp.power = 0;
+ });
+ }
+
+ reset() {
+ this.components = [];
+ this.topology = [];
+ this.nextId = 1;
+ this.voltage = 12;
+ this.initializeDefaultCircuit();
+ }
+
+ getComponentById(id) {
+ return this.components.find(c => c.id === id);
+ }
+
+ getAllComponents() {
+ return this.components;
+ }
+
+ getTopology() {
+ return this.topology;
+ }
+
+ getStats() {
+ return {
+ voltage: this.voltage,
+ totalCurrent: this.totalCurrent,
+ totalResistance: this.totalResistance,
+ totalPower: this.totalPower
+ };
+ }
+
+ getComponentStats(componentId) {
+ const component = this.getComponentById(componentId);
+ if (!component) return null;
+
+ return {
+ id: component.id,
+ name: component.getDisplayName(),
+ type: component.type,
+ resistance: component.resistance,
+ voltage: component.voltage,
+ current: component.current,
+ power: component.power,
+ position: component.position
+ };
+ }
+
+ // Helper method to get maximum power among all bulbs (for brightness normalization)
+ getMaxBulbPower() {
+ const bulbs = this.components.filter(c => c.isBulb());
+ if (bulbs.length === 0) return 0;
+ return Math.max(...bulbs.map(b => b.power));
+ }
+}
+
+// Global circuit instance
+let circuit = new CircuitSimulator();
+
diff --git a/public/static/physics/widgets/Dynamic Circuit/script.js b/public/static/physics/widgets/Dynamic Circuit/script.js
new file mode 100644
index 0000000..37b9def
--- /dev/null
+++ b/public/static/physics/widgets/Dynamic Circuit/script.js
@@ -0,0 +1,397 @@
+// Interactive functionality for the Honey Adventures website
+
+document.addEventListener('DOMContentLoaded', function() {
+ // Initialize all interactive features
+ initializePopups();
+ initializeImagePopups();
+ initializeGallery();
+ initializeSmoothScrolling();
+ initializeAnimations();
+});
+
+// Popup content for health benefits
+const popupContent = {
+ healing: {
+ title: "🩹 Natural Healing Powers!",
+ content: `
+ Honey is like a superhero band-aid! Here's why:
+
+ - 🦠 Fights Germs: Honey has special powers that kill bad bacteria!
+ - 🩹 Heals Cuts: It helps cuts and scrapes heal faster!
+ - 🔥 Burns Relief: Helps soothe burns and makes them feel better!
+ - 🦶 Blister Care: Perfect for helping blisters heal!
+
+ Fun Fact: Doctors sometimes use special medical honey to help heal wounds!
+ `
+ },
+ energy: {
+ title: "⚡ Energy Supercharge!",
+ content: `
+ Honey gives you natural energy to play all day long!
+
+ - 🏃♀️ Quick Energy: Gives you instant energy to run and play!
+ - 🧠 Brain Power: Helps your brain think clearly and focus!
+ - 💪 Strong Muscles: Gives your muscles energy to be strong!
+ - 😊 Happy Mood: Natural sugars make you feel happy and energetic!
+
+ Pro Tip: Eat a spoonful of honey before playing sports for extra energy!
+ `
+ },
+ cough: {
+ title: "🤧 Cough Relief Magic!",
+ content: `
+ Honey is nature's cough medicine!
+
+ - 🫁 Soothes Throat: Coats your throat to make it feel better!
+ - 🌙 Better Sleep: Helps you sleep better when you're sick!
+ - 🍯 Natural Medicine: No yucky chemicals, just pure sweetness!
+ - 👶 Kid Friendly: Tastes so much better than regular medicine!
+
+ Recipe: Mix honey with warm water and lemon for the best cough syrup ever!
+ `
+ },
+ vitamins: {
+ title: "🍯 Full of Good Stuff!",
+ content: `
+ Honey is packed with vitamins and minerals that make you healthy!
+
+ - 💎 Vitamins: Has Vitamin C, B vitamins, and more!
+ - ⚡ Minerals: Contains iron, calcium, and potassium!
+ - 🛡️ Antioxidants: Special molecules that protect your body!
+ - 🌿 Natural Goodness: Made by bees from flower nectar!
+
+ Amazing Fact: Honey contains over 180 different healthy substances!
+ `
+ }
+};
+
+// Initialize popup modals
+function initializePopups() {
+ const modal = document.getElementById('popup-modal');
+ const closeBtn = document.querySelector('.close');
+ const popupContent = document.getElementById('popup-content');
+
+ // Add click listeners to all info cards
+ const infoCards = document.querySelectorAll('.info-card');
+ infoCards.forEach(card => {
+ const button = card.querySelector('.learn-more-btn');
+ button.addEventListener('click', function(e) {
+ e.stopPropagation();
+ const cardType = card.getAttribute('data-popup');
+ showPopup(cardType);
+ });
+ });
+
+ // Close modal when clicking the X
+ closeBtn.addEventListener('click', closeModal);
+
+ // Close modal when clicking outside
+ modal.addEventListener('click', function(e) {
+ if (e.target === modal) {
+ closeModal();
+ }
+ });
+
+ // Close modal with Escape key
+ document.addEventListener('keydown', function(e) {
+ if (e.key === 'Escape' && modal.style.display === 'block') {
+ closeModal();
+ }
+ });
+}
+
+// Show popup with specific content
+function showPopup(type) {
+ const modal = document.getElementById('popup-modal');
+ const popupContent = document.getElementById('popup-content');
+
+ if (popupContent && popupContent[type]) {
+ popupContent.innerHTML = `
+ ${popupContent[type].title}
+ ${popupContent[type].content}
+ `;
+ modal.style.display = 'block';
+ document.body.style.overflow = 'hidden'; // Prevent background scrolling
+ }
+}
+
+// Close the modal
+function closeModal() {
+ const modal = document.getElementById('popup-modal');
+ modal.style.display = 'none';
+ document.body.style.overflow = 'auto'; // Restore scrolling
+}
+
+// Initialize image popups
+function initializeImagePopups() {
+ const beeImage = document.getElementById('bee-image');
+ const beePopup = document.getElementById('bee-popup');
+
+ if (beeImage && beePopup) {
+ beeImage.addEventListener('click', function() {
+ beePopup.style.display = 'block';
+ setTimeout(() => {
+ beePopup.style.opacity = '1';
+ }, 10);
+ });
+
+ beePopup.addEventListener('click', function() {
+ beePopup.style.opacity = '0';
+ setTimeout(() => {
+ beePopup.style.display = 'none';
+ }, 300);
+ });
+ }
+}
+
+// Initialize honey gallery interactions
+function initializeGallery() {
+ const galleryItems = document.querySelectorAll('.gallery-item');
+
+ galleryItems.forEach(item => {
+ item.addEventListener('click', function() {
+ const honeyType = this.getAttribute('data-honey');
+ showHoneyInfo(honeyType);
+ });
+ });
+}
+
+// Show honey type information
+function showHoneyInfo(type) {
+ const honeyInfo = {
+ clover: {
+ title: "🍀 Clover Honey",
+ description: "Sweet and mild flavor, perfect for everyday use! Made from clover flowers."
+ },
+ wildflower: {
+ title: "🌸 Wildflower Honey",
+ description: "Rich and complex flavor from many different wildflowers!"
+ },
+ orange: {
+ title: "🍊 Orange Blossom Honey",
+ description: "Citrusy and light, made from beautiful orange blossoms!"
+ },
+ manuka: {
+ title: "🇳🇿 Manuka Honey",
+ description: "Super special honey from New Zealand with extra healing powers!"
+ }
+ };
+
+ if (honeyInfo[type]) {
+ const modal = document.getElementById('popup-modal');
+ const popupContent = document.getElementById('popup-content');
+
+ popupContent.innerHTML = `
+ ${honeyInfo[type].title}
+ ${honeyInfo[type].description}
+ Fun Fact: Each type of honey tastes different because bees visit different flowers!
+ `;
+
+ modal.style.display = 'block';
+ document.body.style.overflow = 'hidden';
+ }
+}
+
+// Initialize smooth scrolling for navigation
+function initializeSmoothScrolling() {
+ const navLinks = document.querySelectorAll('.nav-link');
+
+ navLinks.forEach(link => {
+ link.addEventListener('click', function(e) {
+ e.preventDefault();
+ const targetId = this.getAttribute('href');
+ const targetSection = document.querySelector(targetId);
+
+ if (targetSection) {
+ targetSection.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start'
+ });
+ }
+ });
+ });
+}
+
+// Initialize animations and effects
+function initializeAnimations() {
+ // Add floating animation to bees in footer
+ const footerBees = document.querySelectorAll('.bouncing-bee');
+ footerBees.forEach((bee, index) => {
+ bee.addEventListener('mouseenter', function() {
+ this.style.animation = 'none';
+ this.style.transform = 'scale(1.5) rotate(360deg)';
+ setTimeout(() => {
+ this.style.animation = 'bounce 1s infinite';
+ this.style.transform = '';
+ }, 500);
+ });
+ });
+
+ // Add click effects to recipe cards
+ const recipeCards = document.querySelectorAll('.recipe-card');
+ recipeCards.forEach(card => {
+ card.addEventListener('click', function() {
+ this.style.transform = 'scale(0.95)';
+ setTimeout(() => {
+ this.style.transform = '';
+ }, 150);
+ });
+ });
+
+ // Add hover effects to fact cards
+ const factCards = document.querySelectorAll('.fact-card');
+ factCards.forEach(card => {
+ card.addEventListener('mouseenter', function() {
+ this.style.animation = 'none';
+ setTimeout(() => {
+ this.style.animation = '';
+ }, 100);
+ });
+ });
+
+ // Add sparkle effect to honey-related elements
+ addSparkleEffect();
+}
+
+// Add sparkle effect to honey elements
+function addSparkleEffect() {
+ const honeyElements = document.querySelectorAll('.info-card, .fact-card, .recipe-card');
+
+ honeyElements.forEach(element => {
+ element.addEventListener('mouseenter', function() {
+ createSparkles(this);
+ });
+ });
+}
+
+// Create sparkle animation
+function createSparkles(element) {
+ const sparkleCount = 5;
+
+ for (let i = 0; i < sparkleCount; i++) {
+ const sparkle = document.createElement('div');
+ sparkle.innerHTML = '✨';
+ sparkle.style.position = 'absolute';
+ sparkle.style.fontSize = '1.5rem';
+ sparkle.style.pointerEvents = 'none';
+ sparkle.style.zIndex = '1000';
+ sparkle.style.animation = 'sparkle 1s ease-out forwards';
+
+ const rect = element.getBoundingClientRect();
+ sparkle.style.left = Math.random() * rect.width + rect.left + 'px';
+ sparkle.style.top = Math.random() * rect.height + rect.top + 'px';
+
+ document.body.appendChild(sparkle);
+
+ setTimeout(() => {
+ sparkle.remove();
+ }, 1000);
+ }
+}
+
+// Add sparkle animation CSS
+const sparkleStyle = document.createElement('style');
+sparkleStyle.textContent = `
+ @keyframes sparkle {
+ 0% {
+ opacity: 1;
+ transform: scale(0) rotate(0deg);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1) rotate(180deg);
+ }
+ 100% {
+ opacity: 0;
+ transform: scale(0) rotate(360deg);
+ }
+ }
+`;
+document.head.appendChild(sparkleStyle);
+
+// Add interactive sound effects (visual feedback)
+function addClickEffect(element) {
+ element.style.transform = 'scale(0.95)';
+ element.style.transition = 'transform 0.1s ease';
+
+ setTimeout(() => {
+ element.style.transform = '';
+ }, 100);
+}
+
+// Add click effects to buttons
+document.addEventListener('click', function(e) {
+ if (e.target.matches('button, .nav-link, .info-card, .fact-card')) {
+ addClickEffect(e.target);
+ }
+});
+
+// Add parallax effect to background
+window.addEventListener('scroll', function() {
+ const scrolled = window.pageYOffset;
+ const header = document.querySelector('.header');
+
+ if (header) {
+ header.style.transform = `translateY(${scrolled * 0.5}px)`;
+ }
+});
+
+// Add loading animation
+window.addEventListener('load', function() {
+ const loadingElements = document.querySelectorAll('.info-card, .fact-card, .recipe-card');
+
+ loadingElements.forEach((element, index) => {
+ setTimeout(() => {
+ element.style.opacity = '0';
+ element.style.transform = 'translateY(50px)';
+ element.style.transition = 'all 0.5s ease';
+
+ setTimeout(() => {
+ element.style.opacity = '1';
+ element.style.transform = 'translateY(0)';
+ }, 100);
+ }, index * 100);
+ });
+});
+
+// Add Easter egg - secret bee dance
+let clickCount = 0;
+const secretBee = document.querySelector('.main-title');
+
+if (secretBee) {
+ secretBee.addEventListener('click', function() {
+ clickCount++;
+ if (clickCount === 5) {
+ triggerSecretDance();
+ clickCount = 0;
+ }
+ });
+}
+
+function triggerSecretDance() {
+ const bees = document.querySelectorAll('.bee');
+ bees.forEach((bee, index) => {
+ bee.style.animation = 'none';
+ bee.style.transform = 'rotate(720deg) scale(1.5)';
+ bee.style.transition = 'transform 2s ease';
+
+ setTimeout(() => {
+ bee.style.transform = '';
+ bee.style.animation = 'fly 6s infinite linear';
+ }, 2000);
+ });
+
+ // Show secret message
+ const modal = document.getElementById('popup-modal');
+ const popupContent = document.getElementById('popup-content');
+
+ popupContent.innerHTML = `
+ 🐝 Secret Bee Dance Unlocked! 🐝
+ You discovered the secret bee dance! The bees are celebrating!
+ 🎉 You're now an official Bee Friend! 🎉
+ `;
+
+ modal.style.display = 'block';
+ document.body.style.overflow = 'hidden';
+}
+
diff --git a/public/static/physics/widgets/Dynamic Circuit/styles.css b/public/static/physics/widgets/Dynamic Circuit/styles.css
new file mode 100644
index 0000000..978d482
--- /dev/null
+++ b/public/static/physics/widgets/Dynamic Circuit/styles.css
@@ -0,0 +1,383 @@
+/* ----------------------------------------------------------------
+ Dynamic Circuit – Harvard‑Westlake style
+ Matches the admin app and style guide at https://learnhw.web.app/admin
+ using the same typography, colors, and button/form patterns.
+------------------------------------------------------------------- */
+
+/* Reset & tokens */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+:root {
+ --hw-black: #231f20;
+ --hw-red: #c8102e;
+ --hw-gold: #f0b323;
+
+ --hw-gray-900: #1f2937;
+ --hw-gray-800: #374151;
+ --hw-gray-700: #4b5563;
+ --hw-gray-600: #6b7280;
+ --hw-gray-500: #9ca3af;
+ --hw-gray-400: #cbd5e1;
+ --hw-gray-300: #e5e7eb;
+ --hw-gray-200: #edf0f3;
+ --hw-gray-100: #f4f6f8;
+
+ --hw-surface: #ffffff;
+ --hw-border: #e5e7eb;
+
+ --hw-font-sans: "Source Sans 3", "Source Sans Pro", "Source Sans",
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
+ Arial, sans-serif;
+
+ --hw-radius: 8px;
+ --hw-radius-sm: 6px;
+
+ --hw-focus-ring: 0 0 0 3px rgba(200, 16, 46, 0.18);
+
+ /* Fluid type (simplified) */
+ --step--1: clamp(0.85rem, 0.78rem + 0.2vw, 0.95rem);
+ --step-0: clamp(1rem, 0.95rem + 0.3vw, 1.1rem);
+ --step-1: clamp(1.25rem, 1.15rem + 0.6vw, 1.5rem);
+ --step-2: clamp(1.5rem, 1.35rem + 1vw, 1.875rem);
+ --step-3: clamp(1.875rem, 1.6rem + 1.5vw, 2.25rem);
+}
+
+html,
+body {
+ height: 100%;
+}
+
+body {
+ margin: 0;
+ font-family: var(--hw-font-sans);
+ font-size: var(--step-0);
+ line-height: 1.6;
+ color: #000;
+ background: #f2f0ec;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.page {
+ padding-block: 1.25rem;
+}
+
+.container {
+ width: min(960px, 100% - 2rem);
+ margin: 0 auto;
+}
+
+.app-wrap {
+ display: block;
+}
+
+/* Typography */
+.h1 {
+ font-size: var(--step-3);
+ line-height: 1.2;
+ font-weight: 900;
+ margin: 0 0 0.75rem;
+}
+
+.h2 {
+ font-size: var(--step-2);
+ line-height: 1.25;
+ font-weight: 800;
+}
+
+.h3 {
+ font-size: var(--step-1);
+ line-height: 1.3;
+ font-weight: 700;
+}
+
+.muted {
+ color: var(--hw-gray-600);
+ font-size: var(--step--1);
+}
+
+.label {
+ font-weight: 700;
+ font-size: var(--step--1);
+ margin-bottom: 0.2rem;
+ color: #000;
+}
+
+/* Layout: controls + readouts */
+.controls {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ gap: 0.75rem;
+ align-items: end;
+ margin-bottom: 1rem;
+}
+
+.controls .field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.controls .inline {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.readouts {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+
+.card {
+ background: var(--hw-surface);
+ padding: 0.75rem 0.85rem;
+ border-radius: 0;
+ border: none;
+ box-shadow: none;
+}
+
+.card strong {
+ display: block;
+ margin-bottom: 0.35rem;
+}
+
+.svg-wrap {
+ margin-top: 0.75rem;
+ background: var(--hw-surface);
+ border-radius: 0;
+ border: none;
+}
+
+.svg-wrap svg {
+ display: block;
+}
+
+.caption {
+ margin-top: 0.5rem;
+ text-align: center;
+ font-size: var(--step--1);
+ color: var(--hw-gray-700);
+}
+
+svg text {
+ user-select: none;
+ font-family: var(--hw-font-sans);
+}
+
+/* User guide card */
+.user-guide {
+ grid-column: 3 / span 2;
+ align-self: start;
+}
+
+.user-guide ul {
+ margin: 0.25rem 0 0;
+ padding-left: 1rem;
+}
+
+.user-guide li {
+ margin-bottom: 0.35rem;
+}
+
+@media (max-width: 720px) {
+ .controls {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .user-guide {
+ grid-column: 1 / -1;
+ }
+
+ .readouts {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+/* Form controls */
+.input,
+.select,
+input[type="number"],
+input[type="text"],
+select {
+ width: 100%;
+ min-height: 44px;
+ padding: 0.5rem 0.6rem;
+ border-radius: 0;
+ border: 1px solid var(--hw-border);
+ background: #fff;
+ color: #000;
+ font-family: inherit;
+ font-size: var(--step-0);
+ box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.02);
+}
+
+.input:focus,
+.select:focus,
+input[type="number"]:focus,
+input[type="text"]:focus,
+select:focus {
+ outline: none;
+ border-color: var(--hw-red);
+ box-shadow: var(--hw-focus-ring);
+}
+
+.range {
+ width: 100%;
+ margin-top: 0.25rem;
+ -webkit-appearance: none;
+ appearance: none;
+ height: 4px;
+ border-radius: 999px;
+ background: var(--hw-gray-300);
+}
+
+.range::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--hw-red);
+ border: 2px solid #fff;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
+ cursor: pointer;
+}
+
+.range::-moz-range-thumb {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--hw-red);
+ border: 2px solid #fff;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
+ cursor: pointer;
+}
+
+/* Buttons – aligned with admin (square corners, no border radius) */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 44px;
+ padding: 0.55rem 1rem;
+ background: var(--hw-red);
+ color: #fff;
+ border: none;
+ border-radius: 0;
+ font-weight: 600;
+ font-size: var(--step--1);
+ text-decoration: none;
+ cursor: pointer;
+ transition: background 0.2s ease, transform 0.2s ease,
+ box-shadow 0.2s ease;
+}
+
+.btn:hover {
+ background: #a60c24;
+ transform: translateY(-1px);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
+}
+
+.btn:disabled {
+ background: var(--hw-gray-400);
+ color: #fff;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+}
+
+.btn--outline {
+ background: transparent;
+ color: #000;
+ border: 1px solid var(--hw-border);
+}
+
+.btn--outline:hover {
+ background: var(--hw-red);
+ color: #fff;
+ border-color: var(--hw-red);
+}
+
+.btn--selected {
+ background: var(--hw-red);
+ color: #fff;
+ border-color: var(--hw-red);
+}
+
+/* Modal (ohm adjustment) – matches admin dialogs */
+#ohmModal {
+ position: fixed;
+ inset: 0;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ background: rgba(17, 24, 39, 0.45);
+ z-index: 9998;
+}
+
+#ohmModal.is-open {
+ display: flex;
+}
+
+.ohm-card {
+ width: 360px;
+ max-width: calc(100% - 2rem);
+ background: #fff;
+ border-radius: 0;
+ border: 1px solid var(--hw-border);
+ box-shadow: 0 18px 40px rgba(15, 23, 42, 0.4);
+ padding: 1rem;
+}
+
+.ohm-title {
+ margin: 0.25rem 0 0.75rem;
+ font-size: var(--step-1);
+ font-weight: 700;
+}
+
+.ohm-body {
+ display: grid;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+}
+
+.ohm-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ margin-top: 0.75rem;
+}
+
+/* Alert overlay created from JS */
+#alertOverlay .ohm-card {
+ max-width: 320px;
+}
+
+/* Selection panel */
+.selection-panel {
+ margin-top: 0.5rem;
+}
+.selection-panel .stat {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.badge-num{
+ display:inline-grid;
+ place-items:center;
+ width: 1.25rem;
+ height: 1.25rem;
+ border-radius:999px;
+ background:#c8102e;
+ color:#fff;
+ font-weight:700;
+ font-size: .8rem;
+}
+