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.
  • +
+
+ + + +
+ +
+
+
Total Current
+
+
+
+
Total Resistance
+
+
+
+
Total Power
+
+
+
+ + +
+
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:

+ +

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!

+ +

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!

+ +

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!

+ +

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; +} +