// =============================================================================
// MATHJAX LOADER
// =============================================================================
mathjax_dependency = {
return new Promise(resolve => {
// Wait a bit to allow parent page MathJax to fully initialize
setTimeout(() => {
// Check if MathJax is already loaded (any version)
if (window.MathJax && (window.MathJax.typesetPromise || window.MathJax.typeset)) {
console.log("MathJax already loaded by parent page - using existing instance");
window.our_mathjax_loaded = true;
resolve("Using existing MathJax");
return;
}
// Only load MathJax if not already present
console.log("Loading MathJax for standalone widget");
window.MathJax = {
tex: {
packages: {'[+]': ['base', 'ams', 'newcommand', 'noerrors', 'noundefined']},
inlineMath: [['$', '$'], ['\\(', '\\)']],
displayMath: [['$$', '$$'], ['\\[', '\\]']]
},
chtml: {
scale: 0.85,
mtextInheritFont: true,
adaptiveCSS: true
},
startup: {
ready: () => {
console.log("MathJax ready with enhanced packages.");
window.MathJax.startup.defaultReady();
window.our_mathjax_loaded = true;
resolve("MathJax is ready.");
}
}
};
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml-full.js';
script.async = true;
document.head.appendChild(script);
}, 100); // Wait 100ms for parent MathJax to initialize
});
}Learning Lab: Irreversible Work
d3 = require("d3@7")
htl = require("htl")
// =============================================================================
// THERMODYNAMIC SYSTEM DOCUMENTATION
// =============================================================================
/*
KEY THERMODYNAMIC CONCEPTS:
1. IRREVERSIBLE WORK: Work done when a gas expands/compresses against constant external pressure
- Formula: w = -P_ext × ΔV = -P_ext × (V_final - V_initial)
- Sign convention: Negative work = energy leaving system (expansion)
Positive work = energy entering system (compression)
2. IDEAL GAS LAW: PV = nRT
- Relates pressure, volume, temperature, and amount of gas
- Assumes gas molecules have no volume and no intermolecular forces
3. ISOTHERMAL PROCESS: Temperature remains constant (T = 298.15 K)
- Heat exchange occurs with surroundings to maintain constant temperature
- Internal energy change (ΔU) = 0 for ideal gas
SYSTEM ASSUMPTIONS:
1. Ideal gas behavior follows PV = nRT exactly
2. Isothermal process at 298.15 K (25°C)
3. Constant external pressure during expansion/compression
4. Frictionless piston movement
5. System reaches equilibrium with external pressure at final state
6. No heat exchange effects explicitly modeled (temperature remains constant)
7. Standard thermodynamic sign convention: work done by system is negative
EDUCATIONAL CONTEXT:
This visualization demonstrates how real piston-cylinder systems behave when
the external pressure differs from the system pressure. Unlike reversible
processes where P_ext ≈ P_system at every step, irreversible processes have
a sudden pressure change, making the work calculation simpler (constant P_ext).
*/
// ==================== CONSTANTS ====================
R_SI = 8.314472 // J/(mol·K) - Universal gas constant (SI units)
R_atm = 0.08314472 // L·atm/(mol·K) - Universal gas constant (convenient units)
L_atm_to_kJ = 0.101325 // Exact conversion factor: 1 L·atm = 0.101325 kJ
n_moles = 1.000 // mol (exact) - Amount of gas in the system
T_kelvin = 298.15 // K (exact, isothermal) - Constant temperature throughout process
// Initial state - calculated from ideal gas law: V = nRT/P
P_initial = 4.00 // atm - Starting system pressure (clear reference point)
V_initial = (n_moles * R_atm * T_kelvin) / P_initial // Initial volume from PV = nRT (~6.12 L)
// ==================== SPRING SYSTEM CONSTANTS ====================
/*
SPRING-BLOCK SYSTEM ANALOGY:
The spring system provides a mechanical analogy to the gas system:
- Force (F) ↔ Pressure (P)
- Displacement (x) ↔ Volume (V)
- Spring constant (k) ↔ Gas compressibility
- Work: w = F × Δx (similar to w = -P × ΔV)
PHYSICAL INTERPRETATION:
- Spring compressed by external force (like gas compressed by external pressure)
- Work done when spring moves between equilibrium positions
- Sign convention: Positive work = energy entering system (compression)
PHYSICAL CONSTRAINTS:
- Maximum extension: 0.36 m beyond rest position (for visualization limits)
- Minimum theoretical force: F_min = -k × 0.36 = -90 N (spring pulling)
- However, we constrain the system to show realistic behavior
*/
k_spring = 250.0; // N/m - Spring constant (stiff spring for visible compression)
x_rest = 1.0; // m - Spring's natural resting position
F_initial = 50; // N - Initial compressing force on spring (reduced for better zero point)
x_initial = x_rest - (F_initial / k_spring);// =============================================================================
// CSS STYLING
// =============================================================================
workWidgetStyles = htl.html`<style>
/* Widget container with basic styling */
#work-widget-container {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
/* Override global website MathJax styles with maximum specificity */
/* Must counter the global mjx-container { font-size: 110% !important; } rule */
#work-widget-container mjx-container,
#work-widget-container mjx-container[jax="CHTML"],
body #work-widget-container mjx-container,
div#work-widget-container mjx-container {
font-size: 100% !important;
overflow-x: visible !important;
overflow-y: visible !important;
display: inline-block !important;
line-height: normal !important;
}
#work-widget-container mjx-container mjx-container {
font-size: 100% !important;
}
#work-widget-container .MathJax {
font-size: 100% !important;
display: inline-block !important;
}
#work-widget-container .MathJax mjx-container {
font-size: 100% !important;
}
#work-widget-container .MathJax_Display {
text-align: center !important;
margin: 1.5em 0 !important;
font-size: 100% !important;
display: block !important;
overflow: visible !important;
}
#work-widget-container .MathJax_Display mjx-container {
font-size: 100% !important;
display: inline-block !important;
}
#work-widget-container .MathJax_CHTML {
font-size: 100% !important;
}
#work-widget-container .MathJax_SVG {
font-size: 100% !important;
}
#work-widget-container .MathJax_CHTML mjx-container {
font-size: 100% !important;
}
#work-widget-container .MathJax_SVG mjx-container {
font-size: 100% !important;
}
/* Hide equation numbers in align* only */
#work-widget-container .MathJax_Display .MathJax_ref,
#work-widget-container .MathJax_Display .mjx-label {
display: none !important;
}
/* Reset and override all MathJax elements to prevent global inheritance */
/* Ultra-high specificity to override site-wide styles */
div#work-widget-container.work-widget-container .MathJax,
div#work-widget-container.work-widget-container .MathJax_Display,
div#work-widget-container.work-widget-container mjx-container,
div#work-widget-container.work-widget-container .MathJax_CHTML,
div#work-widget-container.work-widget-container .MathJax_SVG,
div#work-widget-container.work-widget-container mjx-container mjx-container,
div#work-widget-container.work-widget-container .MathJax mjx-container,
div#work-widget-container.work-widget-container .MathJax_Display mjx-container,
div#work-widget-container.work-widget-container .MathJax_CHTML mjx-container,
div#work-widget-container.work-widget-container .MathJax_SVG mjx-container {
font-family: "MathJax_Math", "MathJax_Main", "MathJax_Size1", "MathJax_Size2", "MathJax_Size3", "MathJax_Size4", "MathJax_AMS", "MathJax_Caligraphic", "MathJax_Script", "MathJax_Typewriter", "MathJax_Fraktur", "MathJax_Main-italic", "MathJax_Math-italic", "MathJax_Caligraphic-bold", "MathJax_Fraktur-bold", "MathJax_SansSerif", "MathJax_SansSerif-italic", "MathJax_Typewriter", serif !important;
font-size: 100% !important;
line-height: normal !important;
overflow: visible !important;
white-space: normal !important;
word-wrap: normal !important;
text-align: left !important;
display: inline-block !important;
}
div#work-widget-container.work-widget-container .MathJax_Display {
text-align: center !important;
margin: 1.5em 0 !important;
display: block !important;
}
div#work-widget-container.work-widget-container .MathJax_Display mjx-container {
text-align: center !important;
}
/* Ensure calculation box has proper MathJax styling */
#work-widget-container .calculation-box mjx-container {
font-size: 100% !important;
}
#work-widget-container .calculation-box .MathJax_Display {
font-size: 100% !important;
text-align: center !important;
margin: 1em 0 !important;
}
/* Scope all text elements in calculation box to prevent global CSS interference */
div#work-widget-container.work-widget-container .calculation-box h4,
div#work-widget-container.work-widget-container .work-calc-container h4 {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 16px;
font-weight: 600;
line-height: 1.4;
margin: 8px 0;
padding: 0;
color: inherit;
}
div#work-widget-container.work-widget-container .calculation-box p,
div#work-widget-container.work-widget-container .work-calc-container p {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
font-weight: normal;
line-height: 1.4;
margin: 8px 0;
padding: 0;
color: #34495e;
}
/* Widget styles using class-based scoping */
.work-widget-container .widget-title {
text-align: center;
font-size: 24px;
font-weight: bold;
color: #2c3e50;
margin-bottom: 6px;
}
.work-widget-container .widget-subtitle {
text-align: center;
font-size: 14px;
color: #7f8c8d;
font-style: italic;
margin-top: 3px;
}
.work-widget-container .visuals-container {
display: flex;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
gap: 20px;
margin: 15px 0;
}
.work-widget-container .piston-container,
.work-widget-container .pv-diagram-container {
flex: 1;
min-width: 280px;
display: flex;
justify-content: center;
}
.work-widget-container .controls-container {
text-align: center;
padding: 5px 15px;
background: white;
border-radius: 6px;
margin: 12px 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
}
/* Scope all labels and text in controls */
div#work-widget-container.work-widget-container .controls-container label,
div#work-widget-container.work-widget-container .controls-container span,
div#work-widget-container.work-widget-container .controls-container div {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
line-height: 1.4;
color: #2c3e50;
}
div#work-widget-container.work-widget-container .controls-container i,
div#work-widget-container.work-widget-container .controls-container sub,
div#work-widget-container.work-widget-container .controls-container sup {
font-family: inherit;
color: inherit;
}
div#work-widget-container.work-widget-container .pressure-slider {
cursor: pointer;
}
div#work-widget-container.work-widget-container .reset-button {
padding: 8px 16px;
font-size: 14px;
font-weight: bold;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #3498db;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background 0.3s;
line-height: 1.4;
margin: 0;
}
div#work-widget-container.work-widget-container .reset-button:hover {
background: #2980b9;
}
div#work-widget-container.work-widget-container .results-table {
border-collapse: collapse;
width: 100%;
background: white;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
div#work-widget-container.work-widget-container .results-table th,
div#work-widget-container.work-widget-container .results-table td {
padding: 8px;
text-align: center;
border-bottom: 1px solid #ecf0f1;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
line-height: 1.4;
color: #2c3e50;
}
div#work-widget-container.work-widget-container .results-table thead {
background: #34495e;
}
div#work-widget-container.work-widget-container .results-table th {
font-weight: bold;
}
div#work-widget-container.work-widget-container .results-table thead th {
color: white;
}
div#work-widget-container.work-widget-container .results-table tbody tr:hover {
background: #f8f9fa;
}
/* Scope italic and sub/sup elements in tables */
div#work-widget-container.work-widget-container .results-table i,
div#work-widget-container.work-widget-container .results-table sub,
div#work-widget-container.work-widget-container .results-table sup,
div#work-widget-container.work-widget-container .results-table strong {
font-family: inherit;
color: inherit;
}
.work-widget-container .calculation-box {
background: white;
padding: 12px;
border-radius: 6px;
border-left: 4px solid #3498db;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.work-widget-container .work-calc-container {
margin-top: 12px;
}
/* SVG styling */
.work-widget-container .x-axis path,
.work-widget-container .y-axis path,
.work-widget-container .x-axis line,
.work-widget-container .y-axis line {
stroke: #555;
}
.work-widget-container .x-axis text,
.work-widget-container .y-axis text {
fill: #555;
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
shape-rendering: crispEdges;
}
.work-widget-container .x-axis-label,
.work-widget-container .y-axis-label {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
shape-rendering: geometricPrecision;
text-rendering: geometricPrecision;
}
/* Mode toggle buttons */
.work-widget-container .mode-button {
padding: 10px 20px;
font-size: 16px;
border: 1px solid #bdc3c7;
background: #ecf0f1;
color: #7f8c8d;
cursor: pointer;
transition: all 0.2s;
margin: 0 5px;
border-radius: 5px;
}
.work-widget-container .mode-button.active {
background: #3498db;
color: white;
border-color: #3498db;
}
.work-widget-container .mode-button:hover:not(.active) {
background: #d5dbdb;
}
/* Spring container - initially hidden */
.work-widget-container .spring-container {
display: none;
}
/* Process explanation container */
.work-widget-container .process-explanation-container {
background: white;
border-radius: 6px;
padding: 12px;
margin: 12px 0;
border-left: 4px solid #3498db;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
div#work-widget-container.work-widget-container .process-explanation-title {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-weight: bold;
font-size: 16px;
line-height: 1.4;
color: #2c3e50;
margin-bottom: 8px;
padding: 0;
}
div#work-widget-container.work-widget-container .process-explanation-content {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
font-weight: normal;
line-height: 1.4;
color: #34495e;
margin: 0;
padding: 0;
}
/* Scope all HTML elements that might be in process explanation content */
div#work-widget-container.work-widget-container .process-explanation-content i,
div#work-widget-container.work-widget-container .process-explanation-content b,
div#work-widget-container.work-widget-container .process-explanation-content sub,
div#work-widget-container.work-widget-container .process-explanation-content sup {
font-family: inherit;
font-size: inherit;
color: inherit;
}
/* Mathematical notation italic styling */
.work-widget-container .math-italic {
font-style: italic;
}
/* Only italicize variable x, not operators like Δ */
.work-widget-container .disp-label {
font-style: italic;
}
.work-widget-container .delta-x-label {
font-style: normal;
}
/* Responsive */
@media (max-width: 768px) {
.work-widget-container .visuals-container {
flex-direction: column;
}
}
/* ===== DARK MODE THEMING - CYBERPUNK CHEMISTRY ===== */
/* Main container */
body.quarto-dark #work-widget-container,
body.quarto-dark .work-widget-container {
background: linear-gradient(135deg, rgb(26, 31, 58), rgb(19, 20, 29));
border: 1px solid var(--accent-cyan, #00d9ff);
box-shadow: 0 0 20px rgba(0, 217, 255, 0.2);
}
/* Calculation boxes */
body.quarto-dark .work-widget-container .calculation-box {
background: rgba(20, 25, 40, 0.6);
border-left-color: var(--accent-cyan, #00d9ff);
box-shadow: 0 0 8px rgba(0, 217, 255, 0.2);
}
body.quarto-dark .work-widget-container .work-calc-container {
background: transparent;
}
body.quarto-dark .work-widget-container .process-explanation-container {
background: rgba(20, 25, 40, 0.6);
border-left-color: var(--accent-magenta, #ff007c);
box-shadow: 0 0 8px rgba(255, 0, 124, 0.2);
}
/* Text colors */
body.quarto-dark div#work-widget-container.work-widget-container .process-explanation-title {
color: var(--accent-magenta, #ff007c);
text-shadow: 0 0 8px rgba(255, 0, 124, 0.3);
}
body.quarto-dark div#work-widget-container.work-widget-container .process-explanation-content {
color: #e0e0e0;
}
body.quarto-dark .work-widget-container .widget-title {
color: var(--accent-cyan, #00d9ff);
text-shadow: 0 0 10px rgba(0, 217, 255, 0.4);
}
body.quarto-dark .work-widget-container .widget-subtitle {
color: #c0c0c0;
}
/* Table styling */
body.quarto-dark div#work-widget-container.work-widget-container .results-table {
background: rgba(20, 25, 40, 0.6);
border: 1px solid rgba(0, 217, 255, 0.3);
color: #e0e0e0;
}
body.quarto-dark div#work-widget-container.work-widget-container .results-table thead {
background: rgba(0, 217, 255, 0.15);
border-bottom: 2px solid var(--accent-cyan, #00d9ff);
}
body.quarto-dark div#work-widget-container.work-widget-container .results-table th {
color: var(--accent-cyan, #00d9ff);
text-shadow: 0 0 6px rgba(0, 217, 255, 0.3);
border-color: rgba(0, 217, 255, 0.3);
}
body.quarto-dark div#work-widget-container.work-widget-container .results-table td {
color: #e0e0e0;
border-color: rgba(0, 217, 255, 0.2);
}
body.quarto-dark div#work-widget-container.work-widget-container .results-table tbody tr:hover {
background: rgba(0, 217, 255, 0.1);
}
/* Controls container and slider boxes */
body.quarto-dark .work-widget-container .controls-container {
background: transparent;
}
body.quarto-dark .work-widget-container .controls-container > div {
background: rgba(20, 25, 40, 0.6);
border: 1px solid rgba(0, 217, 255, 0.3);
border-radius: 6px;
padding: 12px;
box-shadow: 0 0 8px rgba(0, 217, 255, 0.15);
}
body.quarto-dark div#work-widget-container.work-widget-container .controls-container label,
body.quarto-dark div#work-widget-container.work-widget-container .controls-container span,
body.quarto-dark div#work-widget-container.work-widget-container .controls-container div {
color: #e0e0e0;
}
/* Slider inputs - themed with cyan accent */
body.quarto-dark div#work-widget-container.work-widget-container .pressure-slider {
accent-color: var(--accent-cyan, #00d9ff);
}
body.quarto-dark div#work-widget-container.work-widget-container .reset-button {
background: rgba(0, 217, 255, 0.15);
color: var(--accent-cyan, #00d9ff);
border: 1px solid var(--accent-cyan, #00d9ff);
text-shadow: 0 0 6px rgba(0, 217, 255, 0.3);
}
body.quarto-dark div#work-widget-container.work-widget-container .reset-button:hover {
background: rgba(0, 217, 255, 0.25);
box-shadow: 0 0 12px rgba(0, 217, 255, 0.4);
}
/* Mode toggle buttons */
body.quarto-dark .work-widget-container .mode-button {
background: rgba(30, 35, 50, 0.6);
color: #a0a0a0;
border-color: rgba(0, 217, 255, 0.3);
}
body.quarto-dark .work-widget-container .mode-button.active {
background: var(--accent-magenta, #ff007c);
color: white;
border-color: var(--accent-magenta, #ff007c);
box-shadow: 0 0 12px rgba(255, 0, 124, 0.5);
}
body.quarto-dark .work-widget-container .mode-button:hover:not(.active) {
background: rgba(0, 217, 255, 0.15);
border-color: var(--accent-cyan, #00d9ff);
}
/* PV Diagram SVG - axes and text */
body.quarto-dark .work-widget-container .x-axis path,
body.quarto-dark .work-widget-container .y-axis path,
body.quarto-dark .work-widget-container .x-axis line,
body.quarto-dark .work-widget-container .y-axis line {
stroke: var(--accent-cyan, #00d9ff);
filter: drop-shadow(0 0 2px rgba(0, 217, 255, 0.4));
}
body.quarto-dark .work-widget-container .x-axis text,
body.quarto-dark .work-widget-container .y-axis text {
fill: #e0e0e0;
}
body.quarto-dark .work-widget-container .x-axis-label,
body.quarto-dark .work-widget-container .y-axis-label {
fill: var(--accent-cyan, #00d9ff);
}
/* Calculation box headings */
body.quarto-dark div#work-widget-container.work-widget-container .calculation-box h4,
body.quarto-dark div#work-widget-container.work-widget-container .work-calc-container h4 {
color: var(--accent-cyan, #00d9ff);
text-shadow: 0 0 8px rgba(0, 217, 255, 0.3);
}
body.quarto-dark div#work-widget-container.work-widget-container .calculation-box p,
body.quarto-dark div#work-widget-container.work-widget-container .work-calc-container p {
color: #e0e0e0;
}
/* ===== PISTON/CYLINDER SVG THEMING ===== */
/* Cylinder walls - cyan with glow */
body.quarto-dark .work-widget-container svg .cylinder-wall-left,
body.quarto-dark .work-widget-container svg .cylinder-wall-right,
body.quarto-dark .work-widget-container svg .cylinder-base {
fill: var(--accent-cyan, #00d9ff);
filter: drop-shadow(0 0 4px rgba(0, 217, 255, 0.5));
}
/* Gas region - semi-transparent purple with glow */
body.quarto-dark .work-widget-container svg .gas-region {
fill: rgba(192, 132, 252, 0.3);
filter: drop-shadow(0 0 6px rgba(192, 132, 252, 0.4));
}
/* Piston head - dark with cyan outline */
body.quarto-dark .work-widget-container svg .piston-head {
fill: rgba(30, 35, 50, 0.9);
stroke: var(--accent-cyan, #00d9ff);
stroke-width: 2;
filter: drop-shadow(0 0 3px rgba(0, 217, 255, 0.4));
}
/* Piston rod - cyan */
body.quarto-dark .work-widget-container svg .piston-rod {
stroke: var(--accent-cyan, #00d9ff);
filter: drop-shadow(0 0 3px rgba(0, 217, 255, 0.5));
}
/* Volume and pressure labels */
body.quarto-dark .work-widget-container svg .volume-label,
body.quarto-dark .work-widget-container svg .pressure-label {
fill: #e0e0e0;
}
/* Pressure arrow - magenta for external force */
body.quarto-dark .work-widget-container svg .pressure-arrow line {
stroke: var(--accent-magenta, #ff007c);
filter: drop-shadow(0 0 3px rgba(255, 0, 124, 0.6));
}
body.quarto-dark .work-widget-container svg marker#arrowhead polygon {
fill: var(--accent-magenta, #ff007c);
}
/* ===== SPRING SYSTEM SVG THEMING ===== */
/* Spring path - cyan with glow */
body.quarto-dark .work-widget-container svg path[stroke="#3498db"],
body.quarto-dark .work-widget-container svg .spring-path {
stroke: var(--accent-cyan, #00d9ff);
filter: drop-shadow(0 0 4px rgba(0, 217, 255, 0.5));
}
/* Spring block - dark with cyan outline */
body.quarto-dark .work-widget-container svg rect[fill="#2c3e50"] {
fill: rgba(30, 35, 50, 0.9);
stroke: var(--accent-cyan, #00d9ff);
stroke-width: 2;
filter: drop-shadow(0 0 3px rgba(0, 217, 255, 0.4));
}
/* Position indicators - purple */
body.quarto-dark .work-widget-container svg line[stroke-dasharray="4 2"],
body.quarto-dark .work-widget-container svg .initial-position-indicator,
body.quarto-dark .work-widget-container svg .final-position-indicator {
stroke: rgba(192, 132, 252, 0.7);
}
/* Force arrow - magenta */
body.quarto-dark .work-widget-container svg .force-arrow line {
stroke: var(--accent-magenta, #ff007c);
filter: drop-shadow(0 0 3px rgba(255, 0, 124, 0.6));
}
body.quarto-dark .work-widget-container svg marker#force-arrowhead polygon,
body.quarto-dark .work-widget-container svg marker#spring-arrowhead polygon {
fill: var(--accent-magenta, #ff007c);
}
/* Spring SVG text labels */
body.quarto-dark .work-widget-container svg text[fill="#555"],
body.quarto-dark .work-widget-container svg text[fill="#000"] {
fill: #e0e0e0;
}
/* ===== PV DIAGRAM SPECIFIC THEMING ===== */
/* Work area rectangle - semi-transparent purple */
body.quarto-dark .work-widget-container svg rect[fill="rgba(52, 152, 219, 0.3)"] {
fill: rgba(192, 132, 252, 0.25);
}
/* External pressure line - cyan dashed */
body.quarto-dark .work-widget-container svg line[stroke-dasharray="5 3"] {
stroke: var(--accent-cyan, #00d9ff);
}
/* Initial and final state points - cyan circles with glow */
body.quarto-dark .work-widget-container svg circle[fill="#3498db"] {
fill: var(--accent-cyan, #00d9ff);
filter: drop-shadow(0 0 4px rgba(0, 217, 255, 0.6));
}
/* Pressure change arrow - magenta */
body.quarto-dark .work-widget-container svg line[marker-end*="arrow"] {
stroke: var(--accent-magenta, #ff007c);
filter: drop-shadow(0 0 3px rgba(255, 0, 124, 0.5));
}
body.quarto-dark .work-widget-container svg marker polygon {
fill: var(--accent-magenta, #ff007c);
}
</style>`// ==================== WIDGET ====================
{
// Dependencies
mathjax_dependency;
workWidgetStyles;
const container = htl.html`<div id="work-widget-container" class="work-widget-container"></div>`;
// Local constants needed within widget scope
const x_rest_local = 1.0; // m - Spring's natural resting position
const k_spring_local = 250.0; // N/m - Spring constant
const F_initial_local = 50; // N - Initial compressing force on spring (reduced for better zero point)
const x_initial_local = x_rest_local - (F_initial_local / k_spring_local); // m - Initial displacement (~0.8 m)
// ==================== HELPER FUNCTIONS ====================
function countSigFigs(numStr) {
if (!numStr || typeof numStr !== 'string') return null;
let str = numStr.trim().replace(/^[+\-]/, '');
if (str.includes('e') || str.includes('E')) {
const parts = str.split(/[eE]/);
str = parts[0];
}
str = str.replace(/^0+\./, '0.');
if (str.includes('.')) {
str = str.replace(/^0+/, '');
if (str.startsWith('.')) str = '0' + str;
return str.replace('.', '').replace(/^0+/, '').length;
} else {
str = str.replace(/^0+/, '') || '0';
if (str === '0') return 1;
const trailingZeros = str.match(/0+$/);
if (trailingZeros) {
return str.length;
}
return str.replace(/0+$/, '').length;
}
}
function roundBankers(num, sf) {
if (num === 0) return 0;
if (!sf || sf < 1) return num;
const magnitude = Math.pow(10, sf - Math.floor(Math.log10(Math.abs(num))) - 1);
const scaled = num * magnitude;
const floorScaled = Math.floor(scaled);
const diff = scaled - floorScaled;
let roundedScaled;
if (diff === 0.5) {
roundedScaled = (floorScaled % 2 === 0) ? floorScaled : floorScaled + 1;
} else {
roundedScaled = Math.round(scaled);
}
return roundedScaled / magnitude;
}
function getDecimalPlaces(num, sf) {
// Determine the number of decimal places in a number when rounded to sf sig figs
// This is used for addition/subtraction precision tracking
if (num === 0) return sf - 1; // For zero, use sf-1 decimal places
const rounded = roundBankers(num, sf);
const str = rounded.toPrecision(sf);
if (str.includes('e')) {
// Handle scientific notation
const parts = str.split('e');
const decimals = parts[0].includes('.') ? parts[0].split('.')[1].length : 0;
const exponent = parseInt(parts[1]);
return Math.max(0, decimals - exponent);
}
if (str.includes('.')) {
return str.split('.')[1].length;
}
return 0;
}
function getInputWithBar(num, sf, decimalPlaces = null) {
// Format input values with bar over last significant digit (no guard digits)
// e.g., 50.0 with sf=3 → 50.\bar{0}
// If decimalPlaces is provided, use that for precision instead of sf
// Check if number is effectively zero (accounting for floating point errors)
const isZero = Math.abs(num) < 1e-10;
if (isZero || num === 0) {
if (decimalPlaces !== null) {
// Use decimal places for zero values from subtraction
// e.g., decimalPlaces=2 → 0.0\bar{0}
if (decimalPlaces === 0) return `\\bar{0}`;
const zeros = '0'.repeat(decimalPlaces - 1);
return `0.${zeros}\\bar{0}`;
}
if (sf >= 2) {
const zeros = '0'.repeat(sf - 2);
return `0.\\bar{0}${zeros}`;
} else if (sf === 1) {
return `\\bar{0}`;
} else {
return "0";
}
}
const precisionStr = num.toPrecision(sf);
let significantPart = precisionStr;
let exponentPart = '';
if (precisionStr.includes('e')) {
const parts = precisionStr.split('e');
significantPart = parts[0];
exponentPart = 'e' + parts[1];
}
const barredPart = `${significantPart.slice(0, -1)}\\bar{${significantPart.slice(-1)}}`;
return `${barredPart}${exponentPart}`;
}
function getUnroundedWithBar(num, sf, decimalPlaces = null) {
// Format intermediate calculated values with bar and TWO guard digits
// e.g., 0 with sf=3 → 0.\bar{0}00 (showing 2 guard digits)
// If decimalPlaces is provided, use that for precision instead of sf
// Check if number is effectively zero (accounting for floating point errors)
const isZero = Math.abs(num) < 1e-10;
if (isZero || num === 0) {
if (decimalPlaces !== null) {
// Use decimal places for zero values from subtraction
// e.g., decimalPlaces=2 → 0.0\bar{0}00
if (decimalPlaces === 0) return `\\bar{0}00`;
const zeros = '0'.repeat(decimalPlaces - 1);
return `0.${zeros}\\bar{0}00`;
}
if (sf >= 2) {
const zeros = '0'.repeat(sf - 2);
return `0.\\bar{0}${zeros}00`; // Add two guard digits
} else if (sf === 1) {
return `\\bar{0}00`; // Add two guard digits
} else {
return "0";
}
}
const precisionStr = num.toPrecision(sf);
const fullStr = num.toPrecision(sf + 2);
const guardDigits = fullStr.substring(precisionStr.length);
let significantPart = precisionStr;
let exponentPart = '';
if (precisionStr.includes('e')) {
const parts = precisionStr.split('e');
significantPart = parts[0];
exponentPart = 'e' + parts[1];
}
const barredPart = `${significantPart.slice(0, -1)}\\bar{${significantPart.slice(-1)}}`;
return `${barredPart}${guardDigits}${exponentPart}`;
}
function formatNumberForHTML(num, sf) {
// Format number with proper HTML minus sign for HTML/SVG text elements
const rounded = roundBankers(num, sf).toPrecision(sf);
return rounded.startsWith('-') ? rounded.replace('-', '−') : rounded;
}
// State management
let state = {
mode: 'gas', // 'gas' or 'spring' - current visualization mode
P_ext: P_initial, // atm - start at initial pressure (neutral point)
F_ext: F_initial_local, // N - external force for spring system
P_initial: P_initial,
V_initial: V_initial,
F_initial: F_initial_local,
x_initial: x_initial_local,
isResetting: false
};
// ==================== PROCESS EXPLANATION CONTENT ====================
const processExplanations = {
gas: {
negative: {
title: "Expansion",
blurb: "The internal pressure of the gas is greater than the external pressure. The gas expands, pushing the piston outward and doing work on the surroundings. Because the system loses energy by doing work, the sign of <i>w</i> is <b>negative</b>."
},
positive: {
title: "Compression",
blurb: "The external pressure is greater than the internal pressure of the gas. The surroundings push the piston inward, compressing the gas and doing work on the system. Because the system gains energy from the work done on it, the sign of <i>w</i> is <b>positive</b>."
},
zero: {
title: "No Work Done",
blurb: "No work is performed. This occurs either because the initial and final volumes are the same (<b>Δ<i>V</i> = 0</b>), or because the gas is expanding into a vacuum (<b>P<sub>ext</sub> = 0</b>). In either case, no force has moved a distance against opposition."
}
},
spring: {
negative: {
title: "Expansion (Work Done by Spring)",
blurb: "The spring expands from compression, releasing stored elastic potential energy to do work on the surroundings. Work is negative (<i>w</i> < 0) because energy leaves the system as the spring pushes against the external force."
},
positive: {
title: "Compression (Work Done on Spring)",
blurb: "The external force compresses the spring to a new equilibrium position. Work is positive (<i>w</i> > 0) because the surroundings add energy to the system, increasing the spring's stored elastic potential energy."
},
zero: {
title: "Free Expansion",
blurb: "Free expansion to the spring's natural equilibrium (rest length). No work is done because work requires a force acting through a distance against opposition. Without external force, the spring simply releases stored energy as kinetic motion."
}
}
};
// Calculate derived values based on thermodynamic/mechanical principles
function calculateState(controlValue) {
/*
CALCULATION METHOD:
GAS MODE:
1. Final volume from ideal gas law: V_final = nRT/P_ext
2. Final pressure equals external pressure (equilibrium condition)
3. Volume change: ΔV = V_final - V_initial
4. Work calculation: w = -P_ext × ΔV (irreversible work formula)
5. Unit conversion: L·atm → kJ using exact conversion factor
SPRING MODE:
1. Final displacement from Hooke's law: x_final = x_rest - F_ext/k
2. Final force equals external force (equilibrium condition)
3. Displacement change: Δx = x_final - x_initial
4. Work calculation: w = F_ext × Δx (mechanical work formula)
5. Unit: Joules (no conversion needed)
*/
if (state.mode === 'gas') {
const P_ext = controlValue;
const V_final = (n_moles * R_atm * T_kelvin) / P_ext; // From PV = nRT
const P_final = P_ext; // System equilibrates to external pressure at final state
const deltaV = V_final - V_initial; // Volume change (positive = expansion, negative = compression)
const work_Latm = -P_ext * deltaV; // Work in L·atm: w = -P_ext × ΔV
const work_kJ = work_Latm * L_atm_to_kJ; // Convert to kJ for more intuitive units
// Significant figures: P_ext from slider has 3 sig figs (e.g., "2.50")
const sigFigs = 3;
// Decimal places for subtraction results (addition/subtraction uses decimal places, not sig figs)
const deltaV_decimalPlaces = Math.min(getDecimalPlaces(V_final, sigFigs), getDecimalPlaces(V_initial, 3));
// For multiplication involving values from subtraction, when result is near zero,
// track decimal places based on the subtraction result
const work_Latm_decimalPlaces = (Math.abs(work_Latm) < 1e-10) ? deltaV_decimalPlaces : null;
const work_kJ_decimalPlaces = (Math.abs(work_kJ) < 1e-10) ? deltaV_decimalPlaces : null;
return {
P_final,
V_final,
deltaV,
deltaV_decimalPlaces,
work_Latm,
work_Latm_decimalPlaces,
work_kJ,
work_kJ_decimalPlaces,
work_J: work_kJ * 1000, // Convert to Joules for consistency with spring
sigFigs,
isExpansion: P_ext < P_initial, // Gas expands when external pressure is lower
isCompression: P_ext > P_initial // Gas compresses when external pressure is higher
};
} else if (state.mode === 'spring') {
const F_ext = controlValue;
const k = k_spring_local; // Use fixed spring constant from CONSTANTS section
// Calculate theoretical position using the same scaling as visualization
const theoreticalX = springMinBlockX +
(maxExtensionDistance - (F_ext / k)) / visualToPhysicalScale;
// Apply visual constraints - keep within animation window (same as visualization)
const maxBlockRight = springWidth - 25; // Keep 25px from right edge
const maxBlockX = maxBlockRight - springBlockWidth;
const minBlockX = springMinBlockX; // Minimum position from wall
// Constrained final position
const x_final_constrained = Math.max(minBlockX, Math.min(maxBlockX, theoreticalX));
// Convert visual position back to physical coordinates for table display
const actualCompression = maxExtensionDistance - (x_final_constrained - springMinBlockX) * visualToPhysicalScale;
const F_final = Math.round(k * actualCompression * 100) / 100; // Round to 2 decimal places to avoid floating point errors
// Convert final position back to physical coordinates for table display
const x_final_physical = x_rest_local - actualCompression;
const deltaX = x_final_physical - x_initial_local; // Displacement change in physical coordinates
// For work calculation, use the external force (controlValue) since that's the force doing work on the system
// The external force acts in the direction of compression (negative x direction)
// So work = external_force × displacement (where displacement is negative for compression)
const work_J = -controlValue * deltaX; // Work in Joules: w = F_ext × Δx (negative because F_ext acts opposite to positive x)
// Significant figures: F_ext from slider has 3 sig figs
const sigFigs = 3;
// Decimal places for subtraction results (addition/subtraction uses decimal places, not sig figs)
const deltaX_decimalPlaces = Math.min(getDecimalPlaces(x_final_physical, sigFigs), getDecimalPlaces(x_initial_local, 3));
const work_kJ_val = work_J / 1000;
// For multiplication involving values from subtraction, when result is near zero,
// track decimal places based on the subtraction result
const work_J_decimalPlaces = (Math.abs(work_J) < 1e-10) ? deltaX_decimalPlaces : null;
const work_kJ_decimalPlaces = (Math.abs(work_kJ_val) < 1e-10) ? deltaX_decimalPlaces : null;
return {
F_final,
x_final: x_final_physical,
x_final_theoretical: x_rest_local - (controlValue / k), // Keep theoretical for reference
deltaX,
deltaX_decimalPlaces,
work_J,
work_J_decimalPlaces,
work_kJ: work_kJ_val, // Convert to kJ for display consistency
work_kJ_decimalPlaces,
sigFigs,
isExpansion: F_final < F_initial_local, // Spring extends when actual force is lower than initial
isCompression: F_final > F_initial_local, // Spring compresses when actual force is higher than initial
isConstrained: x_final_constrained !== theoreticalX // Flag if constraints are active
};
}
}
// Create main layout with educational context
const titleDiv = htl.html`<div></div>`; // Will be populated dynamically by updateWidgetTitle()
/*
VISUALIZATION COMPONENTS:
1. PISTON ANIMATION:
- Shows gas volume change as piston moves up/down
- Gas color indicates process type: blue (expansion), orange (compression)
- External pressure arrow shows force applied to piston
2. P-V DIAGRAM:
- Plots pressure vs. volume for the process
- Blue point: Initial state (P₁, V₁)
- Red point: Final state (P₂, V₂)
- Shaded area: Work done (rectangular for constant P_ext)
- Vertical arrow: Pressure change from P₁ to P_ext
3. INTERACTIVE CONTROLS:
- Slider to adjust external pressure (1.00 - 8.00 atm)
- Real-time updates of all visualizations
- Reset button to return to initial conditions
EDUCATIONAL NOTES:
- When P_ext < P_initial: Gas expands, does work on surroundings
- When P_ext > P_initial: Gas compresses, work done on gas
- The rectangular area in P-V diagram represents work (easier to calculate than curved area for reversible processes)
*/
// Create mode toggle
const toggleContainer = htl.html`<div style="text-align: center; margin-bottom: 20px;">
<button id="gas-mode-btn" class="mode-button active">Gas/Piston System</button>
<button id="spring-mode-btn" class="mode-button">Spring Analogy</button>
</div>`;
const visualsDiv = htl.html`<div class="visuals-container"></div>`;
const pistonDiv = htl.html`<div class="piston-container"></div>`;
const springDiv = htl.html`<div class="spring-container"></div>`; // Container for spring SVG
const pvDiagramDiv = htl.html`<div class="pv-diagram-container"></div>`;
visualsDiv.append(pistonDiv, springDiv, pvDiagramDiv);
const controlsDiv = htl.html`<div class="controls-container"></div>`;
const sliderContainer = htl.html`<div></div>`; // Dynamic container for sliders
const processExplanationDiv = htl.html`<div class="process-explanation-container"></div>`; // Process explanation area
const stateTableDiv = htl.html`<div class="state-table-container"></div>`;
const workCalcDiv = htl.html`<div class="work-calc-container"></div>`;
container.append(titleDiv, toggleContainer, visualsDiv, controlsDiv, stateTableDiv, workCalcDiv);
controlsDiv.append(sliderContainer); // Append dynamic slider container
controlsDiv.append(processExplanationDiv); // Append process explanation area
// ==================== PISTON ANIMATION ====================
/*
PISTON-CYLINDER VISUALIZATION:
This component shows a physical representation of the gas in a cylinder with a movable piston.
PHYSICAL INTERPRETATION:
- Cylinder walls contain the gas
- Piston moves up/down based on gas volume (from ideal gas law)
- External pressure (P_ext) pushes down on piston
- Gas pressure pushes up on piston
- At equilibrium: P_gas = P_ext (final state)
VISUAL MAPPING:
- Gas volume ↔ Piston height (higher piston = larger volume)
- Gas color ↔ Process type (blue = expansion, orange = compression)
- Arrow size ↔ External pressure magnitude
*/
const pistonWidth = 240;
const pistonHeight = 320;
const cylinderWidth = 160;
const cylinderX = (pistonWidth - cylinderWidth) / 2;
const pistonThickness = 12;
// Volume range based on P_ext range (1.00 - 8.00 atm)
// From ideal gas law: V = nRT/P (inverse relationship with pressure)
const maxVolume = (n_moles * R_atm * T_kelvin) / 1.00; // ~24.5 L at P_ext = 1.00 atm (minimum pressure)
const minVolume = (n_moles * R_atm * T_kelvin) / 8.00; // ~3.06 L at P_ext = 8.00 atm (maximum pressure)
const pistonSvg = d3.create("svg")
.attr("viewBox", [0, 0, pistonWidth, pistonHeight])
.attr("style", "width: 100%; height: auto; max-width: 240px;");
// Cylinder body (fixed) - FLIPPED 180: open at top, closed at bottom, gas inside
const cylinderTop = 50;
const cylinderBottom = 270;
const cylinderHeight = cylinderBottom - cylinderTop;
// Left wall
pistonSvg.append("rect")
.attr("class", "cylinder-wall-left")
.attr("x", cylinderX)
.attr("y", cylinderTop)
.attr("width", 3)
.attr("height", cylinderHeight)
.attr("fill", "#555");
// Right wall
pistonSvg.append("rect")
.attr("class", "cylinder-wall-right")
.attr("x", cylinderX + cylinderWidth - 3)
.attr("y", cylinderTop)
.attr("width", 3)
.attr("height", cylinderHeight)
.attr("fill", "#555");
// NO TOP - cylinder is open at top
// Cylinder base (bottom closed)
pistonSvg.append("rect")
.attr("class", "cylinder-base")
.attr("x", cylinderX)
.attr("y", cylinderBottom)
.attr("width", cylinderWidth)
.attr("height", 5)
.attr("fill", "#555");
// Gas region (will update)
const gasRect = pistonSvg.append("rect")
.attr("class", "gas-region")
.attr("x", cylinderX + 3)
.attr("width", cylinderWidth - 6);
// Piston (will move)
const pistonGroup = pistonSvg.append("g").attr("class", "piston-group");
const pistonRect = pistonGroup.append("rect")
.attr("class", "piston-head")
.attr("x", cylinderX + 3)
.attr("width", cylinderWidth - 6)
.attr("height", pistonThickness)
.attr("fill", "#333")
.attr("stroke", "#000")
.attr("stroke-width", 1);
// Piston rod - extends UPWARD from top of piston head (outside the cylinder, pushing down)
pistonGroup.append("line")
.attr("class", "piston-rod")
.attr("x1", pistonWidth / 2)
.attr("x2", pistonWidth / 2)
.attr("y1", 0)
.attr("y2", -35) // Rod extending upward above piston
.attr("stroke", "#666")
.attr("stroke-width", 4);
// Labels
const volumeLabel = pistonSvg.append("text")
.attr("class", "volume-label")
.attr("x", pistonWidth / 2)
.attr("text-anchor", "middle")
.attr("font-size", "14px")
.attr("font-weight", "bold")
.attr("fill", "#000");
// Arrow for P_ext - above cylinder
const arrowGroup = pistonSvg.append("g").attr("class", "pressure-arrow");
arrowGroup.append("line")
.attr("x1", pistonWidth / 2 - 40)
.attr("x2", pistonWidth / 2 - 40)
.attr("y1", 10)
.attr("y2", 40)
.attr("stroke", "#d00")
.attr("stroke-width", 2)
.attr("marker-end", "url(#arrowhead)");
// P_ext label - positioned to the right of arrow, vertically centered
const pressureLabel = pistonSvg.append("text")
.attr("class", "pressure-label")
.attr("x", pistonWidth / 2 - 25)
.attr("y", 30)
.attr("text-anchor", "start")
.attr("font-size", "12px")
.attr("font-style", "italic")
.attr("fill", "#555");
pistonSvg.append("defs").append("marker")
.attr("id", "arrowhead")
.attr("markerWidth", 10)
.attr("markerHeight", 10)
.attr("refX", 5)
.attr("refY", 3)
.attr("orient", "auto")
.append("polygon")
.attr("points", "0 0, 10 3, 0 6")
.attr("fill", "#d00");
pistonDiv.append(pistonSvg.node());
// ==================== SPRING ANIMATION ====================
/*
SPRING-BLOCK VISUALIZATION:
This component shows a mechanical analogy to the gas system.
PHYSICAL INTERPRETATION:
- Wall represents fixed boundary (like cylinder walls)
- Spring can compress/extend (like gas volume changes)
- Block represents the movable boundary (like piston)
- External force compresses/extends spring (like external pressure)
- PHYSICAL CONSTRAINTS: Spring has limited extension for visualization
VISUAL CONSTRAINTS:
- Maximum extension: 0.36 m beyond rest position (visualization limit)
- When external force < ~35N, spring would extend beyond this limit
- System constrains the block at maximum extension for visualization
- Actual force differs from external force when constraints are active
VISUAL MAPPING:
- Spring compression ↔ Gas compression
- Spring extension ↔ Gas expansion
- Force magnitude ↔ External pressure
- Block position ↔ Gas volume
*/
const springWidth = 240;
const springHeight = 320;
const springWallX = 50;
const springBlockWidth = 40;
const springBlockHeight = 60;
// PHYSICAL CONSTRAINTS: Optimize visual scaling for better compression/extension
// Maximum compression: block should compress spring significantly at F_ext = 100 N
const springMinBlockX = springWallX + 15; // Small gap from wall for visual clarity
// Maximum extension: use full available space for F_ext = 0 N
const springMaxBlockX = springWidth - 25; // Keep 25px from right edge
// Visual rest position for spring (when F_ext = 0 N, spring at natural length)
const springRestX = springWallX + 100; // Positioned to allow more extension space
// Calculate visual range and scaling
const visualRange = springMaxBlockX - springMinBlockX; // Available space for block movement
const maxCompressionDistance = 100 / k_spring_local; // 0.4 m for F_ext = 100 N
const maxExtensionDistance = 0.5; // Allow 0.5 m extension for F_ext = 0 N
const totalPhysicalRange = maxCompressionDistance + maxExtensionDistance; // 0.9 m total
// Dynamic scaling to fit the full physical range in available visual space
const visualToPhysicalScale = totalPhysicalRange / visualRange; // m per pixel
// Calculate rest position relative to physical constraints
// At F_ext = 50 N (initial): x = x_rest - F/k = 1.0 - 50/250 = 0.8 m
// We want this to correspond to the visual rest position
const springSvg = d3.create("svg")
.attr("viewBox", [0, 0, springWidth, springHeight])
.attr("style", "width: 100%; height: auto; max-width: 240px;");
// Wall (fixed)
springSvg.append("rect")
.attr("class", "wall")
.attr("x", springWallX - 5)
.attr("y", 100)
.attr("width", 5)
.attr("height", 120)
.attr("fill", "#555");
// Spring (will update) - improved zigzag with variable coils
const springPath = springSvg.append("path")
.attr("class", "spring-path")
.attr("stroke", "#666")
.attr("stroke-width", 3)
.attr("stroke-linecap", "round")
.attr("stroke-linejoin", "round")
.attr("fill", "none");
// Calculate initial block position based on initial force (50 N)
// x = x_rest - F/k = 1.0 - 50/250 = 0.8 m
// This should be positioned relative to our visual scaling
const initialBlockX = springMinBlockX +
(maxExtensionDistance - (F_initial_local / k_spring_local)) / visualToPhysicalScale;
// Block (will move with constraints)
const springBlock = springSvg.append("rect")
.attr("class", "spring-block")
.attr("x", initialBlockX)
.attr("y", 130)
.attr("width", springBlockWidth)
.attr("height", springBlockHeight)
.attr("fill", "#8b4513")
.attr("stroke", "#000")
.attr("stroke-width", 1);
// Initial position indicator (blue dashed line) - positioned at spring connection point (left edge of block)
const initialPositionIndicator = springSvg.append("line")
.attr("class", "initial-position-indicator")
.attr("x1", initialBlockX)
.attr("y1", 115)
.attr("x2", initialBlockX)
.attr("y2", 200)
.attr("stroke", "#0066cc")
.attr("stroke-width", 2)
.attr("stroke-dasharray", "5,5")
.attr("opacity", 0.8);
// Initial position label
const initialPositionLabel = springSvg.append("text")
.attr("class", "initial-position-label")
.attr("x", initialBlockX)
.attr("y", 108)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("font-weight", "bold")
.attr("fill", "#0066cc")
.html("<tspan style='font-style: italic;'>x</tspan>₁");
// Final position indicator (red dashed line) - initially at same position as initial (spring connection point)
const finalPositionIndicator = springSvg.append("line")
.attr("class", "final-position-indicator")
.attr("x1", initialBlockX)
.attr("y1", 115)
.attr("x2", initialBlockX)
.attr("y2", 200)
.attr("stroke", "#cc0000")
.attr("stroke-width", 2)
.attr("stroke-dasharray", "5,5")
.attr("opacity", 0.8);
// Final position label
const finalPositionLabel = springSvg.append("text")
.attr("class", "final-position-label")
.attr("x", initialBlockX)
.attr("y", 108)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("font-weight", "bold")
.attr("fill", "#cc0000")
.html("<tspan style='font-style: italic;'>x</tspan>₂");
// Force arrow (will update) - pointing LEFT to indicate compression force
const springArrowGroup = springSvg.append("g").attr("class", "force-arrow");
springArrowGroup.append("line")
.attr("x1", initialBlockX + springBlockWidth + 40)
.attr("x2", initialBlockX + springBlockWidth + 10)
.attr("y1", 160)
.attr("y2", 160)
.attr("stroke", "#d00")
.attr("stroke-width", 2)
.attr("marker-end", "url(#spring-arrowhead)");
// Force label - centered over the brown weight/block, positioned above x₁ and x₂ labels
const springForceLabel = springSvg.append("text")
.attr("class", "force-label")
.attr("x", initialBlockX + springBlockWidth/2)
.attr("y", 85)
.attr("text-anchor", "middle")
.attr("font-size", "11px")
.attr("font-style", "italic")
.attr("fill", "#555");
// Displacement label
const springDispLabel = springSvg.append("text")
.attr("class", "disp-label")
.attr("x", springWidth / 2)
.attr("y", 250)
.attr("text-anchor", "middle")
.attr("font-size", "14px")
.attr("font-weight", "bold")
.attr("fill", "#000");
// Displacement change label (Δx)
const springDeltaXLabel = springSvg.append("text")
.attr("class", "delta-x-label")
.attr("x", springWidth / 2)
.attr("y", 270)
.attr("text-anchor", "middle")
.attr("font-size", "13px")
.attr("font-weight", "normal")
.attr("fill", "#555")
.html('Δx = 0.000 m');
// Initial position indicator (vertical line)
const initialPositionLine = springSvg.append("line")
.attr("class", "initial-position-line")
.attr("x1", initialBlockX)
.attr("x2", initialBlockX)
.attr("y1", 120)
.attr("y2", 200)
.attr("stroke", "#0066cc")
.attr("stroke-width", 2)
.attr("stroke-dasharray", "5,5")
.attr("opacity", 0.7);
// Spring arrowhead
springSvg.append("defs").append("marker")
.attr("id", "spring-arrowhead")
.attr("markerWidth", 10)
.attr("markerHeight", 10)
.attr("refX", 5)
.attr("refY", 3)
.attr("orient", "auto")
.append("polygon")
.attr("points", "0 0, 10 3, 0 6")
.attr("fill", "#d00");
// Helper function to draw a realistic spring with smooth coils
function drawSpringPath(startX, endX, y) {
const length = endX - startX;
const restLength = springRestX - springWallX;
const compressionRatio = length / restLength;
// Ensure minimum length to prevent overlap
if (length < 20) {
// Spring is fully compressed - draw as straight line with small bends
const bendHeight = Math.min(6, length / 3);
let path = `M ${startX} ${y}`;
const numBends = Math.max(2, Math.floor(length / 8));
for (let i = 0; i <= numBends; i++) {
const x = startX + (i * length) / numBends;
const yOffset = (i % 2 === 0) ? 0 : ((i % 4 === 1) ? -bendHeight : bendHeight);
path += ` L ${x} ${y + yOffset}`;
}
// Ensure final point is exactly at endX, y
path += ` L ${endX} ${y}`;
return path;
}
// Calculate spring parameters - FIXED number of coils for consistent appearance
let amplitude;
// Keep coil count consistent to maintain spring appearance
const numCoils = 8;
// Adjust amplitude based on compression
if (compressionRatio < 0.6) {
// Compressed - smaller amplitude
amplitude = 10 + (compressionRatio * 8);
} else if (compressionRatio <= 1.2) {
// Normal range
amplitude = 18;
} else {
// Extended - slightly larger amplitude
amplitude = 18 + (compressionRatio - 1.2) * 5;
}
// Ensure reasonable bounds
const finalAmplitude = Math.max(8, Math.min(25, amplitude));
// Create smooth spring path with consistent coils
const pointsPerCoil = 8; // More points for smoother curves
const totalPoints = numCoils * pointsPerCoil;
let path = `M ${startX} ${y}`;
// Generate smooth spring using sine wave, ensuring we end exactly at (endX, y)
for (let i = 0; i <= totalPoints; i++) {
const x = startX + (i * length) / totalPoints;
const angle = (i / pointsPerCoil) * 2 * Math.PI;
const yOffset = Math.sin(angle) * finalAmplitude;
// The last point must be exactly at endX, y to connect to the block
if (i === totalPoints) {
path += ` L ${endX} ${y}`;
} else {
if (i === 0) {
path = `M ${x} ${y + yOffset}`;
} else {
path += ` L ${x} ${y + yOffset}`;
}
}
}
return path;
}
// Initialize the spring path with the initial position
const initialSpringPath = drawSpringPath(springWallX, initialBlockX, 160);
springPath.attr("d", initialSpringPath);
springDiv.append(springSvg.node());
// ==================== P-V DIAGRAM ====================
/*
PRESSURE-VOLUME DIAGRAM:
This is the standard thermodynamic diagram for visualizing work processes.
KEY FEATURES:
- X-axis: Volume (V) in liters
- Y-axis: Pressure (P) in atmospheres
- Blue point: Initial state (P₁, V₁) from ideal gas law
- Red point: Final state (P₂, V₂) after equilibration with P_ext
- Horizontal dashed line: Constant external pressure (P_ext)
- Shaded rectangle: Work done = P_ext × ΔV (area under the process path)
- Vertical arrow: Pressure jump from P₁ to P_ext (irreversible change)
THERMODYNAMIC SIGNIFICANCE:
- Work = Area under process curve on P-V diagram
- For constant P_ext: rectangular area (easy to calculate)
- For reversible process: curved area (requires integration)
- The diagram visually shows why irreversible work is simpler to calculate
*/
const pvWidth = 400;
const pvHeight = 300;
const pvMargin = {top: 20, right: 20, bottom: 50, left: 60};
const pvInnerWidth = pvWidth - pvMargin.left - pvMargin.right;
const pvInnerHeight = pvHeight - pvMargin.top - pvMargin.bottom;
const pvSvg = d3.create("svg")
.attr("viewBox", [0, 0, pvWidth, pvHeight])
.attr("style", "width: 100%; height: auto; max-width: 400px;");
const pvG = pvSvg.append("g")
.attr("transform", `translate(${pvMargin.left},${pvMargin.top})`);
// Scales
const xScale = d3.scaleLinear()
.domain([0, maxVolume * 1.1])
.range([0, pvInnerWidth]);
const yScale = d3.scaleLinear()
.domain([0, 11])
.range([pvInnerHeight, 0]);
// Axes
const xAxis = d3.axisBottom(xScale).ticks(6);
const yAxis = d3.axisLeft(yScale).ticks(6);
pvG.append("g")
.attr("class", "x-axis")
.attr("transform", `translate(0,${pvInnerHeight})`)
.call(xAxis);
pvG.append("g")
.attr("class", "y-axis")
.call(yAxis);
// Axis labels
pvG.append("text")
.attr("class", "x-axis-label")
.attr("x", pvInnerWidth / 2)
.attr("y", pvInnerHeight + 40)
.attr("text-anchor", "middle")
.attr("font-size", "14px")
.attr("font-family", "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif")
.text("Volume (L)");
pvG.append("text")
.attr("class", "y-axis-label")
.attr("transform", "rotate(-90)")
.attr("x", -pvInnerHeight / 2)
.attr("y", -45)
.attr("text-anchor", "middle")
.attr("font-size", "14px")
.attr("font-family", "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif")
.text("Pressure (atm)");
// Work area rectangle
const workRect = pvG.append("rect")
.attr("class", "work-area")
.attr("opacity", 0.3);
// Path for P_ext line
const pExtLine = pvG.append("line")
.attr("class", "p-ext-line")
.attr("stroke", "#000")
.attr("stroke-width", 2)
.attr("stroke-dasharray", "5,5");
// Initial state point
const initialPoint = pvG.append("circle")
.attr("class", "initial-point")
.attr("r", 5)
.attr("fill", "#00f")
.attr("stroke", "#000")
.attr("stroke-width", 1);
pvG.append("text")
.attr("class", "initial-label")
.attr("font-size", "12px")
.attr("fill", "#00f")
.text("Initial");
// Final state point
const finalPoint = pvG.append("circle")
.attr("class", "final-point")
.attr("r", 5)
.attr("fill", "#f00")
.attr("stroke", "#000")
.attr("stroke-width", 1);
const finalLabel = pvG.append("text")
.attr("class", "final-label")
.attr("font-size", "12px")
.attr("fill", "#f00")
.text("Final");
// Vertical arrow from initial point to P_ext line - smaller arrowhead
pvSvg.append("defs").append("marker")
.attr("id", "pressure-change-arrow")
.attr("markerWidth", 4)
.attr("markerHeight", 4)
.attr("refX", 2)
.attr("refY", 2)
.attr("orient", "auto")
.append("polygon")
.attr("points", "0 0, 4 2, 0 4")
.attr("fill", "#666");
const pressureChangeArrow = pvG.append("line")
.attr("class", "pressure-change-arrow")
.attr("stroke", "#666")
.attr("stroke-width", 2)
.attr("marker-end", "url(#pressure-change-arrow)");
pvDiagramDiv.append(pvSvg.node());
// ==================== CONTROLS ====================
// Dynamic control rendering function
function renderControls() {
sliderContainer.innerHTML = '';
if (state.mode === 'gas') {
// Gas mode controls
const sliderLabel = htl.html`<label style="display: block; margin-bottom: 8px; font-weight: bold;">
External Pressure (<i>P</i><sub>ext</sub>): <span id="p-ext-value">${state.P_ext.toFixed(2)}</span> atm
</label>`;
const slider = htl.html`<input type="range"
id="gas-slider"
min="1.00"
max="8.00"
step="0.01"
value="${state.P_ext}"
style="width: 100%; max-width: 500px;"
class="pressure-slider">`;
const resetButton = htl.html`<button class="reset-button" style="margin-left: 15px;">Reset</button>`;
const controlRow = htl.html`<div style="display: flex; align-items: center; gap: 8px; margin: 12px 0;">
${sliderLabel}
${resetButton}
</div>`;
controlRow.insertBefore(slider, resetButton);
sliderContainer.append(controlRow);
// Add event listener for gas slider
slider.addEventListener('input', (e) => {
state.P_ext = parseFloat(e.target.value);
sliderLabel.querySelector('#p-ext-value').textContent = state.P_ext.toFixed(2);
updateVisualization(state.P_ext);
});
// Add event listener for gas reset button
resetButton.addEventListener('click', () => {
state.P_ext = P_initial;
slider.value = state.P_ext;
sliderLabel.querySelector('#p-ext-value').textContent = state.P_ext.toFixed(2);
updateVisualization(state.P_ext);
});
} else if (state.mode === 'spring') {
// Spring mode controls - only external force slider
const forceSliderLabel = htl.html`<label style="display: block; margin-bottom: 8px; font-weight: bold;">
External Force (<i>F</i><sub>ext</sub>): <span id="f-ext-value">${state.F_ext.toFixed(1)}</span> N
</label>`;
const forceSlider = htl.html`<input type="range"
id="force-slider"
min="0"
max="100"
step="1"
value="${state.F_ext}"
style="width: 100%; max-width: 500px;"
class="pressure-slider">`;
const resetButton = htl.html`<button class="reset-button" style="margin-left: 15px;">Reset</button>`;
const controlRow = htl.html`<div style="display: flex; align-items: center; gap: 8px; margin: 12px 0;">
${forceSliderLabel}
${resetButton}
</div>`;
controlRow.insertBefore(forceSlider, resetButton);
sliderContainer.append(controlRow);
// Add event listener for force slider
forceSlider.addEventListener('input', (e) => {
state.F_ext = parseFloat(e.target.value);
forceSliderLabel.querySelector('#f-ext-value').textContent = state.F_ext.toFixed(1);
updateVisualization(state.F_ext);
});
// Add event listener for spring reset button
resetButton.addEventListener('click', () => {
state.F_ext = F_initial_local;
forceSlider.value = state.F_ext;
forceSliderLabel.querySelector('#f-ext-value').textContent = state.F_ext.toFixed(1);
updateVisualization(state.F_ext);
});
}
}
// ==================== WIDGET TITLE UPDATE ====================
function updateWidgetTitle() {
if (state.mode === 'gas') {
titleDiv.innerHTML = `
<div class="widget-title">
Irreversible Work: Piston-Cylinder System
<p class="widget-subtitle">Isothermal Ideal Gas Process (T = ${T_kelvin} K, n = ${n_moles} mol)</p>
</div>
`;
} else if (state.mode === 'spring') {
titleDiv.innerHTML = `
<div class="widget-title">
Irreversible Work: Spring-Block System
<p class="widget-subtitle">Hooke's Law Process (k = ${k_spring_local} N/m, F<sub>initial</sub> = ${F_initial_local} N)</p>
</div>
`;
}
}
// ==================== PROCESS EXPLANATION UPDATE ====================
function updateProcessExplanation(controlValue, calc) {
let explanation;
if (state.mode === 'gas') {
const work = calc.work_kJ;
// Check the sign of work and select appropriate explanation
if (work < 0) {
explanation = processExplanations.gas.negative;
} else if (work > 0) {
explanation = processExplanations.gas.positive;
} else {
// Zero work - sophisticated logic to determine cause
explanation = { ...processExplanations.gas.zero }; // Copy object to allow modification
// Check if zero work is due to ΔV = 0 or P_ext = 0
if (Math.abs(calc.deltaV) < 1e-10) {
// ΔV = 0 case - volumes are the same
explanation.blurb = "No work is performed. This occurs because the initial and final volumes are the same (<b>Δ<i>V</i> = 0</b>). Since there is no volume change, no work was done regardless of the pressure difference.";
} else if (Math.abs(controlValue) < 1e-10) {
// P_ext = 0 case - vacuum expansion
explanation.blurb = "No work is performed. This occurs because the gas is expanding into a vacuum (<b>P<sub>ext</sub> = 0</b>). Although the gas expands, there is no opposing pressure to work against, so no work is done.";
}
// Otherwise, use the default blurb which covers both cases
}
} else if (state.mode === 'spring') {
const work = calc.work_J;
// Check the sign of work and select appropriate explanation
if (work < 0) {
explanation = processExplanations.spring.negative;
} else if (work > 0) {
explanation = processExplanations.spring.positive;
} else {
// Zero work - sophisticated logic to determine cause
explanation = { ...processExplanations.spring.zero }; // Copy object to allow modification
// Check if zero work is due to Δx = 0 or F_ext = 0
if (Math.abs(calc.deltaX) < 1e-10) {
// Δx = 0 case - positions are the same
explanation.blurb = "No work is performed. This occurs because the initial and final positions of the block are the same (<b>Δ<i>x</i> = 0</b>). Since there is no displacement, no work was done regardless of the force applied.";
} else if (Math.abs(controlValue) < 1e-10) {
// F_ext = 0 case - free expansion
explanation.blurb = "This is a free expansion to the spring's natural equilibrium (rest length); no work is done as there is no opposing force.";
}
// Otherwise, use the default blurb which covers both cases
}
}
// No constraint warning needed - visual indicators (red spring, dual force display) are sufficient
let constraintWarning = '';
// Update the process explanation display
processExplanationDiv.innerHTML = `
<div class="process-explanation-title">${explanation.title}</div>
<div class="process-explanation-content">${explanation.blurb}</div>
${constraintWarning}
`;
}
// ==================== UPDATE FUNCTION ====================
function updateVisualization(controlValue) {
const calc = calculateState(controlValue);
if (state.mode === 'gas') {
// Show gas visualization, hide spring
pistonDiv.style.display = 'block';
springDiv.style.display = 'none';
const P_ext = controlValue;
// Update piston position - Map volume to visual position with constraints
const volumeFraction = (calc.V_final - minVolume) / (maxVolume - minVolume);
const usableHeight = cylinderHeight * 0.85;
const pistonY = cylinderTop + (1 - volumeFraction) * usableHeight;
pistonGroup.transition().duration(200)
.attr("transform", `translate(0, ${pistonY})`);
// Update gas region
gasRect.transition().duration(200)
.attr("y", pistonY + pistonThickness)
.attr("height", cylinderBottom - pistonY - pistonThickness)
.attr("fill", calc.isExpansion ? "rgba(100, 200, 255, 0.5)" :
calc.isCompression ? "rgba(255, 150, 100, 0.5)" :
"rgba(150, 150, 255, 0.5)");
// Update labels
volumeLabel
.attr("y", (pistonY + pistonThickness + cylinderBottom) / 2 + 5)
.html(`<tspan style="font-style: italic;">V</tspan> = ${roundBankers(calc.V_final, calc.sigFigs).toPrecision(calc.sigFigs)} L`);
pressureLabel.html(`<tspan style="font-style: italic;">P</tspan><tspan baseline-shift="sub" font-size="9px">ext</tspan> = ${P_ext.toFixed(2)} atm`);
// Update P-V diagram axes and labels for gas mode
pvG.select(".x-axis-label")
.text("Volume (L)")
.attr("font-family", "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif");
pvG.select(".y-axis-label")
.text("Pressure (atm)")
.attr("font-family", "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif");
// Update P-V diagram
initialPoint
.attr("cx", xScale(V_initial))
.attr("cy", yScale(P_initial));
pvG.select(".initial-label")
.attr("x", xScale(V_initial) + 10)
.attr("y", yScale(P_initial) - 10);
finalPoint.transition().duration(200)
.attr("cx", xScale(calc.V_final))
.attr("cy", yScale(calc.P_final));
finalLabel.transition().duration(200)
.attr("x", xScale(calc.V_final) + 10)
.attr("y", yScale(calc.P_final) - 10);
// Update P_ext line
// Hide line if initial and final volumes are equal
const showPextLine = Math.abs(calc.deltaV) > 1e-10;
pExtLine.transition().duration(200)
.attr("x1", xScale(Math.min(V_initial, calc.V_final)))
.attr("y1", yScale(P_ext))
.attr("x2", xScale(Math.max(V_initial, calc.V_final)))
.attr("y2", yScale(P_ext))
.style("opacity", showPextLine ? 1 : 0);
// Update vertical arrow showing pressure change
// Only show arrowhead and line if initial and final states differ
const showArrowhead = Math.abs(P_ext - P_initial) > 1e-10;
pressureChangeArrow.transition().duration(200)
.attr("x1", xScale(V_initial))
.attr("y1", yScale(P_initial))
.attr("x2", xScale(V_initial))
.attr("y2", yScale(P_ext) + 5)
.attr("marker-end", showArrowhead ? "url(#pressure-change-arrow)" : "none")
.style("opacity", showArrowhead ? 1 : 0);
// Update work rectangle
const rectX = Math.min(V_initial, calc.V_final);
const rectWidth = Math.abs(calc.deltaV);
workRect.transition().duration(200)
.attr("x", xScale(rectX))
.attr("y", yScale(P_ext))
.attr("width", xScale(rectX + rectWidth) - xScale(rectX))
.attr("height", yScale(0) - yScale(P_ext))
.attr("fill", calc.isExpansion ? "#888" : "#d00");
} else if (state.mode === 'spring') {
// Show spring visualization, hide gas
pistonDiv.style.display = 'none';
springDiv.style.display = 'block';
const F_ext = controlValue;
// PHYSICAL CONSTRAINTS: Calculate constrained block position
// Calculate theoretical position from Hooke's law: F = k(x_rest - x)
const k_fixed = k_spring_local; // Use fixed spring constant
// Physical position: x = x_rest - F/k
const physicalPosition = x_rest_local - (F_ext / k_fixed);
// Convert to visual coordinates, accounting for our extended range
// Position measured from left edge (minimum compression)
const theoreticalX = springMinBlockX +
(maxExtensionDistance - (F_ext / k_fixed)) / visualToPhysicalScale;
// Apply visual constraints - keep within animation window
const maxBlockRight = springWidth - 25; // Keep 25px from right edge
const maxBlockX = maxBlockRight - springBlockWidth;
const minBlockX = springMinBlockX; // Minimum position from wall
const constrainedX = Math.max(minBlockX, Math.min(maxBlockX, theoreticalX));
// Hide block when F_ext = 0, otherwise show and move it
if (F_ext === 0) {
springBlock.style("opacity", 0);
} else {
springBlock.style("opacity", 1)
.transition().duration(200)
.attr("x", constrainedX);
}
// Update final position indicator to always show current position (spring connection point = left edge of block)
finalPositionIndicator.transition().duration(200)
.attr("x1", constrainedX)
.attr("x2", constrainedX);
finalPositionLabel.transition().duration(200)
.attr("x", constrainedX);
// Update spring path with realistic compression/extension
// Ensure spring connects exactly to the left edge of the block (same as x2 indicator)
const springPathData = drawSpringPath(springWallX, constrainedX, 160);
// Update spring path immediately (SVG path transitions can be problematic between different paths)
springPath.attr("d", springPathData);
// Update spring stroke width based on compression
const compressionRatio = (constrainedX - springWallX) / (springRestX - springWallX);
let strokeWidth = 3;
if (compressionRatio < 0.5) {
strokeWidth = 4; // Thicker when compressed
} else if (compressionRatio > 1.3) {
strokeWidth = 2; // Thinner when extended
}
// Update spring color based on compression/extension state
const isActuallyCompressed = F_ext > F_initial_local; // Higher external force = compression
const isActuallyExtended = F_ext < F_initial_local; // Lower external force = extension
let springColor = isActuallyCompressed ? "#ff8c00" : isActuallyExtended ? "#4169e1" : "#666";
// Warn user if constraint is active
if (constrainedX !== theoreticalX) {
springColor = "#ff0000"; // Red color to indicate constraint
strokeWidth = 4; // Make it thicker when hitting constraint
}
springPath
.attr("stroke", springColor)
.attr("stroke-width", strokeWidth);
// Calculate actual force that would produce constrained position
const actualCompression = maxExtensionDistance - (constrainedX - springMinBlockX) * visualToPhysicalScale;
const actualF_ext = Math.round(k_fixed * actualCompression * 100) / 100; // Round to 2 decimal places to avoid floating point errors
// Hide arrow and block when force is zero (free expansion)
const displayForce = constrainedX !== theoreticalX ? actualF_ext : F_ext;
const arrowLength = displayForce > 0 ? Math.min(40, displayForce / 4) : 0;
springArrowGroup.select("line")
.transition().duration(200)
.attr("x1", displayForce > 0 ? constrainedX + springBlockWidth + 10 + arrowLength : constrainedX + springBlockWidth + 10)
.attr("x2", constrainedX + springBlockWidth + 10)
.style("opacity", displayForce > 0 ? 1 : 0);
// Update labels with actual values - center over block
const forceDisplayText = constrainedX !== theoreticalX ?
`${F_ext.toFixed(1)} N (${actualF_ext.toFixed(1)} N actual)` :
`${F_ext.toFixed(1)} N`;
springForceLabel.html(`<tspan style="font-style: italic;">F</tspan><tspan baseline-shift="sub" font-size="9px">ext</tspan> = ${forceDisplayText}`);
springForceLabel
.transition().duration(200)
.attr("x", constrainedX + springBlockWidth/2) // Center over block
.attr("y", 85) // Keep vertical position constant, positioned above x₁ and x₂ labels
.style("opacity", F_ext > 0 ? 1 : 0.3); // Fade label when force is zero
// Calculate actual displacement for display
const actualX_final = x_rest_local - (actualF_ext / k_fixed);
springDispLabel.html(`<tspan style="font-style: italic;">x</tspan> = ${formatNumberForHTML(actualX_final, calc.sigFigs)} m`);
// Update Δx display with proper Unicode Delta character and HTML minus signs
springDeltaXLabel.html(`Δ<tspan style="font-style: italic;">x</tspan> = ${formatNumberForHTML(calc.deltaX, calc.sigFigs)} m`);
// Show/hide position indicators based on force
const showIndicators = F_ext > 0;
initialPositionLine.style("opacity", showIndicators ? 0.7 : 0.3);
initialPositionLabel.style("opacity", showIndicators ? 1 : 0.3);
finalPositionIndicator.style("opacity", showIndicators ? 0.7 : 0.3);
finalPositionLabel.style("opacity", showIndicators ? 1 : 0.3);
springDeltaXLabel.style("opacity", showIndicators ? 1 : 0.3);
// Update diagram for spring mode (F-x diagram)
pvG.select(".x-axis-label")
.text("Displacement (m)")
.attr("font-family", "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif");
pvG.select(".y-axis-label")
.text("Force (N)")
.attr("font-family", "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif");
// Update scales for spring mode
const xScaleSpring = d3.scaleLinear()
.domain([0.35, 1.05]) // Expanded range to accommodate enhanced compression
.range([0, pvInnerWidth]);
const yScaleSpring = d3.scaleLinear()
.domain([0, 120]) // Increased to 120 N to handle 0-100 N range comfortably
.range([pvInnerHeight, 0]);
// Update axes for spring mode
const xAxisSpring = d3.axisBottom(xScaleSpring).ticks(6);
const yAxisSpring = d3.axisLeft(yScaleSpring).ticks(6);
pvG.select(".x-axis").call(xAxisSpring);
pvG.select(".y-axis").call(yAxisSpring);
// Update points for spring mode with actual constrained values
initialPoint
.attr("cx", xScaleSpring(x_initial_local))
.attr("cy", yScaleSpring(F_initial_local));
pvG.select(".initial-label")
.attr("x", xScaleSpring(x_initial_local) + 10)
.attr("y", yScaleSpring(F_initial_local) - 10);
finalPoint.transition().duration(200)
.attr("cx", xScaleSpring(actualX_final))
.attr("cy", yScaleSpring(actualF_ext));
finalLabel.transition().duration(200)
.attr("x", xScaleSpring(actualX_final) + 10)
.attr("y", yScaleSpring(actualF_ext) - 10);
// Update F_ext line with actual force
// Hide line if initial and final positions are equal
const showFextLine = Math.abs(calc.deltaX) > 1e-10;
pExtLine.transition().duration(200)
.attr("x1", xScaleSpring(Math.min(x_initial_local, actualX_final)))
.attr("y1", yScaleSpring(actualF_ext))
.attr("x2", xScaleSpring(Math.max(x_initial_local, actualX_final)))
.attr("y2", yScaleSpring(actualF_ext))
.style("opacity", showFextLine ? 1 : 0);
// Update vertical arrow showing force change
// Only show arrowhead and line if initial and final states differ
const showArrowheadSpring = Math.abs(F_ext - F_initial_local) > 1e-10;
pressureChangeArrow.transition().duration(200)
.attr("x1", xScaleSpring(x_initial_local))
.attr("y1", yScaleSpring(F_initial_local))
.attr("x2", xScaleSpring(x_initial_local))
.attr("y2", yScaleSpring(F_ext) + 5)
.attr("marker-end", showArrowheadSpring ? "url(#pressure-change-arrow)" : "none")
.style("opacity", showArrowheadSpring ? 1 : 0);
// Update work rectangle for spring mode with actual constrained values
const actualDeltaX = actualX_final - x_initial_local;
const rectX = Math.min(x_initial_local, actualX_final);
const rectWidth = Math.abs(actualDeltaX);
workRect.transition().duration(200)
.attr("x", xScaleSpring(rectX))
.attr("y", yScaleSpring(actualF_ext))
.attr("width", xScaleSpring(rectX + rectWidth) - xScaleSpring(rectX))
.attr("height", yScaleSpring(0) - yScaleSpring(actualF_ext))
.attr("fill", calc.isExpansion ? "#888" : "#d00");
}
// Update state table
updateStateTable(calc);
// Update work calculation
updateWorkCalculation(controlValue, calc);
// Update process explanation
updateProcessExplanation(controlValue, calc);
}
// ==================== STATE TABLE ====================
function updateStateTable(calc) {
let tableHTML;
if (state.mode === 'gas') {
tableHTML = `
<table class="results-table" style="margin: 12px auto; max-width: 600px;">
<thead style="background: #34495e;">
<tr>
<th style="color: white; background: #34495e;">State</th>
<th style="color: white; background: #34495e;"><i>P</i><sub>ext</sub> (atm)</th>
<th style="color: white; background: #34495e;"><i>V</i> (L)</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Initial</strong></td>
<td>${formatNumberForHTML(P_initial, 3)}</td>
<td>${formatNumberForHTML(V_initial, 3)}</td>
</tr>
<tr>
<td><strong>Final</strong></td>
<td>${formatNumberForHTML(calc.P_final, calc.sigFigs)}</td>
<td>${formatNumberForHTML(calc.V_final, calc.sigFigs)}</td>
</tr>
<tr>
<td><strong>Change</strong></td>
<td>${formatNumberForHTML(calc.P_final - P_initial, calc.sigFigs)}</td>
<td>${formatNumberForHTML(calc.deltaV, calc.sigFigs)}</td>
</tr>
</tbody>
</table>
`;
} else {
tableHTML = `
<table class="results-table" style="margin: 12px auto; max-width: 600px;">
<thead style="background: #34495e;">
<tr>
<th style="color: white; background: #34495e;">State</th>
<th style="color: white; background: #34495e;"><i>F</i><sub>ext</sub> (N)</th>
<th style="color: white; background: #34495e;"><i>x</i> (m)</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Initial</strong></td>
<td>${formatNumberForHTML(F_initial_local, 3)}</td>
<td>${formatNumberForHTML(x_initial_local, 3)}</td>
</tr>
<tr>
<td><strong>Final</strong></td>
<td>${formatNumberForHTML(calc.F_final, calc.sigFigs)}</td>
<td>${formatNumberForHTML(calc.x_final, calc.sigFigs)}</td>
</tr>
<tr>
<td><strong>Change</strong></td>
<td>${formatNumberForHTML(Math.abs(calc.F_final - F_initial_local) < 1e-10 ? 0 : calc.F_final - F_initial_local, calc.sigFigs)}</td>
<td>${formatNumberForHTML(calc.deltaX, calc.sigFigs)}</td>
</tr>
</tbody>
</table>
`;
}
stateTableDiv.innerHTML = tableHTML;
}
// ==================== WORK CALCULATION ====================
// Helper function to build LaTeX without Quarto interference
function buildLatexString(lines) {
const mathDelim = '$' + '$';
const bs = String.fromCharCode(92); // Backslash character
// Build the align* environment properly
const alignStart = bs + 'begin{align*}';
const alignEnd = bs + 'end{align*}';
// Create line breaks: \\ followed by [1.5ex]
const lineBreak = bs + bs + '[1.5ex]';
const lineBreak2ex = bs + bs + '[2ex]';
// Process each line and add line breaks
const processedLines = lines.map((line, idx) => {
if (idx === lines.length - 1) {
// Last line - no line break
return line;
}
// Check for special 2ex break marker
if (line.includes('BREAK2EX')) {
return line.replace('BREAK2EX', '') + ' ' + lineBreak2ex;
}
// Normal line break
return line + ' ' + lineBreak;
});
// Join all lines with proper spacing
const latexContent = processedLines.join('\n ');
// Build complete LaTeX string
return mathDelim + ' ' + alignStart + '\n ' + latexContent + '\n ' + alignEnd + ' ' + mathDelim;
}
function updateWorkCalculation(controlValue, calc) {
let latexCalc;
if (state.mode === 'gas') {
const P_ext = controlValue;
/*
WORK CALCULATION METHODOLOGY - GAS MODE:
The work equation w = -P_ext × ΔV derives from the definition of work:
w = -∫P_ext dV, where P_ext is constant for irreversible processes
Step-by-step calculation shown:
1. Start with fundamental equation: w = -P_ext × ΔV
2. Substitute volume values: w = -P_ext × (V_final - V_initial)
3. Insert numerical values with units
4. Calculate volume change
5. Multiply by external pressure (work in L·atm)
6. Apply significant figure rounding
7. Convert to kJ using exact conversion factor (1 L·atm = 0.101325 kJ)
8. Round to appropriate significant figures
PHYSICAL INTERPRETATION:
- Negative work: System does work on surroundings (expansion)
- Positive work: Surroundings do work on system (compression)
- Zero work: No volume change
*/
const processType = calc.isExpansion ? "Expansion (work done <b>by</b> system)" :
calc.isCompression ? "Compression (work done <b>on</b> system)" :
"No net change";
const signNote = calc.work_kJ < 0 ? "negative (the system loses energy)" :
calc.work_kJ > 0 ? "positive (the system gains energy)" : "zero";
// Build LaTeX lines programmatically to avoid Quarto escaping issues
const bs = String.fromCharCode(92); // backslash
const latexLines = [
`w &= -P_{${bs}text{ext}}~${bs}Delta V`,
`&= -P_{${bs}text{ext}}~(V_2 - V_1)`,
`&= -(${getInputWithBar(P_ext, calc.sigFigs)}~${bs}mathrm{atm})(${getInputWithBar(calc.V_final, calc.sigFigs)} - ${getInputWithBar(V_initial, 3)})~${bs}mathrm{L}`,
`&= -(${getInputWithBar(P_ext, calc.sigFigs)}~${bs}mathrm{atm})(${getInputWithBar(calc.deltaV, calc.sigFigs, calc.deltaV_decimalPlaces)}~${bs}mathrm{L})`,
`&= ${getUnroundedWithBar(calc.work_Latm, calc.sigFigs, calc.work_Latm_decimalPlaces)}~${bs}mathrm{L~atm}`,
`&= ${roundBankers(calc.work_Latm, calc.sigFigs).toPrecision(calc.sigFigs)}~${bs}mathrm{L~atm}BREAK2EX`,
`&= (${getUnroundedWithBar(calc.work_Latm, calc.sigFigs, calc.work_Latm_decimalPlaces)}~${bs}mathrm{L~atm})${bs}left( ${bs}frac{0.101325~${bs}mathrm{kJ}}{1~${bs}mathrm{L~atm}} ${bs}right)`,
`&= ${getUnroundedWithBar(calc.work_kJ, calc.sigFigs, calc.work_kJ_decimalPlaces)}~${bs}mathrm{kJ}`,
`&= ${roundBankers(calc.work_kJ, calc.sigFigs).toPrecision(calc.sigFigs)}~${bs}mathrm{kJ}`
];
const latexMath = buildLatexString(latexLines);
// Debug: log the generated LaTeX
console.log('Generated LaTeX for gas mode:', latexMath);
latexCalc = `
<div style="max-width: 800px; margin: 12px auto;">
<div class="calculation-box" style="text-align: center;">
${latexMath}
</div>
</div>
`;
} else {
const F_ext = controlValue;
/*
WORK CALCULATION METHODOLOGY - SPRING MODE:
The work equation w = F_ext × Δx derives from the definition of mechanical work:
w = ∫F_ext dx, where F_ext is constant for the process
Step-by-step calculation shown:
1. Start with fundamental equation: w = F_ext × Δx
2. Substitute displacement values: w = F_ext × (x_final - x_initial)
3. Insert numerical values with units
4. Calculate displacement change
5. Multiply by external force (work in Joules)
6. Apply significant figure rounding
7. Convert to kJ for display consistency
PHYSICAL INTERPRETATION:
- Positive work: Force applied in direction of displacement (compression)
- Negative work: Force applied opposite to displacement (extension)
- Zero work: No displacement change
*/
const processType = calc.isCompression ? "Compression (work done <b>on</b> spring)" :
calc.isExpansion ? "Extension (work done <b>by</b> spring)" :
"No net change";
const signNote = calc.work_kJ > 0 ? "positive (energy stored in spring)" :
calc.work_kJ < 0 ? "negative (energy released from spring)" : "zero";
// Build LaTeX lines programmatically to avoid Quarto escaping issues
const bs = String.fromCharCode(92); // backslash
const latexLines = [
`w &= F_{${bs}text{ext}}~${bs}Delta x`,
`&= F_{${bs}text{ext}}~(x_2 - x_1)`,
`&= (${getInputWithBar(F_ext, calc.sigFigs)}~${bs}mathrm{N})~(${getInputWithBar(calc.x_final, calc.sigFigs)} - ${getInputWithBar(x_initial_local, 3)})~${bs}mathrm{m}`,
`&= (${getInputWithBar(F_ext, calc.sigFigs)}~${bs}mathrm{N}) ~ (${getInputWithBar(calc.deltaX, calc.sigFigs, calc.deltaX_decimalPlaces)}~${bs}mathrm{m})`,
`&= ${getUnroundedWithBar(calc.work_J, calc.sigFigs, calc.work_J_decimalPlaces)}~${bs}mathrm{J}`,
`&= ${roundBankers(calc.work_J, calc.sigFigs).toPrecision(calc.sigFigs)}~${bs}mathrm{J}BREAK2EX`,
`&= (${getUnroundedWithBar(calc.work_J, calc.sigFigs, calc.work_J_decimalPlaces)}~${bs}mathrm{J})${bs}left(${bs}dfrac{1~${bs}mathrm{kJ}}{10^3~${bs}mathrm{J}}${bs}right)`,
`&= ${getUnroundedWithBar(calc.work_kJ, calc.sigFigs, calc.work_kJ_decimalPlaces)}~${bs}mathrm{kJ}`,
`&= ${roundBankers(calc.work_kJ, calc.sigFigs).toPrecision(calc.sigFigs)}~${bs}mathrm{kJ}`
];
const latexMath = buildLatexString(latexLines);
// Debug: log the generated LaTeX
console.log('Generated LaTeX for spring mode:', latexMath);
latexCalc = `
<div style="max-width: 800px; margin: 12px auto;">
<div class="calculation-box" style="text-align: center;">
${latexMath}
</div>
</div>
`;
}
// Clear previous content first to prevent double-rendering
workCalcDiv.innerHTML = '';
// Use a timeout to ensure DOM is ready and avoid race conditions with page-level MathJax
setTimeout(() => {
workCalcDiv.innerHTML = latexCalc;
// Force MathJax to clear any previous typesetting in this div
if (window.MathJax && window.MathJax.typesetClear) {
window.MathJax.typesetClear([workCalcDiv]);
}
// Trigger MathJax rendering with comprehensive style reset
if (window.MathJax && window.MathJax.typesetPromise) {
window.MathJax.typesetPromise([workCalcDiv])
.then(() => {
// Apply comprehensive styling to override global site styles
setTimeout(() => {
const widgetElement = document.getElementById('work-widget-container');
if (widgetElement) {
// Reset ALL MathJax elements to prevent global inheritance
const allMathJaxElements = widgetElement.querySelectorAll('.MathJax, .MathJax_Display, mjx-container, .MathJax_CHTML, .MathJax_SVG');
allMathJaxElements.forEach(element => {
element.style.setProperty('font-size', '100%', 'important');
element.style.setProperty('line-height', 'normal', 'important');
element.style.setProperty('overflow', 'visible', 'important');
element.style.setProperty('white-space', 'normal', 'important');
element.style.setProperty('word-wrap', 'normal', 'important');
element.style.setProperty('display', element.classList.contains('MathJax_Display') ? 'block' : 'inline-block', 'important');
});
// Hide equation numbers in align* environments
const refElements = widgetElement.querySelectorAll('.MathJax_ref, .mjx-label');
refElements.forEach(ref => {
ref.style.setProperty('display', 'none', 'important');
});
// Ensure display equations are centered
const mathDisplays = widgetElement.querySelectorAll('.MathJax_Display');
mathDisplays.forEach(display => {
display.style.setProperty('text-align', 'center', 'important');
display.style.setProperty('margin', '1.5em 0', 'important');
display.style.setProperty('display', 'block', 'important');
});
// Target nested container elements specifically - use setProperty for !important
const nestedContainers = widgetElement.querySelectorAll('mjx-container mjx-container, .MathJax mjx-container, .MathJax_Display mjx-container');
nestedContainers.forEach(container => {
container.style.setProperty('font-size', '100%', 'important');
container.style.setProperty('overflow', 'visible', 'important');
container.style.setProperty('white-space', 'normal', 'important');
container.style.setProperty('display', 'inline-block', 'important');
});
// Special handling for mjx-container elements to override global 110%
const mjxContainers = widgetElement.querySelectorAll('mjx-container');
mjxContainers.forEach(container => {
container.style.setProperty('font-size', '100%', 'important');
container.style.setProperty('overflow-x', 'visible', 'important');
container.style.setProperty('overflow-y', 'visible', 'important');
});
}
}, 100);
})
.catch((err) => {
console.log('MathJax rendering error:', err);
// Fallback: try to render with older MathJax methods
if (window.MathJax && window.MathJax.Hub) {
window.MathJax.Hub.Queue(["Typeset", window.MathJax.Hub, workCalcDiv]);
}
});
}
}, 50);
}
// ==================== EVENT LISTENERS ====================
// Mode toggle event listeners - use querySelector on container to find buttons
const gasModeBtn = container.querySelector('#gas-mode-btn');
const springModeBtn = container.querySelector('#spring-mode-btn');
if (gasModeBtn && springModeBtn) {
gasModeBtn.addEventListener('click', () => {
state.mode = 'gas';
gasModeBtn.classList.add('active');
springModeBtn.classList.remove('active');
updateWidgetTitle(); // Update title when mode changes
renderControls();
updateVisualization(state.P_ext);
});
springModeBtn.addEventListener('click', () => {
state.mode = 'spring';
springModeBtn.classList.add('active');
gasModeBtn.classList.remove('active');
updateWidgetTitle(); // Update title when mode changes
renderControls();
updateVisualization(state.F_ext);
});
}
// Initial render
updateWidgetTitle(); // Set initial title
renderControls();
updateVisualization(state.P_ext);
return container;
}