ionZeffData = await FileAttachment("../files/json/zeff-clementi.json").json()
// Element data: symbol, name, atomic number, electron configuration
ionElementData = [
{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"}
]
// Orbital definitions with aufbau filling order
ionOrbitalDefs = [
{name: "1s", n: 1, l: 0, ml: [0], maxElectrons: 2, fillOrder: 1},
{name: "2s", n: 2, l: 0, ml: [0], maxElectrons: 2, fillOrder: 2},
{name: "2p", n: 2, l: 1, ml: [-1, 0, 1], maxElectrons: 6, fillOrder: 3},
{name: "3s", n: 3, l: 0, ml: [0], maxElectrons: 2, fillOrder: 4},
{name: "3p", n: 3, l: 1, ml: [-1, 0, 1], maxElectrons: 6, fillOrder: 5},
{name: "4s", n: 4, l: 0, ml: [0], maxElectrons: 2, fillOrder: 6},
{name: "3d", n: 3, l: 2, ml: [-2, -1, 0, 1, 2], maxElectrons: 10, fillOrder: 7},
{name: "4p", n: 4, l: 1, ml: [-1, 0, 1], maxElectrons: 6, fillOrder: 8},
{name: "5s", n: 5, l: 0, ml: [0], maxElectrons: 2, fillOrder: 9},
{name: "4d", n: 4, l: 2, ml: [-2, -1, 0, 1, 2], maxElectrons: 10, fillOrder: 10},
{name: "5p", n: 5, l: 1, ml: [-1, 0, 1], maxElectrons: 6, fillOrder: 11},
{name: "6s", n: 6, l: 0, ml: [0], maxElectrons: 2, fillOrder: 12}
]
// Get elements with Zeff data
ionAvailableElements = {
const available = [];
for (const el of ionElementData) {
const zeffInfo = ionZeffData.find(d => d.Z === el.z);
if (zeffInfo) available.push(el.z);
}
return available;
}
ionMaxZ = Math.max(...ionAvailableElements)
// Get Zeff values for current element
ionCurrentZeff = ionZeffData.find(d => d.Z === ionSelectedZ) || {}Learning Lab: Electron Configurations of Ions
mutable ionSelectedZValue = 9 // Start with Fluorine
mutable ionSelectedCharge = 0
// Element selector
viewof ionSelectedZInput = {
const initialZ = 9;
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 = ionMaxZ;
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 = ionMaxZ;
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(ionMaxZ, parsed || 1));
if (!ionAvailableElements.includes(val)) {
const lower = ionAvailableElements.filter(z => z <= val).pop() || ionAvailableElements[0];
const upper = ionAvailableElements.find(z => z >= val) || ionAvailableElements[ionAvailableElements.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 selectedZ
// Note: charge reset is handled by the viewof ionChargeSelector being recreated when Z changes
ionSelectedZ = {
mutable ionSelectedZValue = ionSelectedZInput;
return ionSelectedZInput;
}
// Get current element
ionCurrentElement = ionElementData.find(e => e.z === ionSelectedZ) || ionElementData[0]ionMaxPositiveCharge = ionSelectedZ // Can remove all electrons
ionMaxNegativeCharge = {
// Calculate electrons needed to reach next noble gas config
const nobleGases = [2, 10, 18, 36, 54];
const nextNoble = nobleGases.find(n => n > ionSelectedZ);
if (!nextNoble) return 0;
const electronsToAdd = nextNoble - ionSelectedZ;
// Limit to reasonable anions (max -4, typically -1 to -3)
return Math.min(electronsToAdd, 4);
}
// Charge selector with slider (negative to positive)
// NOTE: Do NOT read ionSelectedCharge here - it would create a circular dependency
viewof ionChargeSelector = {
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";
// Always start at 0 when widget is (re)created (e.g., when element changes)
const initialCharge = 0;
const minCharge = -ionMaxNegativeCharge;
const maxCharge = ionMaxPositiveCharge;
// Use signed number input so arrow keys work for going negative
const numberInput = document.createElement("input");
numberInput.type = "number";
numberInput.min = minCharge;
numberInput.max = maxCharge;
numberInput.step = 1;
numberInput.value = 0;
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 = minCharge;
slider.max = maxCharge;
slider.step = 1;
slider.value = initialCharge;
slider.style.flex = "1";
slider.style.minWidth = "120px";
// Track current charge locally to avoid reading mutable
let currentCharge = initialCharge;
const updateValue = (val, forceValid = false) => {
const parsed = parseInt(val);
if (forceValid || !isNaN(parsed)) {
val = Math.max(minCharge, Math.min(maxCharge, parsed || 0));
currentCharge = val;
slider.value = val;
numberInput.value = val; // Show signed value
mutable ionSelectedCharge = 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));
container.appendChild(label);
container.appendChild(numberInput);
container.appendChild(slider);
container.value = initialCharge;
// Initialize the mutable to match
mutable ionSelectedCharge = initialCharge;
return container;
}function ionParseConfig(configStr) {
const occ = {};
const nobleMatch = configStr.match(/^\[([A-Za-z]+)\]\s*/);
let remainder = configStr;
if (nobleMatch) {
const noble = nobleMatch[1];
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}
};
Object.assign(occ, nobleConfigs[noble] || {});
remainder = configStr.slice(nobleMatch[0].length);
}
const orbRegex = /(\d[spdf])(\d+)/g;
let m;
while ((m = orbRegex.exec(remainder)) !== null) {
occ[m[1]] = parseInt(m[2]);
}
return occ;
}
// Get occupancies for cations (remove electrons)
function ionGetCationOccupancies(neutralOcc, charge, zeffValues) {
if (charge <= 0) return {...neutralOcc};
const occ = {...neutralOcc};
let remaining = charge;
while (remaining > 0) {
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;
occ[lowestZeffOrbital] -= 1;
remaining -= 1;
if (occ[lowestZeffOrbital] === 0) delete occ[lowestZeffOrbital];
}
return occ;
}
// Get occupancies for anions (add electrons following Aufbau)
function ionGetAnionOccupancies(neutralOcc, charge) {
if (charge >= 0) return {...neutralOcc};
const occ = {...neutralOcc};
const aufbauOrder = ['1s', '2s', '2p', '3s', '3p', '4s', '3d', '4p', '5s', '4d', '5p', '6s'];
const maxElectrons = {'1s': 2, '2s': 2, '2p': 6, '3s': 2, '3p': 6, '4s': 2, '3d': 10, '4p': 6, '5s': 2, '4d': 10, '5p': 6, '6s': 2};
let toAdd = Math.abs(charge);
for (const orbital of aufbauOrder) {
if (toAdd <= 0) break;
const current = occ[orbital] || 0;
const max = maxElectrons[orbital];
const canAdd = max - current;
if (canAdd > 0) {
const adding = Math.min(canAdd, toAdd);
occ[orbital] = current + adding;
toAdd -= adding;
}
}
return occ;
}
// Calculate occupancies based on charge
ionNeutralOccupancies = ionParseConfig(ionCurrentElement.config)
ionOccupancies = {
if (ionSelectedCharge > 0) {
return ionGetCationOccupancies(ionNeutralOccupancies, ionSelectedCharge, ionCurrentZeff);
} else if (ionSelectedCharge < 0) {
return ionGetAnionOccupancies(ionNeutralOccupancies, ionSelectedCharge);
}
return {...ionNeutralOccupancies};
}
ionTotalElectrons = Object.values(ionOccupancies).reduce((a, b) => a + b, 0)
// Determine valence vs core
function ionGetOrbitalTypes(occ) {
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;
}
ionOrbitalTypes = ionGetOrbitalTypes(ionOccupancies)
// Get full config notation
function ionGetFullConfig(occ) {
const aufbauOrder = ['1s', '2s', '2p', '3s', '3p', '4s', '3d', '4p', '5s', '4d', '5p', '6s', '4f', '5d', '6p'];
const parts = [];
for (const orb of aufbauOrder) {
if (occ[orb]) {
parts.push({ orbital: orb, count: occ[orb] });
}
}
return parts;
}
ionFullConfigParts = ionGetFullConfig(ionOccupancies)
// Find isoelectronic noble gas
ionIsoelectronicNobleGas = {
const nobleGases = {2: "He", 10: "Ne", 18: "Ar", 36: "Kr", 54: "Xe"};
return nobleGases[ionTotalElectrons] || null;
}
// Determine number of valence electrons for the neutral atom
ionValenceElectrons = {
// Simplified: count electrons in outermost shell + (n-1)d + (n-2)f if present
const occ = ionNeutralOccupancies;
const orbitals = Object.keys(occ);
if (orbitals.length === 0) return 0;
const maxN = Math.max(...orbitals.map(o => parseInt(o[0])));
let valence = 0;
for (const [orb, count] of Object.entries(occ)) {
const n = parseInt(orb[0]);
const l = orb[1];
// Outermost shell
if (n === maxN) valence += count;
// (n-1)d electrons count as valence for transition metals
else if (n === maxN - 1 && l === 'd') valence += count;
// (n-2)f electrons for lanthanides/actinides
else if (n === maxN - 2 && l === 'f') valence += count;
}
return valence;
}
// Check if species is unstable
ionIsUnstable = {
// === UNSTABLE CATIONS ===
if (ionSelectedCharge > 0) {
// If charge exceeds valence electrons, we're removing core electrons = unstable
if (ionSelectedCharge > ionValenceElectrons) return true;
// Max reasonable oxidation states (highest commonly observed)
const maxOxidationStates = {
// Group 1
1: 1, 3: 1, 11: 2, 19: 1, 37: 1, 55: 1, // H, Li, Na(+2 rare), K, Rb, Cs
// Group 2
4: 2, 12: 2, 20: 2, 38: 2, 56: 2, // Be, Mg, Ca, Sr, Ba
// 3d transition metals
21: 3, // Sc
22: 4, // Ti
23: 5, // V
24: 6, // Cr
25: 7, // Mn (permanganate)
26: 4, // Fe (+4 rare, +6 ferrate very rare)
27: 4, // Co
28: 4, // Ni
29: 3, // Cu
30: 2, // Zn
// 4d transition metals
39: 3, // Y
40: 4, // Zr
41: 5, // Nb
42: 6, // Mo
43: 7, // Tc
44: 8, // Ru
45: 6, // Rh
46: 4, // Pd
47: 3, // Ag
48: 2, // Cd
// Groups 13-18
5: 3, 13: 3, 31: 3, 49: 3, // B, Al, Ga, In
6: 4, 14: 4, 32: 4, 50: 4, // C, Si, Ge, Sn
7: 5, 15: 5, 33: 5, 51: 5, // N, P, As, Sb
8: 2, 16: 6, 34: 6, 52: 6, // O, S, Se, Te
9: 1, 17: 7, 35: 7, 53: 7, // F, Cl, Br, I (F only +1 in F₂O)
2: 0, 10: 0, 18: 0, 36: 2, 54: 8 // He, Ne, Ar, Kr(+2), Xe(+8)
};
const maxOx = maxOxidationStates[ionSelectedZ];
if (maxOx !== undefined && ionSelectedCharge > maxOx) return true;
// Fallback for elements not in lookup: if charge > 6, likely unstable
if (maxOx === undefined && ionSelectedCharge > 6) return true;
}
// === UNSTABLE ANIONS ===
if (ionSelectedCharge < 0) {
const charge = ionSelectedCharge; // negative number
// Group 17 (halogens): only -1 is stable
const halogens = [9, 17, 35, 53];
if (halogens.includes(ionSelectedZ)) {
return charge < -1; // F²⁻ etc. are unstable
}
// Group 16 (chalcogens): -1 and -2 can be stable (in ionic compounds)
const chalcogens = [8, 16, 34, 52];
if (chalcogens.includes(ionSelectedZ)) {
return charge < -2; // O³⁻ etc. are unstable
}
// Group 15 (pnictogens): -3 can exist in ionic compounds (nitrides, phosphides)
// But N⁻ isolated is barely stable (Eea ≈ 0)
const pnictogens = [7, 15, 33, 51];
if (pnictogens.includes(ionSelectedZ)) {
if (ionSelectedZ === 7 && charge === -1) return true; // N⁻ is unstable
return charge < -3; // N⁴⁻ etc. are unstable
}
// Group 14 (carbon group): C⁴⁻ in some carbides, but generally -1 max for isolated
const group14 = [6, 14, 32, 50];
if (group14.includes(ionSelectedZ)) {
return charge < -1; // Ge²⁻, Ge³⁻, Ge⁴⁻ all unstable
}
// H⁻ (hydride) is stable
if (ionSelectedZ === 1) {
return charge < -1;
}
// Everything else: any negative charge is unstable
// This includes: noble gases, Group 2, Group 12, alkali metals,
// transition metals, lanthanides, actinides, and other metals
return true;
}
return false;
}// Main visualization
ionDiagram = {
const width = 600; // Wider for more orbital space
const height = 500; // Taller for more vertical spacing
const margin = {top: 75, right: 50, bottom: 95, left: 105};
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.attr("class", "ion-config-svg")
.style("max-width", "100%")
.style("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", "ion-bg");
// Arrow marker
svg.append("defs").append("marker")
.attr("id", "ion-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", "ion-arrow-marker");
// Helper for superscript
const toSuperscript = (n) => {
const superDigits = '⁰¹²³⁴⁵⁶⁷⁸⁹';
return String(Math.abs(n)).split('').map(d => superDigits[parseInt(d)]).join('');
};
// Title
const titleText = svg.append("text")
.attr("x", width / 2)
.attr("y", 28)
.attr("text-anchor", "middle")
.attr("class", "ion-title");
let titleHtml = `<tspan font-weight="bold" font-size="24">${ionCurrentElement.symbol}</tspan>`;
if (ionSelectedCharge !== 0) {
const chargeStr = Math.abs(ionSelectedCharge) === 1
? (ionSelectedCharge > 0 ? "⁺" : "⁻")
: `${toSuperscript(ionSelectedCharge)}${ionSelectedCharge > 0 ? "⁺" : "⁻"}`;
// Use dy="-8" to lift charge to proper superscript position (aligned with top of capital letter)
titleHtml += `<tspan font-size="14" dy="-8">${chargeStr}</tspan>`;
titleHtml += `<tspan font-size="16" dx="8" dy="8">${ionSelectedCharge > 0 ? "cation" : "anion"}</tspan>`;
} else {
titleHtml += `<tspan font-size="16" dx="10">${ionCurrentElement.name}</tspan>`;
}
titleHtml += `<tspan font-size="12" dx="8" dy="0" class="ion-z-label">(<tspan font-style="italic">Z</tspan> = ${ionCurrentElement.z})</tspan>`;
titleText.html(titleHtml);
// Electron count and isoelectronic info
let infoText = `${ionTotalElectrons} electron${ionTotalElectrons !== 1 ? 's' : ''}`;
if (ionIsoelectronicNobleGas && ionSelectedCharge !== 0) {
infoText += ` (isoelectronic with ${ionIsoelectronicNobleGas})`;
}
svg.append("text")
.attr("x", width / 2)
.attr("y", 48)
.attr("text-anchor", "middle")
.attr("class", "ion-info-text")
.text(infoText);
// Unstable species warning
if (ionIsUnstable) {
svg.append("text")
.attr("x", width / 2)
.attr("y", 65)
.attr("text-anchor", "middle")
.attr("class", "ion-unstable-text")
.text("⚠ Unstable — does not exist as isolated species");
}
// Orbital diagram - add extra space at top if unstable warning is shown
const diagramTop = margin.top + (ionIsUnstable ? 20 : 10);
const diagramBottom = height - margin.bottom;
const diagramHeight = diagramBottom - diagramTop;
// Get orbitals to show (occupied in neutral or ion)
const allOccupied = new Set([
...Object.keys(ionNeutralOccupancies),
...Object.keys(ionOccupancies)
]);
// For anions, include orbitals even if they don't have Zeff data for this element
// (e.g., Cl⁻ needs 4s shown even though neutral Cl doesn't have 4s electrons)
const relevantOrbitals = ionOrbitalDefs.filter(o => allOccupied.has(o.name));
const boxSize = 24;
const boxGap = 2;
const columnOffsets = { 's': 0, 'p': 70, 'd': 180, 'f': 340 };
// Zeff-based positioning with minimum spacing
const orbitalZeffs = {};
for (const orb of Object.keys(ionCurrentZeff)) {
if (orb !== 'Z' && orb !== 'symbol') {
orbitalZeffs[orb] = ionCurrentZeff[orb];
}
}
// For orbitals without Zeff data (anion-added orbitals), estimate based on aufbau order
const aufbauOrder = ['1s', '2s', '2p', '3s', '3p', '4s', '3d', '4p', '5s', '4d', '5p', '6s'];
for (const orb of [...allOccupied]) {
if (orbitalZeffs[orb] === undefined) {
// Estimate: find lowest known Zeff and subtract based on aufbau position
const aufIdx = aufbauOrder.indexOf(orb);
let lowestKnownZeff = Infinity;
let lowestKnownIdx = -1;
for (const [knownOrb, zeff] of Object.entries(orbitalZeffs)) {
if (allOccupied.has(knownOrb) && zeff < lowestKnownZeff) {
lowestKnownZeff = zeff;
lowestKnownIdx = aufbauOrder.indexOf(knownOrb);
}
}
// New orbital should have lower Zeff (higher energy) than all known orbitals
const stepsBeyond = aufIdx - lowestKnownIdx;
orbitalZeffs[orb] = Math.max(0.1, lowestKnownZeff - stepsBeyond * 0.8);
}
}
// Sort orbitals by Zeff ascending (lowest Zeff = highest energy = top of diagram)
// In SVG, Y increases downward, so first in array = top = highest energy = lowest Zeff
const sortedOrbitals = relevantOrbitals.slice().sort((a, b) => {
return (orbitalZeffs[a.name] || 0) - (orbitalZeffs[b.name] || 0);
});
// Calculate positions with guaranteed minimum spacing
const minRowSpacing = boxSize + 8; // Minimum pixels between orbital rows
const numOrbitals = sortedOrbitals.length;
// Calculate required height and scale accordingly
const requiredHeight = numOrbitals * minRowSpacing;
const availableHeight = diagramHeight;
// Use evenly spaced positions if we have room, otherwise use minimum spacing
const actualSpacing = Math.max(minRowSpacing, availableHeight / Math.max(numOrbitals, 1));
// Center the orbitals vertically in the available space
const totalUsedHeight = (numOrbitals - 1) * actualSpacing;
const startY = diagramTop + (availableHeight - totalUsedHeight) / 2;
// Create position map
const orbitalPositions = {};
sortedOrbitals.forEach((orbital, idx) => {
orbitalPositions[orbital.name] = startY + idx * actualSpacing;
});
function getYPosition(orbitalName) {
return orbitalPositions[orbitalName] || diagramTop + diagramHeight / 2;
}
// 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", "ion-axis-line")
.attr("marker-end", "url(#ion-arrow)");
svg.append("text")
.attr("x", margin.left - 30)
.attr("y", (diagramTop + diagramBottom) / 2)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("class", "ion-axis-label")
.text("E");
const baseX = margin.left + 15;
// Draw orbitals
relevantOrbitals.forEach((orbital) => {
const subshell = orbital.name.slice(-1);
const y = getYPosition(orbital.name);
const startX = baseX + columnOffsets[subshell];
const orbitalGroup = svg.append("g").attr("class", "ion-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", "ion-orbital-label")
.text(orbital.name);
const electronCount = ionOccupancies[orbital.name] || 0;
const neutralCount = ionNeutralOccupancies[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 = ionOrbitalTypes[orbital.name] || 'empty';
// Highlight added/removed electrons
let boxClass = "ion-box ion-box-empty";
if (boxHasElectron) {
boxClass = orbType === 'core' ? "ion-box ion-box-core" : "ion-box ion-box-valence";
}
orbitalGroup.append("rect")
.attr("x", boxX)
.attr("y", y)
.attr("width", boxSize)
.attr("height", boxSize)
.attr("class", boxClass);
// 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 ? "ion-arrow ion-arrow-unpaired" : "ion-arrow ion-arrow-paired");
}
if (hasSpinDown) {
orbitalGroup.append("path")
.attr("d", arrowDown)
.attr("transform", `translate(${downArrowX}, ${arrowY})`)
.attr("class", "ion-arrow ion-arrow-paired");
}
});
});
// Configuration display - position in bottom margin area
const infoY = height - 55;
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", "ion-config-label")
.text("Config:");
const configText = svg.append("text")
.attr("x", margin.left + 50)
.attr("y", infoY)
.attr("class", "ion-config-text");
if (ionFullConfigParts.length > 0) {
buildConfigString(configText, ionFullConfigParts);
} else {
configText.text("(no electrons)");
}
// Magnetic property
const unpairedCount = Object.entries(ionOccupancies).reduce((total, [orb, count]) => {
const orbDef = ionOrbitalDefs.find(o => o.name === orb);
if (!orbDef) return total;
const numOrbitals = orbDef.ml.length;
if (count <= numOrbitals) return total + count;
return total + (2 * numOrbitals - count);
}, 0);
const magneticProp = unpairedCount === 0 ? "diamagnetic" : `paramagnetic (${unpairedCount} unpaired)`;
svg.append("text")
.attr("x", margin.left)
.attr("y", infoY + 20)
.attr("class", "ion-magnetic-text")
.text(magneticProp);
return svg.node();
}