Electron Configurations
What is an Electron Configuration?
The quantum numbers define which orbitals exist; atomic orbitals describe their shapes and sizes. The question here: how do electrons actually fill these orbitals?
The electron configuration of an atom describes how its electrons are distributed among atomic orbitals. Every atom has a unique arrangement of electrons that determines:
- Its chemical properties (how it reacts with other elements)
- Its position in the periodic table (which group and period it belongs to)
- Its bonding behavior (how many bonds it forms and what type)
- Its magnetic properties (whether it’s attracted to or repelled by magnets)
spdf Notation
Electron configurations are written using spdf notation, where each occupied subshell is listed with a superscript indicating its electron count:
This example shows sodium (Z = 11) with 11 total electrons distributed across four subshells. Reading left to right, the configuration tells us: two electrons fill the 1s subshell, two more fill 2s, six fill 2p, and the final electron occupies 3s.
The Periodic Table and Orbital Blocks
The periodic table is organized into blocks based on which type of orbital receives the last electron. Know the blocks and you can predict most electron configurations.
The placement of lutetium (Lu) and lawrencium (Lr) is an ongoing debate in chemistry. Traditionally, they are shown at the end of the f-block (lanthanides and actinides), but some chemists argue they should be in the d-block as part of group 3.
Arguments for placing Lu and Lr in the d-block (group 3):
- Electron configuration: Lu is [Xe]4f145d16s2 — the 4f subshell is complete, and the differentiating electron occupies a 5d orbital. By the logic that “the last electron determines the block,” Lu belongs in the d-block.
- Chemical properties: Lu and Lr behave more like scandium and yttrium (the other group 3 elements) than like the lanthanides. Their ionic radii, oxidation states, and coordination chemistry align with Sc and Y.
- Continuous atomic numbers: Placing Sc-Y-Lu-Lr in group 3 gives a smoothly increasing sequence of atomic numbers across the periodic table, which La-Ac does not.
Arguments for keeping the traditional f-block placement:
- Historical continuity: Lu and Lr have been taught as the final members of the lanthanide and actinide series for decades.
- Complete f-shell as “core”: Some argue that the filled 4f14 shell in Lu is part of the core, not the valence shell, so Lu still “belongs” with the f-block elements it follows.
- Computational studies: Recent (2024) computational work on cluster compounds found that La, Ac, Lu, and Lr all show f-block-like behavior in certain chemical environments, supporting a 15-element-wide f-block.
Current IUPAC position: IUPAC has not issued a final ruling. Their official periodic table shows two gaps below yttrium, effectively avoiding the choice. A 2021 provisional recommendation favored Lu/Lr in group 3, but this remains under discussion.
The Rules for Building Electron Configurations
Four principles govern how electrons fill orbitals. These rules allow you to predict the electron configuration of any element.
1. The Aufbau Principle
The Aufbau principle (German: Aufbauprinzip, “building-up principle”) states that electrons fill orbitals starting from the lowest energy and proceeding to higher energies. The term reflects how we conceptually “build up” an atom’s electron configuration one electron at a time.
2. The Madelung Rule (n + l Rule)
The Madelung rule provides the filling order:
- Orbitals fill in order of increasing n + l value
- When two orbitals have the same n + l value, the one with lower n fills first
This produces the filling sequence: 1s → 2s → 2p → 3s → 3p → 4s → 3d → 4p → 5s → 4d → …
3. The Pauli Exclusion Principle
The Pauli exclusion principle states that no two electrons in an atom can have the same set of four quantum numbers. Since each orbital is defined by three quantum numbers (n, l, ml), and electrons can only have ms = +½ or −½, each orbital can hold at most two electrons with opposite spins.
4. Hund’s Rule
Hund’s rule states that when filling degenerate orbitals (orbitals with the same energy), electrons occupy them singly with parallel spins before pairing up. This minimizes electron-electron repulsion and maximizes exchange energy (discussed below).
For example, carbon (1s22s22p2) has its two 2p electrons in separate orbitals with parallel spins, not paired in the same orbital.
Core and Valence Electrons
Electrons in an atom are classified as either core or valence electrons:
The noble gas notation abbreviates core electrons using the symbol of the preceding noble gas in brackets:
\[\text{Na: } 1s^2 2s^2 2p^6 3s^1 \longrightarrow [\text{Ne}]~3s^1\]
Here, [Ne] represents the 10 core electrons (1s22s22p6), and 3s1 is the single valence electron.
Counting Valence Electrons by Block
Hydrogen vs. Multi-Electron Energy Levels
Orbital energies behave very differently depending on whether an atom has one electron or many.
aoOrbitals = [
{ name: "1s", type: "s", n: 1, l: 0, boxes: 1 },
{ name: "2s", type: "s", n: 2, l: 0, boxes: 1 },
{ name: "2p", type: "p", n: 2, l: 1, boxes: 3 },
{ name: "3s", type: "s", n: 3, l: 0, boxes: 1 },
{ name: "3p", type: "p", n: 3, l: 1, boxes: 3 },
{ name: "3d", type: "d", n: 3, l: 2, boxes: 5 },
{ name: "4s", type: "s", n: 4, l: 0, boxes: 1 },
{ name: "4p", type: "p", n: 4, l: 1, boxes: 3 },
{ name: "4d", type: "d", n: 4, l: 2, boxes: 5 },
{ name: "4f", type: "f", n: 4, l: 3, boxes: 7 },
{ name: "5s", type: "s", n: 5, l: 0, boxes: 1 },
{ name: "5p", type: "p", n: 5, l: 1, boxes: 3 },
{ name: "5d", type: "d", n: 5, l: 2, boxes: 5 },
{ name: "6s", type: "s", n: 6, l: 0, boxes: 1 }
]
// Energy positions for 1-electron (hydrogen-like) system
// Approximate 1/n² spacing for n=1 through n=4
// n=5 and n=6 orbitals hidden off-screen (slide in for multi-electron)
aoHydrogenEnergies = ({
"1s": 465,
"2s": 135, "2p": 135,
"3s": 30, "3p": 30, "3d": 30,
"4s": -10, "4p": -10, "4d": -10, "4f": -10,
"5s": -120, "5p": -120, "5d": -120,
"6s": -140
})
// Energy positions for multi-electron system
// Aufbau order: ...4p < 5s < 4d < 5p < 6s < 4f < 5d...
aoMultiElectronEnergies = ({
"1s": 465,
"2s": 410, "2p": 365,
"3s": 305, "3p": 250, "3d": 180,
"4s": 160, "4p": 105,
"5s": 70,
"4d": 40,
"5p": 15,
"6s": -10,
"4f": -35,
"5d": -55
})
// Toggle state - default to multi-electron
mutable aoIsHydrogen = false// Create the SVG ONCE (no dependencies on mutable state)
aoEnergyDiagram = {
const width = 720;
const height = 640;
const margin = { top: 70, right: 30, bottom: 40, left: 80 };
const boxSize = 28;
const boxGap = 3;
const columnOffsets = { 's': 0, 'p': 100, 'd': 220, 'f': 410 };
const diagramTop = margin.top + 10;
const diagramBottom = height - margin.bottom;
const diagramHeight = diagramBottom - diagramTop;
// Fixed energy scale so orbitals can animate off-screen
const energyMax = 500; // Most stable (bottom)
const energyMin = -50; // Baseline for visible area (hidden orbitals go more negative)
// Function to calculate Y position for a given mode
function getY(orbitalName, isHydrogen) {
const energies = isHydrogen ? aoHydrogenEnergies : aoMultiElectronEnergies;
const rawY = energies[orbitalName];
return diagramTop + ((rawY - energyMin) / (energyMax - energyMin)) * diagramHeight;
}
// Create SVG
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.attr("class", "ao-energy-svg")
.style("width", "100%")
.style("height", "auto");
// Background
svg.append("rect")
.attr("class", "ao-bg")
.attr("width", width)
.attr("height", height)
.attr("rx", 8);
// Clip path to hide orbitals that slide off-screen
// Starts below column headers so they don't get clipped
svg.append("defs").append("clipPath")
.attr("id", "ao-clip")
.append("rect")
.attr("x", -margin.left)
.attr("y", margin.top + 5)
.attr("width", width)
.attr("height", height - margin.top - margin.bottom + 10);
// Title (will be updated)
const title = svg.append("text")
.attr("class", "ao-title")
.attr("x", width / 2)
.attr("y", 24)
.attr("text-anchor", "middle")
.text("Multi-Electron Atom");
// Energy axis (outside clipped group)
const axisX = margin.left - 45;
svg.append("defs").append("marker")
.attr("id", "ao-arrow")
.attr("viewBox", "0 0 10 10")
.attr("refX", 5)
.attr("refY", 5)
.attr("markerWidth", 4)
.attr("markerHeight", 4)
.attr("orient", "auto-start-reverse")
.append("path")
.attr("d", "M 0 0 L 10 5 L 0 10 z")
.attr("class", "ao-axis-arrow");
svg.append("line")
.attr("class", "ao-axis-line")
.attr("x1", axisX)
.attr("y1", diagramBottom + 15)
.attr("x2", axisX)
.attr("y2", diagramTop - 15)
.attr("marker-end", "url(#ao-arrow)");
svg.append("text")
.attr("class", "ao-axis-label")
.attr("x", axisX)
.attr("y", (diagramTop + diagramBottom) / 2)
.attr("text-anchor", "middle")
.attr("transform", `rotate(-90, ${axisX}, ${(diagramTop + diagramBottom) / 2})`)
.attr("dy", -12)
.text("Energy");
const g = svg.append("g")
.attr("transform", `translate(${margin.left}, 0)`)
.attr("clip-path", "url(#ao-clip)");
// Column headers (outside clipped group so they don't get cut off)
const headers = [
{ type: 's', label: 's', x: columnOffsets['s'] + boxSize / 2 },
{ type: 'p', label: 'p', x: columnOffsets['p'] + (3 * boxSize + 2 * boxGap) / 2 },
{ type: 'd', label: 'd', x: columnOffsets['d'] + (5 * boxSize + 4 * boxGap) / 2 },
{ type: 'f', label: 'f', x: columnOffsets['f'] + (7 * boxSize + 6 * boxGap) / 2 }
];
headers.forEach(h => {
svg.append("text")
.attr("class", `ao-column-header ao-header-${h.type}`)
.attr("x", margin.left + h.x)
.attr("y", margin.top - 5)
.attr("text-anchor", "middle")
.text(h.label);
});
// Create groups for each orbital
const orbitalGroups = {};
const labelWidth = 22; // Approximate width of labels like "3s", "2p"
const labelGap = 6; // Gap between guide line and label
// First pass: draw all guide lines (behind everything else)
const guideLineGroup = g.append("g").attr("class", "ao-guide-lines");
const guideLineStart = -37; // Relative to g's coordinate system (near the axis)
aoOrbitals.forEach(orbital => {
const colX = columnOffsets[orbital.type];
guideLineGroup.append("line")
.attr("class", `ao-guide-line ao-guide-${orbital.name}`)
.attr("x1", guideLineStart)
.attr("x2", colX - labelWidth - labelGap);
});
// Second pass: draw labels and boxes (in front of guide lines)
aoOrbitals.forEach(orbital => {
const colX = columnOffsets[orbital.type];
const group = g.append("g")
.attr("class", `ao-orbital-group ao-orbital-${orbital.name}`);
// Orbital label
group.append("text")
.attr("class", "ao-orbital-label")
.attr("x", colX - labelGap)
.attr("text-anchor", "end")
.attr("dominant-baseline", "middle")
.text(orbital.name);
// Boxes
for (let i = 0; i < orbital.boxes; i++) {
group.append("rect")
.attr("class", `ao-box ao-box-${orbital.type}`)
.attr("x", colX + i * (boxSize + boxGap))
.attr("width", boxSize)
.attr("height", boxSize)
.attr("rx", 2);
}
orbitalGroups[orbital.name] = group;
});
// Update function with smooth D3 transitions
function updatePositions(isHydrogen, animate = true) {
const duration = animate ? 500 : 0;
const easing = d3.easeCubicInOut;
// Update title
title.text(isHydrogen ? "Hydrogen Atom (1 electron)" : "Multi-Electron Atom");
// Update each orbital group
aoOrbitals.forEach(orbital => {
const y = getY(orbital.name, isHydrogen);
const group = orbitalGroups[orbital.name];
// Animate label
group.select("text.ao-orbital-label")
.transition()
.duration(duration)
.ease(easing)
.attr("y", y + boxSize / 2);
// Animate boxes
group.selectAll("rect.ao-box")
.transition()
.duration(duration)
.ease(easing)
.attr("y", y);
// Animate guide line (in separate group)
guideLineGroup.select(`.ao-guide-${orbital.name}`)
.transition()
.duration(duration)
.ease(easing)
.attr("y1", y + boxSize / 2)
.attr("y2", y + boxSize / 2);
});
}
// Initial render (no animation)
updatePositions(false, false);
// Expose update function
svg.node()._updatePositions = updatePositions;
return svg.node();
}// Separate cell to handle toggle changes - triggers animation
aoAnimationTrigger = {
// This cell re-runs when aoIsHydrogen changes
const currentState = aoIsHydrogen;
// Small delay to ensure SVG is mounted
if (aoEnergyDiagram && aoEnergyDiagram._updatePositions) {
aoEnergyDiagram._updatePositions(currentState, true);
}
return currentState;
}// Toggle switch UI
viewof aoHydrogenToggle = {
const container = document.createElement("div");
container.className = "ao-toggle-container";
const label1 = document.createElement("span");
label1.className = "ao-toggle-label";
label1.textContent = "Hydrogen (1 e\u207B)";
const toggle = document.createElement("label");
toggle.className = "ao-toggle-switch";
const input = document.createElement("input");
input.type = "checkbox";
input.checked = true; // Start with multi-electron (checked)
const slider = document.createElement("span");
slider.className = "ao-toggle-slider";
toggle.appendChild(input);
toggle.appendChild(slider);
const label2 = document.createElement("span");
label2.className = "ao-toggle-label active";
label2.textContent = "Multi-electron";
function updateLabels() {
const isMulti = input.checked;
label1.className = "ao-toggle-label" + (isMulti ? "" : " active");
label2.className = "ao-toggle-label" + (isMulti ? " active" : "");
}
label1.onclick = () => {
if (input.checked) {
input.checked = false;
updateLabels();
mutable aoIsHydrogen = true;
}
};
label2.onclick = () => {
if (!input.checked) {
input.checked = true;
updateLabels();
mutable aoIsHydrogen = false;
}
};
input.addEventListener("change", () => {
updateLabels();
mutable aoIsHydrogen = !input.checked;
});
container.appendChild(label1);
container.appendChild(toggle);
container.appendChild(label2);
return container;
}// CSS styles
html`<style>
.ao-energy-svg {
display: block;
margin: 0 auto;
}
.ao-bg {
fill: #fafafa;
}
.ao-title {
font-size: 15px;
font-weight: 600;
fill: #333;
}
.ao-axis-line {
stroke: #555;
stroke-width: 2;
}
.ao-axis-arrow {
fill: #555;
}
.ao-axis-label {
font-size: 13px;
font-weight: 500;
fill: #333;
}
.ao-column-header {
font-size: 16px;
font-weight: 700;
}
.ao-header-s { fill: #9c27b0; }
.ao-header-p { fill: #00838f; }
.ao-header-d { fill: #c62828; }
.ao-header-f { fill: #6a1b9a; }
.ao-orbital-label {
font-size: 13px;
font-weight: 600;
fill: #444;
}
.ao-box {
stroke-width: 1.5;
}
.ao-box-s {
fill: #ce93d8;
stroke: #9c27b0;
}
.ao-box-p {
fill: #4dd0e1;
stroke: #00838f;
}
.ao-box-d {
fill: #ef9a9a;
stroke: #c62828;
}
.ao-box-f {
fill: #ce93d8;
stroke: #6a1b9a;
}
.ao-guide-line {
stroke: #bbb;
stroke-width: 1;
stroke-dasharray: 4,3;
}
/* Toggle container */
.ao-toggle-container {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin: 12px 0 8px 0;
font-family: system-ui, -apple-system, sans-serif;
}
.ao-toggle-label {
font-size: 14px;
color: #999;
cursor: pointer;
transition: color 0.3s ease;
user-select: none;
}
.ao-toggle-label:hover {
color: #666;
}
.ao-toggle-label.active {
color: #333;
font-weight: 600;
}
/* Toggle switch */
.ao-toggle-switch {
position: relative;
display: inline-block;
width: 46px;
height: 24px;
}
.ao-toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.ao-toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ce93d8;
transition: background-color 0.3s ease;
border-radius: 24px;
}
.ao-toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: transform 0.3s ease;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.ao-toggle-switch input:checked + .ao-toggle-slider {
background-color: #4dd0e1;
}
.ao-toggle-switch input:checked + .ao-toggle-slider:before {
transform: translateX(22px);
}
/* ========== DARK MODE ========== */
.quarto-dark .ao-bg,
[data-bs-theme="dark"] .ao-bg {
fill: #0a0a14;
}
.quarto-dark .ao-title,
[data-bs-theme="dark"] .ao-title {
fill: #00f0ff;
filter: drop-shadow(0 0 3px #00f0ff50);
}
.quarto-dark .ao-axis-line,
[data-bs-theme="dark"] .ao-axis-line {
stroke: #00f0ff;
filter: drop-shadow(0 0 2px #00f0ff50);
}
.quarto-dark .ao-axis-arrow,
[data-bs-theme="dark"] .ao-axis-arrow {
fill: #00f0ff;
}
.quarto-dark .ao-axis-label,
[data-bs-theme="dark"] .ao-axis-label {
fill: #00f0ff;
}
.quarto-dark .ao-column-header,
[data-bs-theme="dark"] .ao-column-header {
filter: drop-shadow(0 0 2px currentColor);
}
.quarto-dark .ao-header-s,
[data-bs-theme="dark"] .ao-header-s { fill: #ff6b7a; }
.quarto-dark .ao-header-p,
[data-bs-theme="dark"] .ao-header-p { fill: #7bed9f; }
.quarto-dark .ao-header-d,
[data-bs-theme="dark"] .ao-header-d { fill: #00d9ff; }
.quarto-dark .ao-header-f,
[data-bs-theme="dark"] .ao-header-f { fill: #c084fc; }
.quarto-dark .ao-orbital-label,
[data-bs-theme="dark"] .ao-orbital-label {
fill: #c0d0e0;
}
/* Dark mode boxes - matching ptblock colors */
.quarto-dark .ao-box-s,
[data-bs-theme="dark"] .ao-box-s {
fill: #ff6b7a;
stroke: #ff6b7a;
filter: drop-shadow(0 0 2px #ff6b7a40);
}
.quarto-dark .ao-box-p,
[data-bs-theme="dark"] .ao-box-p {
fill: #7bed9f;
stroke: #7bed9f;
filter: drop-shadow(0 0 2px #7bed9f40);
}
.quarto-dark .ao-box-d,
[data-bs-theme="dark"] .ao-box-d {
fill: #00d9ff;
stroke: #00d9ff;
filter: drop-shadow(0 0 2px #00d9ff40);
}
.quarto-dark .ao-box-f,
[data-bs-theme="dark"] .ao-box-f {
fill: #c084fc;
stroke: #c084fc;
filter: drop-shadow(0 0 2px #c084fc40);
}
.quarto-dark .ao-guide-line,
[data-bs-theme="dark"] .ao-guide-line {
stroke: #00f0ff40;
}
/* Dark mode toggle */
.quarto-dark .ao-toggle-label,
[data-bs-theme="dark"] .ao-toggle-label {
color: #6b7d8f;
}
.quarto-dark .ao-toggle-label:hover,
[data-bs-theme="dark"] .ao-toggle-label:hover {
color: #9ab;
}
.quarto-dark .ao-toggle-label.active,
[data-bs-theme="dark"] .ao-toggle-label.active {
color: #00f0ff;
text-shadow: 0 0 6px #00f0ff50;
}
.quarto-dark .ao-toggle-slider,
[data-bs-theme="dark"] .ao-toggle-slider {
background-color: #ff6b7a;
box-shadow: 0 0 6px #ff6b7a30;
}
.quarto-dark .ao-toggle-switch input:checked + .ao-toggle-slider,
[data-bs-theme="dark"] .ao-toggle-switch input:checked + .ao-toggle-slider {
background-color: #7bed9f;
box-shadow: 0 0 6px #7bed9f30;
}
.quarto-dark .ao-toggle-slider:before,
[data-bs-theme="dark"] .ao-toggle-slider:before {
background-color: #0a0a14;
box-shadow: 0 0 3px #00f0ff30;
}
</style>`In hydrogen, the lone electron experiences only the attraction of the nucleus. With no other electrons present, the orbital energy depends solely on n. The 2s and 2p orbitals have identical energy; they are degenerate. The same holds for 3s, 3p, and 3d.
Add more electrons, and this degeneracy breaks. In multi-electron atoms, inner electrons partially shield outer electrons from the nuclear charge. But s orbitals have significant electron density near the nucleus. They penetrate through the inner electron cloud and “feel” more of the nuclear charge. A higher effective nuclear charge (Zeff) means stronger electrostatic attraction, which pulls the orbital to lower (more negative) energy. This makes s orbitals more stable than p orbitals of the same shell, and p more stable than d.
The result is energy splitting within each shell (s < p < d < f). This splitting determines the filling order we use to build electron configurations.
Interactive Electron Configuration Explorer
The widgets below let you explore electron configurations for all 118 elements. The first shows the orbital diagram with electrons filling according to the Aufbau principle. The second displays how orbital energies change across the periodic table.
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"},
{z: 57, symbol: "La", name: "Lanthanum", config: "[Xe] 6s2 5d1"},
{z: 58, symbol: "Ce", name: "Cerium", config: "[Xe] 6s2 4f1 5d1"},
{z: 59, symbol: "Pr", name: "Praseodymium", config: "[Xe] 6s2 4f3"},
{z: 60, symbol: "Nd", name: "Neodymium", config: "[Xe] 6s2 4f4"},
{z: 61, symbol: "Pm", name: "Promethium", config: "[Xe] 6s2 4f5"},
{z: 62, symbol: "Sm", name: "Samarium", config: "[Xe] 6s2 4f6"},
{z: 63, symbol: "Eu", name: "Europium", config: "[Xe] 6s2 4f7"},
{z: 64, symbol: "Gd", name: "Gadolinium", config: "[Xe] 6s2 4f7 5d1"},
{z: 65, symbol: "Tb", name: "Terbium", config: "[Xe] 6s2 4f9"},
{z: 66, symbol: "Dy", name: "Dysprosium", config: "[Xe] 6s2 4f10"},
{z: 67, symbol: "Ho", name: "Holmium", config: "[Xe] 6s2 4f11"},
{z: 68, symbol: "Er", name: "Erbium", config: "[Xe] 6s2 4f12"},
{z: 69, symbol: "Tm", name: "Thulium", config: "[Xe] 6s2 4f13"},
{z: 70, symbol: "Yb", name: "Ytterbium", config: "[Xe] 6s2 4f14"},
{z: 71, symbol: "Lu", name: "Lutetium", config: "[Xe] 6s2 4f14 5d1"},
{z: 72, symbol: "Hf", name: "Hafnium", config: "[Xe] 6s2 4f14 5d2"},
{z: 73, symbol: "Ta", name: "Tantalum", config: "[Xe] 6s2 4f14 5d3"},
{z: 74, symbol: "W", name: "Tungsten", config: "[Xe] 6s2 4f14 5d4"},
{z: 75, symbol: "Re", name: "Rhenium", config: "[Xe] 6s2 4f14 5d5"},
{z: 76, symbol: "Os", name: "Osmium", config: "[Xe] 6s2 4f14 5d6"},
{z: 77, symbol: "Ir", name: "Iridium", config: "[Xe] 6s2 4f14 5d7"},
{z: 78, symbol: "Pt", name: "Platinum", config: "[Xe] 6s1 4f14 5d9"},
{z: 79, symbol: "Au", name: "Gold", config: "[Xe] 6s1 4f14 5d10"},
{z: 80, symbol: "Hg", name: "Mercury", config: "[Xe] 6s2 4f14 5d10"},
{z: 81, symbol: "Tl", name: "Thallium", config: "[Xe] 6s2 4f14 5d10 6p1"},
{z: 82, symbol: "Pb", name: "Lead", config: "[Xe] 6s2 4f14 5d10 6p2"},
{z: 83, symbol: "Bi", name: "Bismuth", config: "[Xe] 6s2 4f14 5d10 6p3"},
{z: 84, symbol: "Po", name: "Polonium", config: "[Xe] 6s2 4f14 5d10 6p4"},
{z: 85, symbol: "At", name: "Astatine", config: "[Xe] 6s2 4f14 5d10 6p5"},
{z: 86, symbol: "Rn", name: "Radon", config: "[Xe] 6s2 4f14 5d10 6p6"},
{z: 87, symbol: "Fr", name: "Francium", config: "[Rn] 7s1"},
{z: 88, symbol: "Ra", name: "Radium", config: "[Rn] 7s2"},
{z: 89, symbol: "Ac", name: "Actinium", config: "[Rn] 7s2 6d1"},
{z: 90, symbol: "Th", name: "Thorium", config: "[Rn] 7s2 6d2"},
{z: 91, symbol: "Pa", name: "Protactinium", config: "[Rn] 7s2 5f2 6d1"},
{z: 92, symbol: "U", name: "Uranium", config: "[Rn] 7s2 5f3 6d1"},
{z: 93, symbol: "Np", name: "Neptunium", config: "[Rn] 7s2 5f4 6d1"},
{z: 94, symbol: "Pu", name: "Plutonium", config: "[Rn] 7s2 5f6"},
{z: 95, symbol: "Am", name: "Americium", config: "[Rn] 7s2 5f7"},
{z: 96, symbol: "Cm", name: "Curium", config: "[Rn] 7s2 5f7 6d1"},
{z: 97, symbol: "Bk", name: "Berkelium", config: "[Rn] 7s2 5f9"},
{z: 98, symbol: "Cf", name: "Californium", config: "[Rn] 7s2 5f10"},
{z: 99, symbol: "Es", name: "Einsteinium", config: "[Rn] 7s2 5f11"},
{z: 100, symbol: "Fm", name: "Fermium", config: "[Rn] 7s2 5f12"},
{z: 101, symbol: "Md", name: "Mendelevium", config: "[Rn] 7s2 5f13"},
{z: 102, symbol: "No", name: "Nobelium", config: "[Rn] 7s2 5f14"},
{z: 103, symbol: "Lr", name: "Lawrencium", config: "[Rn] 7s2 5f14 7p1"},
{z: 104, symbol: "Rf", name: "Rutherfordium", config: "[Rn] 7s2 5f14 6d2"},
{z: 105, symbol: "Db", name: "Dubnium", config: "[Rn] 7s2 5f14 6d3"},
{z: 106, symbol: "Sg", name: "Seaborgium", config: "[Rn] 7s2 5f14 6d4"},
{z: 107, symbol: "Bh", name: "Bohrium", config: "[Rn] 7s2 5f14 6d5"},
{z: 108, symbol: "Hs", name: "Hassium", config: "[Rn] 7s2 5f14 6d6"},
{z: 109, symbol: "Mt", name: "Meitnerium", config: "[Rn] 7s2 5f14 6d7"},
{z: 110, symbol: "Ds", name: "Darmstadtium", config: "[Rn] 7s2 5f14 6d8"},
{z: 111, symbol: "Rg", name: "Roentgenium", config: "[Rn] 7s2 5f14 6d9"},
{z: 112, symbol: "Cn", name: "Copernicium", config: "[Rn] 7s2 5f14 6d10"},
{z: 113, symbol: "Nh", name: "Nihonium", config: "[Rn] 7s2 5f14 6d10 7p1"},
{z: 114, symbol: "Fl", name: "Flerovium", config: "[Rn] 7s2 5f14 6d10 7p2"},
{z: 115, symbol: "Mc", name: "Moscovium", config: "[Rn] 7s2 5f14 6d10 7p3"},
{z: 116, symbol: "Lv", name: "Livermorium", config: "[Rn] 7s2 5f14 6d10 7p4"},
{z: 117, symbol: "Ts", name: "Tennessine", config: "[Rn] 7s2 5f14 6d10 7p5"},
{z: 118, symbol: "Og", name: "Oganesson", config: "[Rn] 7s2 5f14 6d10 7p6"}
]
// Exception elements - those that don't follow simple aufbau prediction
exceptionElements = ({
"24": { expected: "[Ar] 4s² 3d⁴", reason: "Exchange energy stabilization from half-filled 3d⁵ outweighs 4s² pairing" },
"29": { expected: "[Ar] 4s² 3d⁹", reason: "Filled 3d¹⁰ subshell provides extra stability" },
"41": { expected: "[Kr] 5s² 4d³", reason: "4d and 5s nearly degenerate; 4d⁴ 5s¹ minimizes electron repulsion" },
"42": { expected: "[Kr] 5s² 4d⁴", reason: "Exchange energy favors half-filled 4d⁵ with single 5s electron" },
"44": { expected: "[Kr] 5s² 4d⁶", reason: "4d⁷ 5s¹ configuration minimizes electron-electron repulsion" },
"45": { expected: "[Kr] 5s² 4d⁷", reason: "4d⁸ 5s¹ configuration is lower energy due to orbital size effects" },
"46": { expected: "[Kr] 5s² 4d⁸", reason: "Filled 4d¹⁰ subshell (no 5s!) provides maximum stability" },
"47": { expected: "[Kr] 5s² 4d⁹", reason: "Filled 4d¹⁰ with single 5s electron is more stable" },
"57": { expected: "[Xe] 6s² 4f¹", reason: "5d slightly lower than 4f at this Z; 5d fills first" },
"58": { expected: "[Xe] 6s² 4f²", reason: "Both 4f and 5d are occupied due to near-degeneracy" },
"64": { expected: "[Xe] 6s² 4f⁸", reason: "Exchange energy from half-filled 4f⁷ plus 5d¹ electron" },
"78": { expected: "[Xe] 4f¹⁴ 6s² 5d⁸", reason: "5d⁹ 6s¹ configuration minimizes electron repulsion" },
"79": { expected: "[Xe] 4f¹⁴ 6s² 5d⁹", reason: "Filled 5d¹⁰ with single 6s electron provides stability" },
"89": { expected: "[Rn] 7s² 5f¹", reason: "6d slightly lower than 5f at this Z" },
"90": { expected: "[Rn] 7s² 5f²", reason: "6d² is more stable than 5f² at thorium" },
"91": { expected: "[Rn] 7s² 5f³", reason: "Mixed 5f and 6d occupancy due to near-degeneracy" },
"92": { expected: "[Rn] 7s² 5f⁴", reason: "Mixed 5f and 6d occupancy; 5f³ 6d¹ is more stable" }
})
isException = exceptionElements[String(selectedZ)] !== undefined
exceptionInfo = isException ? exceptionElements[String(selectedZ)] : null
// Orbital definitions with aufbau filling order and energy levels
// Energy is relative (higher number = higher energy for display)
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}
]mutable selectedZValue = 6
// Controls - slider + number input (NO dependency on selectedZValue to avoid circular updates)
viewof selectedZInput = {
const initialZ = 6; // Fixed initial value
const container = document.createElement("div");
container.style.marginBottom = "10px";
container.style.display = "flex";
container.style.alignItems = "center";
container.style.gap = "10px";
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 = 118;
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 = 118;
slider.step = 1;
slider.value = initialZ;
slider.style.flex = "1";
// Helper to update both controls and dispatch event
const updateValue = (val, forceValid = false) => {
const parsed = parseInt(val);
// If forceValid (blur/change), always clamp to valid range
if (forceValid || !isNaN(parsed)) {
val = Math.max(1, Math.min(118, parsed || 1));
slider.value = val;
numberInput.value = val;
container.value = val;
container.dispatchEvent(new CustomEvent("input", {bubbles: true}));
} else {
// During typing, allow empty/partial input without resetting
// Just update slider to show current valid value if any
if (numberInput.value === "") {
// Don't update anything - let user type
}
}
};
slider.oninput = () => updateValue(slider.value, true);
// On input: only update if valid number, allow empty for typing
numberInput.oninput = () => {
const val = numberInput.value;
const parsed = parseInt(val);
if (!isNaN(parsed) && parsed >= 1 && parsed <= 118) {
// Valid number - update everything
slider.value = parsed;
container.value = parsed;
container.dispatchEvent(new CustomEvent("input", {bubbles: true}));
}
// Otherwise let user keep typing (don't reset)
};
// On blur/change: validate and reset to valid value
numberInput.onblur = () => updateValue(numberInput.value, true);
numberInput.onchange = () => updateValue(numberInput.value, true);
// Method for external updates (from Widget 2)
container.setZ = (val) => {
val = Math.max(1, Math.min(118, parseInt(val) || 1));
if (container.value !== val) {
slider.value = val;
numberInput.value = val;
container.value = val;
container.dispatchEvent(new CustomEvent("input", {bubbles: true}));
}
};
container.appendChild(label);
container.appendChild(numberInput);
container.appendChild(slider);
container.value = initialZ;
return container;
}
// Sync slider changes to mutable (slider → mutable)
// This runs whenever selectedZInput changes
syncFromInput = {
mutable selectedZValue = selectedZInput;
return selectedZInput;
}
// selectedZ derives from the mutable (allows programmatic updates)
selectedZ = selectedZValuecurrentElement = elementData.find(e => e.z === selectedZ)
// Parse electron configuration to get orbital occupancies
function parseConfig(configStr, z) {
// For actual configs, we need to compute from Z using aufbau
// But we also need to handle exceptions (Cr, Cu, etc.)
// For now, parse the stored config string
const occupancies = {};
// Expand noble gas notation
let expanded = configStr;
const nobleGases = {
"[He]": "1s2",
"[Ne]": "1s2 2s2 2p6",
"[Ar]": "1s2 2s2 2p6 3s2 3p6",
"[Kr]": "1s2 2s2 2p6 3s2 3p6 4s2 3d10 4p6",
"[Xe]": "1s2 2s2 2p6 3s2 3p6 4s2 3d10 4p6 5s2 4d10 5p6",
"[Rn]": "1s2 2s2 2p6 3s2 3p6 4s2 3d10 4p6 5s2 4d10 5p6 6s2 4f14 5d10 6p6"
};
for (const [ng, exp] of Object.entries(nobleGases)) {
if (expanded.includes(ng)) {
expanded = expanded.replace(ng, exp + " ");
break;
}
}
// Parse orbital notation (e.g., "1s2", "2p6")
const regex = /(\d[spdf])(\d+)/g;
let match;
while ((match = regex.exec(expanded)) !== null) {
const orbital = match[1];
const count = parseInt(match[2]);
occupancies[orbital] = count;
}
return occupancies;
}
occupancies = parseConfig(currentElement.config, selectedZ)
// Count unpaired electrons for para/diamagnetic
function countUnpaired(occ) {
let unpaired = 0;
for (const [orbital, count] of Object.entries(occ)) {
const orbDef = orbitalDefs.find(o => o.name === orbital);
if (!orbDef) continue;
const numOrbitals = orbDef.ml.length;
const maxPerOrbital = 2;
if (count <= numOrbitals) {
// All unpaired (Hund's rule)
unpaired += count;
} else {
// Some paired
const paired = count - numOrbitals;
unpaired += numOrbitals - paired;
}
}
return unpaired;
}
unpairedCount = countUnpaired(occupancies)
isPara = unpairedCount > 0
// Count valence electrons (simplified: outermost shell)
function countValence(occ, z) {
// Find highest n
let maxN = 0;
for (const orbital of Object.keys(occ)) {
const n = parseInt(orbital[0]);
if (n > maxN) maxN = n;
}
// For main group: count electrons in highest n
// For transition metals: include (n-1)d
let valence = 0;
for (const [orbital, count] of Object.entries(occ)) {
const n = parseInt(orbital[0]);
const subshell = orbital[1];
if (n === maxN) {
valence += count;
} else if (n === maxN - 1 && subshell === 'd') {
valence += count;
} else if (n === maxN - 2 && subshell === 'f') {
valence += count;
}
}
return valence;
}
valenceCount = countValence(occupancies, selectedZ)
coreCount = selectedZ - valenceCount
// Determine which orbitals are valence vs core
function getOrbitalTypes(occ, z) {
const types = {};
let maxN = 0;
for (const orbital of Object.keys(occ)) {
const n = parseInt(orbital[0]);
if (n > maxN) maxN = n;
}
for (const [orbital, count] of Object.entries(occ)) {
const n = parseInt(orbital[0]);
const subshell = orbital[1];
// Valence: highest n, or (n-1)d for transition metals, or (n-2)f for lanthanides/actinides
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;
}
orbitalTypes = getOrbitalTypes(occupancies, selectedZ)
// Generate full configuration (no noble gas abbreviation)
function getFullConfig(occ) {
// Sort by aufbau order
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
ecDiagram = {
const width = 800;
const height = 580;
const margin = {top: 70, right: 20, bottom: 80, left: 70};
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.attr("class", "ec-builder-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", "ec-bg");
// Arrow marker definition (must be in defs before use)
svg.append("defs").append("marker")
.attr("id", "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", "ec-arrow-marker");
// Title with element info
const titleText = svg.append("text")
.attr("x", width / 2)
.attr("y", 35)
.attr("text-anchor", "middle")
.attr("class", "ec-title");
// Determine configuration certainty level
const configCertainty = selectedZ <= 100 ? "experimental" : (selectedZ <= 109 ? "calculated" : "predicted");
const certaintyLabels = {
"experimental": "experimental",
"calculated": "calculated",
"predicted": "predicted"
};
// Build title with optional exception warning
let titleHtml = `<tspan font-weight="bold" font-size="28">${currentElement.symbol}</tspan>` +
`<tspan font-size="18" dx="10">${currentElement.name}</tspan>` +
`<tspan font-size="14" dx="10" class="ec-z-label">(<tspan font-style="italic">Z</tspan> = ${currentElement.z})</tspan>`;
if (isException) {
titleHtml += `<tspan dx="8" fill="#FF9800" font-size="16">⚠</tspan>`;
}
titleText.html(titleHtml);
// Determine which orbitals to show based on element
const maxFillOrder = Math.max(...Object.keys(occupancies).map(name => {
const od = orbitalDefs.find(o => o.name === name);
return od ? od.fillOrder : 0;
}));
// Show occupied orbitals plus next 1-2 to fill
const relevantOrbitals = orbitalDefs.filter(o => {
const occ = occupancies[o.name] || 0;
return occ > 0 || (o.fillOrder <= maxFillOrder + 2 && o.fillOrder <= 11);
});
// Box dimensions - SQUARE boxes
const boxSize = 28;
const boxGap = 3;
// Column offsets for each subshell type (s, p, d, f)
// Column widths: s=28px, p=90px, d=152px, f=214px (total 484px)
// Uniform 60px gaps between columns, spans 664px total
// baseX=90 to 754 (26px padding from right edge)
const columnOffsets = {
's': 0,
'p': 88,
'd': 238,
'f': 450
};
// Relative energy positions (approximate, with larger gaps between shells)
// These are Y positions from top (higher energy = smaller Y = higher on screen)
// For hydrogen (Z=1), energy depends only on n, so orbitals with same n have same energy
const multiElectronEnergies = {
'1s': 420,
'2s': 360,
'2p': 340,
'3s': 290,
'3p': 270,
'4s': 230,
'3d': 210,
'4p': 190,
'5s': 155,
'4d': 140,
'5p': 125,
'6s': 95,
'4f': 82,
'5d': 70,
'6p': 58,
'7s': 35,
'5f': 25,
'6d': 15,
'7p': 5
};
// For hydrogen: energy depends only on n (degenerate subshells)
const hydrogenEnergies = {
'1s': 420,
'2s': 340, '2p': 340,
'3s': 260, '3p': 260, '3d': 260,
'4s': 180, '4p': 180, '4d': 180, '4f': 180,
'5s': 110, '5p': 110, '5d': 110, '5f': 110,
'6s': 55, '6p': 55, '6d': 55,
'7s': 15, '7p': 15
};
const energyPositions = selectedZ === 1 ? hydrogenEnergies : multiElectronEnergies;
const diagramTop = margin.top + 20;
const diagramBottom = height - margin.bottom - 40;
const diagramHeight = diagramBottom - diagramTop;
// Scale energy positions to fit diagram
const maxEnergyPos = Math.max(...Object.values(energyPositions));
const minEnergyPos = Math.min(...Object.values(energyPositions));
function getYPosition(orbitalName) {
const rawY = energyPositions[orbitalName] || 200;
// Map: higher rawY (lower energy) -> lower on screen (higher Y in SVG)
// Lower energy orbitals have higher rawY values, should appear at bottom (larger Y)
return diagramTop + ((rawY - minEnergyPos) / (maxEnergyPos - minEnergyPos)) * diagramHeight;
}
// Energy axis arrow (extends down to align with bottom of 1s box)
svg.append("line")
.attr("x1", margin.left - 20)
.attr("y1", diagramBottom + boxSize + 5)
.attr("x2", margin.left - 20)
.attr("y2", diagramTop - 10)
.attr("class", "ec-axis-line")
.attr("marker-end", "url(#arrow)");
svg.append("text")
.attr("x", margin.left - 32)
.attr("y", (diagramTop + diagramBottom) / 2)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("class", "ec-axis-label")
.text("E");
const baseX = margin.left + 20;
// Store tooltip data for later (we'll create tooltip LAST for z-order)
const tooltipData = [];
// Draw each orbital
relevantOrbitals.forEach((orbital) => {
const subshell = orbital.name.slice(-1); // 's', 'p', 'd', or 'f'
const numBoxes = orbital.ml.length;
const y = getYPosition(orbital.name);
const startX = baseX + columnOffsets[subshell];
const orbitalGroup = svg.append("g")
.attr("class", "ec-orbital-group");
// Orbital label (full name, e.g., "1s", "2p", "3d")
// Calculate center of all boxes for this orbital
const totalBoxWidth = numBoxes * boxSize + (numBoxes - 1) * boxGap;
const boxesCenterX = startX + totalBoxWidth / 2;
orbitalGroup.append("text")
.attr("x", startX - 10)
.attr("y", y + boxSize / 2)
.attr("text-anchor", "end")
.attr("dominant-baseline", "central")
.attr("class", "ec-orbital-label")
.text(orbital.name);
const electronCount = occupancies[orbital.name] || 0;
// Draw boxes for each ml value - SQUARE boxes
orbital.ml.forEach((ml, boxIdx) => {
const boxX = startX + boxIdx * (boxSize + boxGap);
const hasElectrons = electronCount > 0;
// Determine if THIS specific box has electrons
const numOrbitals = orbital.ml.length;
let hasSpinUp = false;
let hasSpinDown = false;
if (electronCount > 0) {
if (electronCount <= numOrbitals) {
// All spin up, one per orbital until exhausted
if (boxIdx < electronCount) {
hasSpinUp = true;
}
} else {
// First fill all spin up, then spin down
hasSpinUp = true;
const spinDownCount = electronCount - numOrbitals;
if (boxIdx < spinDownCount) {
hasSpinDown = true;
}
}
}
const boxHasElectron = hasSpinUp || hasSpinDown;
const orbType = orbitalTypes[orbital.name] || 'empty';
// Box - SQUARE, colored by core/valence/empty
let boxClass = "ec-box ec-box-empty";
if (boxHasElectron) {
boxClass = orbType === 'core' ? "ec-box ec-box-core" : "ec-box ec-box-valence";
}
orbitalGroup.append("rect")
.attr("x", boxX)
.attr("y", y)
.attr("width", boxSize)
.attr("height", boxSize)
.attr("class", boxClass);
// Draw electrons as LaTeX-style harpoon arrows (slightly thicker stem, single barb)
// Up harpoon (↿): stem with LEFT barb
const arrowUp = "M 0.7,-10 L 0.7,10 L -0.7,10 L -0.7,-6 L -5,-3 L -0.7,-10 Z";
// Down harpoon (⇂): stem with RIGHT barb
const arrowDown = "M -0.7,10 L -0.7,-10 L 0.7,-10 L 0.7,6 L 5,3 L 0.7,10 Z";
// Fixed positions for arrows (don't shift when paired)
const upArrowX = boxX + boxSize/2 - 3;
const downArrowX = boxX + boxSize/2 + 3;
const arrowY = y + boxSize/2;
// Unpaired = has spin up but no spin down
const isUnpaired = hasSpinUp && !hasSpinDown;
if (hasSpinUp) {
orbitalGroup.append("path")
.attr("d", arrowUp)
.attr("transform", `translate(${upArrowX}, ${arrowY})`)
.attr("class", isUnpaired ? "ec-arrow ec-arrow-unpaired" : "ec-arrow ec-arrow-paired")
.attr("data-n", orbital.n)
.attr("data-l", orbital.l)
.attr("data-ml", ml)
.attr("data-ms", "+½")
.attr("data-x", upArrowX)
.attr("data-y", y);
}
if (hasSpinDown) {
orbitalGroup.append("path")
.attr("d", arrowDown)
.attr("transform", `translate(${downArrowX}, ${arrowY})`)
.attr("class", "ec-arrow ec-arrow-paired")
.attr("data-n", orbital.n)
.attr("data-l", orbital.l)
.attr("data-ml", ml)
.attr("data-ms", "−½")
.attr("data-x", downArrowX)
.attr("data-y", y);
}
});
});
// Create tooltip LAST so it renders on top of everything
const tooltip = svg.append("g")
.attr("class", "ec-tooltip")
.style("opacity", 0)
.style("pointer-events", "none");
tooltip.append("rect")
.attr("class", "ec-tooltip-bg")
.attr("rx", 4)
.attr("ry", 4);
const tooltipText = tooltip.append("text")
.attr("class", "ec-tooltip-text");
// Track if tooltip is pinned (clicked)
let tooltipPinned = false;
let pinnedElement = null;
function showTooltip(el, pinned = false) {
const n = el.attr("data-n");
const l = el.attr("data-l");
const ml = el.attr("data-ml");
const ms = el.attr("data-ms");
const x = parseFloat(el.attr("data-x"));
const y = parseFloat(el.attr("data-y"));
// Build styled tooltip text with tspans
// Format: n = 1, l = 0, m_l = 0, m_s = +½
tooltipText.html('');
tooltipText.append("tspan").attr("class", "ec-tt-var").text("n");
tooltipText.append("tspan").text(" = " + n + ", ");
tooltipText.append("tspan").attr("class", "ec-tt-var").text("l");
tooltipText.append("tspan").text(" = " + l + ", ");
tooltipText.append("tspan").attr("class", "ec-tt-var").text("m");
tooltipText.append("tspan").attr("class", "ec-tt-sub").attr("dy", "3").attr("font-size", "9px").text("l");
tooltipText.append("tspan").attr("dy", "-3").text(" = " + ml + ", ");
tooltipText.append("tspan").attr("class", "ec-tt-var").text("m");
tooltipText.append("tspan").attr("class", "ec-tt-sub").attr("dy", "3").attr("font-size", "9px").text("s");
tooltipText.append("tspan").attr("dy", "-3").text(" = " + ms);
const bbox = tooltipText.node().getBBox();
tooltip.select("rect")
.attr("x", bbox.x - 10)
.attr("y", bbox.y - 6)
.attr("width", bbox.width + 20)
.attr("height", bbox.height + 12);
tooltip.attr("transform", `translate(${x}, ${y - 18})`)
.transition().duration(100).style("opacity", 1);
if (pinned) {
// Remove highlight from previous pinned element
if (pinnedElement) {
d3.select(pinnedElement).classed("ec-arrow-selected", false);
}
tooltipPinned = true;
pinnedElement = el.node();
// Add highlight to newly pinned element
el.classed("ec-arrow-selected", true);
}
}
function hideTooltip(force = false) {
if (!tooltipPinned || force) {
tooltip.transition().duration(100).style("opacity", 0);
if (force) {
// Remove highlight from pinned element
if (pinnedElement) {
d3.select(pinnedElement).classed("ec-arrow-selected", false);
}
tooltipPinned = false;
pinnedElement = null;
}
}
}
// Add mouse and click events to all electron arrows
svg.selectAll(".ec-arrow")
.on("mouseenter", function(event) {
if (!tooltipPinned) {
showTooltip(d3.select(this));
}
})
.on("mouseleave", function() {
if (!tooltipPinned) {
hideTooltip();
}
})
.on("click", function(event) {
event.stopPropagation();
const el = d3.select(this);
if (pinnedElement === this) {
// Clicking same arrow unpins
hideTooltip(true);
} else {
// Pin to this arrow
showTooltip(el, true);
}
});
// Click anywhere else on SVG to dismiss pinned tooltip
svg.on("click", function(event) {
if (tooltipPinned) {
hideTooltip(true);
}
});
// Info panel at bottom
const infoY1 = height - 68;
const infoY2 = height - 48;
const infoY3 = height - 18;
// Helper to build formatted config with proper superscripts
// Key: put dy reset on the NEXT orbital tspan, not an empty one
function buildConfigString(textEl, parts) {
parts.forEach((part, i) => {
// Orbital name - include dy="5" to reset from previous superscript (except first)
const orbTspan = textEl.append("tspan").text((i > 0 ? " " : "") + part.orbital);
if (i > 0) orbTspan.attr("dy", "5");
// Superscript electron count
textEl.append("tspan")
.attr("dy", "-5")
.attr("font-size", "9px")
.text(part.count);
});
// Final reset so text element ends at baseline
textEl.append("tspan").attr("dy", "5").text("");
}
// Noble gas configuration (line 1) with label
svg.append("text")
.attr("x", margin.left)
.attr("y", infoY1)
.attr("class", "ec-config-label")
.text("Core notation:");
const configText1 = svg.append("text")
.attr("x", margin.left + 90)
.attr("y", infoY1)
.attr("class", "ec-config-text");
// Parse noble gas notation for display
const configStr = currentElement.config;
const nobleMatch = configStr.match(/^\[([A-Za-z]+)\]\s*/);
if (nobleMatch) {
configText1.append("tspan").text("[" + nobleMatch[1] + "] ");
const remainder = configStr.slice(nobleMatch[0].length);
const orbRegex = /(\d[spdf])(\d+)/g;
let m;
let needsReset = false;
while ((m = orbRegex.exec(remainder)) !== null) {
// Orbital name with reset if after a superscript
const orbTspan = configText1.append("tspan").text((needsReset ? " " : "") + m[1]);
if (needsReset) orbTspan.attr("dy", "5");
// Superscript
configText1.append("tspan").attr("dy", "-5").attr("font-size", "9px").text(m[2]);
needsReset = true;
}
configText1.append("tspan").attr("dy", "5").text("");
} else {
buildConfigString(configText1, fullConfigParts);
}
// Full configuration (line 2) with label
svg.append("text")
.attr("x", margin.left)
.attr("y", infoY2)
.attr("class", "ec-config-label")
.text("Full notation:");
const configText2 = svg.append("text")
.attr("x", margin.left + 90)
.attr("y", infoY2)
.attr("class", "ec-config-text ec-config-full");
buildConfigString(configText2, fullConfigParts);
// Core/valence counts (centered) and para/diamagnetic (line 3)
svg.append("text")
.attr("x", width / 2)
.attr("y", infoY3)
.attr("text-anchor", "middle")
.attr("class", "ec-counts")
.text(`Core: ${coreCount} | Valence: ${valenceCount}`);
svg.append("text")
.attr("x", width - margin.right)
.attr("y", infoY3)
.attr("text-anchor", "end")
.attr("class", isPara ? "ec-para" : "ec-dia")
.text(isPara ? `Paramagnetic (${unpairedCount} unpaired)` : "Diamagnetic");
// Certainty badge below config notation
svg.append("text")
.attr("x", margin.left)
.attr("y", infoY3)
.attr("text-anchor", "start")
.attr("class", `ec-certainty-badge ec-certainty-${configCertainty}`)
.text(certaintyLabels[configCertainty]);
// Aufbau diagram dimensions (defined early so PT can position relative to it)
const aufbauCellW = 34;
const aufbauCellH = 24;
const aufbauPadTop = 22;
const aufbauPadSide = 12;
const aufbauPadBot = 14;
const aufbauW = 4 * aufbauCellW + aufbauPadSide * 2;
const aufbauH = 7 * aufbauCellH + aufbauPadTop + aufbauPadBot;
const aufbauX = width - aufbauW - 8;
const aufbauY = height - aufbauH - 76;
// Mini Periodic Table skeleton showing current position and path
// Standard periodic table layout (18 columns, 7 rows + 2 for f-block)
const ptCellW = 11;
const ptCellH = 11;
const ptGap = 1;
const ptPadTop = 20;
const ptPadSide = 8;
const ptPadBot = 8;
// Element positions in periodic table [row (0-indexed), col (0-indexed)]
// Row 0-6 are main table, lanthanides/actinides shown in rows 0-6 at their actual positions
const ptPositions = [
[0, 0], // 1 H
[0, 17], // 2 He
[1, 0], // 3 Li
[1, 1], // 4 Be
[1, 12], [1, 13], [1, 14], [1, 15], [1, 16], [1, 17], // 5-10 B-Ne
[2, 0], // 11 Na
[2, 1], // 12 Mg
[2, 12], [2, 13], [2, 14], [2, 15], [2, 16], [2, 17], // 13-18 Al-Ar
[3, 0], // 19 K
[3, 1], // 20 Ca
[3, 2], [3, 3], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], // 21-30 Sc-Zn
[3, 12], [3, 13], [3, 14], [3, 15], [3, 16], [3, 17], // 31-36 Ga-Kr
[4, 0], // 37 Rb
[4, 1], // 38 Sr
[4, 2], [4, 3], [4, 4], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], // 39-48 Y-Cd
[4, 12], [4, 13], [4, 14], [4, 15], [4, 16], [4, 17], // 49-54 In-Xe
[5, 0], // 55 Cs
[5, 1], // 56 Ba
// Lanthanides 57-71 (La-Lu shown in row 7, cols 2-16)
[7, 2], [7, 3], [7, 4], [7, 5], [7, 6], [7, 7], [7, 8], [7, 9], [7, 10], [7, 11], [7, 12], [7, 13], [7, 14], [7, 15], [7, 16],
// Col 2 is blank (lanthanide placeholder), Hf starts at col 3
[5, 3], [5, 4], [5, 5], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], // 72-80 Hf-Hg
[5, 12], [5, 13], [5, 14], [5, 15], [5, 16], [5, 17], // 81-86 Tl-Rn
[6, 0], // 87 Fr
[6, 1], // 88 Ra
// Actinides 89-103 (Ac-Lr shown in row 8, cols 2-16)
[8, 2], [8, 3], [8, 4], [8, 5], [8, 6], [8, 7], [8, 8], [8, 9], [8, 10], [8, 11], [8, 12], [8, 13], [8, 14], [8, 15], [8, 16],
// Col 2 is blank (actinide placeholder), Rf starts at col 3
[6, 3], [6, 4], [6, 5], [6, 6], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], // 104-112 Rf-Cn
[6, 12], [6, 13], [6, 14], [6, 15], [6, 16], [6, 17], // 113-118 Nh-Og
];
const ptW = 18 * (ptCellW + ptGap) + ptPadSide * 2;
const ptH = 9 * (ptCellH + ptGap) + ptPadTop + ptPadBot;
const ptX = width - aufbauW - ptW - 20;
const ptY = aufbauY + (aufbauH - ptH) / 2; // Vertically center with aufbau
const ptGroup = svg.append("g")
.attr("class", "pt-mini")
.attr("transform", `translate(${ptX}, ${ptY})`);
// Background
ptGroup.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", ptW)
.attr("height", ptH)
.attr("rx", 5)
.attr("class", "pt-mini-bg");
// Title
ptGroup.append("text")
.attr("x", ptW / 2)
.attr("y", 12)
.attr("text-anchor", "middle")
.attr("class", "pt-mini-title")
.text("Periodic Table");
// Arrow marker for PT
const ptDefs = ptGroup.append("defs");
ptDefs.append("marker")
.attr("id", "pt-mini-arrowhead")
.attr("viewBox", "0 0 8 5")
.attr("refX", 0)
.attr("refY", 2.5)
.attr("markerWidth", 5)
.attr("markerHeight", 3)
.attr("orient", "auto")
.append("path")
.attr("d", "M 0 0 L 8 2.5 L 0 5 z")
.attr("class", "pt-mini-arrowhead");
// Draw all cells as skeleton
const blockColors = {
s: [0, 1], // columns 0-1
d: [2, 11], // columns 2-11
p: [12, 17], // columns 12-17
f: [2, 16] // f-block in rows 7-8, cols 2-16
};
// Determine block for a cell position
function getBlock(r, c) {
// f-block: rows 7-8
if (r >= 7) return "f";
// s-block: cols 0-1, plus Helium (row 0, col 17)
if (c <= 1 || (r === 0 && c === 17)) return "s";
// d-block: cols 2-11 (only rows 3+)
if (c >= 2 && c <= 11) return "d";
// p-block: cols 12-17
return "p";
}
// Build reverse lookup: [row, col] -> Z
const posToZ = {};
ptPositions.forEach(([r, c], idx) => {
posToZ[`${r},${c}`] = idx + 1; // Z is 1-indexed
});
// Draw skeleton cells for standard table shape
const cellPositions = [];
// Row 0: cols 0, 17
[[0,0], [0,17]].forEach(([r,c]) => cellPositions.push([r,c]));
// Rows 1-2: cols 0-1, 12-17
for (let r = 1; r <= 2; r++) {
for (let c = 0; c <= 1; c++) cellPositions.push([r,c]);
for (let c = 12; c <= 17; c++) cellPositions.push([r,c]);
}
// Rows 3-4: cols 0-17
for (let r = 3; r <= 4; r++) {
for (let c = 0; c <= 17; c++) cellPositions.push([r,c]);
}
// Rows 5-6: cols 0-17 but skip col 2 (lanthanide/actinide placeholder)
for (let r = 5; r <= 6; r++) {
for (let c = 0; c <= 17; c++) {
if (c !== 2) cellPositions.push([r,c]);
}
}
// Rows 7-8 (f-block): cols 2-16
for (let r = 7; r <= 8; r++) {
for (let c = 2; c <= 16; c++) cellPositions.push([r,c]);
}
// Draw skeleton cells with block colors and click handlers
cellPositions.forEach(([r, c]) => {
const x = ptPadSide + c * (ptCellW + ptGap);
const y = ptPadTop + r * (ptCellH + ptGap);
const block = getBlock(r, c);
const z = posToZ[`${r},${c}`];
const cell = ptGroup.append("rect")
.attr("x", x)
.attr("y", y)
.attr("width", ptCellW)
.attr("height", ptCellH)
.attr("class", `pt-mini-cell pt-mini-block-${block}`);
// Add click handler if this cell has an element
if (z) {
const zValue = z; // Capture in closure
cell
.style("cursor", "pointer")
.on("click", function() {
// Update the mutable which triggers reactive updates
mutable selectedZValue = zValue;
// Also update the input widget to stay in sync
const inputWidget = document.querySelector('input[type="range"][max="118"]');
const numberWidget = document.querySelector('input[type="number"][max="118"]');
if (inputWidget) inputWidget.value = zValue;
if (numberWidget) numberWidget.value = zValue;
});
}
});
// Add block labels (s, p, d, f) centered on their regions
// These go above squares but below arrows, with pointer-events: none
const blockLabels = [
{ label: "s", col: 0.5, row: 3.5 }, // s-block: cols 0-1
{ label: "p", col: 14.5, row: 3.5 }, // p-block: cols 12-17
{ label: "d", col: 7, row: 4.5 }, // d-block: cols 3-11
{ label: "f", col: 9, row: 7.5 } // f-block: cols 2-16, rows 7-8
];
blockLabels.forEach(({ label, col, row }) => {
const x = ptPadSide + col * (ptCellW + ptGap) + ptCellW / 2;
const y = ptPadTop + row * (ptCellH + ptGap) + ptCellH / 2;
ptGroup.append("text")
.attr("x", x)
.attr("y", y)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("class", "pt-mini-block-label")
.style("pointer-events", "none")
.text(label);
});
// Draw path arrows and highlight current element
if (selectedZ >= 1 && selectedZ <= 118) {
// Build path through elements 1 to selectedZ
const pathSegments = [];
let prevPos = null;
for (let z = 1; z <= selectedZ; z++) {
const pos = ptPositions[z - 1];
if (pos) {
const x = ptPadSide + pos[1] * (ptCellW + ptGap) + ptCellW / 2;
const y = ptPadTop + pos[0] * (ptCellH + ptGap) + ptCellH / 2;
if (prevPos) {
pathSegments.push({ x1: prevPos.x, y1: prevPos.y, x2: x, y2: y });
}
prevPos = { x, y };
}
}
// Draw path - only rightward arrows (x2 >= x1)
const rightwardSegments = pathSegments.filter(seg => seg.x2 >= seg.x1);
rightwardSegments.forEach((seg, i) => {
const isLast = i === rightwardSegments.length - 1;
ptGroup.append("line")
.attr("x1", seg.x1)
.attr("y1", seg.y1)
.attr("x2", seg.x2)
.attr("y2", seg.y2)
.attr("class", "pt-mini-arrow")
.attr("marker-end", isLast ? "url(#pt-mini-arrowhead)" : null);
});
// Highlight current element position
const curPos = ptPositions[selectedZ - 1];
if (curPos) {
const hx = ptPadSide + curPos[1] * (ptCellW + ptGap);
const hy = ptPadTop + curPos[0] * (ptCellH + ptGap);
ptGroup.append("rect")
.attr("x", hx - 1)
.attr("y", hy - 1)
.attr("width", ptCellW + 2)
.attr("height", ptCellH + 2)
.attr("class", "pt-mini-current");
}
}
// Mini Aufbau diagram in bottom-right corner
// Classic layout: columns for s, p, d, f with diagonal arrows showing fill order
const aufbauOrbitals = [
["1s"],
["2s", "2p"],
["3s", "3p", "3d"],
["4s", "4p", "4d", "4f"],
["5s", "5p", "5d", "5f"],
["6s", "6p", "6d"],
["7s", "7p"]
];
// Map orbital name to [row, col] position
const orbitalPos = {};
aufbauOrbitals.forEach((row, r) => {
row.forEach((orb, c) => {
orbitalPos[orb] = [r, c];
});
});
// Determine which orbitals are filled and which is current
const filledOrbitals = new Set(Object.keys(occupancies));
const lastOrbital = fullConfigParts.length > 0 ? fullConfigParts[fullConfigParts.length - 1].orbital : null;
// Use pre-defined aufbau dimensions
const cellW = aufbauCellW;
const cellH = aufbauCellH;
const padTop = aufbauPadTop;
const padSide = aufbauPadSide;
const padBot = aufbauPadBot;
const aufbauGroup = svg.append("g")
.attr("class", "aufbau-mini")
.attr("transform", `translate(${aufbauX}, ${aufbauY})`);
// Background
aufbauGroup.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", aufbauW)
.attr("height", aufbauH)
.attr("rx", 6)
.attr("class", "aufbau-mini-bg");
// Title
aufbauGroup.append("text")
.attr("x", aufbauW / 2)
.attr("y", 15)
.attr("text-anchor", "middle")
.attr("class", "aufbau-mini-title")
.text("Aufbau Order");
// Down-left diagonal groups (n+l constant) - includes single orbitals
const diagonals = [
["1s"], // n+l=1
["2s"], // n+l=2
["2p", "3s"], // n+l=3
["3p", "4s"], // n+l=4
["3d", "4p", "5s"], // n+l=5
["4d", "5p", "6s"], // n+l=6
["4f", "5d", "6p", "7s"], // n+l=7
["5f", "6d"], // n+l=8
["7p"], // n+l=9
];
// Fill sequence to determine what's been reached
const fillSequence = [
"1s", "2s", "2p", "3s", "3p", "4s", "3d", "4p", "5s", "4d",
"5p", "6s", "4f", "5d", "6p", "7s", "5f", "6d", "7p"
];
const currentIdx = lastOrbital ? fillSequence.indexOf(lastOrbital) : -1;
// Arrow marker definition
const aufbauDefs = aufbauGroup.append("defs");
aufbauDefs.append("marker")
.attr("id", "aufbau-mini-arrowhead")
.attr("viewBox", "0 0 8 5")
.attr("refX", 0)
.attr("refY", 2.5)
.attr("markerWidth", 6)
.attr("markerHeight", 4)
.attr("orient", "auto")
.append("path")
.attr("d", "M 0 0 L 8 2.5 L 0 5 z")
.attr("class", "aufbau-mini-arrowhead");
// Calculate the actual down-left diagonal angle based on grid dimensions
// Each diagonal step: col decreases by 1 (-cellW), row increases by 1 (+cellH)
const downLeftAngle = Math.atan2(cellH, -cellW); // Consistent angle for all arrows
// Draw down-left diagonal arrows only
diagonals.forEach(diag => {
// Check if we've reached the first orbital in this diagonal
const firstOrbIdx = fillSequence.indexOf(diag[0]);
if (firstOrbIdx > currentIdx) return; // Haven't reached this diagonal yet
// Find how far through this diagonal we've progressed
let lastReachedIdx = -1;
for (let i = 0; i < diag.length; i++) {
const orbIdx = fillSequence.indexOf(diag[i]);
if (orbIdx <= currentIdx) {
lastReachedIdx = i;
}
}
if (lastReachedIdx < 0) return;
// Get start and end points for the arrow
const startOrb = diag[0];
const endOrb = diag[lastReachedIdx];
const [r1, c1] = orbitalPos[startOrb];
const [r2, c2] = orbitalPos[endOrb];
const x1 = padSide + c1 * cellW + cellW / 2;
const y1 = padTop + r1 * cellH + cellH / 2;
const x2 = padSide + c2 * cellW + cellW / 2;
const y2 = padTop + r2 * cellH + cellH / 2;
// Always use the same down-left angle for consistency
const angle = downLeftAngle;
// Arrow length for single orbital, or extend past endpoints for multi
const extend = 18;
let sx, sy, ex, ey;
if (lastReachedIdx === 0) {
// Single orbital - draw short arrow through it
sx = x1 - Math.cos(angle) * extend;
sy = y1 - Math.sin(angle) * extend;
ex = x1 + Math.cos(angle) * extend;
ey = y1 + Math.sin(angle) * extend;
} else {
// Multiple orbitals - extend before and after
sx = x1 - Math.cos(angle) * extend;
sy = y1 - Math.sin(angle) * extend;
ex = x2 + Math.cos(angle) * extend;
ey = y2 + Math.sin(angle) * extend;
}
aufbauGroup.append("line")
.attr("x1", sx)
.attr("y1", sy)
.attr("x2", ex)
.attr("y2", ey)
.attr("class", "aufbau-mini-arrow")
.attr("marker-end", "url(#aufbau-mini-arrowhead)");
});
// Draw orbitals (on top of arrows)
aufbauOrbitals.forEach((row, rowIdx) => {
row.forEach((orb, colIdx) => {
const cx = padSide + colIdx * cellW + cellW / 2;
const cy = padTop + rowIdx * cellH + cellH / 2;
const isFilled = filledOrbitals.has(orb);
const isCurrent = orb === lastOrbital;
const isPartial = isFilled && occupancies[orb] < orbitalDefs.find(o => o.name === orb)?.maxElectrons;
let orbClass = "aufbau-mini-empty";
if (isCurrent) {
orbClass = isPartial ? "aufbau-mini-current-partial" : "aufbau-mini-current";
} else if (isFilled) {
orbClass = "aufbau-mini-filled";
}
aufbauGroup.append("text")
.attr("x", cx)
.attr("y", cy)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("class", `aufbau-mini-orb ${orbClass}`)
.text(orb);
});
});
return svg.node();
}// Styles
ec_styles = html`
<style>
/* ========== LIGHT MODE ========== */
.ec-builder-svg .ec-bg {
fill: #fafafa;
}
.ec-builder-svg .ec-title {
fill: #1a1a1a;
}
.ec-builder-svg .ec-z-label {
fill: #888;
}
.ec-builder-svg .ec-certainty-badge {
font-size: 10px;
font-style: italic;
}
.ec-builder-svg .ec-certainty-experimental {
fill: #2e7d32;
}
.ec-builder-svg .ec-certainty-calculated {
fill: #7b1fa2;
}
.ec-builder-svg .ec-certainty-predicted {
fill: #c62828;
}
.ec-builder-svg .ec-axis-label {
fill: #666;
font-size: 14px;
font-weight: bold;
font-style: italic;
}
.ec-builder-svg .ec-axis-line {
stroke: #666;
stroke-width: 2;
}
.ec-builder-svg .ec-arrow-marker {
fill: #666;
}
.ec-builder-svg .ec-orbital-label {
fill: #333;
font-size: 13px;
font-weight: 500;
}
.ec-builder-svg .ec-box {
stroke: #666;
stroke-width: 1.5;
}
.ec-builder-svg .ec-box-empty {
fill: #fff;
}
.ec-builder-svg .ec-box-core {
fill: #e0e0e0;
}
.ec-builder-svg .ec-box-valence {
fill: #e3f2fd;
}
.ec-builder-svg .ec-arrow {
cursor: pointer;
transition: fill 0.15s;
}
.ec-builder-svg .ec-arrow-paired {
fill: #333;
}
.ec-builder-svg .ec-arrow-paired:hover {
fill: #ff9800;
filter: drop-shadow(0 0 3px #ff9800) drop-shadow(0 0 6px #ff980080);
}
.ec-builder-svg .ec-arrow-unpaired {
fill: #c62828;
}
.ec-builder-svg .ec-arrow-unpaired:hover {
fill: #ff9800;
filter: drop-shadow(0 0 3px #ff9800) drop-shadow(0 0 6px #ff980080);
}
.ec-builder-svg .ec-arrow-selected {
fill: #ff9800 !important;
filter: drop-shadow(0 0 3px #ff9800) drop-shadow(0 0 6px #ff980080);
}
.ec-builder-svg .ec-config-label {
fill: #666;
font-size: 11px;
font-family: system-ui, -apple-system, sans-serif;
}
.ec-builder-svg .ec-config-text {
fill: #333;
font-size: 12px;
font-family: system-ui, -apple-system, sans-serif;
}
.ec-builder-svg .ec-config-full {
fill: #555;
}
.ec-builder-svg .ec-config-sup {
font-size: 9px;
}
.ec-builder-svg .ec-para {
fill: #c62828;
font-size: 13px;
font-weight: 500;
}
.ec-builder-svg .ec-dia {
fill: #388e3c;
font-size: 13px;
font-weight: 500;
}
.ec-builder-svg .ec-counts {
fill: #555;
font-size: 12px;
}
.ec-builder-svg .ec-tooltip-bg {
fill: #333;
opacity: 0.92;
}
.ec-builder-svg .ec-tooltip-text {
fill: #fff;
font-size: 12px;
font-family: system-ui, -apple-system, sans-serif;
dominant-baseline: middle;
text-anchor: middle;
}
.ec-builder-svg .ec-tt-var {
font-style: italic;
}
.ec-builder-svg .ec-tt-sub {
font-size: 9px;
font-style: italic;
}
/* Mini Periodic Table */
.ec-builder-svg .pt-mini-bg {
fill: rgba(255, 255, 255, 0.95);
stroke: #ccc;
stroke-width: 1;
}
.ec-builder-svg .pt-mini-title {
fill: #555;
font-size: 9px;
font-weight: 600;
}
.ec-builder-svg .pt-mini-cell {
fill: #e8e8e8;
stroke: none;
}
.ec-builder-svg .pt-mini-block-s {
fill: #ce93d8;
}
.ec-builder-svg .pt-mini-block-p {
fill: #4dd0e1;
}
.ec-builder-svg .pt-mini-block-d {
fill: #ef9a9a;
}
.ec-builder-svg .pt-mini-block-f {
fill: #81c784;
}
.ec-builder-svg .pt-mini-cell:hover {
filter: brightness(0.85);
}
.ec-builder-svg .pt-mini-block-label {
fill: rgba(0, 0, 0, 0.25);
font-size: 18px;
font-weight: 700;
font-family: system-ui, -apple-system, sans-serif;
}
.ec-builder-svg .pt-mini-current {
fill: none;
stroke: #c62828;
stroke-width: 1.5;
}
.ec-builder-svg .pt-mini-arrow {
stroke: #1976d2;
stroke-width: 1.5;
opacity: 0.5;
stroke-linecap: round;
}
.ec-builder-svg .pt-mini-arrowhead {
fill: #1976d2;
}
/* Mini Aufbau diagram */
.ec-builder-svg .aufbau-mini-bg {
fill: rgba(255, 255, 255, 0.95);
stroke: #ccc;
stroke-width: 1;
}
.ec-builder-svg .aufbau-mini-title {
fill: #555;
font-size: 11px;
font-weight: 600;
}
.ec-builder-svg .aufbau-mini-orb {
font-size: 13px;
font-family: system-ui, -apple-system, sans-serif;
}
.ec-builder-svg .aufbau-mini-empty {
fill: #bbb;
}
.ec-builder-svg .aufbau-mini-filled {
fill: #1976d2;
font-weight: 600;
}
.ec-builder-svg .aufbau-mini-current {
fill: #c62828;
font-weight: 700;
}
.ec-builder-svg .aufbau-mini-current-partial {
fill: #e65100;
font-weight: 700;
}
.ec-builder-svg .aufbau-mini-arrow {
stroke: #e91e63;
stroke-width: 2;
opacity: 0.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.ec-builder-svg .aufbau-mini-arrowhead {
fill: #e91e63;
}
/* ========== DARK MODE - CYBERPUNK THEME ========== */
body.quarto-dark .ec-builder-svg .ec-bg,
.quarto-dark .ec-builder-svg .ec-bg,
[data-bs-theme="dark"] .ec-builder-svg .ec-bg {
fill: #0a0a14;
}
body.quarto-dark .ec-builder-svg .ec-title,
.quarto-dark .ec-builder-svg .ec-title,
[data-bs-theme="dark"] .ec-builder-svg .ec-title {
fill: #00f0ff;
filter: drop-shadow(0 0 4px #00f0ff60);
}
body.quarto-dark .ec-builder-svg .ec-z-label,
.quarto-dark .ec-builder-svg .ec-z-label,
[data-bs-theme="dark"] .ec-builder-svg .ec-z-label {
fill: #7dd3fc;
}
body.quarto-dark .ec-builder-svg .ec-certainty-experimental,
.quarto-dark .ec-builder-svg .ec-certainty-experimental,
[data-bs-theme="dark"] .ec-builder-svg .ec-certainty-experimental {
fill: #00ff88;
filter: drop-shadow(0 0 2px #00ff8850);
}
body.quarto-dark .ec-builder-svg .ec-certainty-calculated,
.quarto-dark .ec-builder-svg .ec-certainty-calculated,
[data-bs-theme="dark"] .ec-builder-svg .ec-certainty-calculated {
fill: #bf5fff;
filter: drop-shadow(0 0 2px #bf5fff50);
}
body.quarto-dark .ec-builder-svg .ec-certainty-predicted,
.quarto-dark .ec-builder-svg .ec-certainty-predicted,
[data-bs-theme="dark"] .ec-builder-svg .ec-certainty-predicted {
fill: #ff2d6a;
filter: drop-shadow(0 0 2px #ff2d6a50);
}
body.quarto-dark .ec-builder-svg .ec-axis-label,
.quarto-dark .ec-builder-svg .ec-axis-label,
[data-bs-theme="dark"] .ec-builder-svg .ec-axis-label {
fill: #00f0ff;
filter: drop-shadow(0 0 3px #00f0ff50);
}
body.quarto-dark .ec-builder-svg .ec-axis-line,
.quarto-dark .ec-builder-svg .ec-axis-line,
[data-bs-theme="dark"] .ec-builder-svg .ec-axis-line {
stroke: #00f0ff;
filter: drop-shadow(0 0 2px #00f0ff60);
}
body.quarto-dark .ec-builder-svg .ec-arrow-marker,
.quarto-dark .ec-builder-svg .ec-arrow-marker,
[data-bs-theme="dark"] .ec-builder-svg .ec-arrow-marker {
fill: #00f0ff;
}
body.quarto-dark .ec-builder-svg .ec-orbital-label,
.quarto-dark .ec-builder-svg .ec-orbital-label,
[data-bs-theme="dark"] .ec-builder-svg .ec-orbital-label {
fill: #e0f7ff;
filter: drop-shadow(0 0 2px #00f0ff40);
}
body.quarto-dark .ec-builder-svg .ec-box,
.quarto-dark .ec-builder-svg .ec-box,
[data-bs-theme="dark"] .ec-builder-svg .ec-box {
stroke: #00f0ff60;
}
body.quarto-dark .ec-builder-svg .ec-box-empty,
.quarto-dark .ec-builder-svg .ec-box-empty,
[data-bs-theme="dark"] .ec-builder-svg .ec-box-empty {
fill: #0d0d1a;
}
body.quarto-dark .ec-builder-svg .ec-box-core,
.quarto-dark .ec-builder-svg .ec-box-core,
[data-bs-theme="dark"] .ec-builder-svg .ec-box-core {
fill: #1a1a2e;
filter: drop-shadow(0 0 2px #00f0ff20);
}
body.quarto-dark .ec-builder-svg .ec-box-valence,
.quarto-dark .ec-builder-svg .ec-box-valence,
[data-bs-theme="dark"] .ec-builder-svg .ec-box-valence {
fill: #0f2847;
filter: drop-shadow(0 0 4px #00f0ff30);
}
body.quarto-dark .ec-builder-svg .ec-arrow-paired,
.quarto-dark .ec-builder-svg .ec-arrow-paired,
[data-bs-theme="dark"] .ec-builder-svg .ec-arrow-paired {
fill: #00f0ff;
filter: drop-shadow(0 0 2px #00f0ff80);
}
body.quarto-dark .ec-builder-svg .ec-arrow-paired:hover,
.quarto-dark .ec-builder-svg .ec-arrow-paired:hover,
[data-bs-theme="dark"] .ec-builder-svg .ec-arrow-paired:hover {
fill: #ffdd00;
filter: drop-shadow(0 0 6px #ffdd00) drop-shadow(0 0 12px #ffdd0080);
}
body.quarto-dark .ec-builder-svg .ec-arrow-unpaired,
.quarto-dark .ec-builder-svg .ec-arrow-unpaired,
[data-bs-theme="dark"] .ec-builder-svg .ec-arrow-unpaired {
fill: #ff00ff;
filter: drop-shadow(0 0 3px #ff00ff80);
}
body.quarto-dark .ec-builder-svg .ec-arrow-unpaired:hover,
.quarto-dark .ec-builder-svg .ec-arrow-unpaired:hover,
[data-bs-theme="dark"] .ec-builder-svg .ec-arrow-unpaired:hover {
fill: #ffdd00;
filter: drop-shadow(0 0 6px #ffdd00) drop-shadow(0 0 12px #ffdd0080);
}
body.quarto-dark .ec-builder-svg .ec-arrow-selected,
.quarto-dark .ec-builder-svg .ec-arrow-selected,
[data-bs-theme="dark"] .ec-builder-svg .ec-arrow-selected {
fill: #ffdd00 !important;
filter: drop-shadow(0 0 6px #ffdd00) drop-shadow(0 0 12px #ffdd0080) drop-shadow(0 0 20px #ffdd0040) !important;
}
body.quarto-dark .ec-builder-svg .ec-config-label,
.quarto-dark .ec-builder-svg .ec-config-label,
[data-bs-theme="dark"] .ec-builder-svg .ec-config-label {
fill: #7dd3fc;
}
body.quarto-dark .ec-builder-svg .ec-config-text,
.quarto-dark .ec-builder-svg .ec-config-text,
[data-bs-theme="dark"] .ec-builder-svg .ec-config-text {
fill: #e0f7ff;
filter: drop-shadow(0 0 1px #00f0ff40);
}
body.quarto-dark .ec-builder-svg .ec-config-full,
.quarto-dark .ec-builder-svg .ec-config-full,
[data-bs-theme="dark"] .ec-builder-svg .ec-config-full {
fill: #7dd3fc;
}
body.quarto-dark .ec-builder-svg .ec-para,
.quarto-dark .ec-builder-svg .ec-para,
[data-bs-theme="dark"] .ec-builder-svg .ec-para {
fill: #ff00ff;
filter: drop-shadow(0 0 2px #ff00ff50);
}
body.quarto-dark .ec-builder-svg .ec-dia,
.quarto-dark .ec-builder-svg .ec-dia,
[data-bs-theme="dark"] .ec-builder-svg .ec-dia {
fill: #00ff88;
filter: drop-shadow(0 0 2px #00ff8850);
}
body.quarto-dark .ec-builder-svg .ec-counts,
.quarto-dark .ec-builder-svg .ec-counts,
[data-bs-theme="dark"] .ec-builder-svg .ec-counts {
fill: #7dd3fc;
}
body.quarto-dark .ec-builder-svg .ec-tooltip-bg,
.quarto-dark .ec-builder-svg .ec-tooltip-bg,
[data-bs-theme="dark"] .ec-builder-svg .ec-tooltip-bg {
fill: #0a0a14;
stroke: #00f0ff;
stroke-width: 1;
filter: drop-shadow(0 0 4px #00f0ff40);
}
body.quarto-dark .ec-builder-svg .ec-tooltip-text,
.quarto-dark .ec-builder-svg .ec-tooltip-text,
[data-bs-theme="dark"] .ec-builder-svg .ec-tooltip-text {
fill: #00f0ff;
}
body.quarto-dark .ec-builder-svg .ec-tt-var,
.quarto-dark .ec-builder-svg .ec-tt-var,
[data-bs-theme="dark"] .ec-builder-svg .ec-tt-var {
fill: #ff00ff;
}
body.quarto-dark .ec-builder-svg .ec-tt-sub,
.quarto-dark .ec-builder-svg .ec-tt-sub,
[data-bs-theme="dark"] .ec-builder-svg .ec-tt-sub {
fill: #ff00ff;
}
/* Mini Periodic Table dark mode - Cyberpunk */
body.quarto-dark .ec-builder-svg .pt-mini-bg,
.quarto-dark .ec-builder-svg .pt-mini-bg,
[data-bs-theme="dark"] .ec-builder-svg .pt-mini-bg {
fill: rgba(10, 10, 20, 0.95);
stroke: #00f0ff40;
filter: drop-shadow(0 0 4px #00f0ff20);
}
body.quarto-dark .ec-builder-svg .pt-mini-title,
.quarto-dark .ec-builder-svg .pt-mini-title,
[data-bs-theme="dark"] .ec-builder-svg .pt-mini-title {
fill: #00f0ff;
filter: drop-shadow(0 0 2px #00f0ff40);
}
body.quarto-dark .ec-builder-svg .pt-mini-cell,
.quarto-dark .ec-builder-svg .pt-mini-cell,
[data-bs-theme="dark"] .ec-builder-svg .pt-mini-cell {
fill: #1a1a2e;
}
/* s-block: coral pink (matches ptblock.svg) */
body.quarto-dark .ec-builder-svg .pt-mini-block-s,
.quarto-dark .ec-builder-svg .pt-mini-block-s,
[data-bs-theme="dark"] .ec-builder-svg .pt-mini-block-s {
fill: #ff6b7a;
filter: drop-shadow(0 0 3px #ff6b7a50);
}
/* p-block: neon green (matches ptblock.svg) */
body.quarto-dark .ec-builder-svg .pt-mini-block-p,
.quarto-dark .ec-builder-svg .pt-mini-block-p,
[data-bs-theme="dark"] .ec-builder-svg .pt-mini-block-p {
fill: #7bed9f;
filter: drop-shadow(0 0 3px #7bed9f50);
}
/* d-block: bright cyan (matches ptblock.svg) */
body.quarto-dark .ec-builder-svg .pt-mini-block-d,
.quarto-dark .ec-builder-svg .pt-mini-block-d,
[data-bs-theme="dark"] .ec-builder-svg .pt-mini-block-d {
fill: #00d9ff;
filter: drop-shadow(0 0 3px #00d9ff50);
}
/* f-block: neon purple (matches ptblock.svg) */
body.quarto-dark .ec-builder-svg .pt-mini-block-f,
.quarto-dark .ec-builder-svg .pt-mini-block-f,
[data-bs-theme="dark"] .ec-builder-svg .pt-mini-block-f {
fill: #c084fc;
filter: drop-shadow(0 0 3px #c084fc50);
}
body.quarto-dark .ec-builder-svg .pt-mini-cell:hover,
.quarto-dark .ec-builder-svg .pt-mini-cell:hover,
[data-bs-theme="dark"] .ec-builder-svg .pt-mini-cell:hover {
filter: brightness(1.15) drop-shadow(0 0 6px currentColor);
}
/* Block labels: dark text for visibility on bright fills */
body.quarto-dark .ec-builder-svg .pt-mini-block-label,
.quarto-dark .ec-builder-svg .pt-mini-block-label,
[data-bs-theme="dark"] .ec-builder-svg .pt-mini-block-label {
fill: rgba(0, 0, 0, 0.35);
filter: none;
}
body.quarto-dark .ec-builder-svg .pt-mini-current,
.quarto-dark .ec-builder-svg .pt-mini-current,
[data-bs-theme="dark"] .ec-builder-svg .pt-mini-current {
stroke: #ffdd00;
filter: drop-shadow(0 0 4px #ffdd00);
}
body.quarto-dark .ec-builder-svg .pt-mini-arrow,
.quarto-dark .ec-builder-svg .pt-mini-arrow,
[data-bs-theme="dark"] .ec-builder-svg .pt-mini-arrow {
stroke: #ff00ff;
opacity: 0.7;
filter: drop-shadow(0 0 2px #ff00ff60);
}
body.quarto-dark .ec-builder-svg .pt-mini-arrowhead,
.quarto-dark .ec-builder-svg .pt-mini-arrowhead,
[data-bs-theme="dark"] .ec-builder-svg .pt-mini-arrowhead {
fill: #ff00ff;
}
/* Mini Aufbau dark mode - Cyberpunk */
body.quarto-dark .ec-builder-svg .aufbau-mini-bg,
.quarto-dark .ec-builder-svg .aufbau-mini-bg,
[data-bs-theme="dark"] .ec-builder-svg .aufbau-mini-bg {
fill: rgba(10, 10, 20, 0.95);
stroke: #ff00ff40;
filter: drop-shadow(0 0 4px #ff00ff20);
}
body.quarto-dark .ec-builder-svg .aufbau-mini-title,
.quarto-dark .ec-builder-svg .aufbau-mini-title,
[data-bs-theme="dark"] .ec-builder-svg .aufbau-mini-title {
fill: #ff00ff;
filter: drop-shadow(0 0 2px #ff00ff40);
}
body.quarto-dark .ec-builder-svg .aufbau-mini-empty,
.quarto-dark .ec-builder-svg .aufbau-mini-empty,
[data-bs-theme="dark"] .ec-builder-svg .aufbau-mini-empty {
fill: #2a2a3e;
}
body.quarto-dark .ec-builder-svg .aufbau-mini-filled,
.quarto-dark .ec-builder-svg .aufbau-mini-filled,
[data-bs-theme="dark"] .ec-builder-svg .aufbau-mini-filled {
fill: #00f0ff;
filter: drop-shadow(0 0 2px #00f0ff60);
}
body.quarto-dark .ec-builder-svg .aufbau-mini-current,
.quarto-dark .ec-builder-svg .aufbau-mini-current,
[data-bs-theme="dark"] .ec-builder-svg .aufbau-mini-current {
fill: #ffdd00;
filter: drop-shadow(0 0 4px #ffdd00);
}
body.quarto-dark .ec-builder-svg .aufbau-mini-current-partial,
.quarto-dark .ec-builder-svg .aufbau-mini-current-partial,
[data-bs-theme="dark"] .ec-builder-svg .aufbau-mini-current-partial {
fill: #ff9500;
filter: drop-shadow(0 0 4px #ff9500);
}
body.quarto-dark .ec-builder-svg .aufbau-mini-arrow,
.quarto-dark .ec-builder-svg .aufbau-mini-arrow,
[data-bs-theme="dark"] .ec-builder-svg .aufbau-mini-arrow {
stroke: #ff00ff;
opacity: 0.6;
filter: drop-shadow(0 0 2px #ff00ff60);
}
body.quarto-dark .ec-builder-svg .aufbau-mini-arrowhead,
.quarto-dark .ec-builder-svg .aufbau-mini-arrowhead,
[data-bs-theme="dark"] .ec-builder-svg .aufbau-mini-arrowhead {
fill: #ff00ff;
}
</style>
`function formatConfigSuperscript(config) {
const superscripts = {'0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹'};
return config.replace(/(\d[spdf])(\d+)/g, (match, orbital, num) => {
const superNum = num.split('').map(d => superscripts[d] || d).join('');
return orbital + superNum;
});
}
// Exception info panel - shown when an exception element is selected
exceptionPanel = {
if (!isException) {
return html``;
}
const formattedActual = formatConfigSuperscript(currentElement.config);
return html`
<div class="ec-exception-panel">
<div class="ec-exception-header">
<span class="ec-exception-icon">⚠</span>
<strong>Configuration Exception</strong>
</div>
<div class="ec-exception-content">
<p><strong>Expected by simple aufbau:</strong> ${exceptionInfo.expected}</p>
<p><strong>Actual configuration:</strong> ${formattedActual}</p>
<p><strong>Why?</strong> ${exceptionInfo.reason}</p>
</div>
</div>
<style>
.ec-exception-panel {
margin-top: 16px;
padding: 12px 16px;
border-radius: 8px;
background: #FFF3E0;
border: 1px solid #FFB74D;
max-width: 700px;
font-size: 14px;
line-height: 1.5;
}
.ec-exception-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
color: #E65100;
}
.ec-exception-icon {
font-size: 18px;
}
.ec-exception-content p {
margin: 6px 0;
color: #5D4037;
}
.ec-exception-content strong {
color: #4E342E;
}
/* Dark mode */
.quarto-dark .ec-exception-panel,
[data-bs-theme="dark"] .ec-exception-panel {
background: #2d2510;
border-color: #8b6914;
}
.quarto-dark .ec-exception-header,
[data-bs-theme="dark"] .ec-exception-header {
color: #FFB74D;
}
.quarto-dark .ec-exception-content p,
[data-bs-theme="dark"] .ec-exception-content p {
color: #d4c4a8;
}
.quarto-dark .ec-exception-content strong,
[data-bs-theme="dark"] .ec-exception-content strong {
color: #fff8e1;
}
</style>
`;
}orbitalData2 = await FileAttachment("/files/json/orbital-energies.json").json()
// Define the three crossover regions (non-overlapping ranges for clean auto-switching)
regions = [
{ id: "3d4s", label: "3d/4s", zRange: [18, 36], orbitals: ["3p", "4s", "3d", "4p"], tickStep: 1 },
{ id: "4d5s", label: "4d/5s", zRange: [37, 54], orbitals: ["4p", "5s", "4d", "5p"], tickStep: 1 },
{ id: "5d6s", label: "5d/6s/4f", zRange: [55, 71], orbitals: ["5p", "6s", "4f", "5d"], tickStep: 1 }
]
// Default region for initial load (3d4s is the most commonly taught crossover)
autoRegion = "3d4s"
// Selected region state (UI rendered later, below the chart)
mutable selectedRegionState = "3d4s"
// Get current region config (default to first region if not found)
currentRegion = regions.find(r => r.id === selectedRegionState) || regions[0]
// Colors for each orbital (distinct neon-friendly colors for light/dark modes)
orbitalColors = ({
"1s": "#6366f1", "2s": "#8b5cf6", "3s": "#a855f7", "4s": "#c084fc", "5s": "#d946ef", "6s": "#e879f9", "7s": "#f0abfc",
"2p": "#3b82f6", "3p": "#22c55e", "4p": "#f97316", "5p": "#06b6d4", "6p": "#84cc16",
"3d": "#ec4899", "4d": "#f43f5e", "5d": "#fb7185",
"4f": "#a855f7", "5f": "#c026d3"
})
// Prepare data for the chart
chartData2 = {
const [zMin, zMax] = currentRegion.zRange;
const orbitals = currentRegion.orbitals;
// Build series for each orbital
const series = orbitals.map(orbital => {
const points = [];
for (let z = zMin; z <= zMax; z++) {
const el = orbitalData2.elements[String(z)];
if (el && el.orbitals[orbital] !== undefined) {
points.push({
z: z,
symbol: el.symbol,
energy: el.orbitals[orbital],
orbital: orbital
});
}
}
return { orbital, points, color: orbitalColors[orbital] };
});
return series;
}
// Find energy range for y-axis - dynamic based on actual data
energyRange2 = {
let min = 0, max = 0;
chartData2.forEach(series => {
series.points.forEach(p => {
if (p.energy < min) min = p.energy;
if (p.energy > max) max = p.energy;
});
});
// Add padding (10% on each side)
const range = max - min;
const padding = range * 0.1;
return [min - padding, max + padding];
}
// Get current Z from Widget 1 (falls back to 6 if not available)
currentZ = typeof selectedZValue !== 'undefined' ? selectedZValue : 6
// Determine which region currentZ belongs to (null if outside all regions)
regionForZ = {
const region = regions.find(r => currentZ >= r.zRange[0] && currentZ <= r.zRange[1]);
return region ? region.id : null;
}
// Auto-switch region when currentZ moves to a different region
autoRegionSwitch = {
const targetRegion = regionForZ;
if (targetRegion && targetRegion !== selectedRegionState) {
mutable selectedRegionState = targetRegion;
// Update the button UI to reflect the new active region
const buttons = document.querySelectorAll('.region-selector-btn');
const regionLabel = regions.find(r => r.id === targetRegion)?.label;
buttons.forEach(btn => {
btn.classList.remove('active');
if (btn.textContent === regionLabel) {
btn.classList.add('active');
}
});
}
return targetRegion;
}// Build the D3 chart with zoom/pan support
orbitalEnergyChart = {
// Initialize zoom state in window object (non-reactive, won't trigger re-renders)
if (!window._orbitalChartZoom) {
window._orbitalChartZoom = { yDomain: null, regionId: null };
}
// Capture Widget 1's slider reference at cell level (for click handler to update)
const widget1Slider = viewof selectedZInput;
const width = 720;
const height = 460;
const margin = { top: 50, right: 100, bottom: 70, left: 65 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const [zMin, zMax] = currentRegion.zRange;
// Create scales (these will be modified by zoom)
// Add padding to x-domain so edge data points don't get clipped
const xPadding = 0.5;
const xScale = d3.scaleLinear()
.domain([zMin - xPadding, zMax + xPadding])
.range([0, innerWidth]);
const yScale = d3.scaleLinear()
.domain(energyRange2)
.range([innerHeight, 0]);
// Store original scales for reset
const xScaleOrig = xScale.copy();
const yScaleOrig = yScale.copy();
// Line generator (uses current scales)
const line = d3.line()
.x(d => xScale(d.z))
.y(d => yScale(d.energy))
.curve(d3.curveMonotoneX);
// Create SVG (responsive - uses viewBox, max-width constrains to container)
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.attr("class", "orbital-energy-chart")
.style("max-width", "100%")
.style("height", "auto");
// Define clip path to prevent overflow
svg.append("defs")
.append("clipPath")
.attr("id", "chart-clip")
.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", innerWidth)
.attr("height", innerHeight);
const g = svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
// Chart area with clipping
const chartArea = g.append("g")
.attr("clip-path", "url(#chart-clip)");
// Check if we need to reset zoom (region changed)
const savedState = window._orbitalChartZoom || { yDomain: null, regionId: null };
const shouldResetZoom = savedState.regionId !== currentRegion.id;
// Restore saved y-domain if same region
if (!shouldResetZoom && savedState.yDomain) {
yScale.domain(savedState.yDomain);
}
// Zoom behavior (y-axis only, zooms to mouse position)
const zoom = d3.zoom()
.scaleExtent([1, 10])
.filter(event => {
// Allow wheel zoom and drag, but not on click (so data point clicks work)
return event.type === 'wheel' || event.type === 'mousedown' || event.type === 'touchstart';
})
.on("zoom", zoomed);
// Invisible rect to capture zoom events
const zoomRect = chartArea.append("rect")
.attr("width", innerWidth)
.attr("height", innerHeight)
.attr("fill", "none")
.attr("pointer-events", "all")
.call(zoom);
// Groups for chart elements (for easy updating)
const gridGroup = chartArea.append("g").attr("class", "grid-group");
const zeroLineGroup = chartArea.append("g").attr("class", "zero-line-group");
const linesGroup = chartArea.append("g").attr("class", "lines-group");
const dotsGroup = chartArea.append("g").attr("class", "dots-group");
const markerGroup = chartArea.append("g").attr("class", "marker-group");
const markerLabelGroup = g.append("g").attr("class", "marker-label-group");
// X-axis group
const xAxisGroup = g.append("g")
.attr("class", "x-axis")
.attr("transform", `translate(0,${innerHeight})`);
// Y-axis group
const yAxisGroup = g.append("g")
.attr("class", "y-axis");
// Function to update the chart on zoom/pan
function updateChart() {
// Update gridlines
const gridLines = gridGroup.selectAll("line")
.data(yScale.ticks(8));
gridLines.enter().append("line")
.attr("stroke", "var(--chart-grid, #e0e0e0)")
.attr("stroke-width", 0.5)
.merge(gridLines)
.attr("x1", 0)
.attr("x2", innerWidth)
.attr("y1", d => yScale(d))
.attr("y2", d => yScale(d));
gridLines.exit().remove();
// Update zero line
zeroLineGroup.selectAll("line").remove();
const [yMin, yMax] = [yScale.invert(innerHeight), yScale.invert(0)];
if (yMin < 0 && yMax > 0) {
zeroLineGroup.append("line")
.attr("x1", 0)
.attr("x2", innerWidth)
.attr("y1", yScale(0))
.attr("y2", yScale(0))
.attr("stroke", "var(--chart-zero, #999)")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "4,4");
}
// Update lines
linesGroup.selectAll("path")
.attr("d", d => line(d));
// Update dots
dotsGroup.selectAll("circle")
.attr("cx", d => xScale(d.z))
.attr("cy", d => yScale(d.energy));
// Update marker
if (currentZ >= zMin && currentZ <= zMax) {
const markerX = xScale(currentZ);
markerGroup.selectAll("line")
.attr("x1", markerX)
.attr("x2", markerX);
markerGroup.selectAll("circle")
.attr("cx", d => xScale(d.z))
.attr("cy", d => yScale(d.energy));
markerLabelGroup.selectAll("rect")
.attr("x", markerX - 14);
markerLabelGroup.selectAll("text")
.attr("x", markerX);
}
// Update axes
const tickStep = currentRegion.tickStep || 2;
xAxisGroup.call(d3.axisBottom(xScale)
.tickValues(d3.range(zMin, zMax + 1, tickStep).filter(z => {
const xPos = xScale(z);
return xPos >= 0 && xPos <= innerWidth;
}))
.tickFormat(z => {
const el = orbitalData2.elements[String(z)];
return el ? el.symbol : z;
}));
xAxisGroup.selectAll("text")
.attr("fill", "var(--chart-text, #333)")
.style("font-size", "10px");
xAxisGroup.select(".domain").attr("stroke", "var(--chart-axis, #666)");
xAxisGroup.selectAll(".tick line").attr("stroke", "var(--chart-axis, #666)");
yAxisGroup.call(d3.axisLeft(yScale)
.ticks(8)
.tickFormat(d => d.toFixed(2).replace('-', '−')));
yAxisGroup.selectAll("text").attr("fill", "var(--chart-text, #333)");
yAxisGroup.select(".domain").attr("stroke", "var(--chart-axis, #666)");
yAxisGroup.selectAll(".tick line").attr("stroke", "var(--chart-axis, #666)");
}
// Track if zoomed (for UI indicator)
let isZoomed = !shouldResetZoom && savedState.yDomain !== null;
// Zoom handler (y-axis only)
function zoomed(event) {
const transform = event.transform;
// Only update y-scale based on transform (keep x-scale fixed)
const newYScale = transform.rescaleY(yScaleOrig);
yScale.domain(newYScale.domain());
// x-scale stays at original domain (no horizontal zoom)
xScale.domain(xScaleOrig.domain());
updateChart();
// Save zoom state (non-reactive, won't trigger re-render)
isZoomed = transform.k > 1;
window._orbitalChartZoom = {
yDomain: isZoomed ? yScale.domain().slice() : null,
regionId: currentRegion.id
};
// Update zoom indicator visibility
zoomIndicator.style("opacity", isZoomed ? 1 : 0);
}
// Function to reset zoom
function resetZoom() {
zoomRect.transition().duration(300).call(zoom.transform, d3.zoomIdentity);
window._orbitalChartZoom = { yDomain: null, regionId: currentRegion.id };
isZoomed = false;
}
// Helper to split points into consecutive segments (no gaps in Z)
function getConsecutiveSegments(points) {
if (points.length === 0) return [];
const segments = [];
let currentSegment = [points[0]];
for (let i = 1; i < points.length; i++) {
if (points[i].z === points[i-1].z + 1) {
// Consecutive - add to current segment
currentSegment.push(points[i]);
} else {
// Gap found - start new segment
if (currentSegment.length > 1) {
segments.push(currentSegment);
}
currentSegment = [points[i]];
}
}
// Don't forget the last segment
if (currentSegment.length > 1) {
segments.push(currentSegment);
}
return segments;
}
// Initial draw of lines - only connect consecutive data points
chartData2.forEach(series => {
const segments = getConsecutiveSegments(series.points);
segments.forEach(segment => {
linesGroup.append("path")
.datum(segment)
.attr("fill", "none")
.attr("stroke", series.color)
.attr("stroke-width", 2.5)
.attr("d", line)
.attr("class", `line-${series.orbital}`);
});
// Add dots for each data point
dotsGroup.selectAll(`.dot-${series.orbital}`)
.data(series.points)
.join("circle")
.attr("class", `dot-${series.orbital} clickable-dot`)
.attr("cx", d => xScale(d.z))
.attr("cy", d => yScale(d.energy))
.attr("r", 4)
.attr("fill", series.color)
.attr("stroke", "var(--chart-bg, white)")
.attr("stroke-width", 1.5)
.style("cursor", "pointer")
.on("click", function(event, d) {
event.stopPropagation();
if (widget1Slider && widget1Slider.setZ) {
widget1Slider.setZ(d.z);
}
})
.on("mouseenter", function(event, d) {
d3.select(this)
.attr("r", 7)
.attr("stroke-width", 2);
const tooltipText = `${d.symbol} (Z=${d.z}): ${d.energy.toFixed(3).replace('-', '−')} Ha`;
const tooltipX = xScale(d.z);
const tooltipY = yScale(d.energy) - 14;
chartArea.append("rect")
.attr("class", "hover-tooltip")
.attr("x", tooltipX - 55)
.attr("y", tooltipY - 10)
.attr("width", 110)
.attr("height", 14)
.attr("rx", 3)
.attr("fill", "var(--chart-bg, white)")
.attr("stroke", "var(--chart-axis, #666)")
.attr("stroke-width", 0.5)
.attr("pointer-events", "none");
chartArea.append("text")
.attr("class", "hover-tooltip")
.attr("x", tooltipX)
.attr("y", tooltipY)
.attr("text-anchor", "middle")
.attr("fill", "var(--chart-text, #333)")
.attr("font-size", "10px")
.attr("font-weight", "bold")
.attr("pointer-events", "none")
.text(tooltipText);
})
.on("mouseleave", function(event, d) {
d3.select(this)
.attr("r", 4)
.attr("stroke-width", 1.5);
chartArea.selectAll(".hover-tooltip").remove();
});
});
// Add vertical marker for current element (if in range)
if (currentZ >= zMin && currentZ <= zMax) {
const markerX = xScale(currentZ);
const el = orbitalData2.elements[String(currentZ)];
const symbol = el ? el.symbol : currentZ;
markerGroup.append("line")
.attr("x1", markerX)
.attr("x2", markerX)
.attr("y1", 0)
.attr("y2", innerHeight)
.attr("stroke", "#FF5722")
.attr("stroke-width", 2)
.attr("stroke-dasharray", "6,3")
.attr("opacity", 0.8);
const labelY = 15;
markerLabelGroup.append("rect")
.attr("x", markerX - 14)
.attr("y", labelY - 10)
.attr("width", 28)
.attr("height", 16)
.attr("rx", 3)
.attr("fill", "#FF5722")
.attr("opacity", 0.9);
markerLabelGroup.append("text")
.attr("x", markerX)
.attr("y", labelY + 2)
.attr("text-anchor", "middle")
.attr("fill", "white")
.attr("font-weight", "bold")
.attr("font-size", "11px")
.text(symbol);
// Highlight circles for current Z
chartData2.forEach(series => {
const point = series.points.find(p => p.z === currentZ);
if (point) {
markerGroup.append("circle")
.datum(point)
.attr("cx", xScale(point.z))
.attr("cy", yScale(point.energy))
.attr("r", 7)
.attr("fill", "none")
.attr("stroke", "#FF5722")
.attr("stroke-width", 2);
}
});
}
// Initial axis draw
updateChart();
// X-axis label
g.append("text")
.attr("x", innerWidth / 2)
.attr("y", innerHeight + 42)
.attr("text-anchor", "middle")
.attr("fill", "var(--chart-text, #333)")
.style("font-size", "12px")
.html("Atomic Number (<tspan font-style='italic'>Z</tspan>)");
// Y-axis label
g.append("text")
.attr("transform", "rotate(-90)")
.attr("x", -innerHeight / 2)
.attr("y", -50)
.attr("text-anchor", "middle")
.attr("fill", "var(--chart-text, #333)")
.style("font-size", "12px")
.text("Orbital Energy (Hartrees)");
// Title
svg.append("text")
.attr("x", width / 2)
.attr("y", 22)
.attr("text-anchor", "middle")
.attr("fill", "var(--chart-text, #333)")
.style("font-size", "14px")
.style("font-weight", "bold")
.text(`Orbital Energies: ${currentRegion.label} Crossover Region`);
// Legend
const legend = svg.append("g")
.attr("transform", `translate(${width - margin.right + 12}, ${margin.top + 10})`);
chartData2.forEach((series, i) => {
const ly = i * 24;
legend.append("line")
.attr("class", "legend-line")
.attr("x1", 0).attr("x2", 20)
.attr("y1", ly).attr("y2", ly)
.attr("stroke", series.color)
.attr("stroke-width", 2.5);
legend.append("circle")
.attr("class", "legend-dot")
.attr("cx", 10).attr("cy", ly).attr("r", 4)
.attr("fill", series.color);
legend.append("text")
.attr("class", "legend-text")
.attr("x", 26).attr("y", ly + 4)
.attr("fill", "var(--chart-text, #333)")
.style("font-size", "12px")
.text(series.orbital);
});
// Zoom indicator and reset button (hidden initially)
const zoomIndicator = svg.append("g")
.attr("transform", `translate(${width - margin.right - 70}, ${margin.top - 5})`)
.style("opacity", 0)
.style("cursor", "pointer")
.on("click", resetZoom);
zoomIndicator.append("rect")
.attr("x", 0).attr("y", -12)
.attr("width", 58).attr("height", 18)
.attr("rx", 4)
.attr("fill", "#2196F3")
.attr("opacity", 0.9);
zoomIndicator.append("text")
.attr("x", 29).attr("y", 2)
.attr("text-anchor", "middle")
.attr("fill", "white")
.style("font-size", "10px")
.style("font-weight", "bold")
.text("⟲ Reset");
// Zoom instructions
svg.append("text")
.attr("x", margin.left + 5)
.attr("y", height - 6)
.attr("fill", "var(--chart-text, #333)")
.attr("opacity", 0.6)
.style("font-size", "10px")
.text("↓ Lower energy = more stable • Scroll to zoom (vertical), drag to pan");
// Show reset button if zoomed, update region tracking
if (isZoomed) {
zoomIndicator.style("opacity", 1);
}
if (shouldResetZoom) {
// Update regionId when region changed
window._orbitalChartZoom = { yDomain: null, regionId: currentRegion.id };
}
return svg.node();
}// CSS for dark mode support - Cyberpunk theme
html`<style>
.orbital-energy-chart {
background: var(--chart-bg, white);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
/* Light mode defaults */
:root {
--chart-bg: white;
--chart-text: #333;
--chart-axis: #666;
--chart-grid: #e0e0e0;
--chart-zero: #999;
}
/* ========== DARK MODE - CYBERPUNK THEME ========== */
[data-bs-theme="dark"] .orbital-energy-chart,
.quarto-dark .orbital-energy-chart {
--chart-bg: #0a0a14;
--chart-text: #00f0ff;
--chart-axis: #00f0ff80;
--chart-grid: #00f0ff20;
--chart-zero: #ff00ff60;
background: #0a0a14;
box-shadow: 0 0 20px #00f0ff20, 0 0 40px #00f0ff10, inset 0 0 60px #00f0ff05;
border: none;
}
[data-bs-theme="dark"],
.quarto-dark {
--chart-bg: #0a0a14;
--chart-text: #00f0ff;
--chart-axis: #00f0ff80;
--chart-grid: #00f0ff20;
--chart-zero: #ff00ff60;
}
/* Subtle glow effects for chart elements (reduced for clarity) */
[data-bs-theme="dark"] .orbital-energy-chart text,
.quarto-dark .orbital-energy-chart text {
filter: drop-shadow(0 0 1px currentColor);
}
[data-bs-theme="dark"] .orbital-energy-chart .domain,
[data-bs-theme="dark"] .orbital-energy-chart .tick line,
.quarto-dark .orbital-energy-chart .domain,
.quarto-dark .orbital-energy-chart .tick line {
filter: drop-shadow(0 0 1px #00f0ff30);
}
/* Legend styling in dark mode - no filter to preserve inline colors */
[data-bs-theme="dark"] .orbital-energy-chart .legend-line,
.quarto-dark .orbital-energy-chart .legend-line {
filter: none;
}
[data-bs-theme="dark"] .orbital-energy-chart .legend-dot,
.quarto-dark .orbital-energy-chart .legend-dot {
filter: none;
}
[data-bs-theme="dark"] .orbital-energy-chart .legend-text,
.quarto-dark .orbital-energy-chart .legend-text {
filter: none;
}
/* Lines and dots in dark mode (reduced glow for clarity) */
[data-bs-theme="dark"] .orbital-energy-chart .lines-group path,
.quarto-dark .orbital-energy-chart .lines-group path {
filter: drop-shadow(0 0 1.5px currentColor);
}
[data-bs-theme="dark"] .orbital-energy-chart .dots-group circle,
.quarto-dark .orbital-energy-chart .dots-group circle {
filter: drop-shadow(0 0 2px currentColor);
}
[data-bs-theme="dark"] .orbital-energy-chart .clickable-dot:hover,
.quarto-dark .orbital-energy-chart .clickable-dot:hover {
filter: drop-shadow(0 0 4px currentColor) drop-shadow(0 0 6px currentColor);
}
/* Current element marker glow (subtle) */
[data-bs-theme="dark"] .orbital-energy-chart .marker-group line,
.quarto-dark .orbital-energy-chart .marker-group line {
filter: drop-shadow(0 0 2px #ff5722);
}
[data-bs-theme="dark"] .orbital-energy-chart .marker-group circle,
.quarto-dark .orbital-energy-chart .marker-group circle {
filter: drop-shadow(0 0 3px #ff5722);
}
/* Region selector styling */
.region-selector {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 8px;
font-size: 13px;
}
.region-selector-label {
font-weight: 500;
color: var(--chart-text, #333);
white-space: nowrap;
}
.region-selector-options {
display: flex;
gap: 6px;
}
.region-selector-btn {
padding: 4px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: var(--chart-bg, white);
color: var(--chart-text, #333);
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.region-selector-btn:hover {
border-color: #1976d2;
background: #e3f2fd;
}
.region-selector-btn.active {
background: #1976d2;
color: white;
border-color: #1976d2;
}
/* Cyberpunk region selector buttons */
.quarto-dark .region-selector-btn,
[data-bs-theme="dark"] .region-selector-btn {
border-color: #00f0ff40;
background: #0a0a14;
color: #00f0ff;
box-shadow: 0 0 4px #00f0ff20;
}
.quarto-dark .region-selector-btn:hover,
[data-bs-theme="dark"] .region-selector-btn:hover {
border-color: #00f0ff;
background: #0f2847;
box-shadow: 0 0 8px #00f0ff40, 0 0 16px #00f0ff20;
}
.quarto-dark .region-selector-btn.active,
[data-bs-theme="dark"] .region-selector-btn.active {
background: #00f0ff;
color: #0a0a14;
border-color: #00f0ff;
box-shadow: 0 0 12px #00f0ff60, 0 0 24px #00f0ff30;
font-weight: 600;
}
.quarto-dark .region-selector-label,
[data-bs-theme="dark"] .region-selector-label {
color: #00f0ff;
text-shadow: 0 0 8px #00f0ff60;
}
</style>`// Region selector UI (displayed below chart)
viewof regionSelector = {
const container = document.createElement("div");
container.className = "region-selector";
const label = document.createElement("span");
label.className = "region-selector-label";
label.textContent = "Crossover Region:";
container.appendChild(label);
const options = document.createElement("div");
options.className = "region-selector-options";
// Get reference to Widget 1's slider for updating Z when region changes
const widget1Slider = typeof viewof selectedZInput !== 'undefined' ? viewof selectedZInput : null;
regions.forEach(region => {
const btn = document.createElement("button");
btn.className = "region-selector-btn" + (region.id === selectedRegionState ? " active" : "");
btn.textContent = region.label;
btn.onclick = () => {
options.querySelectorAll(".region-selector-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
mutable selectedRegionState = region.id;
// Also update selected element to first Z in this region
const firstZ = region.zRange[0];
if (widget1Slider && widget1Slider.setZ) {
widget1Slider.setZ(firstZ);
}
container.value = region.id;
container.dispatchEvent(new CustomEvent("input", {bubbles: true}));
};
options.appendChild(btn);
});
container.appendChild(options);
container.value = selectedRegionState;
return container;
}Data source: NIST Atomic Reference Data for Electronic Structure Calculations (DFT LDA calculations)
Understanding the Orbital Energy Chart
The Madelung rule is an approximation that works for most elements. Orbital energies are not fixed; they change as atomic number increases. The chart above shows actual orbital energies calculated from quantum mechanical computations.
Why Orbital Energies Depend on l
In the hydrogen atom (one electron), orbital energy depends only on the principal quantum number n. The 2s and 2p orbitals are degenerate (identical in energy), as are 3s, 3p, and 3d. This is a unique property of the pure Coulomb potential with a single electron.
In multi-electron atoms, this degeneracy breaks. The 2s orbital becomes lower in energy than 2p, and 3s < 3p < 3d. The cause is electron-electron repulsion and its interplay with orbital shape.
Consider lithium (Z = 3) with configuration 1s22s1. The two 1s electrons form a cloud of negative charge that partially shields the nucleus from the outer electron. But the effectiveness of this shielding depends on where the outer electron spends its time:
- The 2s orbital has a radial node and significant electron density very close to the nucleus. It “penetrates” through the 1s shielding and experiences a higher effective nuclear charge.
- The 2p orbital has zero electron density at the nucleus (a node there). It stays farther out on average and is more completely shielded.
The result: 2s is stabilized relative to 2p. This penetration effect grows stronger for orbitals with lower l values, establishing the energy ordering s < p < d < f within any shell.
This l-dependent splitting explains why we need the Madelung rule. If orbitals depended only on n, we would simply fill 1, 2, 3, 4… in order. Instead, the penetration-driven stabilization of low-l orbitals can push them below higher-n orbitals with larger l values. The 4s orbital, despite having n = 4, penetrates so effectively that it drops below 3d for neutral atoms at low Z.
The 3d/4s Crossover
The orbital energy plot shows that orbital energies are not constant across the periodic table. Look at the 3d/4s crossover region (Z = 18–36):
- At potassium (Z = 19): The 4s orbital is lower in energy than 3d, so the 19th electron enters 4s
- By scandium (Z = 21): The 3d orbital has dropped below 4s in energy
- For all transition metals: The 3d electrons are actually lower in energy than 4s
This explains why transition metal cations lose their s electrons first. In the ionized atom, the d orbitals are unambiguously lower in energy.
Why Do These Crossovers Occur?
Two competing effects determine orbital energies:
Shielding (Screening): Inner electrons partially block the nuclear charge from outer electrons. An electron in a 4s orbital, being more diffuse, “sees” a reduced effective nuclear charge Zeff.
Penetration: s orbitals have significant electron density near the nucleus, allowing them to “penetrate” through inner electron shells and experience more nuclear charge. This is why 4s fills before 3d in neutral atoms at low Z. The 4s orbital penetrates better.
As Z increases, the nuclear charge grows faster than shielding can compensate. The 3d orbital, being more compact and experiencing less shielding from the 4s electrons, benefits more from this increased nuclear attraction, causing it to drop in energy relative to 4s.
The following section explains why some electron configurations deviate from Madelung predictions. This material is more advanced than typical General Chemistry coverage.
For your first pass, focus on:
- The Madelung rule correctly predicts ~80% of electron configurations
- Chromium and copper are common exceptions (memorize: Cr is [Ar]4s13d5, Cu is [Ar]4s13d10)
- Exchange energy explains why: more unpaired parallel-spin electrons = more stable
Return to this section after mastering the Aufbau principle, Madelung rule, Pauli exclusion, and Hund’s rule.
The Physics Behind Exceptional Configurations
About 20 elements have ground-state electron configurations that deviate from Madelung predictions. The short explanation: total energy, not just orbital energy, determines the actual configuration.
Three factors compete:
- Orbital energies favor filling lower orbitals first (the Madelung rule)
- Exchange energy stabilizes configurations with more unpaired, parallel-spin electrons
- Pairing energy penalizes putting two electrons in the same orbital
When orbital energies are close (as they are for 3d/4s near the transition metals), exchange effects can tip the balance. Chromium ([Ar]4s13d5) and copper ([Ar]4s13d10) are the classic examples where both gain stability by moving one electron from 4s to 3d.
The Three Energy Terms
The total electronic energy of an atom can be approximated as:
\[E_{\text{total}} = \sum_i \varepsilon_i + E_{\text{exchange}} + P\]
where:
- \(\sum_i \varepsilon_i\) is the sum of one-electron orbital energies
- \(E_{\text{exchange}}\) is the stabilization from exchange interactions (negative, lowering energy)
- \(P\) is the destabilization from electron pairing (positive, raising energy)
Exchange Energy: Quantum Mechanical Stabilization
Exchange energy (Eexchange) arises from the Pauli exclusion principle. When two electrons have parallel spins (both “up” or both “down”) and occupy different orbitals, their wavefunctions must be antisymmetric under exchange. This antisymmetry causes electrons to avoid each other more effectively, reducing their Coulombic repulsion.
The exchange stabilization between a set of parallel-spin electrons is:
\[E_{\text{exchange}} = -K \times \binom{n}{2} = -K \times \frac{n(n-1)}{2}\]
where K is the exchange integral (a positive constant depending on orbital overlap) and n is the number of electrons with parallel spins.
Exchange pairs grow quadratically. Going from 4 to 5 parallel-spin electrons adds 4 new pairs; going from 5 to 6 adds 5 more. This is why maximizing unpaired electrons can overcome orbital energy costs.
Counting Exchange Pairs in Real Configurations
To apply this to actual electron configurations, we need to determine how many parallel-spin electrons exist. Two principles govern spin arrangements:
Hund’s rule: Within a subshell, electrons occupy orbitals singly with parallel spins before pairing. So d4 has 4↑ electrons (one in each of four d orbitals), while d6 has 5↑ and 1↓ (the sixth electron must pair).
Forced pairing in s orbitals: The s subshell has only one orbital, so s2 forces immediate pairing: one ↑ and one ↓. Only the spin-up electron can participate in exchange with other ↑ electrons.
Exchange occurs both within and between subshells. For a configuration like 4s23d4:
- d-d exchange: The 4 parallel-spin d electrons form C(4,2) = 6 pairs among themselves
- s-d exchange: The single ↑ electron in the 4s orbital can exchange with each of the 4↑ d electrons, adding 4 more pairs
- Total: 6 + 4 = 10 exchange pairs
Compare this to 4s13d5:
- d-d exchange: 5 parallel-spin d electrons form C(5,2) = 10 pairs
- s-d exchange: The single ↑ s electron exchanges with 5↑ d electrons = 5 pairs
- Total: 10 + 5 = 15 exchange pairs
The s1d5 configuration gains 5 additional exchange pairs compared to s2d4. Each pair contributes −K to the total energy, so these extra pairs significantly stabilize the atom.
Pairing Energy: The Cost of Double Occupancy
Pairing energy (P) is the energy penalty when two electrons occupy the same orbital. It has two components:
- Coulombic repulsion: Two electrons in the same orbital have substantial spatial overlap, increasing their average repulsion
- Loss of exchange: Paired electrons have opposite spins, so they contribute no exchange stabilization with each other
For transition metals, pairing energies are typically 15,000–25,000 cm−1 (about 180–300 kJ mol−1), while exchange integrals are roughly 5,000–8,000 cm−1 per pair. The balance between these determines the actual configuration.
To put these numbers in perspective: a typical C–C single bond has a bond energy of about 350 kJ mol−1. Pairing two electrons costs roughly half that much energy. This is why electron pairing isn’t trivial; it’s a significant energetic consideration that can override orbital energy predictions when the orbitals are close in energy.
Detailed Analysis: Chromium (Z = 24)
Chromium exhibits the classic d5s1 configuration instead of the Madelung-predicted d4s2.
Why chromium adopts s1d5:
At Z = 24, the 4s and 3d orbitals are very close in energy (see the orbital energy chart). The energy cost of promoting one electron from 4s to 3d is small. Meanwhile:
- The configuration gains 5 additional exchange pairs (from 10 to 15)
- The configuration avoids the pairing energy P in the 4s orbital
- The net energy change: ΔE ≈ Δε − 5K − P < 0
The exchange gain plus avoided pairing together outweigh the small orbital energy penalty.
Detailed Analysis: Copper (Z = 29)
Copper has [Ar]4s13d10 rather than [Ar]4s23d9. This case is more subtle.
Why copper adopts s1d10:
The total exchange is identical for both configurations (25 pairs each). The deciding factors are:
- Pairing energy: The s2d9 configuration pays P for pairing in 4s, while s1d10 does not
- Filled subshell stability: A completely filled d10 subshell has perfect spherical symmetry, which minimizes electron-electron repulsion in ways not captured by simple exchange counting
- Orbital energy gap: At Z = 29, the 3d orbitals have dropped significantly below 4s (see the chart), so the orbital energy cost is minimal
Avoiding the 4s pairing penalty combined with the stability of the filled d shell makes s1d10 lower in total energy.
The Niobium Counterexample (Z = 41)
A common misconception is that “half-filled subshells are always preferred.” Niobium disproves this.
Niobium has [Kr]5s14d4, not the half-filled [Kr]4d5.
Why doesn’t niobium adopt d5?
At Z = 41, the energy gap between 5s and 4d is larger than at chromium (Z = 24). Look at the orbital energy chart: the 5s/4d crossing happens at a different Z than the 4s/3d crossing, and the curves have different slopes.
To achieve d5, niobium would need to completely empty the 5s orbital. But:
- The orbital energy cost (promoting from lower 5s to higher 4d) is substantial
- The exchange energy gain (from 6 to 10 d-d pairs) is the same as for chromium
- At this Z, the orbital penalty outweighs the exchange benefit
The “half-filled stability” heuristic works only when orbital energies are nearly degenerate. This varies across the periodic table.
Classification of All Exceptions
Electron configurations are determined by minimizing total energy, which depends on:
- Orbital energies, which change with Z (see the chart)
- Exchange energy, which favors maximizing parallel-spin electrons
- Pairing energy, the penalty for doubly-occupied orbitals
- Filled subshell effects, additional stability from spherical symmetry
There is no single simple rule that correctly predicts all configurations. The Madelung rule works for about 80% of elements; the exceptions arise when exchange and pairing effects overcome the orbital energy ordering at that specific Z.
Magnetic Properties: Paramagnetism and Diamagnetism
The presence or absence of unpaired electrons determines an atom’s magnetic behavior:
The interactive widget above indicates whether each element is paramagnetic or diamagnetic based on its electron configuration.
Why This Matters: Everyday Magnetism
The connection between electron configuration and magnetism explains familiar phenomena:
Iron (Fe) with [Ar]4s23d6 has four unpaired 3d electrons, making it strongly paramagnetic. This is why iron is attracted to magnets and why iron-based materials are used in permanent magnets, hard drives, and electric motors.
Zinc (Zn) with [Ar]4s23d10 has a completely filled 3d subshell. All electrons are paired, making zinc diamagnetic and non-magnetic. Zinc isn’t attracted to magnets at all.
Oxygen (O2) is paramagnetic because molecular oxygen has two unpaired electrons. Liquid oxygen can be held between the poles of a strong magnet, a striking demonstration that puzzled chemists until molecular orbital theory explained it.
The difference between a magnetic material and a non-magnetic one often comes down to whether a few d-orbital electrons are paired or unpaired.
Complete Electron Configuration Table
Electron Configurations of Ions
When writing electron configurations of ions, electrons are added (to form anions) or removed (to form cations) from the shell of highest n, and within that shell, highest l.
Explore ion electron configurations interactively:
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) || {}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();
}Some examples are presented below.
Fluorine(1−), Z = 9
The fluoride anion, F−, has 10 electrons (2 core and 8 valence). The addition of an electron to a fluorine atom changes the electron configuration as shown below:
\[1s^22s^22p^5 ~\xrightarrow{+e^-}~ 1s^22s^22p^6\]
or
\[[\mathrm{He}]~2s^22p^5 ~\xrightarrow{+e^-}~ [\mathrm{He}]~2s^22p^6\]
The fluoride anion is isoelectronic with the neon atom (i.e. two particles with an equal number of electrons but a different number of protons).
This ion is diamagnetic.
Lithium(1+), Z = 3
The lithium cation, Li+, has 2 electrons (2 core). The removal of an electron from a lithium atom changes the electron configuration as shown below:
\[[\mathrm{He}]~2s^1 ~\xrightarrow{-e^-}~ 1s^2\]
The quantum numbers for each electron are
- (1s “spin-up” core electron) n = 1, l = 0, ml = 0, and ms = +1/2
- (1s “spin-down” core electron) n = 1, l = 0, ml = 0, and ms = −1/2
This ion is diamagnetic.
Iron(2+), Z = 26
The iron dication, Fe2+, has 24 electrons (18 core and 6 valence). The removal of two electrons from an iron atom changes the electron configuration as shown below:
\[[\mathrm{Ar}]~4s^23d^6 ~\xrightarrow{-2e^-}~ [\mathrm{Ar}]~3d^6\]
The 4s electrons are removed first, not the d electrons. In the cation, the 3d orbitals are unambiguously lower in energy than 4s (see the orbital energy chart discussion above).
This ion is paramagnetic (4 unpaired electrons).
Iron(3+), Z = 26
The iron trication, Fe3+, has 23 electrons (18 core and 5 valence). The removal of three electrons from an iron atom changes the electron configuration as shown below:
\[[\mathrm{Ar}]~4s^23d^6 ~\xrightarrow{-3e^-}~ [\mathrm{Ar}]~3d^5\]
The 4s electrons are removed first, then one 3d electron. The resulting d5 configuration is half-filled with maximum exchange stabilization.
This ion is paramagnetic (5 unpaired electrons).