This graph illustrates Council book categories and relations.
The Council 3D Book Evaluator body { font-family: Inter, system-ui, -apple-system, “Segoe UI”, Roboto, Arial; margin: 0; padding: 0; background:#f6f8fb; color:#111; } header { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 16px; background:#fff; border-bottom:1px solid #e6e9ee; position:sticky; top:0; z-index:20; } h1 { margin:0; font-size:18px; } .wrap { display:grid; grid-template-columns: 380px 1fr; gap:12px; padding:12px; } @media(max-width:880px){ .wrap{ grid-template-columns:1fr; } #graph,#threeContainer{height:520px;} textarea{min-height:160px;} .controls{padding:8px;} .pill{padding:10px 12px;font-size:15px;} } textarea { width:100%; min-height:220px; font-family: monospace; padding:8px; box-sizing:border-box; border-radius:8px; border:1px solid #dfe6ee; background:#fff; white-space:pre; } .controls { display:flex; flex-direction:column; gap:10px; margin-top:10px; padding:12px; border-radius:8px; background:#fff; box-shadow:0 2px 6px rgba(20,30,40,0.04); } .pill { padding:8px 12px; border-radius:6px; border:1px solid #d0d7de; background:#fff; cursor:pointer; font-size:14px; transition:all .12s ease; } .pill:active{ transform:scale(.985); } label { font-size:13px; color:#333; display:inline-flex; align-items:center; gap:8px; } #graph,#threeContainer { width:100%; height:640px; background:linear-gradient(180deg,#ffffff,#fbfdff); border-radius:10px; box-shadow:0 6px 20px rgba(16,24,40,0.04); overflow:hidden; } #threeContainer{ display:none; position:relative; } .control-grid{ display:grid; grid-template-columns:1fr 1fr; gap:10px; } select,input[type=range]{ width:100%; padding:6px; border-radius:6px; border:1px solid #dfe6ee; font-size:14px; } .button-row{ display:flex; gap:8px; flex-wrap:wrap; } .tiny{ font-size:12px; color:#666; margin-top:6px; } https://cdn.plot.ly/plotly-latest.min.js https://unpkg.com/three@0.160.0/build/three.min.js https://unpkg.com/three@0.160.0/examples/js/controls/OrbitControls.js https://unpkg.com/three@0.160.0/examples/js/renderers/CSS2DRenderer.js https://unpkg.com/three@0.160.0/examples/js/geometries/ConvexGeometry.js https://unpkg.com/three@0.160.0/examples/js/objects/MarchingCubes.jsThe Council 3D Book Evaluator
X = Hard Sci Fi | Y = Comfort Read | Z = Hidden Gem
Neuromancer 85 40 10
Solaris 85 38 20
Left Hand of Darkness 30 50 15
Roadside Picnic 75 19 40
Handmaid’s Tale 10 10 5
Flow my Tears the Policeman Said 55 20 50
Parable of the Sower 25 15 35
Dune 40 40 1
Snow Crash 50 60 10
Foundation 80 55 5
Red Mars 99 50 10
To Say Nothing of the Dog 10 90 65
Blindsight 96 10 50
Best Short Stories 50 23 35
Trouble on Triton 65 45 70
The Dispossessed 55 55 40
Herland 20 80 75
Brave New World 30 24 5
Feersum Endjinn 75 25 50
The Three-Body Problem 90 71 5
Lightless 85 20 80
A Connecticut Yankee in King Arthur’s Court 5 81 10
Cat’s Cradle 35 50 10
The Fifth Season 40 25 15
Shikasta 20 40 50
The Forever War 90 17 30
Woman on the Edge of Time 30 40 55
A Distant Soil 10 85 85
The Stars my Destination 70 35 20
Never Let Me Go 25 10 10
Last and First Men 85 25 60
Ancillary Justice 60 55 30
The Rise of the Meritocracy 10 10 85
Pattern Recognition 5 60 45
On a Red Station, Drifting 50 40 65
Hyperion 65 45 15
The Female Man 40 74 40
The Context 17 85 95
Exhalation 80 60 30
Children of Time 89 55 33
The Diamond Age 70 80 60
Dream Snake 35 42 55
Dhalgren 15 10 30
The Inverted World 85 45 35
The Blazing World 10 99 99
Deathbird Stories 30 19 25
Cloud Atlas 25 50 7
Aurora 95 50 25
The Player of Games 60 40 24
More than Human 50 35 50
Blood Music 90 34 60
RUR 50 25 90
The Terminator (Screenplay) 30 22 70
Her Smoke Rose Up Forever 70 13 40
The Fifth Head of Cerberus 60 18 55
Terminal Boredom 30 4 70
Diaspora 98 65 40
Flatland 60 85 85
Sphereland 50 75 90
Grass 45 25 60
The World Without Us 0 10 20
City 40 60 66
The Quantum Thief 65 60 17
This Is How You Lose the Time War 40 45 10
Five Ways to Forgiveness 8 11 80
Children of Ruin 85 60 15
Stations of the Tide 20 70 60
Three.js Render
Billboards
Volumetric Fog
Metaballs
Convex Hull
Marker Size
Color by
None
Hard Sci Fi (X)
Comfort Read (Y)
Hidden Gem (Z)
View Mode (Plotly only)
Scatter
Connected Lines
3D Blob
Bounding Box
Tip: use Metaballs or Fog for density; use Hull to see envelope.
Hard Sci Fi: %{x}
Comfort Read: %{y}
Hidden Gem: %{z}” }; } function makeLines(){ return { x: graphData.x, y: graphData.y, z: graphData.z, mode:’lines+markers’, type:’scatter3d’, marker:{size:4, color:’#d32f2f’}, text:graphData.labels }; } function makeMesh(){ return { x:graphData.x, y:graphData.y, z:graphData.z, type:’mesh3d’, opacity:0.35, color:’#8ecae6′ }; } function makeBox(){ if (!graphData.x.length) return {}; const xmin = Math.min(…graphData.x), xmax = Math.max(…graphData.x); const ymin = Math.min(…graphData.y), ymax = Math.max(…graphData.y); const zmin = Math.min(…graphData.z), zmax = Math.max(…graphData.z); return { type:’mesh3d’, x:[xmin,xmax,xmax,xmin,xmin,xmax,xmax,xmin], y:[ymin,ymin,ymax,ymax,ymin,ymin,ymax,ymax], z:[zmin,zmin,zmin,zmin,zmax,zmax,zmax,zmax], i:[0,0,0,4,4,4,1,1,2,2,3,5], j:[1,2,3,5,6,7,5,6,6,7,7,6], k:[2,3,1,6,7,5,2,5,3,6,0,2], opacity:0.08, color:’#999999′ }; } function updateMode(){ const mode = document.getElementById(‘modeSelect’).value; const traces = [makeScatter()]; if (mode === ‘lines’) traces.push(makeLines()); else if (mode === ‘mesh’) traces.push(makeMesh(), makeScatter()); else if (mode === ‘box’) traces.push(makeBox(), makeScatter()); Plotly.react(‘graph’, traces, plotlyLayout, {displaylogo:false}); applyPlotlyColor(); } /* color mapping */ function applyPlotlyColor(){ const opt = document.getElementById(‘colorSelect’).value; if (opt === ‘none’) { Plotly.restyle(‘graph’, {‘marker.color’: [‘#1e88e5’]}); return; } let arr = (opt === ‘x’ ? graphData.x : (opt === ‘y’ ? graphData.y : graphData.z)); if (!arr || !arr.length) { Plotly.restyle(‘graph’, {‘marker.color’: [‘#1e88e5’]}); return; } Plotly.restyle(‘graph’, {‘marker.color’: [arr]}); } /* ========================= Engine / UI handlers ========================= */ function switchEngine(which){ engine = which; document.getElementById(‘graph’).style.display = (which === ‘plotly’) ? ‘block’ : ‘none’; document.getElementById(‘threeContainer’).style.display = (which === ‘three’) ? ‘block’ : ‘none’; if (which === ‘plotly’) updateMode(); else renderThree(); } function toggleTitles(){ showLabels = !showLabels; if (engine===’plotly’) updateMode(); else renderThreeLabels(); } function onSizeChange(v){ if (engine===’plotly’) updateMode(); else renderThree(); } function onColorChange(){ if (engine===’plotly’) applyPlotlyColor(); else renderThree(); } function fitView(){ if (engine === ‘plotly’){ if (!graphData.x.length) return; const eye = {x:1.5,y:1.5,z:1.5}; plotlyLayout.scene.camera.eye = eye; Plotly.relayout(‘graph’, {‘scene.camera.eye’: eye}); } else { if (!threeInit) return; centerThreeOnData(); } } /* ========================= Rotation ========================= */ function startRotation(axis){ stopRotation(); if (engine === ‘plotly’){ rotationTimer = setInterval(()=>{ const eye = {…plotlyLayout.scene.camera.eye}; const a = 0.02; if (axis===’x’){ const y=eye.y, z=eye.z; eye.y = y*Math.cos(a)-z*Math.sin(a); eye.z = y*Math.sin(a)+z*Math.cos(a); } if (axis===’y’){ const x=eye.x, z=eye.z; eye.x = x*Math.cos(a)-z*Math.sin(a); eye.z = x*Math.sin(a)+z*Math.cos(a); } if (axis===’z’){ const x=eye.x, y=eye.y; eye.x = x*Math.cos(a)-y*Math.sin(a); eye.y = x*Math.sin(a)+y*Math.cos(a); } plotlyLayout.scene.camera.eye = eye; Plotly.relayout(‘graph’, {‘scene.camera.eye’: eye}); }, 100); } else { rotationTimer = setInterval(()=>{ if (!threeInit) return; const a = 0.01; if (axis===’x’) threeRoot.rotation.x += a; if (axis===’y’) threeRoot.rotation.y += a; if (axis===’z’) threeRoot.rotation.z += a; }, 16); } } function stopRotation(){ if (rotationTimer){ clearInterval(rotationTimer); rotationTimer = null; } } /* ========================= THREE.JS implementation ========================= */ let threeInit = false; let threeRoot = null; let threeScene, threeCamera, threeRenderer, threeControls, labelRenderer; let threeGradient = null; let threeSprites = [], threeLabels = [], threeMetaballs = null, threeHull = null, threeFogGroup = null; function initThree(){ if (threeInit) return; threeInit = true; const THREE = window.THREE; const container = document.getElementById(‘threeContainer’); container.innerHTML = ”; threeScene = new THREE.Scene(); threeScene.background = new THREE.Color(0xf7fbff); const w = container.clientWidth, h = container.clientHeight; threeCamera = new THREE.PerspectiveCamera(50, w/h, 0.01, 1000); threeCamera.position.set(2.2, 2.0, 2.4); threeRenderer = new THREE.WebGLRenderer({ antialias:true, alpha:false }); threeRenderer.setSize(w,h); threeRenderer.setPixelRatio(Math.min(window.devicePixelRatio,2)); container.appendChild(threeRenderer.domElement); labelRenderer = new THREE.CSS2DRenderer(); labelRenderer.setSize(w,h); labelRenderer.domElement.style.position = ‘absolute’; labelRenderer.domElement.style.top = ‘0’; labelRenderer.domElement.style.pointerEvents = ‘none’; container.appendChild(labelRenderer.domElement); threeControls = new THREE.OrbitControls(threeCamera, labelRenderer.domElement); threeControls.enableDamping = true; threeControls.dampingFactor = 0.08; threeControls.zoomSpeed = 1.2; threeScene.add(new THREE.AmbientLight(0xffffff, 0.9)); const dir = new THREE.DirectionalLight(0xffffff, 0.6); dir.position.set(3,4,5); threeScene.add(dir); threeRoot = new THREE.Group(); threeScene.add(threeRoot); const box = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(2,2,2)), new THREE.LineBasicMaterial({color:0x999999,linewidth:1,opacity:0.25,transparent:true})); threeRoot.add(box); threeGradient = makeRadialTexture(); window.addEventListener(‘resize’, onThreeResize); animateThree(); } function onThreeResize(){ const container = document.getElementById(‘threeContainer’); if (!threeCamera) return; threeCamera.aspect = container.clientWidth / container.clientHeight; threeCamera.updateProjectionMatrix(); threeRenderer.setSize(container.clientWidth, container.clientHeight); labelRenderer.setSize(container.clientWidth, container.clientHeight); } function animateThree(){ requestAnimationFrame(animateThree); if (!threeRenderer) return; threeControls.update(); threeRenderer.render(threeScene, threeCamera); labelRenderer.render(threeScene, threeCamera); } function makeRadialTexture(){ const size = 256; const canvas = document.createElement(‘canvas’); canvas.width = size; canvas.height = size; const ctx = canvas.getContext(‘2d’); const g = ctx.createRadialGradient(size/2, size/2, 1, size/2, size/2, size/2); g.addColorStop(0, ‘rgba(255,255,255,1)’); g.addColorStop(0.2, ‘rgba(255,255,255,0.9)’); g.addColorStop(0.5, ‘rgba(255,255,255,0.35)’); g.addColorStop(1, ‘rgba(255,255,255,0)’); ctx.fillStyle = g; ctx.fillRect(0,0,size,size); const tex = new THREE.Texture(canvas); tex.needsUpdate = true; return tex; } function normalize(arr){ if (!arr || arr.length===0) return []; const mn = Math.min(…arr), mx = Math.max(…arr); if (mx === mn) return arr.map(_=>0); return arr.map(v => ((v – mn) / (mx – mn)) * 2 – 1); } function colorForIndex(mode, i){ if (mode === ‘none’) return ‘#1976d2′; const vals = (mode===’x’ ? graphData.x : (mode===’y’ ? graphData.y : graphData.z)); const vmin = Math.min(…vals), vmax = Math.max(…vals); const t = (vals[i]-vmin)/(vmax-vmin||1); const r = Math.round(40 + (220-40)*t); const g = Math.round(80 + (60-80)*t); const b = Math.round(220 + (50-220)*t); return `rgb(${r},${g},${b})`; } function centerThreeOnData(){ if (!graphData.x.length) return; threeRoot.position.set(0,0,0); threeRoot.scale.set(1,1,1); threeRoot.rotation.set(0,0,0); } /* safe clear (keeps box at index 0) */ function clearThreeRoot(){ if (!threeRoot) return; while (threeRoot.children.length > 1) { const c = threeRoot.children[1]; if (c.geometry) { try{ c.geometry.dispose(); }catch(e){} } if (c.material) { try{ if (Array.isArray(c.material)) c.material.forEach(m=>m.dispose && m.dispose()); else c.material.dispose && c.material.dispose(); }catch(e){} } threeRoot.remove(c); } threeSprites = []; threeLabels = []; threeMetaballs = null; threeHull = null; threeFogGroup = null; } /* full renderThree builder */ function renderThree(){ initThree(); clearThreeRoot(); if (!graphData.x.length) return; const THREE = window.THREE; const mode = document.getElementById(‘threeRenderMode’).value; const size = parseInt(document.getElementById(‘sizeSlider’).value); const colorMode = document.getElementById(‘colorSelect’).value; const nx = normalize(graphData.x), ny = normalize(graphData.y), nz = normalize(graphData.z); if (mode === ‘billboards’) { for (let i=0;i<nx.length;i++){ const col = new THREE.Color(colorForIndex(colorMode, i)); const mat = new THREE.SpriteMaterial({ map: threeGradient, transparent:true, blending: THREE.AdditiveBlending, depthWrite:false, color:col }); const s = new THREE.Sprite(mat); s.scale.setScalar(0.28 * (size/6)); s.position.set(nx[i], ny[i], nz[i]); threeRoot.add(s); threeSprites.push(s); } } else if (mode === 'fog'){ const layers = 6; threeFogGroup = new THREE.Group(); for (let i=0;i<nx.length;i++){ const base = new THREE.Vector3(nx[i], ny[i], nz[i]); const baseColor = new THREE.Color(colorForIndex(colorMode,i)); for (let l=0;l<layers;l++){ const mat = new THREE.SpriteMaterial({ map: threeGradient, transparent:true, blending: THREE.AdditiveBlending, depthWrite:false, color: baseColor.clone() }); const s = new THREE.Sprite(mat); const spread = 0.02 * (l+1) * (size/6); s.position.copy(base).add(new THREE.Vector3(randn()*spread, randn()*spread, randn()*spread)); s.scale.setScalar(0.18 * (size/6) * (1 + l*0.4)); s.material.opacity = 0.16 * (1 – l/(layers+1)); threeFogGroup.add(s); } } threeRoot.add(threeFogGroup); } else if (mode === 'metaballs'){ const resolution = 36; const material = new THREE.MeshStandardMaterial({ color:0x4f83cc, roughness:0.45, metalness:0.05, transparent:true, opacity:0.6 }); const effect = new THREE.MarchingCubes(resolution, material, true, true); effect.position.set(-1,-1,-1); effect.scale.set(2,2,2); effect.isolation = 20; effect.reset(); const strength = 0.55; for (let i=0;i<nx.length;i++){ const fx = (nx[i]+1)/2, fy=(ny[i]+1)/2, fz=(nz[i]+1)/2; effect.addBall(fx,fy,fz,strength,0); } threeRoot.add(effect); threeMetaballs = effect; const pts = new THREE.BufferGeometry(); const arr = new Float32Array(nx.length*3); for (let i=0;i<nx.length;i++){ arr[i*3]=nx[i]; arr[i*3+1]=ny[i]; arr[i*3+2]=nz[i]; } pts.setAttribute('position', new THREE.BufferAttribute(arr,3)); const pmat = new THREE.PointsMaterial({ size:0.02*(size/6), color:0x003f7f }); const points = new THREE.Points(pts, pmat); threeRoot.add(points); } else if (mode === 'hull'){ const pts = []; for (let i=0;i<nx.length;i++) pts.push(new THREE.Vector3(nx[i], ny[i], nz[i])); try { const geom = new THREE.ConvexGeometry(pts); geom.computeVertexNormals(); const mat = new THREE.MeshPhysicalMaterial({ color:0x88bfe6, roughness:0.55, metalness:0.02, transparent:true, opacity:0.45, side: THREE.DoubleSide }); const mesh = new THREE.Mesh(geom, mat); threeRoot.add(mesh); threeHull = mesh; const ptsGeom = new THREE.BufferGeometry(); const arr = new Float32Array(nx.length*3); for (let i=0;i { if (lbl.parent) lbl.parent.remove(lbl); }); threeLabels = []; if (!showLabels) return; const THREE = window.THREE; for (let i=0;i{ try{ plotData(); }catch(e){ console.error(‘plotData error’, e); } }, 120); }catch(e){ console.error(‘init error’, e); } } function waitForLibsAndDOM(){ const check = () => { if (window.Plotly && window.THREE) { if (document.readyState === ‘loading’) { document.addEventListener(‘DOMContentLoaded’, readyToInit); } else { readyToInit(); } } else { setTimeout(check, 60); } }; check(); } waitForLibsAndDOM();
