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 Polarity
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: '#22c55e'},
{t: 0.25, color: '#a3c93a'},
{t: 0.45, color: '#eab308'},
{t: 0.65, color: '#f59e0b'},
{t: 0.80, color: '#ef4444'},
{t: 1, color: '#dc2626'}
];
// 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 = 55, rMaxI = 90;
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 = 2.5;
// Ensure slight overlap: scale up radii if visible extents don't bridge the bond
const extScale = Math.sqrt(-Math.log(0.02) / alphaNuc); // ~1.25
const totalExt = (rLeft + rRight) * extScale;
const minOverlap = 10;
if (totalExt < bondLen + minOverlap) {
const boost = (bondLen + minOverlap) / totalExt;
rLeft *= boost;
rRight *= boost;
}
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.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>`;
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: 10px;
right: 10px;
width: 32px;
height: 32px;
border: 1px solid #d1d5db;
border-radius: 6px;
background-color: rgba(255,255,255,0.8);
color: #6b7280;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
z-index: 10;
}
.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 #d1d5db;
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: #9ca3af; }
.bp-select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.bp-bond-dash {
color: #9ca3af;
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 #d1d5db;
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: #9ca3af;
}
.bp-chart { display: block; }
.bp-bar-border {
stroke: #d1d5db;
stroke-width: 1;
}
.bp-threshold-line {
stroke: #9ca3af;
stroke-width: 1;
stroke-dasharray: 3,3;
}
.bp-zone-label { fill: #6b7280; font-size: 10px; }
.bp-tick-label { fill: #9ca3af; 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: #d1d5db;
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.