bpEnData = [
{s: 'H', z: 1, en: 2.20}, {s: 'Li', z: 3, en: 0.98}, {s: 'Be', z: 4, en: 1.57},
{s: 'B', z: 5, en: 2.04}, {s: 'C', z: 6, en: 2.55}, {s: 'N', z: 7, en: 3.04},
{s: 'O', z: 8, en: 3.44}, {s: 'F', z: 9, en: 3.98}, {s: 'Na', z: 11, en: 0.93},
{s: 'Mg', z: 12, en: 1.31}, {s: 'Al', z: 13, en: 1.61}, {s: 'Si', z: 14, en: 1.90},
{s: 'P', z: 15, en: 2.19}, {s: 'S', z: 16, en: 2.58}, {s: 'Cl', z: 17, en: 3.16},
{s: 'K', z: 19, en: 0.82}, {s: 'Ca', z: 20, en: 1.00}, {s: 'Br', z: 35, en: 2.96},
{s: 'Rb', z: 37, en: 0.82}, {s: 'Sr', z: 38, en: 0.95}, {s: 'I', z: 53, en: 2.66},
{s: 'Cs', z: 55, en: 0.79}, {s: 'Ba', z: 56, en: 0.89}, {s: 'Fr', z: 87, en: 0.70}
]
bpEnSorted = bpEnData.slice().sort((a, b) => a.s.localeCompare(b.s))
bpPresets = [
{label: 'H\u2082', a: 'H', b: 'H'},
{label: 'HF', a: 'H', b: 'F'},
{label: 'HCl', a: 'H', b: 'Cl'},
{label: 'CO', a: 'C', b: 'O'},
{label: 'NaCl', a: 'Na', b: 'Cl'},
{label: 'MgO', a: 'Mg', b: 'O'},
{label: 'CsF', a: 'Cs', b: 'F'}
]Bond Properties
In periodic trends, we introduced electronegativity (χ) as the tendency of an atom to attract shared electrons toward itself. When two atoms form a bond, the electronegativity difference (Δχ) between them determines how the bonding electrons are distributed. This distribution is not an all-or-nothing choice between “shared” and “transferred.” It is a continuum from purely covalent to ionic.
Two identical atoms share electrons equally (pure covalent bond, Δχ = 0). A metal bonded to a nonmetal effectively transfers electrons (ionic bond, large Δχ). Most bonds fall somewhere in between, with electron density shifted toward the more electronegative atom.
Classifying Bonds by Δχ
Most textbooks classify bonds using Δχ thresholds:
- Δχ < 0.4: Nonpolar covalent. Electrons are shared nearly equally.
- 0.4 ≤ Δχ < 1.7: Polar covalent. Electrons are shifted toward the more electronegative atom.
- Δχ ≥ 1.7: Ionic. Electrons are transferred.
These boundaries are approximate guidelines, not sharp dividers. The 0.4 boundary corresponds to ~4 % ionic character, and the 1.7 boundary corresponds to ~50 % ionic character. Some textbooks use 0.5/2.0 or 0.4/1.8 instead.
Visualizing Electron Cloud Deformation
The interactive visualization below shows how the electron cloud deforms as electronegativity difference increases, from symmetric (nonpolar) to skewed (polar) to fully separated (ionic).
// Custom element selectors and presets in one control bar
viewof bpSelection = {
const container = document.createElement('div');
container.className = 'bp-controls';
// Element A
const selA = document.createElement('select');
selA.className = 'bp-select';
bpEnSorted.forEach(d => {
const opt = document.createElement('option');
opt.value = d.s;
opt.textContent = d.s;
if (d.s === 'H') opt.selected = true;
selA.appendChild(opt);
});
// Element B
const selB = document.createElement('select');
selB.className = 'bp-select';
bpEnSorted.forEach(d => {
const opt = document.createElement('option');
opt.value = d.s;
opt.textContent = d.s;
if (d.s === 'Cl') opt.selected = true;
selB.appendChild(opt);
});
const dash = document.createElement('span');
dash.className = 'bp-bond-dash';
dash.textContent = '\u2014';
const selectorGroup = document.createElement('div');
selectorGroup.className = 'bp-selector-group';
selectorGroup.appendChild(selA);
selectorGroup.appendChild(dash);
selectorGroup.appendChild(selB);
// Preset buttons
const presetGroup = document.createElement('div');
presetGroup.className = 'bp-preset-group';
bpPresets.forEach(p => {
const btn = document.createElement('button');
btn.className = 'bp-preset-btn';
btn.textContent = p.label;
btn.addEventListener('click', () => {
selA.value = p.a;
selB.value = p.b;
update();
});
presetGroup.appendChild(btn);
});
container.appendChild(selectorGroup);
container.appendChild(presetGroup);
function update() {
container.value = {a: selA.value, b: selB.value};
container.dispatchEvent(new Event('input', {bubbles: true}));
}
selA.addEventListener('change', update);
selB.addEventListener('change', update);
container.value = {a: 'H', b: 'Cl'};
return container;
}// Compute bond polarity
bpResult = {
const a = bpEnData.find(d => d.s === bpSelection.a);
const b = bpEnData.find(d => d.s === bpSelection.b);
if (!a || !b) return null;
const enA = a.en;
const enB = b.en;
const delta = Math.abs(enA - enB);
// Classification uses metal/nonmetal heuristic, not just delta-chi
// Metal + nonmetal = ionic (regardless of delta-chi)
// Nonmetal + nonmetal = covalent (regardless of delta-chi)
const bpMetals = new Set(['Li','Be','Na','Mg','Al','K','Ca','Rb','Sr','Cs','Ba','Fr']);
const isMetalBond = bpMetals.has(a.s) !== bpMetals.has(b.s);
let classification;
if (isMetalBond) {
classification = 'Ionic';
} else if (delta < 0.4) {
classification = 'Nonpolar covalent';
} else {
classification = 'Polar covalent';
}
const ionicPercent = 100 * (1 - Math.exp(-0.25 * delta * delta));
return {
symA: a.s, symB: b.s,
enA, enB, delta,
ionicPercent,
classification
};
}mutable bpDarkMode = document.documentElement.getAttribute('data-bs-theme') === 'dark'
|| document.body.classList.contains('quarto-dark')
bpThemeObserver = {
const check = () => {
const dark = document.documentElement.getAttribute('data-bs-theme') === 'dark'
|| document.body.classList.contains('quarto-dark');
if (dark !== mutable bpDarkMode) mutable bpDarkMode = dark;
};
const obs = new MutationObserver(check);
obs.observe(document.documentElement, {attributes: true, attributeFilter: ['data-bs-theme']});
obs.observe(document.body, {attributes: true, attributeFilter: ['class']});
invalidation.then(() => obs.disconnect());
return true;
}
// Color scales: light and dark
bpColorScale = {
// Light: green → amber → red (smooth continuous blend, no plateaus)
const lightStops = [
{t: 0, color: '#15803d'},
{t: 0.25, color: '#4d7c0f'},
{t: 0.45, color: '#a16207'},
{t: 0.65, color: '#b45309'},
{t: 0.80, color: '#dc2626'},
{t: 1, color: '#b91c1c'}
];
// Dark (cyberpunk): cyan → purple → hot pink (smooth continuous blend)
const darkStops = [
{t: 0, color: '#00d9ff'},
{t: 0.25, color: '#6da0f6'},
{t: 0.45, color: '#a77cf6'},
{t: 0.65, color: '#c084fc'},
{t: 0.80, color: '#e050a0'},
{t: 1, color: '#ff007c'}
];
function makeScale(stops) {
return d3.scaleLinear()
.domain(stops.map(s => s.t))
.range(stops.map(s => s.color))
.interpolate(d3.interpolateRgb)
.clamp(true);
}
return {
lightStops, darkStops,
lightScale: makeScale(lightStops),
darkScale: makeScale(darkStops)
};
}
// Electron cloud deformation visualization (Canvas 2D + SVG overlay)
bpCloudViz = {
if (!bpResult) return document.createComment('no cloud viz');
const { symA, symB, enA, enB, delta, classification } = bpResult;
const isDark = bpDarkMode;
// Van der Waals radii (pm) - determines cloud extent
const vdwRadii = {
H: 120, Li: 182, Be: 153, B: 192, C: 170, N: 155, O: 152, F: 147,
Na: 227, Mg: 173, Al: 184, Si: 210, P: 180, S: 180, Cl: 175,
K: 275, Ca: 231, Br: 185, Rb: 303, Sr: 249, I: 198, Cs: 343,
Ba: 268, Fr: 348
};
// Covalent radii (pm) - determines nucleus display size
const covRadii = {
H: 31, Li: 128, Be: 96, B: 84, C: 77, N: 75, O: 73, F: 64,
Na: 166, Mg: 141, Al: 121, Si: 111, P: 107, S: 105, Cl: 102,
K: 203, Ca: 176, Br: 120, Rb: 220, Sr: 195, I: 140, Cs: 244,
Ba: 215, Fr: 260
};
// Simplified CPK colors for nuclei
const cpkColors = {
light: {
H: '#6b7280', Li: '#7c3aed', Be: '#059669', B: '#d97706', C: '#1f2937',
N: '#2563eb', O: '#dc2626', F: '#16a34a', Na: '#7c3aed', Mg: '#059669',
Al: '#6b7280', Si: '#d97706', P: '#d97706', S: '#eab308', Cl: '#16a34a',
K: '#7c3aed', Ca: '#059669', Br: '#92400e', Rb: '#7c3aed', Sr: '#059669',
I: '#6b21a8', Cs: '#7c3aed', Ba: '#059669', Fr: '#7c3aed'
},
dark: {
H: '#d1d5db', Li: '#a78bfa', Be: '#34d399', B: '#fbbf24', C: '#e5e7eb',
N: '#60a5fa', O: '#f87171', F: '#4ade80', Na: '#a78bfa', Mg: '#34d399',
Al: '#a8afc7', Si: '#fbbf24', P: '#fbbf24', S: '#facc15', Cl: '#4ade80',
K: '#a78bfa', Ca: '#34d399', Br: '#d97706', Rb: '#a78bfa', Sr: '#34d399',
I: '#c084fc', Cs: '#a78bfa', Ba: '#34d399', Fr: '#a78bfa'
}
};
function nucleusTextDark(sym, dark) {
if (dark) return true;
return ['H', 'S', 'Ca', 'Be', 'Mg', 'Sr', 'Ba'].includes(sym);
}
// --- Parameterization ---
// Always match dropdown order: element A on left, element B on right
const leftSym = symA, rightSym = symB;
const leftEN = enA, rightEN = enB;
// Positions (in viewBox coordinates - 600x340)
const vbW = 600, vbH = 340;
const cxPos = 300, cyPos = 170, bondLen = 180;
const leftX = cxPos - bondLen / 2; // 210
const rightX = cxPos + bondLen / 2; // 390
// Metal/nonmetal classification
const metals = new Set([
'Li','Be','Na','Mg','Al','K','Ca','Rb','Sr','Cs','Ba','Fr'
]);
const hasMetalBond = metals.has(leftSym) !== metals.has(rightSym);
const ionicFrac = 1 - Math.exp(-0.25 * delta * delta);
// === NUCLEAR CLOUD PARAMETERS ===
// Compact spherical Gaussians centered on each nucleus.
// For covalent bonds: small highlights INSIDE the bonding ellipsoid.
// For ionic bonds: these ARE the electron cloud (no bonding ellipsoid).
const vdwL = vdwRadii[leftSym] || 170;
const vdwR = vdwRadii[rightSym] || 170;
const vdwFloor = 120, vdwCeil = 348;
// Nuclear cloud sizing depends on bond type (metal/nonmetal), NOT ionicFrac
// Covalent (NM+NM): compact highlights inside bonding ellipsoid
// Ionic (M+NM): larger clouds that ARE the full electron shell
const leftIsCation = leftEN <= rightEN;
let rLeft, rRight, wNucLeft, wNucRight, alphaNuc;
if (hasMetalBond) {
// Ionic: nuclear clouds are the primary visualization
const rMinI = 85, rMaxI = 135;
const rLeftBase = rMinI + (rMaxI - rMinI) * Math.min(1, Math.max(0, (vdwL - vdwFloor) / (vdwCeil - vdwFloor)));
const rRightBase = rMinI + (rMaxI - rMinI) * Math.min(1, Math.max(0, (vdwR - vdwFloor) / (vdwCeil - vdwFloor)));
rLeft = rLeftBase * (leftIsCation ? (1 - ionicFrac * 0.35) : (1 + ionicFrac * 0.15));
rRight = rRightBase * (leftIsCation ? (1 + ionicFrac * 0.15) : (1 - ionicFrac * 0.35));
alphaNuc = 7;
wNucLeft = leftIsCation ? (1.0 - 0.3 * ionicFrac) : (1.0 + 0.3 * ionicFrac);
wNucRight = leftIsCation ? (1.0 + 0.3 * ionicFrac) : (1.0 - 0.3 * ionicFrac);
} else {
// Covalent: compact highlights, always inside the bonding ellipsoid
const rMinC = 18, rMaxC = 28;
const rLeftBase = rMinC + (rMaxC - rMinC) * Math.min(1, Math.max(0, (vdwL - vdwFloor) / (vdwCeil - vdwFloor)));
const rRightBase = rMinC + (rMaxC - rMinC) * Math.min(1, Math.max(0, (vdwR - vdwFloor) / (vdwCeil - vdwFloor)));
rLeft = rLeftBase;
rRight = rRightBase;
wNucLeft = 1.0;
wNucRight = 1.0;
alphaNuc = 5.5;
}
// === BONDING CLOUD PARAMETERS ===
// Egg-shaped Gaussian spanning the bond axis (shared bonding electrons)
const enDiff = (rightEN - leftEN) / (rightEN + leftEN); // 0 for nonpolar, up to ~0.67
const bondCenterX = cxPos + 0.50 * enDiff * (bondLen / 2); // shift toward more EN atom
const rxBase = bondLen * 0.68 * (1 - 0.3 * ionicFrac); // base parallel radius
const ryBase = bondLen * 0.40; // perpendicular base radius
const eggSign = rightEN >= leftEN ? 1 : -1; // positive = wider on right (more EN), negative = wider on left
const eggAmountY = eggSign * 0.65 * (delta / 3.3); // perpendicular asymmetry
const eggAmountX = eggSign * 0.20 * (delta / 3.3); // parallel asymmetry (shorter on narrow end)
const alphaBond = 2.5;
// Metal+nonmetal: no bonding cloud (ionic — electrons transferred, not shared)
// Nonmetal+nonmetal: bonding cloud fades gently with ionicFrac
const wBond = hasMetalBond ? 0 : 0.65 * (1 - 0.5 * ionicFrac);
// Nucleus display radii
const nucleusScale = 0.09;
const nucRLeft = Math.max(8, (covRadii[leftSym] || 100) * nucleusScale);
const nucRRight = Math.max(8, (covRadii[rightSym] || 100) * nucleusScale);
// Colors
const cloudColor = isDark ? [34, 211, 238] : [59, 130, 246]; // RGB arrays
const palette = isDark ? cpkColors.dark : cpkColors.light;
const nucColorLeft = palette[leftSym] || '#6b7280';
const nucColorRight = palette[rightSym] || '#6b7280';
const plusColor = isDark ? '#f87171' : '#dc2626';
const minusColor = isDark ? '#3b82f6' : '#2563eb';
const dipoleColor = isDark ? '#a8afc7' : '#6b7280';
// --- Build container ---
const container = document.createElement('div');
container.className = 'ec-cloud-container';
container.style.cssText = 'position:relative;width:100%;';
// --- Canvas element ---
// Render at 1x resolution (600x340), CSS scales to fill container
const canvasW = 600, canvasH = 340;
const canvas = document.createElement('canvas');
canvas.width = canvasW;
canvas.height = canvasH;
canvas.className = 'ec-cloud-canvas';
canvas.style.cssText = 'display:block;width:100%;height:auto;';
container.appendChild(canvas);
// --- Render cloud on canvas ---
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(canvasW, canvasH);
const pixels = imageData.data;
const [cr, cg, cb] = cloudColor;
// Three-term density function: nuclear_L + nuclear_R + bonding(egg-shaped)
function density(x, y) {
const dyy = y - cyPos;
// Nuclear clouds (spherical, tight)
const dxL = x - leftX, dxR = x - rightX;
const r2L = (dxL * dxL + dyy * dyy) / (rLeft * rLeft);
const r2R = (dxR * dxR + dyy * dyy) / (rRight * rRight);
const nucL = wNucLeft * Math.exp(-alphaNuc * r2L);
const nucR = wNucRight * Math.exp(-alphaNuc * r2R);
// Bonding cloud (egg-shaped Gaussian along bond axis)
// Both rx and ry vary with position: shorter+narrower on less-EN end, longer+wider on more-EN end
let bond = 0;
if (wBond > 0.001) {
const dx = x - bondCenterX;
const tNorm = dx / rxBase; // normalized position estimate
const rxLocal = rxBase * (1 + eggAmountX * tNorm);
if (rxLocal > 1) {
const t = dx / rxLocal;
const ryLocal = ryBase * (1 + eggAmountY * t);
if (ryLocal > 1) {
const r2B = (dx * dx) / (rxLocal * rxLocal) + (dyy * dyy) / (ryLocal * ryLocal);
bond = wBond * Math.exp(-alphaBond * r2B);
}
}
}
return nucL + nucR + bond;
}
// Gamma compression + alpha mapping
const gamma = 0.45;
const alphaScale = 200; // max alpha value (out of 255)
for (let py = 0; py < canvasH; py++) {
for (let px = 0; px < canvasW; px++) {
const d = density(px, py);
if (d < 0.005) continue; // skip near-zero pixels
const compressed = Math.pow(d, gamma);
const a = Math.min(alphaScale, compressed * alphaScale);
const idx = (py * canvasW + px) * 4;
pixels[idx] = cr;
pixels[idx + 1] = cg;
pixels[idx + 2] = cb;
pixels[idx + 3] = a;
}
}
ctx.putImageData(imageData, 0, 0);
// Optional: apply slight blur for extra softness
// Canvas filter support is good in modern browsers
if (typeof ctx.filter !== 'undefined') {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = canvasW;
tempCanvas.height = canvasH;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.filter = 'blur(2px)';
tempCtx.drawImage(canvas, 0, 0);
ctx.clearRect(0, 0, canvasW, canvasH);
ctx.drawImage(tempCanvas, 0, 0);
}
// --- SVG overlay (nuclei, labels, charges, dipole arrow) ---
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${vbW} ${vbH}`)
.attr("class", "ec-cloud-svg")
.style("position", "absolute")
.style("top", "0")
.style("left", "0")
.style("width", "100%")
.style("height", "100%")
.style("pointer-events", "none"); // let clicks pass through to canvas if needed
const defs = svg.append("defs");
// Soft shadow filter for charge labels — contrast against cloud
const textShadow = defs.append("filter")
.attr("id", "ec-text-shadow")
.attr("x", "-30%").attr("y", "-30%")
.attr("width", "160%").attr("height", "160%");
textShadow.append("feDropShadow")
.attr("dx", 0).attr("dy", 0)
.attr("stdDeviation", 3)
.attr("flood-color", isDark ? "#0a0a14" : "#ffffff")
.attr("flood-opacity", isDark ? 0.9 : 0.85);
// Nucleus shadow filter
const nucShadow = defs.append("filter")
.attr("id", "ec-nuc-shadow")
.attr("x", "-50%").attr("y", "-50%")
.attr("width", "200%").attr("height", "200%");
nucShadow.append("feDropShadow")
.attr("dx", 0).attr("dy", 1.5)
.attr("stdDeviation", 1.5)
.attr("flood-opacity", 0.15);
// Lighten helper for nucleus gradients
function lighten(hex, amt) {
let r = parseInt(hex.slice(1,3), 16);
let g = parseInt(hex.slice(3,5), 16);
let b = parseInt(hex.slice(5,7), 16);
r = Math.min(255, r + amt);
g = Math.min(255, g + amt);
b = Math.min(255, b + amt);
return '#' + [r,g,b].map(c => c.toString(16).padStart(2,'0')).join('');
}
// Nucleus gradients (3D sphere look)
const nucGradLeft = defs.append("radialGradient").attr("id", "ec-nuc-left")
.attr("cx", "35%").attr("cy", "30%").attr("r", "65%").attr("fx", "35%").attr("fy", "30%");
nucGradLeft.append("stop").attr("offset", "0%").attr("stop-color", "#ffffff").attr("stop-opacity", 0.85);
nucGradLeft.append("stop").attr("offset", "30%").attr("stop-color", lighten(nucColorLeft, 60));
nucGradLeft.append("stop").attr("offset", "100%").attr("stop-color", nucColorLeft);
const nucGradRight = defs.append("radialGradient").attr("id", "ec-nuc-right")
.attr("cx", "35%").attr("cy", "30%").attr("r", "65%").attr("fx", "35%").attr("fy", "30%");
nucGradRight.append("stop").attr("offset", "0%").attr("stop-color", "#ffffff").attr("stop-opacity", 0.85);
nucGradRight.append("stop").attr("offset", "30%").attr("stop-color", lighten(nucColorRight, 60));
nucGradRight.append("stop").attr("offset", "100%").attr("stop-color", nucColorRight);
// Arrowhead marker for dipole
const arrowMarker = defs.append("marker")
.attr("id", "ec-arrowhead")
.attr("viewBox", "0 0 14 10")
.attr("refX", 3).attr("refY", 5)
.attr("markerWidth", 10.5).attr("markerHeight", 7)
.attr("orient", "auto");
arrowMarker.append("path")
.attr("d", "M1,0.5 Q0,0 0,1 L2.8,4.2 Q3,5 2.8,5.8 L0,9 Q0,10 1,9.5 L13.5,5.3 Q14,5 13.5,4.7 Z")
.attr("fill", dipoleColor);
// Bond axis line (faint dashed)
svg.append("line")
.attr("x1", leftX - 20).attr("x2", rightX + 20)
.attr("y1", cyPos).attr("y2", cyPos)
.attr("class", "ec-bond-axis");
// Left nucleus
svg.append("circle")
.attr("cx", leftX).attr("cy", cyPos)
.attr("r", nucRLeft)
.attr("fill", "url(#ec-nuc-left)")
.attr("filter", "url(#ec-nuc-shadow)")
.attr("class", "ec-nucleus");
const leftFontSize = Math.min(16, Math.max(10, nucRLeft * 1.4));
svg.append("text")
.attr("x", leftX).attr("y", cyPos)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("class", "ec-nucleus-label")
.style("font-size", leftFontSize + "px")
.style("font-weight", "700")
.style("font-family", "system-ui, sans-serif")
.style("fill", nucleusTextDark(leftSym, isDark) ? '#1f2937' : '#ffffff')
.text(leftSym);
// Right nucleus
svg.append("circle")
.attr("cx", rightX).attr("cy", cyPos)
.attr("r", nucRRight)
.attr("fill", "url(#ec-nuc-right)")
.attr("filter", "url(#ec-nuc-shadow)")
.attr("class", "ec-nucleus");
const rightFontSize = Math.min(16, Math.max(10, nucRRight * 1.4));
svg.append("text")
.attr("x", rightX).attr("y", cyPos)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("class", "ec-nucleus-label")
.style("font-size", rightFontSize + "px")
.style("font-weight", "700")
.style("font-family", "system-ui, sans-serif")
.style("fill", nucleusTextDark(rightSym, isDark) ? '#1f2937' : '#ffffff')
.text(rightSym);
// Charge labels: δ+/δ- for polar covalent, +/- for ionic
// Determine which side gets + and which gets - based on electronegativity
const leftIsMoreEN = leftEN > rightEN;
const plusX = leftIsMoreEN ? rightX : leftX;
const minusX = leftIsMoreEN ? leftX : rightX;
const plusNucR = leftIsMoreEN ? nucRRight : nucRLeft;
const minusNucR = leftIsMoreEN ? nucRLeft : nucRRight;
if (classification === 'Polar covalent') {
const chargeLabelPlus = svg.append("text")
.attr("x", plusX).attr("y", cyPos - plusNucR - 16)
.attr("text-anchor", "middle")
.attr("class", "ec-charge-label")
.attr("filter", "url(#ec-text-shadow)")
.style("fill", plusColor);
chargeLabelPlus.append("tspan").attr("font-style", "italic").text("\u03B4");
chargeLabelPlus.append("tspan").attr("dy", "-3").attr("font-size", "11").text("+");
const chargeLabelMinus = svg.append("text")
.attr("x", minusX).attr("y", cyPos - minusNucR - 16)
.attr("text-anchor", "middle")
.attr("class", "ec-charge-label")
.attr("filter", "url(#ec-text-shadow)")
.style("fill", minusColor);
chargeLabelMinus.append("tspan").attr("font-style", "italic").text("\u03B4");
chargeLabelMinus.append("tspan").attr("dy", "-3").attr("font-size", "11").text("\u2212");
} else if (classification === 'Ionic') {
svg.append("text")
.attr("x", plusX).attr("y", cyPos - plusNucR - 16)
.attr("text-anchor", "middle")
.attr("class", "ec-charge-label")
.attr("filter", "url(#ec-text-shadow)")
.style("fill", plusColor)
.style("font-size", "16px")
.style("font-weight", "700")
.text("+");
svg.append("text")
.attr("x", minusX).attr("y", cyPos - minusNucR - 16)
.attr("text-anchor", "middle")
.attr("class", "ec-charge-label")
.attr("filter", "url(#ec-text-shadow)")
.style("fill", minusColor)
.style("font-size", "16px")
.style("font-weight", "700")
.text("\u2212");
}
// Dipole arrow (only for polar covalent)
// Arrow points FROM less EN atom TOWARD more EN atom
if (classification === 'Polar covalent') {
const arrowY = cyPos + 50;
const fromX = leftIsMoreEN ? rightX : leftX; // less EN side (δ+)
const toX = leftIsMoreEN ? leftX : rightX; // more EN side (δ-)
const dir = toX > fromX ? 1 : -1; // +1 if pointing right, -1 if pointing left
const crossX = fromX + dir * 38;
const stemStart = crossX - dir * 8;
svg.append("line")
.attr("x1", stemStart).attr("x2", toX - dir * 30)
.attr("y1", arrowY).attr("y2", arrowY)
.attr("class", "ec-dipole-arrow")
.style("stroke", dipoleColor)
.style("stroke-width", "1.5")
.style("stroke-linecap", "round")
.attr("marker-end", "url(#ec-arrowhead)");
svg.append("line")
.attr("x1", crossX).attr("x2", crossX)
.attr("y1", arrowY - 6).attr("y2", arrowY + 6)
.attr("class", "ec-dipole-cross")
.style("stroke", dipoleColor)
.style("stroke-width", "1.5")
.style("stroke-linecap", "round");
}
container.appendChild(svg.node());
return container;
}
// Render the widget: wraps controls + chart in one container
bpWidget = {
if (!bpResult) return html`<p>Select two elements.</p>`;
const {symA, symB, enA, enB, delta, ionicPercent, classification} = bpResult;
const isDark = bpDarkMode;
// Pick theme-appropriate stops and scale
const stops = isDark ? bpColorScale.darkStops : bpColorScale.lightStops;
const colorScale = isDark ? bpColorScale.darkScale : bpColorScale.lightScale;
const barOpacity = isDark ? 0.55 : 0.25;
// Outer wrapper
const wrapper = document.createElement('div');
wrapper.className = 'bp-widget';
// Expand button (open in new tab)
const expandBtn = document.createElement('button');
expandBtn.className = 'bp-expand-btn';
expandBtn.textContent = '\u26F6'; // ⛶ expand/fullscreen icon
expandBtn.title = 'Open in new tab';
expandBtn.addEventListener('click', function(e) {
e.stopPropagation();
window.open('/files/ojs/bond-polarity-viewer.html', '_blank');
});
wrapper.appendChild(expandBtn);
// Insert controls
wrapper.appendChild(viewof bpSelection);
// Insert electron cloud visualization
wrapper.appendChild(bpCloudViz);
// Build SVG chart
const width = 600;
const height = 210;
const barHeight = 36;
const barX = 50;
const barW = width - 100;
const barY = 30;
const maxDelta = 3.3;
const t = Math.min(delta / maxDelta, 1);
const markerColor = colorScale(t);
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.attr("class", "bp-chart")
.style("max-width", "100%")
.style("height", "auto");
// Gradient definition
const defs = svg.append("defs");
const grad = defs.append("linearGradient")
.attr("id", "bp-bar-gradient")
.attr("x1", "0%").attr("x2", "100%");
stops.forEach(s => {
grad.append("stop")
.attr("offset", `${s.t * 100}%`)
.attr("stop-color", s.color);
});
// Gradient bar
svg.append("rect")
.attr("x", barX).attr("y", barY)
.attr("width", barW).attr("height", barHeight)
.attr("rx", 6)
.attr("fill", "url(#bp-bar-gradient)")
.attr("opacity", barOpacity)
.attr("class", "bp-bar-fill");
// Outer border
svg.append("rect")
.attr("x", barX).attr("y", barY)
.attr("width", barW).attr("height", barHeight)
.attr("rx", 6)
.attr("fill", "none")
.attr("class", "bp-bar-border");
// Zone labels above bar
const zone1Mid = (0.4 / maxDelta) / 2;
const zone2Mid = ((0.4 / maxDelta) + (1.7 / maxDelta)) / 2;
const zone3Mid = ((1.7 / maxDelta) + 1) / 2;
const zones = [
{label: "Nonpolar covalent", x: zone1Mid},
{label: "Polar covalent", x: zone2Mid},
{label: "Ionic", x: zone3Mid}
];
zones.forEach(z => {
svg.append("text")
.attr("x", barX + z.x * barW)
.attr("y", barY - 12)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "auto")
.attr("class", "bp-zone-label")
.text(z.label);
});
// Threshold tick marks and labels below bar
[0.4, 1.7].forEach(tv => {
const x = barX + (tv / maxDelta) * barW;
svg.append("line")
.attr("x1", x).attr("x2", x)
.attr("y1", barY).attr("y2", barY + barHeight)
.attr("class", "bp-threshold-line");
svg.append("text")
.attr("x", x).attr("y", barY + barHeight + 14)
.attr("text-anchor", "middle")
.attr("class", "bp-tick-label")
.text(tv);
});
// 0 label
svg.append("text")
.attr("x", barX).attr("y", barY + barHeight + 14)
.attr("text-anchor", "middle")
.attr("class", "bp-tick-label")
.text("0");
// Marker line
const markerX = barX + Math.min(t, 1) * barW;
// White outline behind marker for contrast
svg.append("line")
.attr("x1", markerX).attr("x2", markerX)
.attr("y1", barY - 4).attr("y2", barY + barHeight + 4)
.attr("stroke", "var(--bp-marker-outline, #fff)")
.attr("stroke-width", 6)
.attr("stroke-linecap", "round")
.attr("class", "bp-marker-outline");
svg.append("line")
.attr("x1", markerX).attr("x2", markerX)
.attr("y1", barY - 3).attr("y2", barY + barHeight + 3)
.style("stroke", markerColor)
.attr("stroke-width", 3)
.attr("stroke-linecap", "round")
.attr("class", "bp-marker-line");
// Math: line 1 (individual electronegativities)
const mathY1 = barY + barHeight + 44;
const bondLabel = svg.append("text")
.attr("x", width / 2).attr("y", mathY1)
.attr("text-anchor", "middle")
.attr("class", "bp-math-text");
bondLabel.append("tspan").attr("font-style", "italic").text("\u03C7");
bondLabel.append("tspan").text(`(${symA}) = ${enA.toFixed(2)} `);
bondLabel.append("tspan").attr("font-style", "italic").text("\u03C7");
bondLabel.append("tspan").text(`(${symB}) = ${enB.toFixed(2)}`);
// Math: line 2 (delta chi calculation)
const mathY2 = mathY1 + 18;
const deltaLabel = svg.append("text")
.attr("x", width / 2).attr("y", mathY2)
.attr("text-anchor", "middle")
.attr("class", "bp-math-text");
deltaLabel.append("tspan").text(`\u0394`);
deltaLabel.append("tspan").attr("font-style", "italic").text("\u03C7");
deltaLabel.append("tspan").text(` = |${enA.toFixed(2)} \u2212 ${enB.toFixed(2)}| = `);
deltaLabel.append("tspan").attr("font-weight", "bold").style("fill", markerColor)
.attr("class", "bp-delta-value")
.text(delta.toFixed(2));
// Math: line 3 (% ionic character)
const mathY3 = mathY2 + 18;
const ionicLabel = svg.append("text")
.attr("x", width / 2).attr("y", mathY3)
.attr("text-anchor", "middle")
.attr("class", "bp-math-text");
ionicLabel.append("tspan").text("% ionic character = ");
ionicLabel.append("tspan").attr("font-weight", "bold").style("fill", markerColor)
.text(ionicPercent.toFixed(1) + "%");
// Classification
const mathY4 = mathY3 + 28;
svg.append("text")
.attr("x", width / 2).attr("y", mathY4)
.attr("text-anchor", "middle")
.attr("class", "bp-classification")
.style("fill", markerColor)
.text(classification);
wrapper.appendChild(svg.node());
return wrapper;
}html`<style>
/* ========== BOND POLARITY WIDGET - LIGHT MODE ========== */
.bp-widget {
background: #f8f9fa;
border-radius: 8px;
padding: 16px 20px 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
position: relative;
}
.bp-expand-btn {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
border: 1px solid #848d9c;
border-radius: 6px;
background-color: #f8f9fa;
color: #374151;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
z-index: 10;
font-size: 15px;
line-height: 28px;
text-align: center;
}
.bp-expand-btn:hover {
background-color: #e5e7eb;
color: #374151;
border-color: #9ca3af;
}
.bp-controls {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.bp-selector-group {
display: flex;
align-items: center;
gap: 6px;
}
.bp-select {
padding: 5px 10px;
font-size: 14px;
font-weight: 600;
border: 1px solid #848d9c;
border-radius: 6px;
background-color: #fff;
color: #1f2937;
cursor: pointer;
outline: none;
min-width: 64px;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' stroke='%236b7280' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 28px;
}
.bp-select:hover { border-color: #6b7280; }
.bp-select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.bp-bond-dash {
color: #6b7280;
font-size: 16px;
user-select: none;
}
.bp-preset-group {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.bp-preset-btn {
padding: 4px 12px;
border: 1px solid #848d9c;
border-radius: 14px;
background-color: #fff;
color: #374151;
font-size: 13px;
cursor: pointer;
transition: all 0.15s ease;
font-family: inherit;
}
.bp-preset-btn:hover {
background-color: #e5e7eb;
border-color: #6b7280;
}
.bp-chart { display: block; }
.bp-bar-border {
stroke: #848d9c;
stroke-width: 1;
}
.bp-threshold-line {
stroke: #848d9c;
stroke-width: 1;
stroke-dasharray: 3,3;
}
.bp-zone-label { fill: #6b7280; font-size: 10px; }
.bp-tick-label { fill: #6b7280; font-size: 10px; }
.bp-math-text { fill: #374151; font-size: 13px; }
.bp-classification {
font-size: 16px;
font-weight: 700;
}
/* ========== ELECTRON CLOUD VIZ - LIGHT MODE ========== */
.ec-cloud-container {
position: relative;
width: 100%;
margin: 4px 0 8px;
}
.ec-cloud-canvas {
display: block;
width: 100%;
height: auto;
border-radius: 4px;
}
.ec-cloud-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.ec-bond-axis {
stroke: #848d9c;
stroke-width: 1;
stroke-dasharray: 4,4;
}
.ec-charge-label {
font-size: 14px;
font-family: system-ui, sans-serif;
}
[data-bs-theme="dark"] .bp-expand-btn,
body.quarto-dark .bp-expand-btn,
.quarto-dark .bp-expand-btn {
background-color: rgba(26, 31, 53, 0.8);
border-color: #4a4f6a;
color: #a8afc7;
}
[data-bs-theme="dark"] .bp-expand-btn:hover,
body.quarto-dark .bp-expand-btn:hover,
.quarto-dark .bp-expand-btn:hover {
border-color: rgba(0, 217, 255, 0.5);
color: #00d9ff;
box-shadow: 0 0 6px rgba(0, 217, 255, 0.15);
}
/* ========== DARK MODE - CYBERPUNK ========== */
[data-bs-theme="dark"] .bp-widget,
body.quarto-dark .bp-widget,
.quarto-dark .bp-widget {
background: linear-gradient(145deg, #0b0c14 0%, #0d1020 50%, #0b0c14 100%);
border: 1px solid rgba(0, 217, 255, 0.2);
box-shadow: 0 0 20px rgba(0, 217, 255, 0.06),
0 0 40px rgba(192, 132, 252, 0.04),
0 4px 16px rgba(0, 0, 0, 0.4);
}
[data-bs-theme="dark"] .bp-select,
body.quarto-dark .bp-select,
.quarto-dark .bp-select {
background-color: rgba(26, 31, 53, 0.8);
border-color: #4a4f6a;
color: #e0e4f0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' stroke='%23a8afc7' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
transition: all 0.2s ease;
}
[data-bs-theme="dark"] .bp-select:hover,
body.quarto-dark .bp-select:hover,
.quarto-dark .bp-select:hover {
border-color: rgba(0, 217, 255, 0.5);
box-shadow: 0 0 6px rgba(0, 217, 255, 0.15);
}
[data-bs-theme="dark"] .bp-select:focus,
body.quarto-dark .bp-select:focus,
.quarto-dark .bp-select:focus {
border-color: #c084fc;
box-shadow: 0 0 8px rgba(192, 132, 252, 0.4),
0 0 16px rgba(192, 132, 252, 0.15);
}
[data-bs-theme="dark"] .bp-bond-dash,
body.quarto-dark .bp-bond-dash,
.quarto-dark .bp-bond-dash {
color: #4a4f6a;
}
[data-bs-theme="dark"] .bp-preset-btn,
body.quarto-dark .bp-preset-btn,
.quarto-dark .bp-preset-btn {
background-color: rgba(26, 31, 53, 0.6);
border-color: rgba(74, 79, 106, 0.6);
color: #a8afc7;
transition: all 0.2s ease;
}
[data-bs-theme="dark"] .bp-preset-btn:hover,
body.quarto-dark .bp-preset-btn:hover,
.quarto-dark .bp-preset-btn:hover {
border-color: rgba(0, 217, 255, 0.5);
background-color: rgba(0, 217, 255, 0.08);
color: #00d9ff;
box-shadow: 0 0 8px rgba(0, 217, 255, 0.15);
}
[data-bs-theme="dark"] .bp-bar-border,
body.quarto-dark .bp-bar-border,
.quarto-dark .bp-bar-border {
stroke: rgba(0, 217, 255, 0.2);
stroke-width: 1;
}
[data-bs-theme="dark"] .bp-marker-outline,
body.quarto-dark .bp-marker-outline,
.quarto-dark .bp-marker-outline {
--bp-marker-outline: #0b0c14;
}
[data-bs-theme="dark"] .bp-threshold-line,
body.quarto-dark .bp-threshold-line,
.quarto-dark .bp-threshold-line {
stroke: rgba(168, 175, 199, 0.25);
}
[data-bs-theme="dark"] .bp-zone-label,
body.quarto-dark .bp-zone-label,
.quarto-dark .bp-zone-label {
fill: #a8afc7;
}
[data-bs-theme="dark"] .bp-tick-label,
body.quarto-dark .bp-tick-label,
.quarto-dark .bp-tick-label {
fill: #4a4f6a;
}
[data-bs-theme="dark"] .bp-math-text,
body.quarto-dark .bp-math-text,
.quarto-dark .bp-math-text {
fill: #e8ecf8;
}
[data-bs-theme="dark"] .bp-marker-line,
body.quarto-dark .bp-marker-line,
.quarto-dark .bp-marker-line {
filter: drop-shadow(0 0 3px currentColor)
drop-shadow(0 0 8px currentColor);
}
/* ========== ELECTRON CLOUD VIZ - DARK MODE ========== */
[data-bs-theme="dark"] .ec-bond-axis,
body.quarto-dark .ec-bond-axis,
.quarto-dark .ec-bond-axis {
stroke: rgba(168, 175, 199, 0.2);
}
</style>`Pauling’s Ionic Character Formula
Where do the 0.4 and 1.7 boundaries come from? Pauling’s empirical formula, derived from dipole moment measurements of gas-phase diatomic molecules, quantifies the percent ionic character of a bond:
\[\%\,\text{ionic character} = 100\% \times \left(1 - e^{-(\Delta\chi/2)^2}\right)\]
As Δχ increases, the percent ionic character rises smoothly toward 100%:
The value Δχ = 1.7 gives approximately 50 % ionic character, which is why many textbooks use it as the dividing line between “polar covalent” and “ionic.” The exact Δχ for 50 % is √(4 ln 2) ≈ 1.665. The commonly used 1.7 is a convenient round number.
Ionic Character Across Real Compounds
The plot below shows the Pauling curve with compounds placed according to their theoretical Pauling electronegativity differences. Hover over any point to see details.
// ========== IONIC CHARACTER PLOT - STYLES ==========
html`<style>
/* ========== IONIC CHARACTER WIDGET - LIGHT MODE ========== */
.ic-widget {
background: #f8f9fa;
border-radius: 8px;
padding: 16px 20px 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
position: relative;
}
.ic-widget svg text {
user-select: none;
-webkit-user-select: none;
}
.ic-tooltip {
position: absolute;
pointer-events: none;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 6px 10px;
font-size: 12px;
line-height: 1.4;
color: #374151;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
opacity: 0;
transition: opacity 0.15s ease;
z-index: 20;
white-space: nowrap;
}
.ic-tooltip.visible {
opacity: 1;
}
.ic-tooltip .ic-tt-name {
font-weight: 600;
margin-bottom: 2px;
}
.ic-tooltip .ic-tt-row {
color: #6b7280;
}
/* ========== DARK MODE - CYBERPUNK ========== */
[data-bs-theme="dark"] .ic-widget,
body.quarto-dark .ic-widget,
.quarto-dark .ic-widget {
background: linear-gradient(145deg, #0b0c14 0%, #0d1020 50%, #0b0c14 100%);
border: 1px solid rgba(0, 217, 255, 0.2);
box-shadow: 0 0 20px rgba(0, 217, 255, 0.06),
0 0 40px rgba(192, 132, 252, 0.04),
0 4px 16px rgba(0, 0, 0, 0.4);
}
[data-bs-theme="dark"] .ic-tooltip,
body.quarto-dark .ic-tooltip,
.quarto-dark .ic-tooltip {
background: #1a1b2e;
border-color: rgba(0, 217, 255, 0.3);
color: #e2e8f0;
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
}
[data-bs-theme="dark"] .ic-tooltip .ic-tt-row,
body.quarto-dark .ic-tooltip .ic-tt-row,
.quarto-dark .ic-tooltip .ic-tt-row {
color: #a8afc7;
}
</style>`// ========== IONIC CHARACTER PLOT - MAIN WIDGET ==========
icPlot = {
const isDark = bpDarkMode;
// --- Electronegativity values ---
const enValues = {
H: 2.20, Li: 0.98, Be: 1.57, B: 2.04, C: 2.55, N: 3.04, O: 3.44, F: 3.98,
Na: 0.93, Mg: 1.31, Al: 1.61, Si: 1.90, P: 2.19, S: 2.58, Cl: 3.16,
K: 0.82, Ca: 1.00, Br: 2.96, I: 2.66, Cs: 0.79, Ba: 0.89
};
// --- Compound data ---
const compounds = [
// Homonuclear
// 'label': shown permanently on plot. No label = dot only (tooltip on hover).
// Homonuclear (all at Δχ=0)
{name: 'H\u2082', a: 'H', b: 'H', originGroup: true},
{name: 'Cl\u2082', a: 'Cl', b: 'Cl'},
{name: 'O\u2082', a: 'O', b: 'O'},
{name: 'N\u2082', a: 'N', b: 'N'},
{name: 'F\u2082', a: 'F', b: 'F'},
// Weakly polar
{name: 'I\u2013Br', a: 'I', b: 'Br'},
{name: 'C\u2013H', a: 'C', b: 'H', label: 'C\u2013H'},
{name: 'I\u2013Cl', a: 'I', b: 'Cl', label: 'I\u2013Cl'},
// Polar covalent
{name: 'H\u2013Br', a: 'H', b: 'Br', label: 'H\u2013Br'},
{name: 'N\u2013H', a: 'N', b: 'H'},
{name: 'S\u2013O', a: 'S', b: 'O'},
{name: 'C\u2013O', a: 'C', b: 'O', label: 'C\u2013O'},
{name: 'H\u2013Cl', a: 'H', b: 'Cl', label: 'H\u2013Cl'},
// Boundary cases (highlighted)
{name: 'H\u2013F', a: 'H', b: 'F', highlight: true},
{name: 'LiI', a: 'Li', b: 'I', highlight: true},
// Ionic
{name: 'NaI', a: 'Na', b: 'I', label: 'NaI'},
{name: 'LiBr', a: 'Li', b: 'Br', label: 'LiBr'},
{name: 'NaBr', a: 'Na', b: 'Br'},
{name: 'NaCl', a: 'Na', b: 'Cl', label: 'NaCl'},
{name: 'KCl', a: 'K', b: 'Cl', label: 'KCl'},
{name: 'MgO', a: 'Mg', b: 'O', label: 'MgO'},
{name: 'CaO', a: 'Ca', b: 'O', label: 'CaO'},
{name: 'LiF', a: 'Li', b: 'F', label: 'LiF'},
{name: 'NaF', a: 'Na', b: 'F'},
{name: 'KF', a: 'K', b: 'F', label: 'KF'},
{name: 'CsF', a: 'Cs', b: 'F', label: 'CsF'},
];
// Compute delta-chi and % ionic for each compound
const data = compounds.map(c => {
const enA = enValues[c.a];
const enB = enValues[c.b];
const deltaChi = Math.abs(enA - enB);
const ionicPct = 100 * (1 - Math.exp(-0.25 * deltaChi * deltaChi));
return { ...c, deltaChi, ionicPct };
});
// --- SVG dimensions ---
const svgW = 680, svgH = 420;
const margin = { top: 30, right: 40, bottom: 50, left: 60 };
const plotW = svgW - margin.left - margin.right;
const plotH = svgH - margin.top - margin.bottom;
// --- Scales ---
const xScale = d3.scaleLinear().domain([0, 3.5]).range([0, plotW]);
const yScale = d3.scaleLinear().domain([0, 100]).range([plotH, 0]);
// --- Theme colors ---
const colors = {
bg: isDark ? 'transparent' : 'transparent',
axis: isDark ? 'rgba(168, 175, 199, 0.3)' : '#d1d5db',
tickText: isDark ? '#a8afc7' : '#6b7280',
labelText: isDark ? '#cbd5e1' : '#374151',
curve: isDark ? '#a8afc7' : '#6b7280',
grid: isDark ? 'rgba(168, 175, 199, 0.12)' : 'rgba(0,0,0,0.06)',
refLine: isDark ? 'rgba(168, 175, 199, 0.3)' : '#d1d5db',
refText: isDark ? 'rgba(168, 175, 199, 0.6)' : '#9ca3af',
dotFill: isDark ? '#00d9ff' : '#3b82f6',
dotStroke: isDark ? '#22d3ee' : '#1d4ed8',
hlFill: isDark ? '#fbbf24' : '#f59e0b',
hlStroke: isDark ? '#f59e0b' : '#d97706',
annotText: isDark ? '#e2e8f0' : '#374151',
};
// --- Wrapper ---
const wrapper = document.createElement('div');
wrapper.className = 'ic-widget';
wrapper.style.position = 'relative';
// --- Tooltip ---
const tooltip = document.createElement('div');
tooltip.className = 'ic-tooltip';
wrapper.appendChild(tooltip);
// --- SVG ---
const svg = d3.create('svg')
.attr('viewBox', `0 0 ${svgW} ${svgH}`)
.attr('width', '100%')
.attr('preserveAspectRatio', 'xMidYMid meet')
.style('display', 'block');
const g = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// --- Grid lines (horizontal at 20, 40, 60, 80%) ---
[20, 40, 60, 80].forEach(pct => {
g.append('line')
.attr('x1', 0).attr('x2', plotW)
.attr('y1', yScale(pct)).attr('y2', yScale(pct))
.attr('stroke', colors.grid)
.attr('stroke-width', 1);
});
// --- X axis ---
const xAxis = d3.axisBottom(xScale)
.tickValues([0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5])
.tickSize(-4);
const xAxisG = g.append('g')
.attr('transform', `translate(0,${plotH})`)
.call(xAxis);
xAxisG.select('.domain').attr('stroke', colors.axis);
xAxisG.selectAll('.tick line').attr('stroke', colors.axis);
xAxisG.selectAll('.tick text')
.attr('fill', colors.tickText)
.attr('font-size', '11px');
// X axis label
g.append('text')
.attr('x', plotW / 2)
.attr('y', plotH + 40)
.attr('text-anchor', 'middle')
.attr('fill', colors.labelText)
.attr('font-size', '13px')
.call(t => {
t.append('tspan').text('Electronegativity Difference (\u0394');
t.append('tspan').attr('font-style', 'italic').text('\u03C7');
t.append('tspan').text(')');
});
// --- Y axis ---
const yAxis = d3.axisLeft(yScale)
.ticks(5)
.tickSize(-4);
const yAxisG = g.append('g')
.call(yAxis);
yAxisG.select('.domain').attr('stroke', colors.axis);
yAxisG.selectAll('.tick line').attr('stroke', colors.axis);
yAxisG.selectAll('.tick text')
.attr('fill', colors.tickText)
.attr('font-size', '11px');
// Y axis label
g.append('text')
.attr('transform', 'rotate(-90)')
.attr('x', -plotH / 2)
.attr('y', -42)
.attr('text-anchor', 'middle')
.attr('fill', colors.labelText)
.attr('font-size', '13px')
.text('% Ionic Character');
// --- Reference lines ---
// Vertical dashed line at delta-chi = 1.7
const refX = xScale(1.7);
g.append('line')
.attr('x1', refX).attr('x2', refX)
.attr('y1', 0).attr('y2', plotH)
.attr('stroke', colors.refLine)
.attr('stroke-width', 1)
.attr('stroke-dasharray', '4 3');
g.append('text')
.attr('x', refX + 4)
.attr('y', 14)
.attr('fill', colors.refText)
.attr('font-size', '10px')
.text('~50% ionic');
// Horizontal dashed line at 50%
const refY = yScale(50);
g.append('line')
.attr('x1', 0).attr('x2', plotW)
.attr('y1', refY).attr('y2', refY)
.attr('stroke', colors.refLine)
.attr('stroke-width', 1)
.attr('stroke-dasharray', '4 3');
// --- Pauling curve ---
const nPts = 100;
const curveData = d3.range(0, 3.51, 3.5 / nPts).map(x => ({
x,
y: 100 * (1 - Math.exp(-0.25 * x * x))
}));
const line = d3.line()
.x(d => xScale(d.x))
.y(d => yScale(d.y))
.curve(d3.curveNatural);
g.append('path')
.datum(curveData)
.attr('d', line)
.attr('fill', 'none')
.attr('stroke', colors.curve)
.attr('stroke-width', 1.25)
.attr('stroke-dasharray', isDark ? 'none' : '6 3');
// Curve label
const labelX = 2.8;
const labelY = 100 * (1 - Math.exp(-0.25 * labelX * labelX));
g.append('text')
.attr('x', xScale(labelX) + 4)
.attr('y', yScale(labelY) + 14)
.attr('fill', colors.refText)
.attr('font-size', '10px')
.attr('font-style', 'italic')
.text('Pauling');
// --- Data points ---
// Regular compounds first
const regularData = data.filter(d => !d.highlight);
const highlightData = data.filter(d => d.highlight);
// Regular dots (smaller)
g.selectAll('.ic-dot-regular')
.data(regularData)
.join('circle')
.attr('class', 'ic-dot-regular')
.attr('cx', d => xScale(d.deltaChi))
.attr('cy', d => yScale(d.ionicPct))
.attr('r', 3)
.attr('fill', colors.dotFill)
.attr('stroke', colors.dotStroke)
.attr('stroke-width', 1)
.style('cursor', 'pointer');
// Highlighted dots
g.selectAll('.ic-dot-highlight')
.data(highlightData)
.join('circle')
.attr('class', 'ic-dot-highlight')
.attr('cx', d => xScale(d.deltaChi))
.attr('cy', d => yScale(d.ionicPct))
.attr('r', 4)
.attr('fill', colors.hlFill)
.attr('stroke', colors.hlStroke)
.attr('stroke-width', 1.5)
.style('cursor', 'pointer');
// --- Origin group: stacked vertical label ---
// The homonuclear compounds (H₂, F₂, Cl₂, O₂, N₂) all sit at (0, 0).
// Render a stacked label above-left of the origin dot with a leader line.
const originNames = ['H\u2082', 'F\u2082', 'Cl\u2082', 'O\u2082', 'N\u2082'];
const originDotX = xScale(0), originDotY = yScale(0);
const originLabelX = 10, originLabelTopY = originDotY - 70;
const originLineH = 11;
// Leader line from dot to label stack
g.append('line')
.attr('x1', originDotX).attr('y1', originDotY - 4)
.attr('x2', originLabelX + 10).attr('y2', originLabelTopY + originNames.length * originLineH + 2)
.attr('stroke', colors.refLine).attr('stroke-width', 0.75);
originNames.forEach((name, i) => {
g.append('text')
.attr('x', originLabelX).attr('y', originLabelTopY + i * originLineH)
.attr('text-anchor', 'start')
.attr('fill', colors.annotText)
.attr('font-size', '9px')
.text(name);
});
// --- Force-directed label placement ---
const allLabeled = [];
regularData.forEach(d => { if (d.label) allLabeled.push({...d, isHL: false}); });
highlightData.forEach(d => allLabeled.push({...d, label: d.name, isHL: true}));
const fontSize = 10;
const hlFontSize = 12;
// Build label nodes for force simulation
// Each node has: anchor (ax,ay), current position (x,y), dimensions (w,h)
const labelNodes = allLabeled.map(d => {
const ax = xScale(d.deltaChi);
const ay = yScale(d.ionicPct);
const fs = d.isHL ? hlFontSize : fontSize;
const w = d.label.length * fs * 0.6 + 4; // text width + padding
const h = fs + 6;
// Initialize: alternate above/below curve based on index
return { d, ax, ay, x: ax, y: ay - 20, w, h, fs, vx: 0, vy: 0 };
});
// Origin obstacle bbox
const originBox = {
x: originLabelX - 4, y: originLabelTopY - 12,
w: 64, h: originNames.length * originLineH + 18
};
// Pauling curve y at plot x
function curveYAtX(px) {
const dchi = xScale.invert(Math.max(0, Math.min(plotW, px)));
return yScale(100 * (1 - Math.exp(-0.25 * dchi * dchi)));
}
// All dot positions for repulsion
const dotPositions = data.map(d => ({ x: xScale(d.deltaChi), y: yScale(d.ionicPct) }));
// Manual force simulation (not using d3.forceSimulation to keep it synchronous)
const alpha = 0.3;
const simIterations = 200;
for (let iter = 0; iter < simIterations; iter++) {
const decay = 1 - iter / simIterations; // force strength decays over time
for (const node of labelNodes) {
let fx = 0, fy = 0;
const ncx = node.x + node.w / 2;
const ncy = node.y + node.h / 2;
// 1. Spring force: pull toward anchor
const dxA = node.ax - ncx;
const dyA = node.ay - ncy;
const distA = Math.sqrt(dxA * dxA + dyA * dyA) || 1;
// Weak spring — allows labels to drift but not too far
fx += dxA * 0.04;
fy += dyA * 0.04;
// 2. Repulsion from other label nodes (rect-rect)
for (const other of labelNodes) {
if (other === node) continue;
const ocx = other.x + other.w / 2;
const ocy = other.y + other.h / 2;
// Check overlap with padding
const padX = 4, padY = 2;
const overlapX = (node.w / 2 + other.w / 2 + padX) - Math.abs(ncx - ocx);
const overlapY = (node.h / 2 + other.h / 2 + padY) - Math.abs(ncy - ocy);
if (overlapX > 0 && overlapY > 0) {
// Push apart along the axis of least overlap
const dx = ncx - ocx || 0.1;
const dy = ncy - ocy || 0.1;
if (overlapX < overlapY) {
fx += Math.sign(dx) * overlapX * 0.8;
} else {
fy += Math.sign(dy) * overlapY * 0.8;
}
}
}
// 3. Repulsion from dots — labels must not overlap any dot
for (const dot of dotPositions) {
// Check if dot is inside or near the label bbox
const nearX = Math.max(node.x, Math.min(dot.x, node.x + node.w));
const nearY = Math.max(node.y, Math.min(dot.y, node.y + node.h));
const dx2 = (nearX - dot.x) ** 2 + (nearY - dot.y) ** 2;
const minR = 8; // minimum clearance from dot center to label edge
if (dx2 < minR * minR) {
const dx = ncx - dot.x || 0.1;
const dy = ncy - dot.y || 0.1;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
fx += (dx / dist) * 3;
fy += (dy / dist) * 3;
}
}
// 4. Push away from curve
const curveY = curveYAtX(ncx);
const dyCurve = ncy - curveY;
const absDy = Math.abs(dyCurve);
if (absDy < node.h * 0.8) {
// Push perpendicular to curve (above or below)
const pushDir = dyCurve >= 0 ? 1 : -1;
fy += pushDir * (node.h * 0.8 - absDy) * 0.4;
}
// 5. Push away from origin label area
const oOverlapX = (node.w / 2 + originBox.w / 2 + 4) - Math.abs(ncx - (originBox.x + originBox.w / 2));
const oOverlapY = (node.h / 2 + originBox.h / 2 + 4) - Math.abs(ncy - (originBox.y + originBox.h / 2));
if (oOverlapX > 0 && oOverlapY > 0) {
const dx = ncx - (originBox.x + originBox.w / 2);
const dy = ncy - (originBox.y + originBox.h / 2);
fx += Math.sign(dx || 1) * oOverlapX * 0.4;
fy += Math.sign(dy || 1) * oOverlapY * 0.4;
}
// Apply forces with decay
node.x += fx * alpha * decay;
node.y += fy * alpha * decay;
// Clamp to plot bounds
node.x = Math.max(0, Math.min(plotW - node.w, node.x));
node.y = Math.max(0, Math.min(plotH - node.h, node.y));
}
}
// Render labels with leader lines
for (const node of labelNodes) {
const { d, ax, ay, x, y, w, h, fs } = node;
// Leader line: from dot to nearest point on label bbox
const lineEndX = Math.max(x, Math.min(ax, x + w));
const lineEndY = Math.max(y, Math.min(ay, y + h));
const lineDist = Math.sqrt((ax - lineEndX) ** 2 + (ay - lineEndY) ** 2);
if (lineDist > 6) {
g.append('line')
.attr('x1', ax).attr('y1', ay)
.attr('x2', lineEndX).attr('y2', lineEndY)
.attr('stroke', d.isHL ? colors.hlFill : colors.refLine)
.attr('stroke-width', 0.75)
.attr('stroke-opacity', 0.5);
}
g.append('text')
.attr('x', x + 2).attr('y', y + fs)
.attr('text-anchor', 'start')
.attr('fill', d.isHL ? colors.hlFill : colors.annotText)
.attr('font-size', fs + 'px')
.attr('font-weight', d.isHL ? '600' : '400')
.text(d.label);
}
// --- Tooltip interaction ---
// We need to get SVG bounding rect for positioning
const svgNode = svg.node();
wrapper.appendChild(svgNode);
function showTooltip(d, event) {
const wrapperRect = wrapper.getBoundingClientRect();
const svgRect = svgNode.getBoundingClientRect();
// Map data coordinates to pixel position within the SVG element
const svgDisplayW = svgRect.width;
const svgDisplayH = svgRect.height;
const scaleRatioX = svgDisplayW / svgW;
const scaleRatioY = svgDisplayH / svgH;
const px = svgRect.left - wrapperRect.left
+ (margin.left + xScale(d.deltaChi)) * scaleRatioX;
const py = svgRect.top - wrapperRect.top
+ (margin.top + yScale(d.ionicPct)) * scaleRatioY;
// For homonuclear dots (all at deltaChi=0), show combined tooltip
const displayName = d.deltaChi === 0
? 'H\u2082, F\u2082, Cl\u2082, O\u2082, N\u2082'
: d.name;
tooltip.innerHTML =
`<div class="ic-tt-name">${displayName}</div>` +
`<div class="ic-tt-row">\u0394\u03C7 = ${d.deltaChi.toFixed(2)}</div>` +
`<div class="ic-tt-row">Ionic: ${d.ionicPct.toFixed(1)}%</div>`;
// Position tooltip (offset to the right and above)
tooltip.style.left = (px + 12) + 'px';
tooltip.style.top = (py - 40) + 'px';
tooltip.classList.add('visible');
}
function hideTooltip() {
tooltip.classList.remove('visible');
}
// Add event listeners to all dots
svg.selectAll('.ic-dot-regular, .ic-dot-highlight')
.on('mouseenter', function(event) {
const d = d3.select(this).datum();
d3.select(this).transition().duration(100).attr('r', d.highlight ? 8 : 6);
showTooltip(d, event);
})
.on('mouseleave', function() {
const d = d3.select(this).datum();
d3.select(this).transition().duration(100).attr('r', d.highlight ? 6 : 4);
hideTooltip();
});
return wrapper;
}HF and LiI sit close together on the x-axis (Δχ ≈ 1.7–1.8) but behave very differently.
WarningWhen Δχ Gets It Wrong: HF and LiI
The Pauling formula predicts similar ionic character for HF (Δχ = 1.78, ~55 %) and LiI (Δχ = 1.68, ~51 %). Their properties diverge:
HF is a molecular (covalent) compound:
- Gas at room temperature (boiling point: −19.5 °C)
- Exists as discrete HF molecules, not a crystal lattice
- Dissolves in water as hydrofluoric acid (a weak acid, pKa = 3.17)
- Both atoms are small nonmetals, so strong nuclear attraction prevents full electron transfer
LiI is an ionic compound:
- Forms a NaCl-type face-centered cubic crystal lattice
- Melting point: 469 °C, boiling point: 1171 °C
- Fully dissociates in water into Li+ and I− (strong electrolyte)
- Conducts electricity when molten
- Metal + nonmetal combination
HF exceeds the 1.7 threshold but behaves as covalent. LiI falls below it but behaves as ionic. The Δχ classification fails in both directions. Atom size, electron configuration, and whether the bond is between a metal and a nonmetal are often more reliable predictors than Δχ alone.
Bond Order
The bond order is the number of electron pairs shared between two atoms. A single bond has bond order 1, a double bond has bond order 2, and a triple bond has bond order 3.
For molecules with resonance structures, bond order can be fractional. It is calculated as:
\[\mathrm{bond~order} = \frac{\mathrm{total~bonding~pairs~between~two~atoms~across~all~resonance~structures}}{\mathrm{number~of~resonance~structures}}\]
For example, ozone has two resonance structures. In one, the left O–O bond is a double bond (2 pairs) and in the other it is a single bond (1 pair). The bond order is (2 + 1) / 2 = 1.5. Similarly, each C–O bond in carbonate has a bond order of (2 + 1 + 1) / 3 = 4/3.
Bond Length
Bond length is the distance between the nuclei of two bonded atoms. It is directly related to bond order:
Triple bonds are shorter than double bonds, which are shorter than single bonds.
More shared electron density between two nuclei pulls them closer together.
Bond Energy (Bond Dissociation Energy)
Bond energy (or bond dissociation energy) is the energy required to break one mole of a particular bond in the gas phase. The interactive plot below shows the potential energy of H2 as a function of internuclear distance. At the equilibrium bond length (0.74 Å), the energy is at a minimum. Pulling the atoms apart requires 432 kJ/mol of energy.
mpParams = ({
De: 458, // kJ/mol (true well depth, Huber & Herzberg)
D0: 432, // kJ/mol (BDE = De - ZPE, experimentally measured)
ZPE: 26, // kJ/mol (zero-point energy, v=0 level above minimum)
re: 0.7414, // Angstroms (equilibrium bond length)
a: 1.94, // Angstrom^-1 (Morse width parameter)
rMin: 0.30, // curve drawing range start
rMax: 5.0, // curve drawing range end
sliderMin: 0.34,
sliderMax: 5.0,
snapTarget: 0.7414,
snapThreshold: 0.05,
v0Inner: 0.6312, // inner classical turning point of v=0 (exact)
v0Outer: 0.8817 // outer classical turning point of v=0 (exact)
})
// Morse potential energy function: V(r) = De * (1 - e^(-a(r-re)))^2 - De
mpEnergy = function(r) {
const { De, a, re } = mpParams;
const ex = 1 - Math.exp(-a * (r - re));
return De * ex * ex - De;
}
// Dark mode detection
mutable mpDarkMode = document.documentElement.getAttribute('data-bs-theme') === 'dark'
|| document.body.classList.contains('quarto-dark')
mpThemeObserver = {
const check = () => {
const dark = document.documentElement.getAttribute('data-bs-theme') === 'dark'
|| document.body.classList.contains('quarto-dark');
if (dark !== mutable mpDarkMode) mutable mpDarkMode = dark;
};
const obs = new MutationObserver(check);
obs.observe(document.documentElement, {attributes: true, attributeFilter: ['data-bs-theme']});
obs.observe(document.body, {attributes: true, attributeFilter: ['class']});
invalidation.then(() => obs.disconnect());
}// ========== MORSE POTENTIAL - SLIDER INPUT ==========
viewof mpDistance = {
const { sliderMin, sliderMax, snapTarget, snapThreshold } = mpParams;
const container = document.createElement('div');
container.className = 'mp-slider-container';
const label = document.createElement('span');
label.className = 'mp-slider-title';
label.textContent = 'Internuclear distance';
const track = document.createElement('div');
track.className = 'mp-slider-track-wrap';
const input = document.createElement('input');
input.type = 'range';
input.className = 'mp-slider';
input.min = sliderMin;
input.max = sliderMax;
input.step = 0.01;
input.value = snapTarget;
const valueDisplay = document.createElement('span');
valueDisplay.className = 'mp-slider-value';
valueDisplay.textContent = snapTarget.toFixed(2) + ' \u00C5';
track.appendChild(input);
container.appendChild(label);
container.appendChild(track);
container.appendChild(valueDisplay);
function emitValue(val) {
container.value = +val;
container.dispatchEvent(new Event('input', {bubbles: true}));
}
input.addEventListener('input', () => {
valueDisplay.textContent = (+input.value).toFixed(2) + ' \u00C5';
emitValue(input.value);
});
// Snap-to-equilibrium on release
function snapCheck() {
const val = +input.value;
if (Math.abs(val - snapTarget) <= snapThreshold) {
// Animate to equilibrium
const startVal = val;
const startTime = performance.now();
const duration = 200; // ms
function animate(now) {
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
// Ease-out cubic
const eased = 1 - Math.pow(1 - t, 3);
const current = startVal + (snapTarget - startVal) * eased;
input.value = current.toFixed(4);
valueDisplay.textContent = current.toFixed(2) + ' \u00C5';
emitValue(current);
if (t < 1) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
}
}
input.addEventListener('mouseup', snapCheck);
input.addEventListener('touchend', snapCheck);
container.value = snapTarget;
return container;
}// ========== MORSE POTENTIAL - REACTIVE COMPUTATION ==========
mpState = {
const r = mpDistance;
const energy = mpEnergy(r);
const { De, re, snapThreshold } = mpParams;
let region, description;
if (Math.abs(r - re) <= snapThreshold) {
region = 'equilibrium';
description = 'Equilibrium bond length';
} else {
region = r < re ? 'compressed' : 'stretched';
description = null;
}
return { r, energy, region, description };
}// ========== MORSE POTENTIAL - MAIN SVG VISUALIZATION ==========
mpViz = {
const isDark = mpDarkMode;
const { r, energy, region, description } = mpState;
const { De, D0, ZPE, re, a, rMin, rMax, sliderMin, sliderMax, v0Inner, v0Outer } = mpParams;
// --- Dimensions ---
const svgW = 650, svgH = 430;
const margin = { top: 24, right: 50, bottom: 56, left: 72 };
const plotW = svgW - margin.left - margin.right;
const plotH = svgH - margin.top - margin.bottom;
// --- Theme colors ---
const c = {
bg: 'transparent',
axis: isDark ? 'rgba(168, 175, 199, 0.3)' : '#848d9c',
grid: isDark ? 'rgba(168, 175, 199, 0.1)' : 'rgba(0,0,0,0.06)',
tickText: isDark ? '#a8afc7' : '#6b7280',
labelText: isDark ? '#cbd5e1' : '#374151',
curve: isDark ? '#00d9ff' : '#3b82f6',
curveGlow: isDark ? 'rgba(0, 217, 255, 0.35)' : 'none',
dot: isDark ? '#00d9ff' : '#3b82f6',
dotStroke: isDark ? '#22d3ee' : '#1d4ed8',
dotGlow: isDark ? 'rgba(0, 217, 255, 0.5)' : 'rgba(59, 130, 246, 0.25)',
refLine: isDark ? 'rgba(168, 175, 199, 0.25)' : '#848d9c',
refText: isDark ? 'rgba(168, 175, 199, 0.55)' : '#848d9c',
infoText: isDark ? '#e8ecf8' : '#374151',
infoSub: isDark ? '#a8afc7' : '#6b7280',
atomFill: isDark ? '#1a1f35' : '#ffffff',
atomStroke: isDark ? '#00d9ff' : '#3b82f6',
atomText: isDark ? '#e8ecf8' : '#374151',
atomGlow: isDark ? 'rgba(0, 217, 255, 0.3)' : 'rgba(59, 130, 246, 0.15)',
spring: isDark ? 'rgba(168, 175, 199, 0.4)' : '#9ca3af',
eqMarker: isDark ? '#c084fc' : '#7c3aed',
descText: isDark ? '#c084fc' : '#7c3aed',
};
// --- Scales ---
// Energy range: show from -450 to +200 for the plot, but compute actual range
const yMin = -500, yMax = 200;
const xScale = d3.scaleLinear().domain([rMin, rMax]).range([0, plotW]);
const yScale = d3.scaleLinear().domain([yMax, yMin]).range([0, plotH]);
// --- SVG ---
const svg = d3.create('svg')
.attr('viewBox', `0 0 ${svgW} ${svgH}`)
.attr('class', 'mp-chart')
.style('text-rendering', 'optimizeLegibility')
.attr('preserveAspectRatio', 'xMidYMid meet');
// Defs for glow filter (dark mode)
if (isDark) {
const defs = svg.append('defs');
const filter = defs.append('filter')
.attr('id', 'mp-glow')
.attr('x', '-50%').attr('y', '-50%')
.attr('width', '200%').attr('height', '200%');
filter.append('feGaussianBlur')
.attr('stdDeviation', '3')
.attr('result', 'blur');
const merge = filter.append('feMerge');
merge.append('feMergeNode').attr('in', 'blur');
merge.append('feMergeNode').attr('in', 'SourceGraphic');
const dotFilter = defs.append('filter')
.attr('id', 'mp-dot-glow')
.attr('x', '-100%').attr('y', '-100%')
.attr('width', '300%').attr('height', '300%');
dotFilter.append('feGaussianBlur')
.attr('stdDeviation', '4')
.attr('result', 'blur');
const dotMerge = dotFilter.append('feMerge');
dotMerge.append('feMergeNode').attr('in', 'blur');
dotMerge.append('feMergeNode').attr('in', 'SourceGraphic');
}
const g = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// --- Reference lines and annotations ---
const y0 = yScale(0); // dissociation limit
const yDeBot = yScale(-De); // well minimum
const yZPE = yScale(-De + ZPE); // v=0 level = -432 kJ/mol
const xRe = xScale(re);
// Color additions for annotations
const cDe = isDark ? '#f87171' : '#dc2626'; // red for D_e
const cD0 = isDark ? '#4ade80' : '#15803d'; // green for D_0 (WCAG AA 5.02:1)
const cZPE = isDark ? '#60a5fa' : '#2563eb'; // blue for ZPE/v=0
// 1. Dissociation limit (horizontal dashed, full width)
g.append('line')
.attr('x1', 0).attr('x2', plotW)
.attr('y1', y0).attr('y2', y0)
.attr('stroke', c.refLine)
.attr('stroke-width', 1)
.attr('stroke-dasharray', '6 4');
g.append('text')
.attr('x', plotW + 6).attr('y', y0 - 4)
.attr('fill', c.refText)
.attr('font-size', '10px')
.attr('font-family', 'system-ui, sans-serif')
.text('H + H');
// 2. v=0 / ZPE level (horizontal line, clipped to classical turning points)
g.append('line')
.attr('x1', xScale(v0Inner)).attr('x2', xScale(v0Outer))
.attr('y1', yZPE).attr('y2', yZPE)
.attr('stroke', cZPE)
.attr('stroke-width', 1.5);
const v0Label = g.append('text')
.attr('x', xScale(v0Inner) - 6).attr('y', yZPE + 4)
.attr('text-anchor', 'end')
.attr('fill', cZPE)
.attr('font-size', '10px')
.attr('font-family', 'system-ui, sans-serif');
v0Label.append('tspan').attr('font-style', 'italic').text('v');
v0Label.append('tspan').text(' = 0');
// 3. r_e label below well minimum, centered (no vertical line)
const reLabelG = g.append('text')
.attr('x', xRe).attr('y', yDeBot + 16)
.attr('text-anchor', 'middle')
.attr('fill', c.eqMarker)
.attr('font-size', '10px')
.attr('font-family', 'system-ui, sans-serif');
reLabelG.append('tspan').attr('font-style', 'italic').text('r');
reLabelG.append('tspan').attr('baseline-shift', 'sub').attr('font-size', '8px').text('e');
reLabelG.append('tspan').text(' = 0.74 \u00C5');
// Helper: draw a double-headed arrow
function drawArrow(x, y1, y2, color) {
g.append('line')
.attr('x1', x).attr('x2', x)
.attr('y1', y1).attr('y2', y2)
.attr('stroke', color).attr('stroke-width', 1);
g.append('path')
.attr('d', `M${x-3},${y1+6} L${x},${y1} L${x+3},${y1+6}`)
.attr('fill', 'none').attr('stroke', color).attr('stroke-width', 1);
g.append('path')
.attr('d', `M${x-3},${y2-6} L${x},${y2} L${x+3},${y2-6}`)
.attr('fill', 'none').attr('stroke', color).attr('stroke-width', 1);
}
// Helper: label with italic variable + upright value
function drawLabel(x, y, color, varPart, subPart, valPart, anchor) {
const t = g.append('text')
.attr('x', x).attr('y', y)
.attr('text-anchor', anchor || 'start')
.attr('fill', color)
.attr('font-size', '11px')
.attr('font-family', 'system-ui, sans-serif');
t.append('tspan').attr('font-style', 'italic').text(varPart);
if (subPart) t.append('tspan').attr('baseline-shift', 'sub').attr('font-size', '9px').text(subPart);
t.append('tspan').text(valPart);
}
// 4. D_e arrow (well minimum to asymptote) at x ≈ 1.4 Å
const deArrowX = xScale(1.4);
drawArrow(deArrowX, y0, yDeBot, cDe);
drawLabel(deArrowX + 8, (y0 + yDeBot) / 2 + 4, cDe, 'D', 'e', ' = 458 kJ/mol');
// 5. D₀ arrow (ZPE to asymptote) at x ≈ 2.35 Å
const d0ArrowX = xScale(2.35);
drawArrow(d0ArrowX, y0, yZPE, cD0);
drawLabel(d0ArrowX + 8, (y0 + yZPE) / 2 + 4, cD0, 'D', '0', ' = 432 kJ/mol');
// 6. ZPE bracket (well minimum to v=0) — too small for a double arrow
const zpeX = xScale(1.0);
// Simple bracket: two short horizontal ticks connected by a vertical line
const tickW = 4;
g.append('line')
.attr('x1', zpeX).attr('x2', zpeX)
.attr('y1', yZPE).attr('y2', yDeBot)
.attr('stroke', cZPE).attr('stroke-width', 1);
g.append('line')
.attr('x1', zpeX - tickW).attr('x2', zpeX + tickW)
.attr('y1', yZPE).attr('y2', yZPE)
.attr('stroke', cZPE).attr('stroke-width', 1);
g.append('line')
.attr('x1', zpeX - tickW).attr('x2', zpeX + tickW)
.attr('y1', yDeBot).attr('y2', yDeBot)
.attr('stroke', cZPE).attr('stroke-width', 1);
g.append('text')
.attr('x', zpeX + 8).attr('y', (yZPE + yDeBot) / 2 + 4)
.attr('fill', cZPE)
.attr('font-size', '11px')
.attr('font-family', 'system-ui, sans-serif')
.text('ZPE');
// --- X Axis ---
const xTicks = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0];
const xAxisGen = d3.axisBottom(xScale)
.tickValues(xTicks)
.tickSize(4)
.tickFormat(d => d % 1 === 0 ? d.toFixed(0) : d.toFixed(1));
const xAxisG = g.append('g')
.attr('transform', `translate(0,${plotH})`)
.call(xAxisGen);
xAxisG.select('.domain').attr('stroke', c.axis);
xAxisG.selectAll('.tick line').attr('stroke', c.axis);
xAxisG.selectAll('.tick text')
.attr('fill', c.tickText)
.attr('font-size', '12px')
.attr('font-family', 'system-ui, sans-serif');
// X axis label
const xLabel = g.append('text')
.attr('x', plotW / 2)
.attr('y', plotH + 42)
.attr('text-anchor', 'middle')
.attr('fill', c.labelText)
.attr('font-size', '13px')
.attr('font-family', 'system-ui, sans-serif');
xLabel.append('tspan').text('Internuclear distance (');
xLabel.append('tspan').attr('font-style', 'italic').text('r');
xLabel.append('tspan').text(' in \u00C5)');
// --- Y Axis ---
const yTickValues = [-400, -300, -200, -100, 0, 100, 200];
const yAxisGen = d3.axisLeft(yScale)
.tickValues(yTickValues)
.tickSize(4)
.tickFormat(d => d === 0 ? '0' : d);
const yAxisG = g.append('g')
.call(yAxisGen);
yAxisG.select('.domain').attr('stroke', c.axis);
yAxisG.selectAll('.tick line').attr('stroke', c.axis);
yAxisG.selectAll('.tick text')
.attr('fill', c.tickText)
.attr('font-size', '12px')
.attr('font-family', 'system-ui, sans-serif');
// Y axis label
g.append('text')
.attr('transform', 'rotate(-90)')
.attr('x', -plotH / 2)
.attr('y', -54)
.attr('text-anchor', 'middle')
.attr('fill', c.labelText)
.attr('font-size', '13px')
.attr('font-family', 'system-ui, sans-serif')
.text('Energy (kJ/mol)');
// --- Morse potential curve ---
const nPts = 300;
const curveData = [];
for (let i = 0; i <= nPts; i++) {
const ri = rMin + (rMax - rMin) * i / nPts;
const vi = mpEnergy(ri);
// Clamp to visible range
if (vi <= yMax + 50) {
curveData.push({ r: ri, v: Math.min(vi, yMax + 20) });
}
}
const line = d3.line()
.x(d => xScale(d.r))
.y(d => yScale(d.v))
.curve(d3.curveMonotoneX);
// Glow layer (dark mode)
if (isDark) {
g.append('path')
.datum(curveData)
.attr('d', line)
.attr('fill', 'none')
.attr('stroke', c.curve)
.attr('stroke-width', 5)
.attr('stroke-opacity', 0.2)
.attr('filter', 'url(#mp-glow)');
}
// Main curve
g.append('path')
.datum(curveData)
.attr('d', line)
.attr('fill', 'none')
.attr('stroke', c.curve)
.attr('stroke-width', 2.5)
.attr('stroke-linecap', 'round');
// --- Tracking dot ---
const dotX = xScale(r);
const dotY = yScale(Math.min(energy, yMax));
const dotVisible = r >= rMin && energy <= yMax + 20;
if (dotVisible) {
// Glow circle (dark mode)
if (isDark) {
g.append('circle')
.attr('cx', dotX).attr('cy', dotY)
.attr('r', 10)
.attr('fill', c.dotGlow)
.attr('filter', 'url(#mp-dot-glow)');
}
g.append('circle')
.attr('cx', dotX).attr('cy', dotY)
.attr('r', 6)
.attr('fill', c.dot)
.attr('stroke', c.dotStroke)
.attr('stroke-width', 2)
.style('filter', isDark ? 'none' : `drop-shadow(0 0 4px ${c.dotGlow})`);
}
// --- H atom visualization (upper area, left of center) ---
const atomAreaCx = plotW * 0.35;
const atomAreaCy = plotH * 0.12;
const atomR = 14;
const atomMinSep = 4; // minimum gap at closest slider position
const atomMaxSep = 90; // maximum gap at farthest slider position
// Map slider distance to atom separation
const tAtom = (r - sliderMin) / (sliderMax - sliderMin);
const atomSep = atomMinSep + tAtom * (atomMaxSep - atomMinSep);
const halfSep = atomSep / 2;
const atom1X = atomAreaCx - halfSep - atomR;
const atom2X = atomAreaCx + halfSep + atomR;
const atomY = atomAreaCy;
// "H₂ molecule" label
const gAtoms = g; // use the main plot group since atoms are above it
gAtoms.append('text')
.attr('x', atomAreaCx)
.attr('y', atomAreaCy - atomR - 10)
.attr('text-anchor', 'middle')
.attr('fill', c.infoSub)
.attr('font-size', '10px')
.attr('font-family', 'system-ui, sans-serif')
.text('H\u2082 molecule');
// Connection line (spring-like)
const springY = atomY;
const springX1 = atom1X + atomR + 1;
const springX2 = atom2X - atomR - 1;
if (springX2 > springX1 + 2) {
// Draw a zigzag spring
const springLen = springX2 - springX1;
const nZig = 6;
const segLen = springLen / (nZig * 2);
const amplitude = Math.min(5, springLen * 0.08);
let pathD = `M${springX1},${springY}`;
for (let i = 0; i < nZig * 2; i++) {
const sx = springX1 + segLen * (i + 1);
const sy = springY + (i % 2 === 0 ? -amplitude : amplitude);
pathD += ` L${sx},${sy}`;
}
pathD += ` L${springX2},${springY}`;
gAtoms.append('path')
.attr('d', pathD)
.attr('fill', 'none')
.attr('stroke', c.spring)
.attr('stroke-width', 1.5)
.attr('stroke-linecap', 'round');
}
// Atom circles
[atom1X, atom2X].forEach(ax => {
// Glow (dark mode)
if (isDark) {
gAtoms.append('circle')
.attr('cx', ax).attr('cy', atomY)
.attr('r', atomR + 3)
.attr('fill', 'none')
.attr('stroke', c.atomGlow)
.attr('stroke-width', 2)
.attr('filter', 'url(#mp-glow)');
}
gAtoms.append('circle')
.attr('cx', ax).attr('cy', atomY)
.attr('r', atomR)
.attr('fill', c.atomFill)
.attr('stroke', c.atomStroke)
.attr('stroke-width', 2);
gAtoms.append('text')
.attr('x', ax).attr('y', atomY)
.attr('text-anchor', 'middle')
.attr('dy', '0.36em')
.attr('fill', c.atomText)
.attr('font-size', '14px')
.attr('font-weight', '700')
.attr('font-family', 'system-ui, sans-serif')
.text('H');
});
// --- Static info box (right of H₂ atoms, same vertical band) ---
const infoX = plotW * 0.62;
const infoY = atomAreaCy - 10;
const eStr = energy >= 0
? '+' + Math.round(energy) + ' kJ/mol'
: '\u2212' + Math.abs(Math.round(energy)) + ' kJ/mol';
// Distance
const rLabel = g.append('text')
.attr('x', infoX).attr('y', infoY)
.attr('fill', c.infoText)
.attr('font-size', '13px')
.attr('font-weight', '600')
.attr('font-family', 'system-ui, sans-serif');
rLabel.append('tspan').attr('font-style', 'italic').text('r');
rLabel.append('tspan').text(' = ' + r.toFixed(2) + ' \u00C5');
// Energy
const eLabel = g.append('text')
.attr('x', infoX).attr('y', infoY + 17)
.attr('fill', c.infoSub)
.attr('font-size', '12px')
.attr('font-family', 'system-ui, sans-serif');
eLabel.append('tspan').attr('font-style', 'italic').text('V');
eLabel.append('tspan').text(' = ' + eStr);
// Region description
if (description) {
g.append('text')
.attr('x', infoX).attr('y', infoY + 33)
.attr('fill', c.descText)
.attr('font-size', '11px')
.attr('font-style', 'italic')
.attr('font-family', 'system-ui, sans-serif')
.text(description);
}
// --- Expand button (top-right, opens viewer in new tab) ---
const expandBtn = document.createElement('button');
expandBtn.className = 'mp-expand-btn';
expandBtn.textContent = '\u26F6'; // ⛶ expand/fullscreen icon
expandBtn.title = 'Open in new tab';
expandBtn.addEventListener('click', function(e) {
e.preventDefault();
window.open('/files/ojs/morse-potential-viewer.html', '_blank');
});
// --- Wrapper ---
const wrapper = document.createElement('div');
wrapper.className = 'mp-widget';
wrapper.appendChild(expandBtn);
wrapper.appendChild(svg.node());
return wrapper;
}// ========== MORSE POTENTIAL - CSS STYLES ==========
html`<style>
/* ========== MORSE POTENTIAL WIDGET - LIGHT MODE ========== */
.mp-widget {
background: #f8f9fa;
border-radius: 8px;
padding: 12px 16px 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
position: relative;
font-family: system-ui, sans-serif;
}
.mp-widget > svg.mp-chart {
display: block;
width: 100%;
height: auto;
aspect-ratio: 650 / 430;
}
.mp-expand-btn {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
border: 1px solid #848d9c;
border-radius: 6px;
background-color: #f8f9fa;
color: #374151;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
line-height: 28px;
text-align: center;
transition: all 0.15s ease;
z-index: 10;
}
.mp-expand-btn svg {
width: 16px;
height: 16px;
}
.mp-expand-btn:hover {
background-color: #d1d5db;
color: #1e293b;
border-color: #6b7280;
}
.mp-widget svg text {
user-select: none;
-webkit-user-select: none;
}
.mp-chart {
display: block;
}
/* --- Slider --- */
.mp-slider-container {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
font-family: system-ui, sans-serif;
line-height: 1;
}
.mp-slider-title {
font-size: 12px;
color: #6b7280;
white-space: nowrap;
font-weight: 500;
line-height: 18px;
transform: translateY(-2px);
}
.mp-slider-track-wrap {
flex: 1;
display: flex;
align-items: center;
height: 18px;
}
.mp-slider-value {
font-size: 13px;
font-weight: 600;
color: #374151;
min-width: 52px;
text-align: right;
font-variant-numeric: tabular-nums;
line-height: 18px;
transform: translateY(-2px);
}
/* Custom range slider */
.mp-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 3px;
background: linear-gradient(90deg, #dbeafe 0%, #3b82f6 35%, #3b82f6 50%, #dbeafe 100%);
outline: none;
cursor: pointer;
margin: 0;
padding: 0;
}
.mp-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #3b82f6;
border: 2px solid #ffffff;
box-shadow: 0 1px 4px rgba(59, 130, 246, 0.4);
cursor: pointer;
transition: box-shadow 0.15s ease;
}
.mp-slider::-webkit-slider-thumb:hover {
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15), 0 1px 4px rgba(59, 130, 246, 0.4);
}
.mp-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #3b82f6;
border: 2px solid #ffffff;
box-shadow: 0 1px 4px rgba(59, 130, 246, 0.4);
cursor: pointer;
}
.mp-slider::-moz-range-track {
height: 6px;
border-radius: 3px;
background: linear-gradient(90deg, #dbeafe 0%, #3b82f6 35%, #3b82f6 50%, #dbeafe 100%);
}
/* ========== DARK MODE - CYBERPUNK ========== */
[data-bs-theme="dark"] .mp-widget,
body.quarto-dark .mp-widget,
.quarto-dark .mp-widget {
background: linear-gradient(145deg, #0b0c14 0%, #0d1020 50%, #0b0c14 100%);
border: 1px solid rgba(0, 217, 255, 0.2);
box-shadow: 0 0 20px rgba(0, 217, 255, 0.06),
0 0 40px rgba(192, 132, 252, 0.04),
0 4px 16px rgba(0, 0, 0, 0.4);
}
[data-bs-theme="dark"] .mp-expand-btn,
body.quarto-dark .mp-expand-btn,
.quarto-dark .mp-expand-btn {
background-color: rgba(11, 12, 20, 0.8);
border-color: rgba(0, 217, 255, 0.2);
color: #a8afc7;
}
[data-bs-theme="dark"] .mp-expand-btn:hover,
body.quarto-dark .mp-expand-btn:hover,
.quarto-dark .mp-expand-btn:hover {
background-color: rgba(0, 217, 255, 0.08);
border-color: rgba(0, 217, 255, 0.4);
color: #00d9ff;
}
[data-bs-theme="dark"] .mp-slider-title,
body.quarto-dark .mp-slider-title,
.quarto-dark .mp-slider-title {
color: #a8afc7;
}
[data-bs-theme="dark"] .mp-slider-value,
body.quarto-dark .mp-slider-value,
.quarto-dark .mp-slider-value {
color: #e8ecf8;
}
[data-bs-theme="dark"] .mp-slider,
body.quarto-dark .mp-slider,
.quarto-dark .mp-slider {
background: linear-gradient(90deg, #1a1f35 0%, #00d9ff 35%, #00d9ff 50%, #1a1f35 100%);
}
[data-bs-theme="dark"] .mp-slider::-webkit-slider-thumb,
body.quarto-dark .mp-slider::-webkit-slider-thumb,
.quarto-dark .mp-slider::-webkit-slider-thumb {
background: #00d9ff;
border-color: #0b0c14;
box-shadow: 0 0 8px rgba(0, 217, 255, 0.5), 0 0 16px rgba(0, 217, 255, 0.2);
}
[data-bs-theme="dark"] .mp-slider::-webkit-slider-thumb:hover,
body.quarto-dark .mp-slider::-webkit-slider-thumb:hover,
.quarto-dark .mp-slider::-webkit-slider-thumb:hover {
box-shadow: 0 0 0 4px rgba(0, 217, 255, 0.15), 0 0 8px rgba(0, 217, 255, 0.5), 0 0 16px rgba(0, 217, 255, 0.2);
}
[data-bs-theme="dark"] .mp-slider::-moz-range-thumb,
body.quarto-dark .mp-slider::-moz-range-thumb,
.quarto-dark .mp-slider::-moz-range-thumb {
background: #00d9ff;
border-color: #0b0c14;
box-shadow: 0 0 8px rgba(0, 217, 255, 0.5);
}
[data-bs-theme="dark"] .mp-slider::-moz-range-track,
body.quarto-dark .mp-slider::-moz-range-track,
.quarto-dark .mp-slider::-moz-range-track {
background: linear-gradient(90deg, #1a1f35 0%, #00d9ff 35%, #00d9ff 50%, #1a1f35 100%);
}
</style>`Bond energy follows the same trend as bond order:
Triple bonds are stronger than double bonds, which are stronger than single bonds.
More shared electrons means a stronger attraction between the nuclei and the bonding electrons, requiring more energy to break the bond.
Note
Bond energies are averages. The actual energy required to break a specific bond depends on the molecular context. For example, the C–H bond energy in methane (CH4) is slightly different from the C–H bond energy in ethane (C2H6).