ieData = await FileAttachment("../files/json/ionization-energies.json").json()
// Load Zeff data for accurate orbital energies
ieZeffData = await FileAttachment("../files/json/zeff-clementi.json").json()
// Element data: symbol, name, atomic number, electron configuration
elementData = [
{z: 1, symbol: "H", name: "Hydrogen", config: "1s1"},
{z: 2, symbol: "He", name: "Helium", config: "1s2"},
{z: 3, symbol: "Li", name: "Lithium", config: "[He] 2s1"},
{z: 4, symbol: "Be", name: "Beryllium", config: "[He] 2s2"},
{z: 5, symbol: "B", name: "Boron", config: "[He] 2s2 2p1"},
{z: 6, symbol: "C", name: "Carbon", config: "[He] 2s2 2p2"},
{z: 7, symbol: "N", name: "Nitrogen", config: "[He] 2s2 2p3"},
{z: 8, symbol: "O", name: "Oxygen", config: "[He] 2s2 2p4"},
{z: 9, symbol: "F", name: "Fluorine", config: "[He] 2s2 2p5"},
{z: 10, symbol: "Ne", name: "Neon", config: "[He] 2s2 2p6"},
{z: 11, symbol: "Na", name: "Sodium", config: "[Ne] 3s1"},
{z: 12, symbol: "Mg", name: "Magnesium", config: "[Ne] 3s2"},
{z: 13, symbol: "Al", name: "Aluminum", config: "[Ne] 3s2 3p1"},
{z: 14, symbol: "Si", name: "Silicon", config: "[Ne] 3s2 3p2"},
{z: 15, symbol: "P", name: "Phosphorus", config: "[Ne] 3s2 3p3"},
{z: 16, symbol: "S", name: "Sulfur", config: "[Ne] 3s2 3p4"},
{z: 17, symbol: "Cl", name: "Chlorine", config: "[Ne] 3s2 3p5"},
{z: 18, symbol: "Ar", name: "Argon", config: "[Ne] 3s2 3p6"},
{z: 19, symbol: "K", name: "Potassium", config: "[Ar] 4s1"},
{z: 20, symbol: "Ca", name: "Calcium", config: "[Ar] 4s2"},
{z: 21, symbol: "Sc", name: "Scandium", config: "[Ar] 4s2 3d1"},
{z: 22, symbol: "Ti", name: "Titanium", config: "[Ar] 4s2 3d2"},
{z: 23, symbol: "V", name: "Vanadium", config: "[Ar] 4s2 3d3"},
{z: 24, symbol: "Cr", name: "Chromium", config: "[Ar] 4s1 3d5"},
{z: 25, symbol: "Mn", name: "Manganese", config: "[Ar] 4s2 3d5"},
{z: 26, symbol: "Fe", name: "Iron", config: "[Ar] 4s2 3d6"},
{z: 27, symbol: "Co", name: "Cobalt", config: "[Ar] 4s2 3d7"},
{z: 28, symbol: "Ni", name: "Nickel", config: "[Ar] 4s2 3d8"},
{z: 29, symbol: "Cu", name: "Copper", config: "[Ar] 4s1 3d10"},
{z: 30, symbol: "Zn", name: "Zinc", config: "[Ar] 4s2 3d10"},
{z: 31, symbol: "Ga", name: "Gallium", config: "[Ar] 4s2 3d10 4p1"},
{z: 32, symbol: "Ge", name: "Germanium", config: "[Ar] 4s2 3d10 4p2"},
{z: 33, symbol: "As", name: "Arsenic", config: "[Ar] 4s2 3d10 4p3"},
{z: 34, symbol: "Se", name: "Selenium", config: "[Ar] 4s2 3d10 4p4"},
{z: 35, symbol: "Br", name: "Bromine", config: "[Ar] 4s2 3d10 4p5"},
{z: 36, symbol: "Kr", name: "Krypton", config: "[Ar] 4s2 3d10 4p6"},
{z: 37, symbol: "Rb", name: "Rubidium", config: "[Kr] 5s1"},
{z: 38, symbol: "Sr", name: "Strontium", config: "[Kr] 5s2"},
{z: 39, symbol: "Y", name: "Yttrium", config: "[Kr] 5s2 4d1"},
{z: 40, symbol: "Zr", name: "Zirconium", config: "[Kr] 5s2 4d2"},
{z: 41, symbol: "Nb", name: "Niobium", config: "[Kr] 5s1 4d4"},
{z: 42, symbol: "Mo", name: "Molybdenum", config: "[Kr] 5s1 4d5"},
{z: 43, symbol: "Tc", name: "Technetium", config: "[Kr] 5s2 4d5"},
{z: 44, symbol: "Ru", name: "Ruthenium", config: "[Kr] 5s1 4d7"},
{z: 45, symbol: "Rh", name: "Rhodium", config: "[Kr] 5s1 4d8"},
{z: 46, symbol: "Pd", name: "Palladium", config: "[Kr] 4d10"},
{z: 47, symbol: "Ag", name: "Silver", config: "[Kr] 5s1 4d10"},
{z: 48, symbol: "Cd", name: "Cadmium", config: "[Kr] 5s2 4d10"},
{z: 49, symbol: "In", name: "Indium", config: "[Kr] 5s2 4d10 5p1"},
{z: 50, symbol: "Sn", name: "Tin", config: "[Kr] 5s2 4d10 5p2"},
{z: 51, symbol: "Sb", name: "Antimony", config: "[Kr] 5s2 4d10 5p3"},
{z: 52, symbol: "Te", name: "Tellurium", config: "[Kr] 5s2 4d10 5p4"},
{z: 53, symbol: "I", name: "Iodine", config: "[Kr] 5s2 4d10 5p5"},
{z: 54, symbol: "Xe", name: "Xenon", config: "[Kr] 5s2 4d10 5p6"},
{z: 55, symbol: "Cs", name: "Cesium", config: "[Xe] 6s1"},
{z: 56, symbol: "Ba", name: "Barium", config: "[Xe] 6s2"}
]
// Orbital definitions with aufbau filling order
orbitalDefs = [
{name: "1s", n: 1, l: 0, ml: [0], maxElectrons: 2, energy: 1, fillOrder: 1},
{name: "2s", n: 2, l: 0, ml: [0], maxElectrons: 2, energy: 2, fillOrder: 2},
{name: "2p", n: 2, l: 1, ml: [-1, 0, 1], maxElectrons: 6, energy: 3, fillOrder: 3},
{name: "3s", n: 3, l: 0, ml: [0], maxElectrons: 2, energy: 4, fillOrder: 4},
{name: "3p", n: 3, l: 1, ml: [-1, 0, 1], maxElectrons: 6, energy: 5, fillOrder: 5},
{name: "4s", n: 4, l: 0, ml: [0], maxElectrons: 2, energy: 6, fillOrder: 6},
{name: "3d", n: 3, l: 2, ml: [-2, -1, 0, 1, 2], maxElectrons: 10, energy: 7, fillOrder: 7},
{name: "4p", n: 4, l: 1, ml: [-1, 0, 1], maxElectrons: 6, energy: 8, fillOrder: 8},
{name: "5s", n: 5, l: 0, ml: [0], maxElectrons: 2, energy: 9, fillOrder: 9},
{name: "4d", n: 4, l: 2, ml: [-2, -1, 0, 1, 2], maxElectrons: 10, energy: 10, fillOrder: 10},
{name: "5p", n: 5, l: 1, ml: [-1, 0, 1], maxElectrons: 6, energy: 11, fillOrder: 11},
{name: "6s", n: 6, l: 0, ml: [0], maxElectrons: 2, energy: 12, fillOrder: 12},
{name: "4f", n: 4, l: 3, ml: [-3, -2, -1, 0, 1, 2, 3], maxElectrons: 14, energy: 13, fillOrder: 13},
{name: "5d", n: 5, l: 2, ml: [-2, -1, 0, 1, 2], maxElectrons: 10, energy: 14, fillOrder: 14},
{name: "6p", n: 6, l: 1, ml: [-1, 0, 1], maxElectrons: 6, energy: 15, fillOrder: 15},
{name: "7s", n: 7, l: 0, ml: [0], maxElectrons: 2, energy: 16, fillOrder: 16},
{name: "5f", n: 5, l: 3, ml: [-3, -2, -1, 0, 1, 2, 3], maxElectrons: 14, energy: 17, fillOrder: 17},
{name: "6d", n: 6, l: 2, ml: [-2, -1, 0, 1, 2], maxElectrons: 10, energy: 18, fillOrder: 18},
{name: "7p", n: 7, l: 1, ml: [-1, 0, 1], maxElectrons: 6, energy: 19, fillOrder: 19}
]
// Get elements that have both IE data and Zeff data available
// Zeff data only goes to Z=54 (Xe), so limit to that
availableElements = {
const available = [];
const maxZeff = 54; // Clementi-Raimondi data ends at Xe
for (const el of elementData) {
if (el.z > maxZeff) continue;
const ieInfo = ieData.elements[String(el.z)];
const zeffInfo = ieZeffData.find(d => d.Z === el.z);
if (ieInfo && (ieInfo.wel.length > 0 || ieInfo.crc.length > 0) && zeffInfo) {
available.push(el.z);
}
}
return available;
}
maxZ = Math.max(...availableElements)
// Get Zeff values for current element
currentZeff = ieZeffData.find(d => d.Z === selectedZ) || {}Learning Lab: Ionization Energies
mutable selectedZValue = 6
mutable selectedCharge = 0
mutable selectedUnit = "wel" // "wel" for kJ/mol, "crc" for eV
// Element selector with slider and number input
viewof selectedZInput = {
const initialZ = 6;
const container = document.createElement("div");
container.style.marginBottom = "10px";
container.style.display = "flex";
container.style.alignItems = "center";
container.style.gap = "10px";
container.style.flexWrap = "wrap";
const label = document.createElement("label");
label.innerHTML = "Atomic number (<i>Z</i>):";
label.style.fontWeight = "500";
label.style.whiteSpace = "nowrap";
const numberInput = document.createElement("input");
numberInput.type = "number";
numberInput.min = 1;
numberInput.max = maxZ;
numberInput.step = 1;
numberInput.value = initialZ;
numberInput.style.width = "70px";
numberInput.style.padding = "4px 8px";
numberInput.style.fontSize = "14px";
numberInput.style.fontWeight = "bold";
numberInput.style.textAlign = "center";
numberInput.style.border = "1px solid #ccc";
numberInput.style.borderRadius = "4px";
const slider = document.createElement("input");
slider.type = "range";
slider.min = 1;
slider.max = maxZ;
slider.step = 1;
slider.value = initialZ;
slider.style.flex = "1";
slider.style.minWidth = "150px";
const updateValue = (val, forceValid = false) => {
const parsed = parseInt(val);
if (forceValid || !isNaN(parsed)) {
val = Math.max(1, Math.min(maxZ, parsed || 1));
// Find nearest available element
if (!availableElements.includes(val)) {
const lower = availableElements.filter(z => z <= val).pop() || availableElements[0];
const upper = availableElements.find(z => z >= val) || availableElements[availableElements.length - 1];
val = Math.abs(val - lower) <= Math.abs(val - upper) ? lower : upper;
}
slider.value = val;
numberInput.value = val;
container.value = val;
container.dispatchEvent(new CustomEvent("input", {bubbles: true}));
}
};
slider.addEventListener("input", () => updateValue(slider.value));
numberInput.addEventListener("input", () => updateValue(numberInput.value, false));
numberInput.addEventListener("change", () => updateValue(numberInput.value, true));
numberInput.addEventListener("blur", () => updateValue(numberInput.value, true));
container.appendChild(label);
container.appendChild(numberInput);
container.appendChild(slider);
container.value = initialZ;
return container;
}
// Sync selectedZValue with input
selectedZ = {
mutable selectedZValue = selectedZInput;
// Reset charge when element changes
mutable selectedCharge = 0;
return selectedZInput;
}
// Get current element data
currentElement = elementData.find(e => e.z === selectedZ) || elementData[0]
// Get IE data for current element
currentIEData = {
const data = ieData.elements[String(selectedZ)];
if (!data) return { wel: [], crc: [], symbol: currentElement.symbol, name: currentElement.name };
return data;
}
// Max charge is limited by available IE data for the CURRENT unit
// If we have N IE values (IE₁ to IEₙ), max charge is N-1
// (at charge N-1, we can still show IEₙ as the next ionization)
maxCharge = {
const ieValues = selectedUnit === "wel" ? currentIEData.wel : currentIEData.crc;
return Math.max(0, ieValues.length - 1);
}
// Clamp selectedCharge if it exceeds maxCharge (e.g., when switching units)
clampedCharge = {
if (selectedCharge > maxCharge) {
mutable selectedCharge = maxCharge;
}
return null;
}viewof chargeSelector = {
const container = document.createElement("div");
container.style.marginBottom = "10px";
container.style.display = "flex";
container.style.alignItems = "center";
container.style.gap = "10px";
container.style.flexWrap = "wrap";
const label = document.createElement("label");
label.textContent = "Ion charge:";
label.style.fontWeight = "500";
label.style.whiteSpace = "nowrap";
const initialCharge = selectedCharge || 0;
const numberInput = document.createElement("input");
numberInput.type = "number";
numberInput.min = 0;
numberInput.max = maxCharge;
numberInput.step = 1;
numberInput.value = initialCharge;
numberInput.style.width = "60px";
numberInput.style.padding = "4px 8px";
numberInput.style.fontSize = "14px";
numberInput.style.fontWeight = "bold";
numberInput.style.textAlign = "center";
numberInput.style.border = "1px solid #ccc";
numberInput.style.borderRadius = "4px";
// Add "+" suffix display
const chargeDisplay = document.createElement("span");
chargeDisplay.textContent = initialCharge > 0 ? "+" : "";
chargeDisplay.style.fontWeight = "bold";
chargeDisplay.style.marginLeft = "-5px";
const slider = document.createElement("input");
slider.type = "range";
slider.min = 0;
slider.max = maxCharge;
slider.step = 1;
slider.value = initialCharge;
slider.style.flex = "1";
slider.style.minWidth = "120px";
const updateValue = (val, forceValid = false) => {
const parsed = parseInt(val);
if (forceValid || !isNaN(parsed)) {
val = Math.max(0, Math.min(maxCharge, parsed || 0));
slider.value = val;
numberInput.value = val;
chargeDisplay.textContent = val > 0 ? "+" : "";
mutable selectedCharge = val;
container.value = val;
container.dispatchEvent(new CustomEvent("input", {bubbles: true}));
}
};
// Update max when maxCharge changes
const updateMax = (newMax) => {
slider.max = newMax;
numberInput.max = newMax;
if (parseInt(slider.value) > newMax) {
updateValue(newMax, true);
}
};
slider.addEventListener("input", () => updateValue(slider.value));
numberInput.addEventListener("input", () => updateValue(numberInput.value, false));
numberInput.addEventListener("change", () => updateValue(numberInput.value, true));
numberInput.addEventListener("blur", () => updateValue(numberInput.value, true));
container.appendChild(label);
container.appendChild(numberInput);
container.appendChild(chargeDisplay);
container.appendChild(slider);
container.value = initialCharge;
container.updateMax = updateMax;
return container;
}
// Unit selector
viewof unitSelector = {
const container = document.createElement("div");
container.style.display = "flex";
container.style.alignItems = "center";
container.style.gap = "8px";
container.style.marginBottom = "10px";
const label = document.createElement("span");
label.textContent = "Energy units:";
label.style.fontWeight = "500";
container.appendChild(label);
const btnGroup = document.createElement("div");
btnGroup.style.display = "flex";
btnGroup.style.gap = "0";
const welBtn = document.createElement("button");
welBtn.textContent = "kJ/mol";
welBtn.className = "ie-unit-btn";
welBtn.style.padding = "4px 12px";
welBtn.style.border = "1px solid #ccc";
welBtn.style.borderRadius = "4px 0 0 4px";
welBtn.style.cursor = "pointer";
welBtn.style.fontSize = "13px";
const crcBtn = document.createElement("button");
crcBtn.textContent = "eV";
crcBtn.className = "ie-unit-btn";
crcBtn.style.padding = "4px 12px";
crcBtn.style.border = "1px solid #ccc";
crcBtn.style.borderLeft = "none";
crcBtn.style.borderRadius = "0 4px 4px 0";
crcBtn.style.cursor = "pointer";
crcBtn.style.fontSize = "13px";
function updateStyles() {
const isWel = selectedUnit === "wel";
welBtn.style.background = isWel ? "#4263eb" : "#fff";
welBtn.style.color = isWel ? "#fff" : "#333";
welBtn.style.fontWeight = isWel ? "bold" : "normal";
crcBtn.style.background = !isWel ? "#4263eb" : "#fff";
crcBtn.style.color = !isWel ? "#fff" : "#333";
crcBtn.style.fontWeight = !isWel ? "bold" : "normal";
}
welBtn.onclick = () => {
mutable selectedUnit = "wel";
container.value = "wel";
updateStyles();
container.dispatchEvent(new CustomEvent("input", {bubbles: true}));
};
crcBtn.onclick = () => {
mutable selectedUnit = "crc";
container.value = "crc";
updateStyles();
container.dispatchEvent(new CustomEvent("input", {bubbles: true}));
};
updateStyles();
btnGroup.appendChild(welBtn);
btnGroup.appendChild(crcBtn);
container.appendChild(btnGroup);
container.value = selectedUnit;
return container;
}
currentUnit = unitSelectorfunction parseConfig(configStr) {
const occ = {};
// Remove noble gas core notation
const nobleMatch = configStr.match(/^\[([A-Za-z]+)\]\s*/);
let remainder = configStr;
if (nobleMatch) {
const noble = nobleMatch[1];
// Add noble gas electrons
const nobleConfigs = {
"He": {"1s": 2},
"Ne": {"1s": 2, "2s": 2, "2p": 6},
"Ar": {"1s": 2, "2s": 2, "2p": 6, "3s": 2, "3p": 6},
"Kr": {"1s": 2, "2s": 2, "2p": 6, "3s": 2, "3p": 6, "4s": 2, "3d": 10, "4p": 6},
"Xe": {"1s": 2, "2s": 2, "2p": 6, "3s": 2, "3p": 6, "4s": 2, "3d": 10, "4p": 6, "5s": 2, "4d": 10, "5p": 6},
"Rn": {"1s": 2, "2s": 2, "2p": 6, "3s": 2, "3p": 6, "4s": 2, "3d": 10, "4p": 6, "5s": 2, "4d": 10, "5p": 6, "6s": 2, "4f": 14, "5d": 10, "6p": 6}
};
Object.assign(occ, nobleConfigs[noble] || {});
remainder = configStr.slice(nobleMatch[0].length);
}
// Parse remaining orbitals
const orbRegex = /(\d[spdf])(\d+)/g;
let m;
while ((m = orbRegex.exec(remainder)) !== null) {
occ[m[1]] = parseInt(m[2]);
}
return occ;
}
// Get occupancies after removing 'charge' electrons
// Uses Zeff to determine removal order: lowest Zeff = highest energy = removed first
function getOccupanciesForCharge(neutralOcc, charge, zeffValues) {
if (charge === 0) return {...neutralOcc};
const occ = {...neutralOcc};
let remaining = charge;
while (remaining > 0) {
// Find occupied orbital with lowest Zeff (highest energy)
let lowestZeffOrbital = null;
let lowestZeff = Infinity;
for (const orbital of Object.keys(occ)) {
if (occ[orbital] > 0 && zeffValues[orbital] !== undefined) {
if (zeffValues[orbital] < lowestZeff) {
lowestZeff = zeffValues[orbital];
lowestZeffOrbital = orbital;
}
}
}
if (!lowestZeffOrbital) break; // No more electrons to remove
// Remove one electron from this orbital
occ[lowestZeffOrbital] -= 1;
remaining -= 1;
if (occ[lowestZeffOrbital] === 0) delete occ[lowestZeffOrbital];
}
return occ;
}
// Calculate neutral occupancies
neutralOccupancies = parseConfig(currentElement.config)
// Get occupancies for current charge using Zeff-based removal
occupancies = getOccupanciesForCharge(neutralOccupancies, selectedCharge, currentZeff)
// Determine valence vs core orbitals
function getOrbitalTypes(occ, z) {
const types = {};
const occupiedOrbitals = Object.keys(occ);
if (occupiedOrbitals.length === 0) return types;
const maxN = Math.max(...occupiedOrbitals.map(orb => parseInt(orb[0])));
for (const orbital of occupiedOrbitals) {
const n = parseInt(orbital[0]);
const subshell = orbital[1];
if (n === maxN) {
types[orbital] = 'valence';
} else if (n === maxN - 1 && subshell === 'd') {
types[orbital] = 'valence';
} else if (n === maxN - 2 && subshell === 'f') {
types[orbital] = 'valence';
} else {
types[orbital] = 'core';
}
}
return types;
}
ieOrbitalTypes = getOrbitalTypes(occupancies, selectedZ)
// Identify which orbital the NEXT electron will be removed from
// This helps clarify why a bar is colored as valence or core
function getNextRemovalOrbital(occ, zeffValues) {
if (Object.keys(occ).length === 0) return null;
let lowestZeffOrbital = null;
let lowestZeff = Infinity;
for (const orbital of Object.keys(occ)) {
if (occ[orbital] > 0 && zeffValues[orbital] !== undefined) {
if (zeffValues[orbital] < lowestZeff) {
lowestZeff = zeffValues[orbital];
lowestZeffOrbital = orbital;
}
}
}
return lowestZeffOrbital;
}
nextRemovalOrbital = getNextRemovalOrbital(occupancies, currentZeff)
// Is the next removal from a valence or core orbital?
nextRemovalType = {
if (!nextRemovalOrbital) return null;
return ieOrbitalTypes[nextRemovalOrbital] || 'core';
}
// Get config parts for display
function getFullConfig(occ) {
const aufbauOrder = ['1s', '2s', '2p', '3s', '3p', '4s', '3d', '4p', '5s', '4d', '5p', '6s', '4f', '5d', '6p', '7s', '5f', '6d', '7p'];
const parts = [];
for (const orb of aufbauOrder) {
if (occ[orb]) {
parts.push({ orbital: orb, count: occ[orb] });
}
}
return parts;
}
fullConfigParts = getFullConfig(occupancies)// Main visualization
ieDiagram = {
const width = 800;
const height = 520;
const margin = {top: 60, right: 320, bottom: 80, left: 100};
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.attr("class", "ie-widget-svg")
.style("max-width", `${width}px`)
.style("height", "auto")
.style("display", "block")
.style("margin", "0 auto");
// Background
svg.append("rect")
.attr("width", width)
.attr("height", height)
.attr("class", "ie-bg");
// Arrow marker for energy axis
svg.append("defs").append("marker")
.attr("id", "ie-arrow")
.attr("viewBox", "0 0 14 8")
.attr("refX", 7)
.attr("refY", 4)
.attr("markerWidth", 7)
.attr("markerHeight", 4)
.attr("orient", "auto-start-reverse")
.append("path")
.attr("d", "M 0 0 L 14 4 L 0 8 z")
.attr("class", "ie-arrow-marker");
// Title with element info and charge (matching electron config widget style)
const titleText = svg.append("text")
.attr("x", (width - margin.right) / 2 + margin.left / 2)
.attr("y", 28)
.attr("text-anchor", "middle")
.attr("class", "ie-title");
// Helper to convert number to superscript Unicode
const toSuperscript = (n) => {
const superDigits = '⁰¹²³⁴⁵⁶⁷⁸⁹';
return String(n).split('').map(d => superDigits[parseInt(d)]).join('');
};
// Build title HTML - only include charge tspan if there's a charge
let titleHtml = `<tspan font-weight="bold" font-size="24">${currentElement.symbol}</tspan>`;
if (selectedCharge > 0) {
const chargeStr = selectedCharge === 1 ? "⁺" : `${toSuperscript(selectedCharge)}⁺`;
titleHtml += `<tspan font-size="14">${chargeStr}</tspan>`;
titleHtml += `<tspan font-size="16" dx="8">${currentElement.name}</tspan>`;
} else {
titleHtml += `<tspan font-size="16" dx="10">${currentElement.name}</tspan>`;
}
titleHtml += `<tspan font-size="12" dx="8" class="ie-z-label">(<tspan font-style="italic">Z</tspan> = ${currentElement.z})</tspan>`;
titleText.html(titleHtml);
// Show current IE value if applicable
if (selectedCharge < maxCharge) {
const ieValues = currentUnit === "wel" ? currentIEData.wel : currentIEData.crc;
const ieVal = ieValues[selectedCharge];
const unitLabel = currentUnit === "wel" ? "kJ/mol" : "eV";
if (ieVal !== undefined) {
const ieDisplay = svg.append("text")
.attr("x", (width - margin.right) / 2 + margin.left / 2)
.attr("y", 46)
.attr("text-anchor", "middle")
.attr("class", "ie-value-display");
ieDisplay.html(
`<tspan>IE</tspan><tspan font-size="10" baseline-shift="sub">${selectedCharge + 1}</tspan>` +
`<tspan> = ${ieVal.toLocaleString()} ${unitLabel}</tspan>`
);
// Show which orbital the electron is removed from
if (nextRemovalOrbital) {
const orbLabel = svg.append("text")
.attr("x", (width - margin.right) / 2 + margin.left / 2)
.attr("y", 60)
.attr("text-anchor", "middle")
.attr("class", `ie-removal-info ie-removal-${nextRemovalType}`);
orbLabel.html(
`<tspan font-size="10">(removes </tspan>` +
`<tspan font-weight="600">${nextRemovalOrbital}</tspan>` +
`<tspan> – ${nextRemovalType})</tspan>`
);
}
}
}
// Orbital energy diagram
const diagramTop = margin.top + 25; // Extra space for removal info line
const diagramBottom = height - margin.bottom - 30;
const diagramHeight = diagramBottom - diagramTop;
// Determine which orbitals to show
// Show orbitals that are occupied in the NEUTRAL atom (so we can see empty boxes after ionization)
// Only show orbitals that have Zeff data
const relevantOrbitals = orbitalDefs.filter(o => {
const neutralOcc = neutralOccupancies[o.name] || 0;
const hasZeff = currentZeff[o.name] !== undefined;
return neutralOcc > 0 && hasZeff;
});
// Box dimensions
const boxSize = 24;
const boxGap = 2;
const columnOffsets = { 's': 0, 'p': 70, 'd': 180, 'f': 340 };
// Use Zeff values for orbital positioning
// Lower Zeff = higher energy = higher on diagram (lower Y)
// Higher Zeff = lower energy = lower on diagram (higher Y)
const orbitalZeffs = {};
for (const orb of Object.keys(currentZeff)) {
if (orb !== 'Z' && orb !== 'symbol') {
orbitalZeffs[orb] = currentZeff[orb];
}
}
// Get Zeff range for occupied orbitals only
const occupiedZeffs = Object.keys(neutralOccupancies)
.filter(orb => orbitalZeffs[orb] !== undefined)
.map(orb => orbitalZeffs[orb]);
const maxZeff = occupiedZeffs.length > 0 ? Math.max(...occupiedZeffs) : 10;
const minZeff = occupiedZeffs.length > 0 ? Math.min(...occupiedZeffs) : 1;
const zeffRange = maxZeff - minZeff || 1;
function getYPosition(orbitalName) {
const zeff = orbitalZeffs[orbitalName];
if (zeff === undefined) return diagramTop + diagramHeight / 2;
// Higher Zeff = lower energy = lower on diagram (higher Y value)
// Normalize: (zeff - minZeff) / range gives 0 for lowest Zeff, 1 for highest
const normalized = (zeff - minZeff) / zeffRange;
// Map: lowest Zeff (0) -> top (diagramTop), highest Zeff (1) -> bottom (diagramBottom)
return diagramTop + normalized * diagramHeight;
}
// Energy axis
svg.append("line")
.attr("x1", margin.left - 15)
.attr("y1", diagramBottom + boxSize + 5)
.attr("x2", margin.left - 15)
.attr("y2", diagramTop - 10)
.attr("class", "ie-axis-line")
.attr("marker-end", "url(#ie-arrow)");
svg.append("text")
.attr("x", margin.left - 28)
.attr("y", (diagramTop + diagramBottom) / 2)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("class", "ie-axis-label")
.text("E");
const baseX = margin.left + 15; // Extra space for orbital labels
// Draw orbitals
relevantOrbitals.forEach((orbital) => {
const subshell = orbital.name.slice(-1);
const numBoxes = orbital.ml.length;
const y = getYPosition(orbital.name);
const startX = baseX + columnOffsets[subshell];
const orbitalGroup = svg.append("g").attr("class", "ie-orbital-group");
// Label
orbitalGroup.append("text")
.attr("x", startX - 8)
.attr("y", y + boxSize / 2)
.attr("text-anchor", "end")
.attr("dominant-baseline", "central")
.attr("class", "ie-orbital-label")
.text(orbital.name);
const electronCount = occupancies[orbital.name] || 0;
// Draw boxes
orbital.ml.forEach((ml, boxIdx) => {
const boxX = startX + boxIdx * (boxSize + boxGap);
const numOrbitals = orbital.ml.length;
let hasSpinUp = false;
let hasSpinDown = false;
if (electronCount > 0) {
if (electronCount <= numOrbitals) {
if (boxIdx < electronCount) hasSpinUp = true;
} else {
hasSpinUp = true;
const spinDownCount = electronCount - numOrbitals;
if (boxIdx < spinDownCount) hasSpinDown = true;
}
}
const boxHasElectron = hasSpinUp || hasSpinDown;
const orbType = ieOrbitalTypes[orbital.name] || 'empty';
let boxClass = "ie-box ie-box-empty";
if (boxHasElectron) {
boxClass = orbType === 'core' ? "ie-box ie-box-core" : "ie-box ie-box-valence";
}
orbitalGroup.append("rect")
.attr("x", boxX)
.attr("y", y)
.attr("width", boxSize)
.attr("height", boxSize)
.attr("class", boxClass);
// Harpoon arrows
const arrowUp = "M 0.6,-8 L 0.6,8 L -0.6,8 L -0.6,-5 L -4,-2.5 L -0.6,-8 Z";
const arrowDown = "M -0.6,8 L -0.6,-8 L 0.6,-8 L 0.6,5 L 4,2.5 L 0.6,8 Z";
const upArrowX = boxX + boxSize/2 - 2.5;
const downArrowX = boxX + boxSize/2 + 2.5;
const arrowY = y + boxSize/2;
const isUnpaired = hasSpinUp && !hasSpinDown;
if (hasSpinUp) {
orbitalGroup.append("path")
.attr("d", arrowUp)
.attr("transform", `translate(${upArrowX}, ${arrowY})`)
.attr("class", isUnpaired ? "ie-arrow ie-arrow-unpaired" : "ie-arrow ie-arrow-paired");
}
if (hasSpinDown) {
orbitalGroup.append("path")
.attr("d", arrowDown)
.attr("transform", `translate(${downArrowX}, ${arrowY})`)
.attr("class", "ie-arrow ie-arrow-paired");
}
});
});
// === IE Bar Chart (right side) ===
const chartX = width - margin.right + 30;
const chartWidth = margin.right - 50;
const chartY = margin.top;
const chartHeight = height - margin.top - margin.bottom - 20;
const ieValues = currentUnit === "wel" ? currentIEData.wel : currentIEData.crc;
const numIEs = ieValues.length;
if (numIEs > 0) {
const chartGroup = svg.append("g")
.attr("class", "ie-bar-chart")
.attr("transform", `translate(${chartX}, ${chartY})`);
// Background
chartGroup.append("rect")
.attr("x", -10)
.attr("y", -15)
.attr("width", chartWidth + 20)
.attr("height", chartHeight + 50)
.attr("rx", 6)
.attr("class", "ie-chart-bg");
// Title
chartGroup.append("text")
.attr("x", chartWidth / 2)
.attr("y", -2)
.attr("text-anchor", "middle")
.attr("class", "ie-chart-title")
.text("Successive IEs");
// Calculate which bars to show - center around selected charge
const maxVisible = 10;
let startIdx = 0;
let endIdx = Math.min(numIEs, maxVisible);
if (numIEs > maxVisible) {
// Center the selected charge in the visible window
const halfWindow = Math.floor(maxVisible / 2);
startIdx = Math.max(0, selectedCharge - halfWindow);
endIdx = startIdx + maxVisible;
// Adjust if we're near the end
if (endIdx > numIEs) {
endIdx = numIEs;
startIdx = Math.max(0, endIdx - maxVisible);
}
}
const visibleCount = endIdx - startIdx;
const barHeight = Math.min(28, (chartHeight - 20) / visibleCount - 4);
const barGap = 4;
// Scale to max of VISIBLE bars for better readability
const visibleIEs = ieValues.slice(startIdx, endIdx);
const maxVisibleIE = Math.max(...visibleIEs);
const barMaxWidth = chartWidth - 50;
// Determine which electrons are valence vs core for coloring
// Count electrons in valence shell of neutral atom
const neutralTypes = getOrbitalTypes(neutralOccupancies, selectedZ);
let valenceCount = 0;
for (const [orb, type] of Object.entries(neutralTypes)) {
if (type === 'valence') valenceCount += neutralOccupancies[orb] || 0;
}
// Show range indicator if not showing all bars
if (numIEs > maxVisible) {
chartGroup.append("text")
.attr("x", chartWidth / 2)
.attr("y", 10)
.attr("text-anchor", "middle")
.attr("class", "ie-range-indicator")
.text(`Showing IE${startIdx + 1}–IE${endIdx} of ${numIEs}`);
}
// Draw bars
for (let idx = startIdx; idx < endIdx; idx++) {
const displayIdx = idx - startIdx; // Position in visible area
const barY = 18 + displayIdx * (barHeight + barGap);
const ie = ieValues[idx];
const barWidth = (ie / maxVisibleIE) * barMaxWidth;
const isValence = idx < valenceCount;
const isActive = idx === selectedCharge;
// Bar background (clickable area)
chartGroup.append("rect")
.attr("x", 25)
.attr("y", barY)
.attr("width", barMaxWidth)
.attr("height", barHeight)
.attr("class", "ie-bar-hitbox")
.style("cursor", "pointer")
.on("click", (function(chargeIdx) {
return function() { mutable selectedCharge = chargeIdx; };
})(idx));
// Actual bar
chartGroup.append("rect")
.attr("x", 25)
.attr("y", barY)
.attr("width", barWidth)
.attr("height", barHeight)
.attr("rx", 3)
.attr("class", `ie-bar ${isValence ? "ie-bar-valence" : "ie-bar-core"} ${isActive ? "ie-bar-active" : ""}`)
.style("cursor", "pointer")
.on("click", (function(chargeIdx) {
return function() { mutable selectedCharge = chargeIdx; };
})(idx));
// IE label (actual IE number, not display index)
chartGroup.append("text")
.attr("x", 20)
.attr("y", barY + barHeight / 2)
.attr("text-anchor", "end")
.attr("dominant-baseline", "central")
.attr("class", "ie-bar-label")
.text(idx + 1);
// Value on bar
const valueX = Math.max(barWidth + 30, 60);
chartGroup.append("text")
.attr("x", valueX)
.attr("y", barY + barHeight / 2)
.attr("text-anchor", "start")
.attr("dominant-baseline", "central")
.attr("class", "ie-bar-value")
.text(ie.toLocaleString());
}
// Legend
const legendY = chartHeight + 18;
chartGroup.append("rect")
.attr("x", 0)
.attr("y", legendY)
.attr("width", 10)
.attr("height", 10)
.attr("rx", 2)
.attr("class", "ie-bar ie-bar-valence");
chartGroup.append("text")
.attr("x", 14)
.attr("y", legendY + 8)
.attr("class", "ie-legend-text")
.text("Valence");
chartGroup.append("rect")
.attr("x", 70)
.attr("y", legendY)
.attr("width", 10)
.attr("height", 10)
.attr("rx", 2)
.attr("class", "ie-bar ie-bar-core");
chartGroup.append("text")
.attr("x", 84)
.attr("y", legendY + 8)
.attr("class", "ie-legend-text")
.text("Core");
}
// Configuration display at bottom
const infoY = height - 50;
// Build config string helper
function buildConfigString(textEl, parts) {
parts.forEach((part, i) => {
const orbTspan = textEl.append("tspan").text((i > 0 ? " " : "") + part.orbital);
if (i > 0) orbTspan.attr("dy", "4");
textEl.append("tspan")
.attr("dy", "-4")
.attr("font-size", "9px")
.text(part.count);
});
textEl.append("tspan").attr("dy", "4").text("");
}
svg.append("text")
.attr("x", margin.left)
.attr("y", infoY)
.attr("class", "ie-config-label")
.text("Config:");
const configText = svg.append("text")
.attr("x", margin.left + 50)
.attr("y", infoY)
.attr("class", "ie-config-text");
if (fullConfigParts.length > 0) {
buildConfigString(configText, fullConfigParts);
} else {
configText.text("(fully ionized)");
}
// Data source citation
svg.append("a")
.attr("href", ieData.source)
.attr("target", "_blank")
.append("text")
.attr("x", width - 10)
.attr("y", height - 10)
.attr("text-anchor", "end")
.attr("class", "ie-citation")
.text("Data: Wikipedia (WEL/CRC)");
return svg.node();
}