Spaces:
Running
Running
| <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 · 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> | |