noddyverse-explorer / index.html
AshkanTaghipour's picture
Upload index.html with huggingface_hub
e4a93cc verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Noddyverse Explorer</title>
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap');
/* ── Splash screen (dark, with fluid) ── */
#splash{position:fixed;top:0;left:0;width:100%;height:100%;z-index:200;background:#070410;
display:flex;align-items:center;justify-content:center;flex-direction:column;cursor:pointer;
transition:opacity 0.8s ease;font-family:'Space Grotesk','DM Sans',system-ui,sans-serif}
#splash.hidden{opacity:0;pointer-events:none}
#fluid-canvas{position:fixed;top:0;left:0;width:100%;height:100%;z-index:201;pointer-events:none}
#splash-content{position:relative;z-index:202;text-align:center;pointer-events:none;max-width:660px;padding:0 28px}
#splash-content h1{font-family:'Space Grotesk',sans-serif;font-size:3.4rem;font-weight:700;
background:linear-gradient(135deg,#c084fc,#e0b0ff,#a78bfa);
-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:20px;letter-spacing:-0.03em;
text-shadow:0 0 60px rgba(168,85,247,0.3)}
#splash-content p{font-family:'DM Sans',sans-serif;font-size:1.1rem;color:#d4d0e0;line-height:1.7;margin-bottom:36px;font-weight:400}
#splash-content .cta{font-family:'Space Grotesk',sans-serif;font-size:0.9rem;color:#e0b0ff;
letter-spacing:0.12em;text-transform:uppercase;font-weight:600;
animation:pulse 2.5s ease-in-out infinite;
border:1px solid rgba(168,85,247,0.3);padding:10px 28px;border-radius:40px;display:inline-block;
background:rgba(168,85,247,0.08)}
@keyframes pulse{0%,100%{opacity:0.6;transform:scale(1)}50%{opacity:1;transform:scale(1.02)}}
/* ── App (light theme) ── */
:root{--bg:#f8f9fc;--surface:#ffffff;--surface2:#f0f1f5;--border:#e2e4ea;
--text:#1a1a2e;--text2:#6b7084;--accent:#7c3aed;--accent2:#a855f7}
html,body{height:100%;overflow:hidden;font-family:'DM Sans','Space Grotesk',system-ui,sans-serif;background:var(--bg);color:var(--text)}
@media(max-width:768px){html,body{overflow:auto}}
#app{position:relative;z-index:1;display:flex;height:100vh;opacity:0;transition:opacity 0.6s ease 0.4s}
#app.visible{opacity:1}
/* Sidebar */
#sidebar{width:270px;min-width:270px;background:var(--surface);
border-right:1px solid var(--border);padding:20px;display:flex;flex-direction:column;gap:14px;overflow-y:auto}
#sidebar h1{font-family:'Space Grotesk',sans-serif;font-size:1.25rem;font-weight:700;background:linear-gradient(135deg,var(--accent),var(--accent2));
-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:-0.02em}
#sidebar p.sub{font-size:0.72rem;color:var(--text2);line-height:1.6;margin-top:2px}
.control-group{display:flex;flex-direction:column;gap:5px}
.control-group label{font-size:0.65rem;text-transform:uppercase;letter-spacing:0.08em;color:var(--text2);font-weight:600}
select{background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:8px;
padding:9px 32px 9px 10px;font-size:0.82rem;cursor:pointer;outline:none;transition:border-color 0.2s;
appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%236b7084'%3E%3Cpath d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat:no-repeat;background-position:right 10px center}
select:hover,select:focus{border-color:var(--accent)}
.info-card{background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:12px;font-size:0.72rem;line-height:1.6}
.info-card .label{color:var(--text2);font-size:0.62rem;text-transform:uppercase;letter-spacing:0.06em}
.info-card .value{color:var(--text);font-weight:600}
.legend{display:flex;flex-direction:column;gap:4px;margin-top:6px}
.legend-item{display:flex;align-items:center;gap:6px;font-size:0.66rem;color:var(--text);line-height:1.3}
.legend-swatch{width:12px;height:12px;border-radius:3px;border:1px solid rgba(0,0,0,0.12);flex-shrink:0}
.credits{font-size:0.58rem;color:var(--text2);line-height:1.5;margin-top:auto;padding-top:8px;border-top:1px solid var(--border)}
/* Loading */
#loading-bar{position:absolute;top:0;left:0;width:0%;height:3px;background:linear-gradient(90deg,var(--accent),var(--accent2));
z-index:50;transition:width 0.3s;border-radius:0 2px 2px 0}
#loading-bar.hidden{opacity:0}
/* Main content */
#main{flex:1;display:flex;flex-direction:column;min-width:0;position:relative}
#viewer-container{flex:1;position:relative;min-height:0;background:var(--surface)}
#three-canvas{width:100%;height:100%;display:block}
#viewer-label{position:absolute;top:10px;left:14px;font-size:0.68rem;color:var(--text2);
background:rgba(255,255,255,0.85);padding:3px 10px;border-radius:6px;backdrop-filter:blur(8px);pointer-events:none;
border:1px solid var(--border)}
#fields-panel{height:210px;min-height:210px;display:flex;border-top:1px solid var(--border);background:var(--surface)}
.field-container{flex:1;padding:10px 14px;display:flex;flex-direction:column;position:relative}
.field-container+.field-container{border-left:1px solid var(--border)}
.field-title{font-family:'Space Grotesk',sans-serif;font-size:0.82rem;color:var(--text);letter-spacing:0.03em;margin-bottom:6px;font-weight:600}
.field-canvas-wrap{flex:1;position:relative;display:flex;align-items:center;justify-content:center}
.field-canvas-wrap canvas{max-width:100%;max-height:100%;border-radius:4px;border:1px solid var(--border)}
.colorbar{position:absolute;right:2px;top:0;bottom:0;width:16px;display:flex;flex-direction:column;
align-items:center;justify-content:space-between;font-size:0.52rem;color:var(--text2)}
.colorbar-gradient{width:8px;flex:1;border-radius:3px;margin:2px 0;border:1px solid var(--border)}
/* ── Mobile / Responsive ── */
@media(max-width:768px){
/* Splash */
#splash-content h1{font-size:2rem}
#splash-content p{font-size:0.9rem;margin-bottom:24px}
#splash-content .cta{font-size:0.78rem;padding:8px 22px}
/* App: vertical layout, calc-based so everything fits in viewport */
#app{flex-direction:column;height:100vh;overflow:hidden}
/* Sidebar β†’ compact top panel */
#sidebar{width:100%;min-width:unset;flex-direction:column;gap:8px;padding:10px 14px;
border-right:none;border-bottom:1px solid var(--border);flex-shrink:0}
#sidebar h1{font-size:1rem}
#sidebar p.sub{display:none}
.control-group{flex-direction:row;align-items:center;gap:8px}
.control-group label{min-width:fit-content;margin:0;white-space:nowrap}
select{flex:1;min-width:0;font-size:0.78rem;padding:7px 30px 7px 8px}
/* Hide info card on mobile to save space */
.info-card{display:none}
.credits{display:none}
/* 3D viewer takes remaining space minus fields */
#main{flex:1;min-height:0;display:flex;flex-direction:column}
#viewer-container{flex:1;min-height:0}
#viewer-label{font-size:0.6rem;top:6px;left:8px;padding:2px 8px}
/* Fields panel: fixed height, side by side */
#fields-panel{height:200px;min-height:200px;max-height:200px;flex-shrink:0}
.field-title{font-size:0.7rem;margin-bottom:3px}
.field-canvas-wrap canvas{max-height:150px}
}
/* Small phones */
@media(max-width:480px){
#splash-content h1{font-size:1.6rem}
#splash-content p{font-size:0.8rem}
#sidebar{padding:8px 10px;gap:6px}
.control-group{flex-direction:column;gap:3px}
#fields-panel{height:170px;min-height:170px;max-height:170px}
.field-canvas-wrap canvas{max-height:125px}
}
</style>
</head>
<body>
<!-- ============ SPLASH SCREEN ============ -->
<div id="splash">
<canvas id="fluid-canvas"></canvas>
<div id="splash-content">
<h1>Noddyverse Explorer</h1>
<p>Explore 3D synthetic geological models with interactive gravity and magnetic field visualizations.
Built from the Noddyverse dataset β€” one million models generated with the Noddy modelling engine.</p>
<div class="cta">Click anywhere to explore</div>
</div>
</div>
<!-- ============ MAIN APP ============ -->
<div id="app">
<div id="loading-bar"></div>
<div id="sidebar">
<div>
<h1>Noddyverse Explorer</h1>
<p class="sub">Drag to rotate the 3D model. Scroll to zoom. Select different geology types below.</p>
</div>
<div class="control-group">
<label>Geology Type</label>
<select id="type-select"></select>
</div>
<div class="control-group">
<label>Sample</label>
<select id="sample-select"></select>
</div>
<div class="info-card" id="info-card">
<div><span class="label">Lithology classes: </span><span class="value" id="info-classes">β€”</span></div>
<div style="margin-top:6px"><span class="label">Legend</span>
<div class="legend" id="legend"></div>
</div>
</div>
<div class="credits">
Data: Noddyverse (Jessell et al., 2022)<br>
DOI: 10.25914/p91d-x082 &middot; License: CC-BY 4.0
</div>
</div>
<div id="main">
<div id="viewer-container">
<canvas id="three-canvas"></canvas>
<div id="viewer-label">3D Geology Model β€” surface voxels colored by lithology</div>
</div>
<div id="fields-panel">
<div class="field-container">
<div class="field-title">Gravity Field (mGal)</div>
<div class="field-canvas-wrap">
<canvas id="grv-canvas"></canvas>
<div class="colorbar"><span id="grv-max"></span><div class="colorbar-gradient" id="grv-bar"></div><span id="grv-min"></span></div>
</div>
</div>
<div class="field-container">
<div class="field-title">Magnetic Field (nT)</div>
<div class="field-canvas-wrap">
<canvas id="mag-canvas"></canvas>
<div class="colorbar"><span id="mag-max"></span><div class="colorbar-gradient" id="mag-bar"></div><span id="mag-min"></span></div>
</div>
</div>
</div>
</div>
</div>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.163.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.163.0/examples/jsm/"}}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// ============================================================
// SPLASH β†’ APP TRANSITION
// ============================================================
const splash = document.getElementById('splash');
const app = document.getElementById('app');
let appStarted = false;
splash.addEventListener('click', () => {
if (appStarted) return;
appStarted = true;
splash.classList.add('hidden');
app.classList.add('visible');
// Stop fluid rendering after transition
setTimeout(() => { fluidRunning = false; }, 1000);
loadManifest();
});
// ============================================================
// FLUID SIMULATION (WebGL, runs only on splash)
// ============================================================
let fluidRunning = true;
const FluidSim = (() => {
const canvas = document.getElementById('fluid-canvas');
const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false });
if (!gl) return { init(){}, loop(){} };
const ext = gl.getExtension('OES_texture_half_float');
gl.getExtension('OES_texture_half_float_linear');
const halfFloat = ext ? ext.HALF_FLOAT_OES : gl.UNSIGNED_BYTE;
let simW = 128, simH = 128, dyeW = 512, dyeH = 512;
let pointer = { x: 0.5, y: 0.5, dx: 0, dy: 0, moved: false };
function resize() {
canvas.width = canvas.clientWidth; canvas.height = canvas.clientHeight;
const a = canvas.width / canvas.height;
simH = Math.round(128 / a); dyeH = Math.round(512 / a);
}
function compileShader(type, src) {
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return s;
}
function createProgram(vs, fs) {
const p = gl.createProgram();
gl.attachShader(p, compileShader(gl.VERTEX_SHADER, vs));
gl.attachShader(p, compileShader(gl.FRAGMENT_SHADER, fs));
gl.linkProgram(p);
const u = {}, n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < n; i++) { const info = gl.getActiveUniform(p, i); u[info.name] = gl.getUniformLocation(p, info.name); }
return { program: p, uniforms: u };
}
const baseVS = `attribute vec2 aPosition;varying vec2 vUv;varying vec2 vL;varying vec2 vR;varying vec2 vT;varying vec2 vB;uniform vec2 texelSize;
void main(){vUv=aPosition*0.5+0.5;vL=vUv-vec2(texelSize.x,0);vR=vUv+vec2(texelSize.x,0);
vT=vUv+vec2(0,texelSize.y);vB=vUv-vec2(0,texelSize.y);gl_Position=vec4(aPosition,0,1);}`;
const splatFS = `precision highp float;varying vec2 vUv;uniform sampler2D uTarget;uniform float aspectRatio;
uniform vec3 color;uniform vec2 point;uniform float radius;
void main(){vec2 p=vUv-point;p.x*=aspectRatio;vec3 s=exp(-dot(p,p)/radius)*color;
gl_FragColor=vec4(texture2D(uTarget,vUv).xyz+s,1.0);}`;
const advFS = `precision highp float;varying vec2 vUv;uniform sampler2D uVelocity;uniform sampler2D uSource;
uniform vec2 texelSize;uniform float dt;uniform float dissipation;
void main(){vec2 c=vUv-dt*texture2D(uVelocity,vUv).xy*texelSize;gl_FragColor=dissipation*texture2D(uSource,c);gl_FragColor.a=1.0;}`;
const divFS = `precision highp float;varying vec2 vL;varying vec2 vR;varying vec2 vT;varying vec2 vB;uniform sampler2D uVelocity;
void main(){float L=texture2D(uVelocity,vL).x;float R=texture2D(uVelocity,vR).x;
float T=texture2D(uVelocity,vT).y;float B=texture2D(uVelocity,vB).y;gl_FragColor=vec4(0.5*(R-L+T-B),0,0,1);}`;
const pressFS = `precision highp float;varying vec2 vUv;varying vec2 vL;varying vec2 vR;varying vec2 vT;varying vec2 vB;
uniform sampler2D uPressure;uniform sampler2D uDivergence;
void main(){float L=texture2D(uPressure,vL).x;float R=texture2D(uPressure,vR).x;
float T=texture2D(uPressure,vT).x;float B=texture2D(uPressure,vB).x;
float d=texture2D(uDivergence,vUv).x;gl_FragColor=vec4((L+R+B+T-d)*0.25,0,0,1);}`;
const gradFS = `precision highp float;varying vec2 vUv;varying vec2 vL;varying vec2 vR;varying vec2 vT;varying vec2 vB;
uniform sampler2D uPressure;uniform sampler2D uVelocity;
void main(){float L=texture2D(uPressure,vL).x;float R=texture2D(uPressure,vR).x;
float T=texture2D(uPressure,vT).x;float B=texture2D(uPressure,vB).x;
vec2 v=texture2D(uVelocity,vUv).xy-vec2(R-L,T-B);gl_FragColor=vec4(v,0,1);}`;
const curlFS = `precision highp float;varying vec2 vL;varying vec2 vR;varying vec2 vT;varying vec2 vB;uniform sampler2D uVelocity;
void main(){float L=texture2D(uVelocity,vL).y;float R=texture2D(uVelocity,vR).y;
float T=texture2D(uVelocity,vT).x;float B=texture2D(uVelocity,vB).x;gl_FragColor=vec4(0.5*((R-L)-(T-B)),0,0,1);}`;
const vortFS = `precision highp float;varying vec2 vUv;varying vec2 vL;varying vec2 vR;varying vec2 vT;varying vec2 vB;
uniform sampler2D uVelocity;uniform sampler2D uCurl;uniform float curl;uniform vec2 texelSize;
void main(){float L=texture2D(uCurl,vL).x;float R=texture2D(uCurl,vR).x;
float T=texture2D(uCurl,vT).x;float B=texture2D(uCurl,vB).x;float C=texture2D(uCurl,vUv).x;
vec2 f=0.5*vec2(abs(T)-abs(B),abs(R)-abs(L));f/=length(f)+1e-5;f*=curl*C;f.y*=-1.0;
gl_FragColor=vec4(texture2D(uVelocity,vUv).xy+f*texelSize,0,1);}`;
const dispFS = `precision highp float;varying vec2 vUv;uniform sampler2D uTexture;
void main(){vec3 c=texture2D(uTexture,vUv).rgb;gl_FragColor=vec4(c,length(c)*0.7);}`;
let splatP, advP, divP, pressP, gradP, dispP, curlP, vortP;
let velocity, pressure, divergence, dye, curlFBO;
function createFBO(w, h) {
const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, halfFloat, null);
const fb = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0);
gl.viewport(0, 0, w, h); gl.clear(gl.COLOR_BUFFER_BIT);
return { texture: t, fb, w, h };
}
function createDoubleFBO(w, h) {
let a = createFBO(w, h), b = createFBO(w, h);
return { get read(){ return a; }, get write(){ return b; }, swap(){ const t=a;a=b;b=t; } };
}
function blit(tgt) {
if (tgt) { gl.bindFramebuffer(gl.FRAMEBUFFER, tgt.fb); gl.viewport(0,0,tgt.w,tgt.h); }
else { gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0,0,canvas.width,canvas.height); }
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
function init() {
resize();
const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
splatP=createProgram(baseVS,splatFS); advP=createProgram(baseVS,advFS);
divP=createProgram(baseVS,divFS); pressP=createProgram(baseVS,pressFS);
gradP=createProgram(baseVS,gradFS); dispP=createProgram(baseVS,dispFS);
curlP=createProgram(baseVS,curlFS); vortP=createProgram(baseVS,vortFS);
velocity=createDoubleFBO(simW,simH); pressure=createDoubleFBO(simW,simH);
divergence=createFBO(simW,simH); curlFBO=createFBO(simW,simH); dye=createDoubleFBO(dyeW,dyeH);
}
function doSplat(x, y, dx, dy, color) {
gl.useProgram(splatP.program);
gl.uniform1i(splatP.uniforms.uTarget,0); gl.uniform1f(splatP.uniforms.aspectRatio,canvas.width/canvas.height);
gl.uniform2f(splatP.uniforms.point,x,y); gl.uniform3f(splatP.uniforms.color,dx,dy,0);
gl.uniform1f(splatP.uniforms.radius,0.0003);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,velocity.read.texture);
blit(velocity.write); velocity.swap();
gl.uniform3fv(splatP.uniforms.color,color); gl.uniform1f(splatP.uniforms.radius,0.005);
gl.bindTexture(gl.TEXTURE_2D,dye.read.texture); blit(dye.write); dye.swap();
}
function step(dt) {
gl.disable(gl.BLEND);
const sTS=[1/simW,1/simH], dTS=[1/dyeW,1/dyeH];
// Curl
gl.useProgram(curlP.program); gl.uniform2fv(curlP.uniforms.texelSize,sTS);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,velocity.read.texture);
gl.uniform1i(curlP.uniforms.uVelocity,0); blit(curlFBO);
// Vorticity
gl.useProgram(vortP.program); gl.uniform2fv(vortP.uniforms.texelSize,sTS); gl.uniform1f(vortP.uniforms.curl,20);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,velocity.read.texture); gl.uniform1i(vortP.uniforms.uVelocity,0);
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,curlFBO.texture); gl.uniform1i(vortP.uniforms.uCurl,1);
blit(velocity.write); velocity.swap();
// Advect velocity
gl.useProgram(advP.program); gl.uniform2fv(advP.uniforms.texelSize,sTS);
gl.uniform1f(advP.uniforms.dt,dt); gl.uniform1f(advP.uniforms.dissipation,1.0);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,velocity.read.texture); gl.uniform1i(advP.uniforms.uVelocity,0);
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,velocity.read.texture); gl.uniform1i(advP.uniforms.uSource,1);
blit(velocity.write); velocity.swap();
// Advect dye
gl.uniform2fv(advP.uniforms.texelSize,dTS); gl.uniform1f(advP.uniforms.dissipation,0.97);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,velocity.read.texture);
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,dye.read.texture); gl.uniform1i(advP.uniforms.uSource,1);
blit(dye.write); dye.swap();
// Divergence
gl.useProgram(divP.program); gl.uniform2fv(divP.uniforms.texelSize,sTS);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,velocity.read.texture); gl.uniform1i(divP.uniforms.uVelocity,0);
blit(divergence);
// Pressure
gl.useProgram(pressP.program); gl.uniform2fv(pressP.uniforms.texelSize,sTS);
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,divergence.texture); gl.uniform1i(pressP.uniforms.uDivergence,1);
for(let i=0;i<6;i++){gl.activeTexture(gl.TEXTURE0);gl.bindTexture(gl.TEXTURE_2D,pressure.read.texture);
gl.uniform1i(pressP.uniforms.uPressure,0);blit(pressure.write);pressure.swap();}
// Gradient subtract
gl.useProgram(gradP.program); gl.uniform2fv(gradP.uniforms.texelSize,sTS);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,pressure.read.texture); gl.uniform1i(gradP.uniforms.uPressure,0);
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,velocity.read.texture); gl.uniform1i(gradP.uniforms.uVelocity,1);
blit(velocity.write); velocity.swap();
// Display
gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
gl.useProgram(dispP.program); gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D,dye.read.texture); gl.uniform1i(dispP.uniforms.uTexture,0); blit(null);
}
let lastT = 0;
function loop(time) {
if (!fluidRunning) return;
const dt = Math.min((time - lastT) / 1000, 0.016); lastT = time;
if (pointer.moved) {
doSplat(pointer.x, pointer.y, pointer.dx*8, pointer.dy*8, [0.15, 0.0, 0.6]);
pointer.moved = false;
}
step(dt || 0.016);
requestAnimationFrame(loop);
}
window.addEventListener('resize', () => {
if (!fluidRunning) return;
resize(); velocity=createDoubleFBO(simW,simH); pressure=createDoubleFBO(simW,simH);
divergence=createFBO(simW,simH); curlFBO=createFBO(simW,simH); dye=createDoubleFBO(dyeW,dyeH);
});
document.addEventListener('mousemove', e => {
const x=e.clientX/canvas.width, y=1-e.clientY/canvas.height;
pointer.dx=(x-pointer.x)*10; pointer.dy=(y-pointer.y)*10;
pointer.x=x; pointer.y=y; pointer.moved=true;
});
document.addEventListener('touchmove', e => {
e.preventDefault(); const t=e.touches[0];
const x=t.clientX/canvas.width, y=1-t.clientY/canvas.height;
pointer.dx=(x-pointer.x)*10; pointer.dy=(y-pointer.y)*10;
pointer.x=x; pointer.y=y; pointer.moved=true;
}, { passive: false });
return { init, loop };
})();
FluidSim.init();
requestAnimationFrame(FluidSim.loop);
// ============================================================
// COLORS
// ============================================================
const LITH_COLORS = [
null,
[0.26,0.52,0.96],[0.94,0.40,0.27],[0.30,0.78,0.47],[0.90,0.70,0.20],
[0.70,0.35,0.85],[0.20,0.82,0.82],[0.92,0.50,0.70],[0.55,0.75,0.30],
[0.98,0.60,0.40],[0.45,0.55,0.90],[0.80,0.80,0.30],[0.40,0.70,0.70],
[0.85,0.45,0.55],[0.60,0.60,0.40],[0.55,0.40,0.70],[0.75,0.55,0.35],
[0.35,0.65,0.55],[0.80,0.35,0.35],[0.50,0.80,0.60],[0.70,0.50,0.80],
];
// ============================================================
// THREE.JS 3D VIEWER
// ============================================================
const GRID = 50;
const threeCanvas = document.getElementById('three-canvas');
const renderer = new THREE.WebGLRenderer({ canvas: threeCanvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0xf8f9fc, 1);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 300);
camera.position.set(70, 55, 70);
const controls = new OrbitControls(camera, threeCanvas);
controls.enableDamping = true; controls.dampingFactor = 0.08;
controls.target.set(GRID/2, GRID/2, GRID/2); controls.update();
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const d1 = new THREE.DirectionalLight(0xffffff, 0.8); d1.position.set(60, 90, 70); scene.add(d1);
const d2 = new THREE.DirectionalLight(0x8888cc, 0.3); d2.position.set(-40, -20, -40); scene.add(d2);
// Wireframe bounding box β€” dark edges for contrast
const boxGeo = new THREE.BoxGeometry(GRID, GRID, GRID);
const boxEdge = new THREE.LineSegments(
new THREE.EdgesGeometry(boxGeo),
new THREE.LineBasicMaterial({ color: 0x333344 })
);
boxEdge.position.set(GRID/2, GRID/2, GRID/2);
scene.add(boxEdge);
// Axis labels β€” X and Y at the top (surface), Depth arrow going down
function makeLabel(text, pos) {
const c = document.createElement('canvas'); c.width=256; c.height=64;
const ctx = c.getContext('2d'); ctx.fillStyle='#000000';
ctx.font='bold 32px "Space Grotesk",system-ui,sans-serif';
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText(text,128,32);
const tex = new THREE.CanvasTexture(c);
const sp = new THREE.Sprite(new THREE.SpriteMaterial({map:tex,transparent:true}));
sp.position.copy(pos); sp.scale.set(14,3.5,1); scene.add(sp);
}
// Surface is at Y=GRID (top). Labels on top edges.
makeLabel('Easting (X)', new THREE.Vector3(GRID/2, GRID+4, -3));
makeLabel('Northing (Y)', new THREE.Vector3(-3, GRID+4, GRID/2));
makeLabel('Depth \u2193', new THREE.Vector3(-6, GRID/2, -3));
let currentMesh = null;
function render3D() {
requestAnimationFrame(render3D); controls.update();
const rect = threeCanvas.parentElement.getBoundingClientRect();
const w = rect.width, h = rect.height;
if (threeCanvas.width !== w*renderer.getPixelRatio() || threeCanvas.height !== h*renderer.getPixelRatio()) {
renderer.setSize(w, h, false); camera.aspect = w/h; camera.updateProjectionMatrix();
}
renderer.render(scene, camera);
}
render3D();
function loadVoxels(data) {
if (currentMesh) { scene.remove(currentMesh); currentMesh.geometry.dispose(); currentMesh.material.dispose(); }
const { surfaceVoxels, nClasses } = data;
const n = surfaceVoxels.length / 4;
const geo = new THREE.BoxGeometry(1, 1, 1);
const mat = new THREE.MeshPhongMaterial({ color: 0xffffff, shininess: 40 });
const mesh = new THREE.InstancedMesh(geo, mat, n);
const dummy = new THREE.Object3D();
const color = new THREE.Color();
for (let i = 0; i < n; i++) {
// Binary stores: [dim0, dim1, dim2, class]
// dim0 = depth layers (0=top), dim1 = northing(Y), dim2 = easting(X)
// Three.js: X=easting, Y=up(inverted depth), Z=northing
const dim0 = surfaceVoxels[i * 4]; // depth
const dim1 = surfaceVoxels[i * 4 + 1]; // northing
const dim2 = surfaceVoxels[i * 4 + 2]; // easting
const cls = surfaceVoxels[i * 4 + 3];
dummy.position.set(dim2 + 0.5, (GRID - 1 - dim0) + 0.5, dim1 + 0.5);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
const c = LITH_COLORS[cls] || [0.5, 0.5, 0.5];
color.setRGB(c[0], c[1], c[2]);
mesh.setColorAt(i, color);
}
mesh.instanceMatrix.needsUpdate = true;
mesh.instanceColor.needsUpdate = true;
scene.add(mesh);
currentMesh = mesh;
document.getElementById('info-classes').textContent = nClasses;
// Legend with rock names from manifest
const legend = document.getElementById('legend'); legend.innerHTML = '';
const rockNames = currentRockNames || [];
for (let i = 1; i <= nClasses; i++) {
const c = LITH_COLORS[i] || [0.5,0.5,0.5];
const name = rockNames[i-1] || `Class ${i}`;
const div = document.createElement('div'); div.className = 'legend-item';
div.innerHTML = `<span class="legend-swatch" style="background:rgb(${c[0]*255|0},${c[1]*255|0},${c[2]*255|0})"></span>${name}`;
legend.appendChild(div);
}
}
// ============================================================
// HEATMAPS
// ============================================================
function viridis(t) {
t = Math.max(0, Math.min(1, t));
return [Math.max(0,Math.min(255,(68+t*(1-t)*600-t*t*350)|0)),
Math.max(0,Math.min(255,(2+t*330-t*t*130)|0)),
Math.max(0,Math.min(255,(85+t*180-t*t*350+t*t*t*200)|0))];
}
function magma(t) {
t = Math.max(0, Math.min(1, t));
return [Math.max(0,Math.min(255,(1+t*780-t*t*500+t*t*t*200)|0)),
Math.max(0,Math.min(255,(0+t*50+t*t*500-t*t*t*350)|0)),
Math.max(0,Math.min(255,(40+t*560-t*t*900+t*t*t*550)|0))];
}
function renderHeatmap(canvasId, data, rows, cols, cmap, barId, minId, maxId) {
const canvas = document.getElementById(canvasId);
canvas.width = cols; canvas.height = rows;
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(cols, rows);
let min = Infinity, max = -Infinity;
for (let i = 0; i < data.length; i++) { if(data[i]<min)min=data[i]; if(data[i]>max)max=data[i]; }
const range = max - min || 1;
for (let i = 0; i < data.length; i++) {
const [r,g,b] = cmap((data[i]-min)/range);
img.data[i*4]=r; img.data[i*4+1]=g; img.data[i*4+2]=b; img.data[i*4+3]=255;
}
ctx.putImageData(img, 0, 0);
document.getElementById(minId).textContent = min.toFixed(1);
document.getElementById(maxId).textContent = max.toFixed(1);
const bar = document.getElementById(barId);
let grad = 'linear-gradient(to bottom,';
for (let i = 0; i <= 20; i++) {
const [r,g,b] = cmap(1-i/20);
grad += `rgb(${r},${g},${b})${i<20?',':''}`;
}
bar.style.background = grad + ')';
}
// ============================================================
// DATA LOADING
// ============================================================
let manifest = null;
let currentRockNames = [];
async function loadManifest() {
const resp = await fetch('data/manifest.json');
manifest = await resp.json();
const typeSelect = document.getElementById('type-select');
Object.keys(manifest.types).forEach(type => {
const opt = document.createElement('option'); opt.value = type;
opt.textContent = type.replace(/_/g, ' \u2192 ').replace(/SHEAR-ZONE/g,'SHEAR ZONE');
typeSelect.appendChild(opt);
});
typeSelect.addEventListener('change', populateSamples);
document.getElementById('sample-select').addEventListener('change', loadSample);
populateSamples();
}
function populateSamples() {
const type = document.getElementById('type-select').value;
const sel = document.getElementById('sample-select'); sel.innerHTML = '';
manifest.types[type].forEach((entry, i) => {
const opt = document.createElement('option'); opt.value = i;
opt.textContent = `Sample ${i+1}`; sel.appendChild(opt);
});
loadSample();
}
async function loadSample() {
const type = document.getElementById('type-select').value;
const idx = parseInt(document.getElementById('sample-select').value);
if (isNaN(idx)) return;
const entry = manifest.types[type][idx];
const fileName = entry.file;
currentRockNames = entry.rocks || [];
const bar = document.getElementById('loading-bar');
bar.classList.remove('hidden'); bar.style.width = '30%';
try {
const resp = await fetch(`data/${fileName}`);
bar.style.width = '70%';
const buf = await resp.arrayBuffer();
bar.style.width = '90%';
const view = new DataView(buf);
let off = 0;
const nSurface = view.getUint32(off,true); off+=4;
const grvR = view.getUint32(off,true); off+=4;
const grvC = view.getUint32(off,true); off+=4;
const magR = view.getUint32(off,true); off+=4;
const magC = view.getUint32(off,true); off+=4;
const nCls = view.getUint32(off,true); off+=4;
const sv = new Uint8Array(buf, off, nSurface*4); off += nSurface*4;
const grv = new Float32Array(buf, off, grvR*grvC); off += grvR*grvC*4;
const mag = new Float32Array(buf, off, magR*magC);
loadVoxels({ surfaceVoxels: sv, nClasses: nCls });
renderHeatmap('grv-canvas', grv, grvR, grvC, viridis, 'grv-bar', 'grv-min', 'grv-max');
renderHeatmap('mag-canvas', mag, magR, magC, magma, 'mag-bar', 'mag-min', 'mag-max');
bar.style.width = '100%';
} catch(e) { console.error('Load failed:', e); }
setTimeout(() => { bar.style.width = '0%'; bar.classList.add('hidden'); }, 400);
}
</script>
</body>
</html>