crystalView = (extxyzContent) => {
const [Nx, Ny, Nz] = [3, 3, 3];
const radius = 0.6;
const canvasWidth = 800;
const canvasHeight = 600;
function parseExtXYZ(extxyzContent) {
const frames = [];
const lines = extxyzContent.split('\n').map(line => line.trim()).filter(line => line.length > 0);
let i = 0;
while (i < lines.length) {
const numAtoms = parseInt(lines[i++], 10);
if (isNaN(numAtoms)) break;
const commentLine = lines[i++];
const latticeMatch = commentLine.match(/Lattice="([^"]+)"/);
const propertiesMatch = commentLine.includes("Properties=species:S:1:pos:R:3");
if (!latticeMatch || !propertiesMatch) {
console.warn("Skipping frame due to missing Lattice or Properties info:", commentLine);
i += numAtoms;
continue;
}
const latticeVals = latticeMatch[1].split(/\s+/).map(parseFloat);
if (latticeVals.length !== 9) {
console.warn("Skipping frame: Lattice string malformed with", latticeVals.length, "values.");
i += numAtoms;
continue;
}
const v1_extxyz = new three.Vector3(latticeVals[0], latticeVals[1], latticeVals[2]);
const v2_extxyz = new three.Vector3(latticeVals[3], latticeVals[4], latticeVals[5]);
const v3_extxyz = new three.Vector3(latticeVals[6], latticeVals[7], latticeVals[8]);
const a = v1_extxyz.length();
const b = v2_extxyz.length();
const c = v3_extxyz.length();
const cos_gamma = Math.max(-1, Math.min(1, v1_extxyz.dot(v2_extxyz) / (a * b)));
const cos_beta = Math.max(-1, Math.min(1, v1_extxyz.dot(v3_extxyz) / (a * c)));
const cos_alpha = Math.max(-1, Math.min(1, v2_extxyz.dot(v3_extxyz) / (b * c)));
const alpha = Math.acos(cos_alpha);
const beta = Math.acos(cos_beta);
const gamma = Math.acos(cos_gamma);
const currentAtoms = [];
const currentSpecies = [];
for (let j = 0; j < numAtoms; j++) {
if (i >= lines.length) {
console.warn("Incomplete frame, reached end of file prematurely.");
break;
}
const atomLine = lines[i++].split(/\s+/).filter(Boolean);
if (atomLine.length < 4) {
console.warn("Skipping malformed atom line:", atomLine.join(' '));
continue;
}
const symbol = atomLine[0];
const x = parseFloat(atomLine[1]);
const y = parseFloat(atomLine[2]);
const z = parseFloat(atomLine[3]);
currentAtoms.push([x, y, z]);
currentSpecies.push(symbol);
}
frames.push({
cell: { a, b, c, alpha, beta, gamma },
latticeVectors: [v1_extxyz, v2_extxyz, v3_extxyz],
basis: currentAtoms,
species: currentSpecies
});
}
return frames;
}
const allCrystalsData = parseExtXYZ(extxyzContent);
if (allCrystalsData.length === 0) {
return html`<div style="color: #c00;">No valid crystal frames found in the EXTXYZ file.</div>`;
}
// --- Common Configuration ---
const aspectRatio = canvasWidth / canvasHeight;
const colorMap = {
H: { color: 0xFFFFFF, Z: 1 }, // white
He: { color: 0xDDA0DD, Z: 2 }, // plum (added)
Li: { color: 0x8B008B, Z: 3 }, // dark magenta (added)
Be: { color: 0x32CD32, Z: 4 }, // lime green (added)
B: { color: 0xFFD700, Z: 5 }, // gold (added)
C: { color: 0x222222, Z: 6 }, // dark gray
N: { color: 0x0000FF, Z: 7 }, // blue
O: { color: 0xFF0000, Z: 8 }, // red
F: { color: 0x00FF00, Z: 9 }, // green
Ne: { color: 0xFFA500, Z: 10 }, // orange (added)
Na: { color: 0x0000FF, Z: 11 }, // sodium blue
Mg: { color: 0x1E90FF, Z: 12 }, // dodger blue
Al: { color: 0xA9A9A9, Z: 13 }, // dark gray
Si: { color: 0xDAA520, Z: 14 }, // goldenrod
P: { color: 0xFFA500, Z: 15 }, // orange
S: { color: 0xFFFF00, Z: 16 }, // yellow
Cl: { color: 0x00FF00, Z: 17 }, // lime green
Ar: { color: 0xADD8E6, Z: 18 }, // light blue (added)
K: { color: 0x8B00FF, Z: 19 }, // violet
Ca: { color: 0x40E0D0, Z: 20 }, // turquoise
Sc: { color: 0x4B0082, Z: 21 }, // indigo (added)
Ti: { color: 0xB0C4DE, Z: 22 }, // light steel blue
V: { color: 0x228B22, Z: 23 }, // forest green
Cr: { color: 0x008080, Z: 24 }, // teal
Mn: { color: 0x800080, Z: 25 }, // purple
Fe: { color: 0xD2691E, Z: 26 }, // chocolate
Co: { color: 0x4169E1, Z: 27 }, // royal blue
Ni: { color: 0xA9A9A9, Z: 28 }, // dark gray
Cu: { color: 0xB87333, Z: 29 }, // copper
Zn: { color: 0x7FFFD4, Z: 30 }, // aquamarine
Ga: { color: 0xC0C0C0, Z: 31 }, // silver
Ge: { color: 0x696969, Z: 32 }, // dim gray
As: { color: 0x556B2F, Z: 33 }, // dark olive green
Se: { color: 0xFFA07A, Z: 34 }, // light salmon
Br: { color: 0xA52A2A, Z: 35 }, // brown
Kr: { color: 0xFFEFD5, Z: 36 }, // papaya whip (added)
Rb: { color: 0x9932CC, Z: 37 }, // dark orchid
Sr: { color: 0x00CED1, Z: 38 }, // dark turquoise
Y: { color: 0x708090, Z: 39 }, // slate gray
Zr: { color: 0x4682B4, Z: 40 }, // steel blue
Nb: { color: 0x2F4F4F, Z: 41 }, // dark slate gray
Mo: { color: 0x00FA9A, Z: 42 }, // medium spring green
Tc: { color: 0x8B0000, Z: 43 }, // dark red (added)
Ru: { color: 0x40E0D0, Z: 44 }, // turquoise (added)
Rh: { color: 0x8A2BE2, Z: 45 }, // blue violet (added)
Pd: { color: 0x708090, Z: 46 }, // slate gray (added)
Ag: { color: 0xC0C0C0, Z: 47 }, // silver
Cd: { color: 0xFFD700, Z: 48 }, // gold
In: { color: 0x7CFC00, Z: 49 }, // lawn green
Sn: { color: 0xD3D3D3, Z: 50 }, // light gray
Sb: { color: 0x8B4513, Z: 51 }, // saddle brown
Te: { color: 0x008B8B, Z: 52 }, // dark cyan
I: { color: 0x9400D3, Z: 53 }, // dark violet
Xe: { color: 0x87CEEB, Z: 54 }, // sky blue (added)
Cs: { color: 0xFF4500, Z: 55 }, // orange red
Ba: { color: 0x00FFEF, Z: 56 }, // cyan
La: { color: 0xADD8E6, Z: 57 }, // light blue
Ce: { color: 0x4682B4, Z: 58 }, // steel blue (added)
Pr: { color: 0x7FFFD4, Z: 59 }, // aquamarine (added)
Nd: { color: 0x00CED1, Z: 60 }, // dark turquoise (added)
Pm: { color: 0x9932CC, Z: 61 }, // dark orchid (added)
Sm: { color: 0x90EE90, Z: 62 }, // light green (added)
Eu: { color: 0xDAA520, Z: 63 }, // goldenrod (added)
Gd: { color: 0xFFA500, Z: 64 }, // orange (added)
Tb: { color: 0x228B22, Z: 65 }, // forest green (added)
Dy: { color: 0x66CDAA, Z: 66 }, // medium aquamarine (added)
Ho: { color: 0x800080, Z: 67 }, // purple (added)
Er: { color: 0xD2691E, Z: 68 }, // chocolate (added)
Tm: { color: 0x4169E1, Z: 69 }, // royal blue (added)
Yb: { color: 0xA9A9A9, Z: 70 }, // dark gray (added)
Lu: { color: 0xB87333, Z: 71 }, // copper (added)
Hf: { color: 0x4682B4, Z: 72 }, // steel blue
Ta: { color: 0x00FFFF, Z: 73 }, // cyan
W: { color: 0xDAA520, Z: 74 }, // goldenrod
Re: { color: 0xFFDAB9, Z: 75 }, // peach puff
Os: { color: 0x1E90FF, Z: 76 }, // dodger blue
Ir: { color: 0xBA55D3, Z: 77 }, // medium orchid
Pt: { color: 0xE5E4E2, Z: 78 }, // platinum
Au: { color: 0xFFD700, Z: 79 }, // gold
Hg: { color: 0xB0C4DE, Z: 80 }, // light steel blue
Tl: { color: 0x696969, Z: 81 }, // dim gray (added)
Pb: { color: 0x708090, Z: 82 }, // slate gray
Bi: { color: 0x8B0000, Z: 83 }, // dark red
Po: { color: 0x8B008B, Z: 84 }, // dark magenta
At: { color: 0xA52A2A, Z: 85 }, // brown (added)
Rn: { color: 0xFFDAB9, Z: 86 }, // peach puff (added)
Fr: { color: 0xFF4500, Z: 87 }, // orange red (added)
Ra: { color: 0x00FFEF, Z: 88 }, // cyan (added)
Ac: { color: 0xADD8E6, Z: 89 } // light blue (added)
};
// --- Material Definitions (Shared across all viewers) ---
const createSpeciesMaterials = (baseColor, exteriorPlanes, interiorPlanes) => ({
outside: new three.MeshStandardMaterial({
color: baseColor, transparent: true, opacity: 0.2, side: three.DoubleSide,
clippingPlanes: exteriorPlanes, clipIntersection: true, depthWrite: false
}),
inside: new three.MeshStandardMaterial({
color: baseColor, transparent: true, opacity: 0.6, side: three.DoubleSide,
clippingPlanes: interiorPlanes, clipIntersection: false, depthWrite: true
})
});
// --- Helper to Create Corners ---
function createCorners(scaleVectors, signs) {
const corners = [];
signs.forEach(s1 => signs.forEach(s2 => signs.forEach(s3 => {
corners.push(
new three.Vector3()
.addScaledVector(scaleVectors[0], s1)
.addScaledVector(scaleVectors[1], s2)
.addScaledVector(scaleVectors[2], s3)
);
})));
return corners;
}
// --- Helper to create Text Sprites for Legend ---
const createTextSprite = (message, color, fontSize = 36) => {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const font = `Bold ${fontSize}px system-ui, sans-serif`;
context.font = font;
const metrics = context.measureText(message);
const textWidth = metrics.width;
const textHeight = fontSize * 1.2;
canvas.width = textWidth + 20;
canvas.height = textHeight + 20;
context.font = font;
context.fillStyle = `#${color.getHexString()}`;
context.textBaseline = 'top';
context.fillText(message, 10, 10);
const texture = new three.CanvasTexture(canvas);
texture.minFilter = three.LinearFilter;
texture.needsUpdate = true;
const spriteMaterial = new three.SpriteMaterial({ map: texture, transparent: true });
const sprite = new three.Sprite(spriteMaterial);
sprite.scale.set(canvas.width / canvas.height * 0.8 * 1.5, 0.8 * 1.5, 1);
return sprite;
};
// --- Array to hold all viewer instances and their HTML containers ---
const viewerInstances = [];
let currentActiveViewerIndex = 0;
// --- Create a viewer for each crystal frame ---
allCrystalsData.forEach((crystalData, index) => {
const { cell, latticeVectors, basis, species } = crystalData;
const { a, b, c } = cell;
// --- Scene Setup for EACH viewer ---
const scene = new three.Scene();
const camera = new three.PerspectiveCamera(45, aspectRatio, 0.1, 1000);
const renderer = new three.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(canvasWidth, canvasHeight);
renderer.setPixelRatio(devicePixelRatio);
renderer.setClearColor(0xffffff, 1);
renderer.localClippingEnabled = true;
const viewerContainer = html`<div style="background:white; width:${canvasWidth}px; height:${canvasHeight}px; border: 1px solid #eee; position: relative; ${index === 0 ? '' : 'display: none;'}"></div>`;
viewerContainer.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enablePan = false; controls.enableZoom = true;
controls.minPolarAngle = 0; controls.maxPolarAngle = Math.PI / 2;
controls.enableDamping = true;
controls.dampingFactor = 0.1;
scene.add(new three.AmbientLight(0xffffff, 0.5));
// --- Directional Light Setup ---
const dl = new three.DirectionalLight(0xffffff, 0.8);
dl.position.set(-10, -30, -20);
scene.add(dl);
const fillLight = new three.DirectionalLight(0xffffff, 0.4);
fillLight.position.set(10, 30, 20);
scene.add(fillLight);
// Groups unique to this viewer instance
const atomsGroup = new three.Group();
const cellEdgesGroup = new three.Group();
const supercellEdgesGroup = new three.Group();
scene.add(atomsGroup, cellEdgesGroup, supercellEdgesGroup);
// --- Lattice Setup ---
const scaled = latticeVectors;
const scaledLengths = scaled.map(v => v.length());
// --- Clipping Planes ---
const planes = [];
for (let i = 0; i < 3; i++) {
const normal = scaled[(i + 1) % 3].clone().cross(scaled[(i + 2) % 3]).normalize();
const point1 = new three.Vector3(0,0,0);
const point2 = scaled[i].clone();
planes.push(new three.Plane().setFromNormalAndCoplanarPoint(normal, point2));
planes.push(new three.Plane().setFromNormalAndCoplanarPoint(normal.clone().negate(), point1));
}
const exteriorPlanes = planes;
const interiorPlanes = planes.map(p => p.clone().negate());
// --- Materials (per species, based on current crystal's planes) ---
const speciesMats = {};
species.forEach(sp => {
if (!speciesMats[sp]) {
const baseColor = new three.Color(colorMap[sp]?.color ?? 0x008080);
speciesMats[sp] = createSpeciesMaterials(baseColor, exteriorPlanes, interiorPlanes);
}
});
// --- Atoms in Supercell ---
for (let ix = 0; ix < Nx; ix++) {
for (let iy = 0; iy < Ny; iy++) {
for (let iz = 0; iz < Nz; iz++) {
basis.forEach(([x, y, z], i) => {
const atomOriginalCartesian = new three.Vector3(x,y,z);
const pos = atomOriginalCartesian
.clone()
.add(scaled[0].clone().multiplyScalar(ix))
.add(scaled[1].clone().multiplyScalar(iy))
.add(scaled[2].clone().multiplyScalar(iz));
const sp = species[i];
const mats = speciesMats[sp];
const Z = colorMap[sp]?.Z || 20;
const r = radius * (0.5 + 0.5 * (1 - Math.exp(-Z / 20)));
const geo = new three.SphereGeometry(r, 32, 32);
const outMesh = new three.Mesh(geo, mats.outside);
outMesh.position.copy(pos); outMesh.renderOrder = 0; atomsGroup.add(outMesh);
const inMesh = new three.Mesh(geo, mats.inside);
inMesh.position.copy(pos); inMesh.renderOrder = 1; atomsGroup.add(inMesh);
});
}
}
}
// --- Unit cell edges ---
const unitCellCorners = createCorners(scaled, [0, 1]);
const edgeMat = new three.LineBasicMaterial({ color: 0x222222, depthTest: true });
unitCellCorners.forEach((c1, i) =>
unitCellCorners.slice(i + 1).forEach(c2 => {
const d = c1.distanceTo(c2);
if (Math.abs(d - scaledLengths[0]) < 1e-6 || Math.abs(d - scaledLengths[1]) < 1e-6 || Math.abs(d - scaledLengths[2]) < 1e-6) {
const line = new three.Line(new three.BufferGeometry().setFromPoints([c1, c2]), edgeMat);
line.renderOrder = 2; cellEdgesGroup.add(line);
}
})
);
// --- Supercell edges ---
const supercellCenter = new three.Vector3().addScaledVector(scaled[0], Nx / 2).addScaledVector(scaled[1], Ny / 2).addScaledVector(scaled[2], Nz / 2);
const superCornersCentered = createCorners(
[scaled[0].clone().multiplyScalar(Nx / 2), scaled[1].clone().multiplyScalar(Ny / 2), scaled[2].clone().multiplyScalar(Nz / 2)], [-1, 1]
);
const superCorners = superCornersCentered.map(c => c.add(supercellCenter));
const superMat = new three.LineBasicMaterial({ color: 0xcccccc, depthTest: false });
superCorners.forEach((c1, i) =>
superCorners.slice(i + 1).forEach(c2 => {
const d = c1.distanceTo(c2);
if (Math.abs(d - scaledLengths[0] * Nx) < 1e-6 || Math.abs(d - scaledLengths[1] * Ny) < 1e-6 || Math.abs(d - scaledLengths[2] * Nz) < 1e-6) {
const superLine = new three.Line(new three.BufferGeometry().setFromPoints([c1, c2]), superMat);
superLine.renderOrder = 1; supercellEdgesGroup.add(superLine);
}
})
);
// --- Camera Positioning for (-3, -1, 0) direction ---
const unitCellCenter = new three.Vector3().addScaledVector(scaled[0], 0.5).addScaledVector(scaled[1], 0.5).addScaledVector(scaled[2], 0.5);
controls.target.copy(unitCellCenter); // Still look at the center of the first unit cell
const viewDirection = new three.Vector3(-3, -1, 0).normalize(); // Normalized view direction
const unitCellMaxDim = Math.max(a, b, c);
camera.position.copy(unitCellCenter).add(viewDirection.multiplyScalar(unitCellMaxDim * 1.5)); // Zoomed onto unit cell
camera.lookAt(unitCellCenter);
// --- HTML Legend (REINSTATED AND POSITIONED WITHIN VIEWER CONTAINER) ---
const legendDiv = html`<div style="position: absolute; top: 10px; right: 10px; background-color: rgba(255, 255, 255, 0.8); border: 1px solid #ccc; padding: 10px; border-radius: 5px; font-family: system-ui, sans-serif; pointer-events: none; /* Allows mouse events to pass through */">
<h4 style="margin-top: 0; margin-bottom: 10px;">Elements</h4>
<ul style="list-style: none; padding: 0; margin: 0; font-size: 14px;"></ul>
</div>`;
const legendList = legendDiv.querySelector('ul');
// Sort by Atomic Number (Z)
const uniqueSpeciesForLegend = [...new Set(species)].sort((spA, spB) => {
const ZA = colorMap[spA]?.Z || Infinity;
const ZB = colorMap[spB]?.Z || Infinity;
return ZA - ZB;
});
uniqueSpeciesForLegend.forEach(sp => {
const colorData = colorMap[sp] ?? { color: 0x008080 };
const cssColor = '#' + new three.Color(colorData.color).getHexString();
const listItem = html`<li style="display: flex; align-items: center; margin-bottom: 6px;">
<span style="width: 16px; height: 16px; border-radius: 50%; background-color: ${cssColor}; margin-right: 10px; border: 1px solid #ccc;"></span>
<span>${sp}</span>
</li>`;
legendList.appendChild(listItem);
});
viewerContainer.appendChild(legendDiv); // Add this HTML legend to the current viewer's container
// Store this viewer instance's data
viewerInstances.push({
container: viewerContainer,
renderer: renderer,
scene: scene,
camera: camera,
controls: controls,
animationFrameId: null,
animate: () => {
// No try-catch in animate loop as we're reverting to a stable version
controls.update();
renderer.render(scene, camera);
viewerInstances[index].animationFrameId = requestAnimationFrame(viewerInstances[index].animate);
}
});
}); // End of forEach crystalData
// --- UI for Tabs ---
const tabsContainer = html`
<div style="
display: flex;
justify-content: start;
border-bottom: 1px solid #ccc;
margin-bottom: 10px;
flex-wrap: wrap;
position: relative;
z-index: 2;
"></div>`;
const viewerContainersWrapper = html`
<div style="
position: relative;
z-index: 1;
"></div>`;
let activeViewer = null;
let activeTabButton = null;
const switchTab = (index) => {
// Hide current viewer and deactivate its tab
if (activeViewer) {
activeViewer.container.style.display = 'none';
cancelAnimationFrame(activeViewer.animationFrameId);
// Removed controls.stop() as it might not be needed or cause other issues
if (activeTabButton) {
activeTabButton.style.backgroundColor = '#f0f0f0';
activeTabButton.style.color = '#333';
activeTabButton.style.fontWeight = 'normal';
activeTabButton.style.borderBottom = '1px solid #ccc';
}
}
// Show new viewer and activate its tab
activeViewer = viewerInstances[index];
activeViewer.container.style.display = 'block';
currentActiveViewerIndex = index; // Keep track of the active index if needed
activeViewer.controls.update(); // Update controls for new view immediately
activeViewer.animationFrameId = requestAnimationFrame(activeViewer.animate); // Start its animation loop
// Update tab button styling
activeTabButton = tabsContainer.children[index];
activeTabButton.style.backgroundColor = '#fff';
activeTabButton.style.color = '#000';
activeTabButton.style.fontWeight = 'bold';
activeTabButton.style.borderBottom = '1px solid #fff';
};
allCrystalsData.forEach((crystalData, idx) => {
// Generate tab name: unique sorted species by Atomic Number (Z)
const tabName = [...new Set(crystalData.species)].sort((spA, spB) => {
const ZA = colorMap[spA]?.Z || Infinity;
const ZB = colorMap[spB]?.Z || Infinity;
return ZA - ZB;
}).join('-');
const button = document.createElement('button'); // Create button imperatively
button.textContent = tabName; // Set text content
button.style.cssText = `
padding: 10px 15px;
border: 1px solid #ccc;
border-bottom: 1px solid #ccc;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
cursor: pointer;
background: #f0f0f0;
font-family: system-ui, sans-serif;
margin-right: 2px;
position: relative;
bottom: -1px;
outline: none;
color: #333;
font-weight: normal;
`;
// Imperatively add event listener
button.addEventListener('click', () => switchTab(idx));
tabsContainer.appendChild(button);
viewerContainersWrapper.appendChild(viewerInstances[idx].container);
});
// --- Initial Display ---
switchTab(0);
// --- Cleanup on invalidation ---
// This cleans up all renderers and controls when the script is re-run (e.g., in Observable)
// or when the page is unloaded (in a browser).
invalidation.then(() => {
viewerInstances.forEach(viewer => {
cancelAnimationFrame(viewer.animationFrameId); // Ensure all loops are stopped
viewer.renderer.dispose();
viewer.controls.dispose();
});
});
// Return a wrapper that contains both the tabs and the 3D viewers
return html`<div>${tabsContainer}${viewerContainersWrapper}</div>`;
};