Science — Molecule / Structure Viewer Card
An interactive ball-and-stick molecule viewer card built with inline SVG and vanilla JavaScript. Drag to rotate a pseudo-3D structure, scroll or click to zoom, and toggle auto-spin while depth is faked through radius scaling and opacity. A structure selector switches between water, methane, benzene, and caffeine, each with a CPK element legend, formula, molar mass, atom and bond counts, and a fictional Helix Structural Database citation.
MCP
Code
:root {
--bg: #ffffff;
--bg-alt: #f6f8fb;
--ink: #0f1b2d;
--ink-2: #33445c;
--muted: #697892;
--accent: #1a4f8a;
--accent-d: #123a66;
--accent-50: #e9f0f9;
--teal: #0f7d78;
--teal-50: #e4f3f1;
--line: rgba(15, 27, 45, 0.12);
--line-2: rgba(15, 27, 45, 0.2);
--ok: #2f9e6f;
--warn: #c9821f;
--danger: #cf4538;
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
--shadow-sm: 0 1px 2px rgba(15, 27, 45, 0.06), 0 1px 3px rgba(15, 27, 45, 0.05);
--shadow-md: 0 6px 22px rgba(15, 27, 45, 0.08), 0 2px 6px rgba(15, 27, 45, 0.05);
--serif: "Source Serif 4", Georgia, serif;
--ui: "Inter", system-ui, -apple-system, sans-serif;
--mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", monospace;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: var(--serif);
background: var(--bg);
color: var(--ink);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.page {
max-width: 980px;
margin: 0 auto;
padding: 40px 20px 64px;
}
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
/* ---------- card ---------- */
.viewer-card {
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-md);
overflow: hidden;
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 22px 24px 18px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, var(--bg-alt), var(--bg));
}
.eyebrow {
margin: 0 0 6px;
font-family: var(--ui);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
}
.title {
margin: 0;
font-size: 25px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--ink);
}
.sub {
margin: 6px 0 0;
font-size: 14.5px;
color: var(--ink-2);
max-width: 52ch;
}
.badge {
font-family: var(--ui);
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 5px 10px;
border-radius: 999px;
white-space: nowrap;
border: 1px solid transparent;
}
.badge-accent {
background: var(--accent-50);
color: var(--accent-d);
border-color: rgba(26, 79, 138, 0.22);
}
/* ---------- body grid ---------- */
.card-body {
display: grid;
grid-template-columns: 1.35fr 1fr;
gap: 0;
}
.stage-wrap {
padding: 22px 22px 20px;
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 16px;
}
.stage {
position: relative;
aspect-ratio: 1 / 1;
width: 100%;
border-radius: var(--r-md);
border: 1px solid var(--line);
background:
radial-gradient(120% 120% at 50% 30%, #ffffff 0%, var(--bg-alt) 78%, #eef2f8 100%);
overflow: hidden;
cursor: grab;
touch-action: none;
outline: none;
}
.stage:focus-visible { box-shadow: 0 0 0 3px rgba(26, 79, 138, 0.35); border-color: var(--accent); }
.stage.dragging { cursor: grabbing; }
.stage svg { display: block; width: 100%; height: 100%; }
.stage-hud {
position: absolute;
top: 10px;
left: 10px;
display: flex;
gap: 6px;
}
.hud-chip {
font-family: var(--ui);
font-size: 11px;
font-weight: 600;
color: var(--ink-2);
background: rgba(255, 255, 255, 0.82);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 3px 8px;
backdrop-filter: blur(4px);
}
.hud-chip.mono { font-family: var(--mono); }
.stage-hint {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
font-family: var(--ui);
font-size: 11px;
color: var(--muted);
background: rgba(255, 255, 255, 0.8);
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px 12px;
transition: opacity 0.4s ease;
pointer-events: none;
}
.stage-hint.hide { opacity: 0; }
/* atoms / bonds */
.atom { transition: opacity 0.06s linear; }
.atom-core { stroke: rgba(15, 27, 45, 0.35); stroke-width: 0.8; }
.atom-spec { fill: rgba(255, 255, 255, 0.55); }
.bond-line { stroke-linecap: round; }
/* ---------- controls ---------- */
.controls { display: flex; flex-direction: column; gap: 12px; }
.seg {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
padding: 4px;
background: var(--bg-alt);
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.seg-btn {
font-family: var(--ui);
font-size: 12.5px;
font-weight: 600;
color: var(--ink-2);
background: transparent;
border: 1px solid transparent;
border-radius: var(--r-sm);
padding: 7px 6px;
cursor: pointer;
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.seg-btn:hover { color: var(--ink); background: rgba(26, 79, 138, 0.06); }
.seg-btn:focus-visible { outline: none; box-shadow: 0 0 0 2px rgba(26, 79, 138, 0.4); }
.seg-btn.is-active {
background: var(--bg);
color: var(--accent-d);
box-shadow: var(--shadow-sm);
border-color: var(--line);
}
.ctrl-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.btn {
font-family: var(--ui);
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
background: var(--bg);
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
padding: 8px 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.btn:hover { border-color: var(--accent); color: var(--accent-d); }
.btn:focus-visible { outline: none; box-shadow: 0 0 0 2px rgba(26, 79, 138, 0.4); }
.btn.icon { padding: 8px 0; width: 38px; justify-content: center; font-size: 16px; line-height: 1; }
.btn .dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--muted);
transition: background 0.15s, box-shadow 0.15s;
}
.btn[aria-pressed="true"] {
border-color: var(--teal);
color: var(--teal);
background: var(--teal-50);
}
.btn[aria-pressed="true"] .dot {
background: var(--teal);
box-shadow: 0 0 0 3px rgba(15, 125, 120, 0.2);
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.45; } }
.zoom-group { display: flex; gap: 4px; }
/* ---------- info ---------- */
.info { padding: 22px 24px 20px; display: flex; flex-direction: column; gap: 18px; }
.info-name { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -0.01em; }
.info-iupac { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); }
.props {
margin: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--line);
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
}
.prop { background: var(--bg); padding: 10px 12px; }
.prop dt {
font-family: var(--ui);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 3px;
}
.prop dd { margin: 0; font-size: 14.5px; font-weight: 500; color: var(--ink); }
.legend-label, .fig figcaption {
font-family: var(--ui);
}
.legend-label {
margin: 0 0 8px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
}
.legend-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
.legend-item {
display: inline-flex;
align-items: center;
gap: 7px;
font-family: var(--ui);
font-size: 12px;
font-weight: 500;
color: var(--ink-2);
background: var(--bg-alt);
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px 10px 4px 6px;
}
.legend-swatch {
width: 13px; height: 13px; border-radius: 50%;
border: 1px solid rgba(15, 27, 45, 0.25);
box-shadow: inset -2px -2px 3px rgba(0,0,0,0.18), inset 2px 2px 3px rgba(255,255,255,0.4);
}
.legend-item .sym { font-family: var(--mono); font-weight: 500; color: var(--ink); }
.legend-item .cnt { color: var(--muted); font-family: var(--mono); }
.fig {
margin: 0;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.fig figcaption { font-size: 12px; color: var(--muted); line-height: 1.5; }
.fig figcaption strong { color: var(--ink-2); }
/* ---------- footer ---------- */
.card-foot {
padding: 14px 24px;
border-top: 1px solid var(--line);
background: var(--bg-alt);
}
.cite { font-size: 11.5px; color: var(--muted); line-height: 1.55; display: block; }
.cite em { font-style: italic; }
/* ---------- toast ---------- */
.toast-wrap {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 50;
pointer-events: none;
}
.toast {
font-family: var(--ui);
font-size: 13px;
font-weight: 500;
color: #fff;
background: var(--ink);
border-radius: var(--r-sm);
padding: 10px 16px;
box-shadow: var(--shadow-md);
opacity: 0;
transform: translateY(8px);
transition: opacity 0.22s ease, transform 0.22s ease;
}
.toast.show { opacity: 1; transform: translateY(0); }
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
/* ---------- responsive ---------- */
@media (max-width: 640px) {
.page { padding: 24px 14px 48px; }
.card-head { flex-direction: column; gap: 12px; padding: 18px 16px 14px; }
.title { font-size: 21px; }
.card-body { grid-template-columns: 1fr; }
.stage-wrap { border-right: none; border-bottom: 1px solid var(--line); padding: 16px; }
.info { padding: 18px 16px; }
.seg { grid-template-columns: repeat(2, 1fr); }
.card-foot { padding: 12px 16px; }
}/* Molecule / Structure Viewer Card — vanilla JS, no external libs.
* Pseudo-3D ball-and-stick: atoms have (x,y,z) coords, projected to 2D
* with rotation matrices; depth cued by radius scaling + opacity.
* Fictional / illustrative geometry — not real optimized coordinates. */
(function () {
// CPK-ish element palette (illustrative).
var ELEMENTS = {
H: { color: "#eef2f8", r: 11, name: "Hydrogen" },
C: { color: "#3a4250", r: 17, name: "Carbon" },
O: { color: "#cf4538", r: 16, name: "Oxygen" },
N: { color: "#2f6fd0", r: 16, name: "Nitrogen" }
};
// Molecules: atoms {el,x,y,z}, bonds [i,j,order]. Coords in arbitrary units.
var MOLECULES = {
water: {
name: "Water",
iupac: "oxidane",
formula: "H₂O",
mass: "18.02 g·mol⁻¹",
atoms: [
{ el: "O", x: 0, y: 0, z: 0 },
{ el: "H", x: 58, y: 44, z: 0 },
{ el: "H", x: -58, y: 44, z: 0 }
],
bonds: [[0, 1, 1], [0, 2, 1]]
},
methane: {
name: "Methane",
iupac: "methane",
formula: "CH₄",
mass: "16.04 g·mol⁻¹",
atoms: [
{ el: "C", x: 0, y: 0, z: 0 },
{ el: "H", x: 52, y: 52, z: 52 },
{ el: "H", x: -52, y: -52, z: 52 },
{ el: "H", x: -52, y: 52, z: -52 },
{ el: "H", x: 52, y: -52, z: -52 }
],
bonds: [[0, 1, 1], [0, 2, 1], [0, 3, 1], [0, 4, 1]]
},
benzene: (function () {
var b = ringBenzene();
return {
name: "Benzene",
iupac: "benzene",
formula: "C₆H₆",
mass: "78.11 g·mol⁻¹",
atoms: b.atoms,
bonds: b.bonds
};
})(),
caffeine: {
name: "Caffeine",
iupac: "1,3,7-trimethylpurine-2,6-dione",
formula: "C₈H₁₀N₄O₂",
mass: "194.19 g·mol⁻¹",
atoms: caffeineAtoms(),
bonds: caffeineBonds()
}
};
function ringBenzene() {
// 6 carbons in a hexagon (slightly puckered for depth) + 6 H outward.
var atoms = [];
var bonds = [];
var R = 62;
var i;
for (i = 0; i < 6; i++) {
var a = (Math.PI / 3) * i;
atoms.push({ el: "C", x: Math.cos(a) * R, y: Math.sin(a) * R, z: (i % 2 ? 12 : -12) });
}
for (i = 0; i < 6; i++) {
var b = (Math.PI / 3) * i;
atoms.push({ el: "H", x: Math.cos(b) * (R + 42), y: Math.sin(b) * (R + 42), z: (i % 2 ? 12 : -12) });
}
// alternating single/double around the ring + C-H bonds
for (i = 0; i < 6; i++) {
bonds.push([i, (i + 1) % 6, i % 2 === 0 ? 2 : 1]);
bonds.push([i, i + 6, 1]);
}
return { atoms: atoms, bonds: bonds };
}
function caffeineAtoms() {
// Illustrative fused bicyclic layout (not real coords).
return [
{ el: "C", x: -10, y: -52, z: 8 }, // 0
{ el: "N", x: 46, y: -34, z: -6 }, // 1
{ el: "C", x: 58, y: 18, z: 10 }, // 2
{ el: "C", x: 16, y: 50, z: -8 }, // 3
{ el: "N", x: -38, y: 34, z: 6 }, // 4
{ el: "C", x: -52, y: -16, z: -10 }, // 5
{ el: "N", x: 100, y: 36, z: 4 }, // 6 (imidazole)
{ el: "C", x: 92, y: -28, z: -8 }, // 7
{ el: "N", x: 48, y: -78, z: 10 }, // 8
{ el: "O", x: -100, y: -28, z: -14 },// 9
{ el: "O", x: 30, y: 96, z: -16 }, // 10
{ el: "C", x: -84, y: 64, z: 12 }, // 11 methyl
{ el: "C", x: 84, y: -76, z: 18 }, // 12 methyl
{ el: "C", x: 150, y: 58, z: 6 }, // 13 methyl
{ el: "H", x: 130, y: -52, z: -16 }, // 14
{ el: "H", x: -116, y: 90, z: 18 }, // 15
{ el: "H", x: -64, y: 88, z: 6 }, // 16
{ el: "H", x: -98, y: 46, z: 30 }, // 17
{ el: "H", x: 110, y: -98, z: 30 }, // 18
{ el: "H", x: 60, y: -100, z: 36 }, // 19
{ el: "H", x: 96, y: -94, z: 4 }, // 20
{ el: "H", x: 178, y: 44, z: 0 }, // 21
{ el: "H", x: 154, y: 84, z: 22 }, // 22
{ el: "H", x: 162, y: 70, z: -16 } // 23
];
}
function caffeineBonds() {
return [
[0, 1, 1], [1, 2, 1], [2, 3, 2], [3, 4, 1], [4, 5, 1], [5, 0, 2],
[2, 6, 1], [6, 7, 2], [7, 8, 1], [8, 0, 1],
[5, 9, 2], [3, 10, 2],
[4, 11, 1], [8, 12, 1], [6, 13, 1],
[7, 14, 1],
[11, 15, 1], [11, 16, 1], [11, 17, 1],
[12, 18, 1], [12, 19, 1], [12, 20, 1],
[13, 21, 1], [13, 22, 1], [13, 23, 1]
];
}
var ENTRY = {
water: "HSDB-00018", methane: "HSDB-00016",
benzene: "HSDB-00078", caffeine: "HSDB-04127"
};
// ---- DOM refs ----
var $ = function (id) { return document.getElementById(id); };
var stage = $("stage");
var bondsG = $("bonds");
var atomsG = $("atoms");
var hint = $("hint");
var hudFormula = $("hud-formula");
var hudZoom = $("hud-zoom");
var SVGNS = "http://www.w3.org/2000/svg";
// ---- view state ----
var state = {
mol: "water",
rotX: -0.35,
rotY: 0.5,
zoom: 1,
spin: false
};
var current = MOLECULES.water;
// ---- rotation + projection ----
function rotate(p) {
var cy = Math.cos(state.rotY), sy = Math.sin(state.rotY);
var cx = Math.cos(state.rotX), sx = Math.sin(state.rotX);
// rotate around Y then X
var x = p.x * cy + p.z * sy;
var z = -p.x * sy + p.z * cy;
var y = p.y * cx - z * sx;
var z2 = p.y * sx + z * cx;
return { x: x, y: y, z: z2 };
}
function render() {
// project all atoms
var pts = current.atoms.map(function (a) {
var r = rotate(a);
return { el: a.el, x: r.x * state.zoom, y: r.y * state.zoom, z: r.z };
});
// depth-based opacity/scale helpers
var zs = pts.map(function (p) { return p.z; });
var zMin = Math.min.apply(null, zs), zMax = Math.max.apply(null, zs);
var zRange = (zMax - zMin) || 1;
function depth(z) { return (z - zMin) / zRange; } // 0 back .. 1 front
// ----- bonds (drawn first, painter sorted by avg depth) -----
var bondList = current.bonds.map(function (b) {
var pa = pts[b[0]], pb = pts[b[1]];
return { a: pa, b: pb, order: b[2], z: (pa.z + pb.z) / 2 };
}).sort(function (m, n) { return m.z - n.z; });
var bondHtml = "";
bondList.forEach(function (bd) {
var d = depth(bd.z);
var op = (0.45 + d * 0.5).toFixed(3);
var w = (3.2 + d * 1.6).toFixed(2);
var col = "#7a8aa0";
if (bd.order === 2) {
var ox = bd.b.y - bd.a.y, oy = -(bd.b.x - bd.a.x);
var len = Math.hypot(ox, oy) || 1;
var off = 3.4;
ox = (ox / len) * off; oy = (oy / len) * off;
bondHtml += line(bd.a.x + ox, bd.a.y + oy, bd.b.x + ox, bd.b.y + oy, w * 0.62, col, op);
bondHtml += line(bd.a.x - ox, bd.a.y - oy, bd.b.x - ox, bd.b.y - oy, w * 0.62, col, op);
} else {
bondHtml += line(bd.a.x, bd.a.y, bd.b.x, bd.b.y, w, col, op);
}
});
bondsG.innerHTML = bondHtml;
// ----- atoms (painter sorted back-to-front) -----
var atomList = pts.map(function (p, i) { return { p: p, i: i }; })
.sort(function (m, n) { return m.p.z - n.p.z; });
var atomHtml = "";
atomList.forEach(function (item) {
var p = item.p;
var meta = ELEMENTS[p.el];
var d = depth(p.z);
var rad = meta.r * state.zoom * (0.78 + d * 0.34);
var op = (0.62 + d * 0.38).toFixed(3);
atomHtml +=
'<g class="atom" opacity="' + op + '">' +
'<circle class="atom-core" cx="' + f(p.x) + '" cy="' + f(p.y) + '" r="' + f(rad) +
'" fill="' + meta.color + '" />' +
'<circle class="atom-spec" cx="' + f(p.x - rad * 0.3) + '" cy="' + f(p.y - rad * 0.32) +
'" r="' + f(rad * 0.42) + '" />' +
"</g>";
});
atomsG.innerHTML = atomHtml;
hudFormula.textContent = current.formula;
hudZoom.textContent = Math.round(state.zoom * 100) + "%";
}
function line(x1, y1, x2, y2, w, col, op) {
return '<line class="bond-line" x1="' + f(x1) + '" y1="' + f(y1) +
'" x2="' + f(x2) + '" y2="' + f(y2) + '" stroke="' + col +
'" stroke-width="' + f(w) + '" opacity="' + op + '" />';
}
function f(n) { return Math.round(n * 100) / 100; }
// ---- info panel + legend ----
function loadMolecule(key) {
if (!MOLECULES[key]) return;
state.mol = key;
current = MOLECULES[key];
$("info-name").textContent = current.name;
$("info-iupac").textContent = current.iupac;
$("prop-formula").innerHTML = current.formula;
$("prop-mass").textContent = current.mass;
$("prop-atoms").textContent = current.atoms.length;
$("prop-bonds").textContent = current.bonds.length;
$("fig-cap").innerHTML =
"Approximate ball-and-stick model of " + current.formula +
" (SDB " + ENTRY[key] + "). Atom radii scaled by covalent radius; " +
"depth conveyed via foreshortening and opacity.";
buildLegend();
render();
}
function buildLegend() {
var counts = {};
current.atoms.forEach(function (a) { counts[a.el] = (counts[a.el] || 0) + 1; });
var order = ["C", "H", "N", "O"];
var keys = Object.keys(counts).sort(function (a, b) {
return order.indexOf(a) - order.indexOf(b);
});
var list = $("legend-list");
list.innerHTML = keys.map(function (el) {
var m = ELEMENTS[el];
return '<li class="legend-item">' +
'<span class="legend-swatch" style="background:' + m.color + '"></span>' +
'<span class="sym">' + el + '</span> ' + m.name +
' <span class="cnt">×' + counts[el] + "</span></li>";
}).join("");
}
// ---- interaction: drag to rotate ----
var dragging = false, lastX = 0, lastY = 0;
function pointerDown(e) {
dragging = true;
stage.classList.add("dragging");
var pt = point(e);
lastX = pt.x; lastY = pt.y;
if (hint) hint.classList.add("hide");
if (e.cancelable) e.preventDefault();
}
function pointerMove(e) {
if (!dragging) return;
var pt = point(e);
state.rotY += (pt.x - lastX) * 0.011;
state.rotX += (pt.y - lastY) * 0.011;
state.rotX = Math.max(-1.4, Math.min(1.4, state.rotX));
lastX = pt.x; lastY = pt.y;
render();
}
function pointerUp() {
dragging = false;
stage.classList.remove("dragging");
}
function point(e) {
if (e.touches && e.touches[0]) return { x: e.touches[0].clientX, y: e.touches[0].clientY };
return { x: e.clientX, y: e.clientY };
}
stage.addEventListener("mousedown", pointerDown);
window.addEventListener("mousemove", pointerMove);
window.addEventListener("mouseup", pointerUp);
stage.addEventListener("touchstart", pointerDown, { passive: false });
stage.addEventListener("touchmove", function (e) { pointerMove(e); if (e.cancelable) e.preventDefault(); }, { passive: false });
stage.addEventListener("touchend", pointerUp);
// keyboard rotation
stage.addEventListener("keydown", function (e) {
var step = 0.18;
if (e.key === "ArrowLeft") state.rotY -= step;
else if (e.key === "ArrowRight") state.rotY += step;
else if (e.key === "ArrowUp") state.rotX = Math.max(-1.4, state.rotX - step);
else if (e.key === "ArrowDown") state.rotX = Math.min(1.4, state.rotX + step);
else if (e.key === "+" || e.key === "=") setZoom(state.zoom + 0.12);
else if (e.key === "-") setZoom(state.zoom - 0.12);
else return;
e.preventDefault();
if (hint) hint.classList.add("hide");
render();
});
// wheel zoom
stage.addEventListener("wheel", function (e) {
e.preventDefault();
setZoom(state.zoom + (e.deltaY < 0 ? 0.08 : -0.08));
if (hint) hint.classList.add("hide");
}, { passive: false });
function setZoom(z) {
state.zoom = Math.max(0.5, Math.min(2.4, z));
render();
}
// ---- spin loop ----
var raf = null;
function spinLoop() {
if (!state.spin) return;
state.rotY += 0.012;
render();
raf = requestAnimationFrame(spinLoop);
}
function setSpin(on) {
state.spin = on;
var btn = $("spin-toggle");
btn.setAttribute("aria-pressed", on ? "true" : "false");
if (on) { spinLoop(); toast("Auto-spin on"); }
else { if (raf) cancelAnimationFrame(raf); toast("Auto-spin paused"); }
}
// ---- toolbar wiring ----
Array.prototype.forEach.call(document.querySelectorAll(".seg-btn"), function (b) {
b.addEventListener("click", function () {
document.querySelectorAll(".seg-btn").forEach(function (x) { x.classList.remove("is-active"); });
b.classList.add("is-active");
loadMolecule(b.getAttribute("data-mol"));
toast(MOLECULES[b.getAttribute("data-mol")].name + " loaded");
});
});
$("spin-toggle").addEventListener("click", function () { setSpin(!state.spin); });
$("zoom-in").addEventListener("click", function () { setZoom(state.zoom + 0.15); });
$("zoom-out").addEventListener("click", function () { setZoom(state.zoom - 0.15); });
$("zoom-reset").addEventListener("click", function () {
state.rotX = -0.35; state.rotY = 0.5; state.zoom = 1;
render(); toast("View reset");
});
// ---- toast helper ----
var toastTimer = null;
function toast(msg) {
var wrap = $("toast-wrap");
var el = document.createElement("div");
el.className = "toast";
el.textContent = msg;
wrap.appendChild(el);
requestAnimationFrame(function () { el.classList.add("show"); });
setTimeout(function () {
el.classList.remove("show");
setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 260);
}, 1600);
}
// hide hint after idle
setTimeout(function () { if (hint && !dragging) hint.classList.add("hide"); }, 5200);
// ---- init ----
loadMolecule("water");
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Molecule / Structure Viewer Card</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,wght@0,400;0,600;0,700;1,400&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<article class="viewer-card" aria-labelledby="mv-title">
<header class="card-head">
<div class="head-text">
<p class="eyebrow">Structural Chemistry · Interactive Figure</p>
<h1 id="mv-title" class="title">Molecule / Structure Viewer</h1>
<p class="sub">Ball-and-stick rendering with drag-to-rotate, zoom, and auto-spin. Geometry approximated for illustration.</p>
</div>
<span class="badge badge-accent" title="Rendering mode">Ball & stick</span>
</header>
<div class="card-body">
<!-- Stage -->
<section class="stage-wrap" aria-label="Molecule viewport">
<div class="stage" id="stage" role="img" aria-label="Interactive molecular structure" tabindex="0">
<svg id="scene" viewBox="-160 -160 320 320" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<defs>
<radialGradient id="depthVignette" cx="50%" cy="42%" r="62%">
<stop offset="0%" stop-color="rgba(26,79,138,0.05)" />
<stop offset="100%" stop-color="rgba(15,27,45,0.0)" />
</radialGradient>
</defs>
<rect x="-160" y="-160" width="320" height="320" fill="url(#depthVignette)" />
<g id="bonds"></g>
<g id="atoms"></g>
</svg>
<div class="stage-hud">
<span class="hud-chip" id="hud-formula">—</span>
<span class="hud-chip mono" id="hud-zoom">100%</span>
</div>
<div class="stage-hint" id="hint">Drag to rotate · scroll to zoom</div>
</div>
<!-- Controls -->
<div class="controls" role="group" aria-label="Viewer controls">
<div class="seg" role="group" aria-label="Select molecule">
<button class="seg-btn is-active" data-mol="water" type="button">Water</button>
<button class="seg-btn" data-mol="methane" type="button">Methane</button>
<button class="seg-btn" data-mol="benzene" type="button">Benzene</button>
<button class="seg-btn" data-mol="caffeine" type="button">Caffeine</button>
</div>
<div class="ctrl-row">
<button class="btn" id="spin-toggle" type="button" aria-pressed="false">
<span class="dot" aria-hidden="true"></span> Auto-spin
</button>
<div class="zoom-group" role="group" aria-label="Zoom">
<button class="btn icon" id="zoom-out" type="button" aria-label="Zoom out">−</button>
<button class="btn icon" id="zoom-reset" type="button" aria-label="Reset view">⟳</button>
<button class="btn icon" id="zoom-in" type="button" aria-label="Zoom in">+</button>
</div>
</div>
</div>
</section>
<!-- Info side -->
<aside class="info" aria-label="Molecule information">
<div class="info-block">
<h2 class="info-name" id="info-name">Water</h2>
<p class="info-iupac mono" id="info-iupac">oxidane</p>
</div>
<dl class="props">
<div class="prop">
<dt>Formula</dt>
<dd class="mono" id="prop-formula">H₂O</dd>
</div>
<div class="prop">
<dt>Molar mass</dt>
<dd class="mono" id="prop-mass">18.02 g·mol⁻¹</dd>
</div>
<div class="prop">
<dt>Atoms</dt>
<dd class="mono" id="prop-atoms">3</dd>
</div>
<div class="prop">
<dt>Bonds</dt>
<dd class="mono" id="prop-bonds">2</dd>
</div>
</dl>
<div class="legend">
<p class="legend-label">Element legend</p>
<ul class="legend-list" id="legend-list"></ul>
</div>
<figure class="fig">
<figcaption><strong>Figure 1.</strong> <span id="fig-cap">Approximate ball-and-stick model of H₂O. Atom radii scaled by covalent radius; depth conveyed via foreshortening and opacity.</span></figcaption>
</figure>
</aside>
</div>
<footer class="card-foot">
<span class="cite mono">Liang, M., Okonkwo, A., & Reyhani, S. (2025). <em>An interactive teaching atlas of small-molecule geometry.</em> J. Vis. Chem. Educ. 12(4), 220–238. doi:10.4821/jvce.2025.0412</span>
</footer>
</article>
</main>
<div class="toast-wrap" id="toast-wrap" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Molecule / Structure Viewer Card
A self-contained molecular structure viewer rendered entirely with inline SVG and a few hundred lines of vanilla JavaScript — no WebGL, no 3D libraries, and no math heavier than a pair of rotation matrices. Atoms are colored circles with a specular highlight, bonds are single or double lines, and a pseudo-3D feel is produced by projecting each atom’s (x, y, z) coordinate and cueing depth with radius scaling and opacity so closer atoms read larger and more opaque.
The viewport is fully interactive: drag (mouse or touch) to rotate the model, scroll or use the zoom buttons to scale, and the arrow keys rotate when the stage is focused for keyboard users. An auto-spin toggle drives a requestAnimationFrame loop, and a reset button restores the default camera. Painter’s-algorithm sorting redraws atoms and bonds back-to-front on every frame so overlaps stay believable as the molecule turns.
A segmented selector switches between water, methane, benzene, and caffeine. Each structure repopulates the info panel — IUPAC name, formula, molar mass, atom and bond counts — and rebuilds the CPK element legend with per-element counts. Figure captions, a fictional Helix Structural Database entry ID, and a mock journal citation round out the academic framing, and a small toast helper confirms actions like loading a structure or toggling spin.
Illustrative UI only — fictional authors, data, and figures; not real scientific results.