<!
doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>2.5D Spherical Graph — Demo</title>
<style>
html,body{height:100%;margin:0;background:#0b1220;color:#ddd;font-
family:Inter,system-ui,Arial}
#container{width:100%;height:100%;position:relative;overflow:hidden}
#ui {
position:absolute;right:12px;top:12px;background:rgba(10,12,20,0.45);
color:#fff;padding:10px;border-radius:8px;font-size:13px;backdrop-filter:
blur(6px);
}
#legend {position:absolute;left:12px;bottom:12px;background:rgba(10,12,20,0.45);
padding:8px;border-radius:8px;}
.label {
padding:4px 7px;background:rgba(0,0,0,0.6);border-radius:4px;font-weight:600;
font-size:12px;color:#fff;pointer-events:none;white-space:nowrap;
}
a.small{font-size:12px;color:#9fd; text-decoration:none}
</style>
</head>
<body>
<div id="container"></div>
<div id="ui">
<div><strong>2.5D Spherical Graph</strong></div>
<div style="margin-top:6px">
<label style="display:block">Node scale <input id="nodeScale" type="range"
min="0.2" max="3" step="0.1" value="1"></label>
<label>Auto rotate <input id="autoRotate" type="checkbox" checked></label>
</div>
<div style="margin-top:6px"><small><a class="small" href="#"
id="toggleLabels">Toggle labels</a></small></div>
</div>
<div id="legend">
<div style="font-size:12px;margin-bottom:6px">Legend</div>
<div style="display:flex;gap:8px;align-items:center">
<div style="width:12px;height:12px;border-radius:50%;background:#ff8c42"></
div><div style="font-size:12px">High value</div>
</div>
<div style="display:flex;gap:8px;align-items:center;margin-top:4px">
<div style="width:12px;height:12px;border-radius:50%;background:#4499ff"></
div><div style="font-size:12px">Low value</div>
</div>
</div>
<!-- Three.js from CDN -->
<script src="https://unpkg.com/
[email protected]/build/three.min.js"></script>
<script
src="https://unpkg.com/
[email protected]/examples/js/controls/OrbitControls.js"></
script>
<script>
/*
2.5D Spherical Graph — Interactive demo
Data format: array of nodes:
{ id, name, lat (deg), lon (deg), value (0..1), group (optional) }
*/
const sampleData = [
{id:1, name:"Core", lat: 10, lon: -10, value: 0.95, group: "A"},
{id:2, name:"Node B", lat: 40, lon: 60, value: 0.7, group: "A"},
{id:3, name:"Node C", lat: -20, lon: 120, value: 0.4, group: "B"},
{id:4, name:"Node D", lat: -45, lon: -140, value: 0.25, group: "B"},
{id:5, name:"Node E", lat: 60, lon: -60, value: 0.5, group: "C"}
];
const connections = [
[1,2],[1,3],[2,5],[3,4],[4,5]
];
const container = document.getElementById('container');
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x071019, 0.06);
const camera = new THREE.PerspectiveCamera(50, innerWidth/innerHeight, 0.1, 1000);
camera.position.set(0, 0, 8);
const renderer = new THREE.WebGLRenderer({antialias:true, alpha:true});
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);
// controls
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.enablePan = false;
controls.minDistance = 4;
controls.maxDistance = 30;
// lighting
const hemi = new THREE.HemisphereLight(0xbfe8ff, 0x202030, 0.9);
scene.add(hemi);
const spot = new THREE.PointLight(0xffffff, 0.9);
spot.position.set(5,5,5);
scene.add(spot);
// globe base (subtle glassy)
const globeRadius = 3;
const globeGeo = new THREE.SphereGeometry(globeRadius, 64, 40);
const globeMat = new THREE.MeshPhysicalMaterial({
color: 0x0f2a46,
metalness: 0.1,
roughness: 0.2,
transmission: 0.5,
transparent: true,
opacity: 0.9,
clearcoat: 0.6,
clearcoatRoughness: 0.1
});
const globeMesh = new THREE.Mesh(globeGeo, globeMat);
globeMesh.renderOrder = 0;
scene.add(globeMesh);
// faint grid lines (latitude/longitude)
const gridMat = new THREE.LineBasicMaterial({color:0x2b4b6b, transparent:true,
opacity:0.35});
const gridLines = new THREE.Group();
for(let lat=-80; lat<=80; lat+=20){
const radius = globeRadius * Math.cos(THREE.MathUtils.degToRad(lat));
const y = globeRadius * Math.sin(THREE.MathUtils.degToRad(lat));
const circle = new THREE.CircleGeometry(radius, 128);
circle.vertices = circle.attributes.position.array; // compatibility
const geom = new THREE.BufferGeometry();
const pts = [];
for(let i=0;i<=128;i++){
const a = i/128 * Math.PI * 2;
pts.push(new THREE.Vector3(Math.cos(a)*radius, y, Math.sin(a)*radius));
}
geom.setFromPoints(pts);
const line = new THREE.Line(geom, gridMat);
gridLines.add(line);
}
scene.add(gridLines);
// node group
const nodeGroup = new THREE.Group();
scene.add(nodeGroup);
// helper: lat/lon -> 3D point on sphere
function latLonToVector3(lat, lon, radius){
const phi = THREE.MathUtils.degToRad(90 - lat);
const theta = THREE.MathUtils.degToRad(lon + 180);
const x = radius * Math.sin(phi) * Math.cos(theta);
const y = radius * Math.cos(phi);
const z = radius * Math.sin(phi) * Math.sin(theta);
return new THREE.Vector3(x,y,z);
}
// color scale: maps value 0..1 into blue->orange
function valueToColor(v){
const c1 = new THREE.Color(0x4499ff); // low
const c2 = new THREE.Color(0xff8c42); // high
return c1.lerp(c2, THREE.MathUtils.clamp(v,0,1));
}
// create node sprite label
function makeLabel(text){
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = '600 36px sans-serif';
const padding = 12;
const w = Math.ceil(ctx.measureText(text).width) + padding*2;
const h = 48;
canvas.width = w;
canvas.height = h;
// background
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(0,0,w,h);
// text
ctx.fillStyle = '#fff';
ctx.font = '600 26px sans-serif';
ctx.textBaseline = 'middle';
ctx.fillText(text, padding, h/2 + 2);
const tex = new THREE.CanvasTexture(canvas);
tex.minFilter = THREE.LinearFilter;
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({map: tex,
transparent:true}));
sprite.scale.set((w/64), (h/64), 1);
return sprite;
}
// add nodes
const nodeData = [];
const labelSprites = [];
for(const d of sampleData){
const pos = latLonToVector3(d.lat, d.lon, globeRadius + 0.06 +
(d.value*0.12)); // slightly above surface
const color = valueToColor(d.value);
// sphere
const sphereGeo = new THREE.SphereGeometry(0.05 + d.value*0.14, 16, 12);
const sphereMat = new THREE.MeshStandardMaterial({
color: color,
emissive: color.clone().multiplyScalar(0.12),
metalness: 0.4,
roughness: 0.3
});
const nodeMesh = new THREE.Mesh(sphereGeo, sphereMat);
nodeMesh.position.copy(pos);
nodeMesh.userData = {...d, basePos: pos.clone()};
nodeGroup.add(nodeMesh);
nodeData.push(nodeMesh);
// glow (a transparent, slightly larger sprite)
const spriteMat = new THREE.SpriteMaterial({
map: new THREE.TextureLoader().load(createGlowCanvas(color.getStyle())),
blending: THREE.AdditiveBlending,
opacity: 0.65,
depthWrite: false
});
const glow = new THREE.Sprite(spriteMat);
glow.scale.set(0.6 + d.value*0.6, 0.6 + d.value*0.6, 1);
glow.position.copy(pos);
nodeGroup.add(glow);
// label
const sprite = makeLabel(d.name + ' • ' + Math.round(d.value*100) + '%');
sprite.position.copy(pos.clone().multiplyScalar(1.06));
scene.add(sprite);
labelSprites.push(sprite);
}
// create connectors with smooth curve
const connGroup = new THREE.Group();
for(const pair of connections){
const a = nodeData.find(n=>n.userData.id===pair[0]);
const b = nodeData.find(n=>n.userData.id===pair[1]);
if(!a || !b) continue;
const mid = a.position.clone().add(b.position).multiplyScalar(0.5);
// push midpoint slightly outwards to create an arc
const direction = mid.clone().normalize();
const arcHeight = 0.6 + (a.userData.value + b.userData.value) * 0.5;
mid.add(direction.multiplyScalar(arcHeight));
const curve = new THREE.CatmullRomCurve3([a.position.clone(), mid,
b.position.clone()]);
const pts = curve.getPoints(60);
const geom = new THREE.BufferGeometry().setFromPoints(pts);
const mat = new THREE.LineBasicMaterial({color:0xffffff, linewidth:1,
transparent:true, opacity:0.45});
const line = new THREE.Line(geom, mat);
connGroup.add(line);
}
scene.add(connGroup);
// small subtle rim outline (2.5D layering effect)
const rim = new THREE.Mesh(
new THREE.SphereGeometry(globeRadius + 0.02, 64, 32),
new THREE.MeshBasicMaterial({color:0xffffff, transparent:true, opacity:0.035})
);
scene.add(rim);
// helpers
function createGlowCanvas(colorStyle){
const size = 128;
const c = document.createElement('canvas');
c.width=c.height=size;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(size/2,size/2,0,size/2,size/2,size/2);
g.addColorStop(0, colorStyle);
g.addColorStop(0.2, colorStyle);
g.addColorStop(0.7, 'rgba(0,0,0,0.05)');
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.fillRect(0,0,size,size);
return c.toDataURL();
}
// interaction: raycast hover for tiny pop
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let hovered = null;
function onPointerMove(e){
const rect = renderer.domElement.getBoundingClientRect();
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
}
window.addEventListener('pointermove', onPointerMove);
renderer.domElement.addEventListener('click', (e)=>{
if(hovered){
alert('Clicked: ' + hovered.userData.name + '\\nvalue: ' +
hovered.userData.value);
}
});
// UI bindings
const nodeScaleEl = document.getElementById('nodeScale');
const autoRotateEl = document.getElementById('autoRotate');
const toggleLabels = document.getElementById('toggleLabels');
let labelsOn = true;
toggleLabels.addEventListener('click', (ev)=>{ev.preventDefault(); labelsOn = !
labelsOn; labelSprites.forEach(s=>s.visible=labelsOn);});
window.addEventListener('resize', ()=>{
camera.aspect = innerWidth/innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// animation
const clock = new THREE.Clock();
function animate(){
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
// gentle globe breathing / parallax (2.5D feel)
globeMesh.rotation.y += (autoRotateEl.checked ? 0.004 : 0) ;
gridLines.rotation.y = globeMesh.rotation.y * 0.98;
rim.rotation.copy(globeMesh.rotation);
// node subtle bob & face labels to camera
const scaleFactor = parseFloat(nodeScaleEl.value);
nodeData.forEach((n, i)=>{
// bobbing
const offset = 0.06 * Math.sin(t*1.2 + i);
const target =
n.userData.basePos.clone().normalize().multiplyScalar(globeRadius + 0.06 +
n.userData.value*0.12 + offset);
n.position.lerp(target, 0.08);
// scale by UI
n.scale.setScalar(scaleFactor);
// label facing camera
labelSprites[i].position.copy(n.position.clone().multiplyScalar(1.06));
labelSprites[i].lookAt(camera.position);
});
// hover detection
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(nodeData, false);
if(intersects.length){
const obj = intersects[0].object;
if(hovered !== obj){
if(hovered) hovered.material.emissive.multiplyScalar(0.2);
hovered = obj;
hovered.material.emissive = hovered.material.emissive.clone().add(new
THREE.Color(0x333333));
}
} else {
if(hovered) {
// restore
hovered.material.emissive.multiplyScalar(0.6);
hovered = null;
}
}
controls.update();
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>