Skip to content

Instantly share code, notes, and snippets.

@danbri
Created August 31, 2025 18:31
Show Gist options
  • Select an option

  • Save danbri/8485a7b4e7b588091106d631d501e672 to your computer and use it in GitHub Desktop.

Select an option

Save danbri/8485a7b4e7b588091106d631d501e672 to your computer and use it in GitHub Desktop.
layerfake2
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="viewport-fit=cover,width=device-width,initial-scale=1.0" />
<title>Neon Triples</title>
<style>
:root {
--pill-bg: rgba(0, 0, 0, .55);
--pill-br: rgba(255, 255, 255, .22);
--btn-bg: rgba(255, 255, 255, .12);
--btn-bg-on: rgba(255, 255, 255, .28);
--btn-br: rgba(255, 255, 255, .25)
}
html,
body {
height: 100%
}
body {
margin: 0;
overflow: hidden;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%)
}
#canvas-container {
position: relative;
width: 100vw;
height: 100vh
}
#canvas-container,
#canvas-container canvas {
touch-action: none
}
#info {
position: fixed;
top: max(10px, env(safe-area-inset-top));
right: max(10px, env(safe-area-inset-right));
color: #fff;
background: rgba(0, 0, 0, .72);
border-radius: 12px;
backdrop-filter: blur(10px);
width: 300px;
box-shadow: 0 4px 20px rgba(0, 0, 0, .3);
transition: .25s;
z-index: 1000
}
#info.collapsed {
width: 48px;
height: 48px;
opacity: .95
}
#info-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px
}
#info-header .title {
margin: 0;
font-size: 14px
}
#info-content {
padding: 0 14px 14px;
max-height: 60vh;
overflow: auto;
transition: max-height .25s
}
#info.collapsed #info-content {
max-height: 0;
padding: 0;
overflow: hidden
}
.button-group {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap
}
.btn {
appearance: none;
border: 1px solid var(--btn-br);
background: var(--btn-bg);
color: #fff;
border-radius: 18px;
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
line-height: 1;
transition: .2s
}
.btn:hover {
transform: translateY(-1px)
}
.btn[aria-pressed="true"] {
background: var(--btn-bg-on)
}
.btn-icon {
width: 36px;
display: grid;
place-items: center
}
#reopen {
position: fixed;
top: max(10px, env(safe-area-inset-top));
right: max(10px, env(safe-area-inset-right));
width: 44px;
height: 44px;
border-radius: 22px;
display: none;
place-items: center;
background: rgba(0, 0, 0, .75);
color: #fff;
border: 1px solid var(--btn-br);
z-index: 1100;
backdrop-filter: blur(10px);
cursor: pointer
}
#mini-hud {
position: fixed;
left: 10px;
bottom: max(10px, env(safe-area-inset-bottom));
display: none;
gap: 8px;
z-index: 1000
}
#mini-hud .btn {
padding: 8px 10px
}
#tooltip {
position: absolute;
padding: 8px 12px;
background: rgba(0, 0, 0, .9);
color: #fff;
border-radius: 6px;
font-size: 12px;
pointer-events: none;
display: none;
z-index: 1000;
backdrop-filter: blur(10px);
box-shadow: 0 2px 10px rgba(0, 0, 0, .3)
}
.label3d {
position: absolute;
color: #fff;
font-size: 12px;
text-shadow: 0 0 4px rgba(0, 0, 0, .85), 0 0 8px rgba(0, 0, 0, .65);
pointer-events: none;
user-select: none;
white-space: nowrap;
font-weight: 700
}
.edge-pill {
padding: 4px 8px;
border-radius: 999px;
background: var(--pill-bg);
border: 1px solid var(--pill-br)
}
.edge-pill .arrow {
opacity: .95;
padding: 0 2px
}
.layer-item {
display: flex;
align-items: center;
justify-content: space-between;
margin: 6px 0;
font-size: 11px
}
.layer-left {
display: flex;
align-items: center;
gap: 8px
}
.layer-color {
width: 16px;
height: 16px;
border-radius: 3px;
box-shadow: 0 2px 5px rgba(0, 0, 0, .2)
}
canvas.grab {
cursor: grab
}
canvas.grabbing {
cursor: grabbing
}
@media (max-width:768px) {
#info {
width: 260px
}
#info-header .title {
font-size: 12px
}
.btn {
font-size: 10px;
padding: 7px 10px
}
}
</style>
</head>
<body>
<div id="canvas-container"></div>
<button id="reopen" aria-label="Reopen controls">☰</button>
<div id="info" role="region" aria-label="Graph controls">
<div id="info-header">
<h3 class="title">Triple Graph (FOAF / Schema)</h3><button id="collapseBtn" class="btn btn-icon" aria-label="Toggle panel" aria-pressed="false">▾</button>
</div>
<div id="info-content">
<div class="button-group">
<button id="animBtn" class="btn" aria-pressed="true">⏯️ Anim</button>
<button id="labelsBtn" class="btn" data-mode="smart" aria-pressed="true">🏷️ Labels: Smart</button>
<button id="topBtn" class="btn" title="Top">⌂ Top</button>
<button id="orthoBtn" class="btn" title="Ortho">▦ Ortho</button>
<button id="resetBtn" class="btn" title="Reset">🎯 Reset</button>
</div>
<p><strong>Drag</strong> orbit • <strong>Wheel/pinch</strong> zoom • Keys: ←/→/↑/↓, W/A/S/D, +/-</p>
<div class="layer-legend">
<div class="layer-item">
<div class="layer-left">
<div class="layer-color" style="background:#95E1D3;"></div><span>Concept</span>
</div><input class="layer-fade" type="checkbox" id="fade-concept" checked>
</div>
<div class="layer-item">
<div class="layer-left">
<div class="layer-color" style="background:#4ECDC4;"></div><span>Document</span>
</div><input class="layer-fade" type="checkbox" id="fade-document" checked>
</div>
<div class="layer-item">
<div class="layer-left">
<div class="layer-color" style="background:#FF6B6B;"></div><span>Technology</span>
</div><input class="layer-fade" type="checkbox" id="fade-technology" checked>
</div>
<div class="layer-item">
<div class="layer-left">
<div class="layer-color" style="background:#FFD700;"></div><span>Social</span>
</div><input class="layer-fade" type="checkbox" id="fade-social" checked>
</div>
<div class="layer-item">
<div class="layer-left">
<div class="layer-color" style="background:#00ff00;"></div><span>Shared Connectors</span>
</div><input class="layer-fade" type="checkbox" id="toggle-vertical" checked>
</div>
</div>
</div>
</div>
<div id="mini-hud"><button id="miniLabels" class="btn">🏷️</button><button id="miniTop" class="btn">⌂</button><button id="miniPanel" class="btn">☰</button></div>
<div id="tooltip"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<!-- examples deps (r128) -->
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/Pass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
<script>
let scene, renderer, perspCamera, orthoCamera, activeCameraType = 'persp',
raycaster, mouse, composer, renderPass, bloomPass, haloTexture;
let layersByName = {},
allNodes = [],
literalNodes = new Set(),
verticalConnections = [],
nodeLabels = [],
edges = [];
let animating = true,
labelMode = 'smart',
radius = 28,
theta = Math.PI / 4,
phi = Math.PI / 4,
dragging = false,
lastX = 0,
lastY = 0,
forceOrtho = false;
const EDGE_RADIUS = .10,
EDGE_SEGMENTS = 24,
EDGE_LABEL_T = .6,
lookTarget = new THREE.Vector3(0, 7.5, 0),
SNAP_IN = .96,
SNAP_OUT = .90,
uprightBias = true;
const layers = {
social: {
color: 0xFFD700,
height: 0
},
technology: {
color: 0xFF6B6B,
height: 5
},
document: {
color: 0x4ECDC4,
height: 10
},
concept: {
color: 0x95E1D3,
height: 15
}
};
const nodes = [{
id: 'ex:DanBri',
label: 'Dan Brickley',
kind: 'person',
layer: 'social',
x: 0,
z: 0
}, {
id: 'ex:TimBL',
label: 'Tim Berners-Lee',
kind: 'person',
layer: 'social',
x: -5,
z: -3
}, {
id: 'ex:W3C',
label: 'W3C',
kind: 'org',
layer: 'social',
x: 5,
z: -3
}, {
id: 'ex:Google',
label: 'Google',
kind: 'org',
layer: 'social',
x: 5,
z: 3
}, {
id: 'ex:SchemaOrg',
label: 'Schema.org',
kind: 'org',
layer: 'social',
x: 0,
z: 5
}, {
id: 'ex:RDF',
label: 'RDF',
kind: 'tech',
layer: 'technology',
x: -5,
z: -3
}, {
id: 'ex:SPARQL',
label: 'SPARQL',
kind: 'tech',
layer: 'technology',
x: 5,
z: -3
}, {
id: 'ex:JSON-LD',
label: 'JSON-LD',
kind: 'tech',
layer: 'technology',
x: 0,
z: 0
}, {
id: 'ex:FOAF',
label: 'FOAF',
kind: 'tech',
layer: 'technology',
x: -5,
z: 3
}, {
id: 'ex:OWL',
label: 'OWL',
kind: 'tech',
layer: 'technology',
x: 5,
z: 3
}, {
id: 'ex:Turtle',
label: 'Turtle',
kind: 'tech',
layer: 'technology',
x: -3,
z: 0
}, {
id: 'ex:SchemaOrgTech',
label: 'Schema.org (voc)',
kind: 'tech',
layer: 'technology',
x: 0,
z: 5
}, {
id: 'ex:RDFPrimer',
label: 'RDF Primer',
kind: 'doc',
layer: 'document',
x: -5,
z: -3
}, {
id: 'ex:FOAFSpec',
label: 'FOAF Spec',
kind: 'doc',
layer: 'document',
x: -5,
z: 3
}, {
id: 'ex:SchemaDocs',
label: 'Schema Docs',
kind: 'doc',
layer: 'document',
x: 0,
z: 5
}, {
id: 'ex:DansBlog',
label: "Dan's Blog",
kind: 'doc',
layer: 'document',
x: 0,
z: 0
}, {
id: 'ex:KGpaper',
label: 'Knowledge Graphs',
kind: 'doc',
layer: 'document',
x: 5,
z: 0
}, {
id: 'ex:LinkedData',
label: 'Linked Data',
kind: 'concept',
layer: 'concept',
x: 0,
z: 0
}, {
id: 'ex:KnowledgeGraph',
label: 'Knowledge Graph',
kind: 'concept',
layer: 'concept',
x: 5,
z: 0
}, {
id: 'ex:Ontology',
label: 'Ontology',
kind: 'concept',
layer: 'concept',
x: -5,
z: -3
}, {
id: 'ex:Metadata',
label: 'Metadata',
kind: 'concept',
layer: 'concept',
x: -5,
z: 3
}, {
id: 'ex:OpenData',
label: 'Open Data',
kind: 'concept',
layer: 'concept',
x: 0,
z: 5
}];
function L(id, label, layer, x, z) {
return {
id,
label,
kind: 'literal',
layer,
x,
z
}
}
nodes.push(L('"https://danbri.org"', '"https://danbri.org"', 'document', -2, 0), L('"2014-06-10"', '"2014-06-10"', 'document', 2, -.5));
const triples = [{
s: 'ex:DanBri',
p: 'foaf:knows',
o: 'ex:TimBL',
layer: 'social'
}, {
s: 'ex:DanBri',
p: 'schema:worksFor',
o: 'ex:Google',
layer: 'social'
}, {
s: 'ex:DanBri',
p: 'schema:affiliation',
o: 'ex:W3C',
layer: 'social'
}, {
s: 'ex:SchemaOrg',
p: 'schema:isPartOf',
o: 'ex:W3C',
layer: 'social'
}, {
s: 'ex:RDF',
p: 'schema:relatedTo',
o: 'ex:SPARQL',
layer: 'technology'
}, {
s: 'ex:RDF',
p: 'schema:encoding',
o: 'ex:JSON-LD',
layer: 'technology'
}, {
s: 'ex:RDF',
p: 'schema:encoding',
o: 'ex:Turtle',
layer: 'technology'
}, {
s: 'ex:FOAF',
p: 'schema:about',
o: 'ex:Person',
layer: 'technology'
}, {
s: 'ex:SchemaOrgTech',
p: 'schema:about',
o: 'ex:JSON-LD',
layer: 'technology'
}, {
s: 'ex:DansBlog',
p: 'schema:url',
o: '"https://danbri.org"',
layer: 'document'
}, {
s: 'ex:RDFPrimer',
p: 'schema:about',
o: 'ex:RDF',
layer: 'document'
}, {
s: 'ex:FOAFSpec',
p: 'schema:about',
o: 'ex:FOAF',
layer: 'document'
}, {
s: 'ex:SchemaDocs',
p: 'schema:about',
o: 'ex:SchemaOrgTech',
layer: 'document'
}, {
s: 'ex:SchemaDocs',
p: 'schema:datePublished',
o: '"2014-06-10"',
layer: 'document'
}, {
s: 'ex:KGpaper',
p: 'schema:about',
o: 'ex:KnowledgeGraph',
layer: 'document'
}, {
s: 'ex:LinkedData',
p: 'schema:about',
o: 'ex:RDF',
layer: 'concept'
}, {
s: 'ex:KnowledgeGraph',
p: 'schema:about',
o: 'ex:Ontology',
layer: 'concept'
}, {
s: 'ex:OpenData',
p: 'schema:about',
o: 'ex:Metadata',
layer: 'concept'
}];
const sharedEntities = [{
id: 'ex:SchemaOrg',
layers: ['social'],
connectTo: 'ex:SchemaDocs'
}, {
id: 'ex:FOAF',
layers: ['technology'],
connectTo: 'ex:FOAFSpec'
}, {
id: 'ex:RDF',
layers: ['technology'],
connectTo: 'ex:RDFPrimer'
}];
const NEON = {
yellow: 0xEEFF00,
orange: 0xFF7A00,
pink: 0xFF00A8,
green: 0x39FF14
};
const NEON_MATS = [],
OUTLINE_MATS = [],
halos = [];
function neonMat(color, opacity = .9) {
const m = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity,
blending: THREE.AdditiveBlending,
depthWrite: false
});
NEON_MATS.push(m);
return m
}
function addHaloFor(node, color) {
const s = new THREE.Sprite(new THREE.SpriteMaterial({
map: haloTexture,
color,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false
}));
s.scale.set(2.2, 2.2, 1);
s.position.copy(node.position);
scene.add(s);
halos.push({
sprite: s,
node
})
}
function guessLayerColorByHeight(y) {
if (Math.abs(y - layers.concept.height) < .6) return layers.concept.color;
if (Math.abs(y - layers.document.height) < .6) return layers.document.color;
if (Math.abs(y - layers.technology.height) < .6) return layers.technology.color;
return layers.social.color
}
function addNodeOutline(n) {
if (n.userData._outline) return;
const om = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: .30
});
OUTLINE_MATS.push(om);
const o = new THREE.Mesh(n.geometry.clone(), om);
o.scale.set(1.18, 1.18, 1.18);
o.renderOrder = 1;
n.add(o);
n.userData._outline = o
}
function applyNeonTheme() {
allNodes.forEach(n => {
const col = (layers[n.userData.layer || ''] || {}).color || 0xffffff;
if (n.material) n.material.dispose();
n.material = neonMat(col, .95);
n.castShadow = n.receiveShadow = false;
addHaloFor(n, col);
addNodeOutline(n)
});
edges.forEach(e => {
const col = (layers[e.layerName || ''] || {}).color || 0xffffff;
e.tube.material.dispose();
e.tube.material = neonMat(col, .95);
e.tube.castShadow = e.tube.receiveShadow = false;
e.cone.material.dispose();
e.cone.material = neonMat(col, 1);
e.cone.castShadow = e.cone.receiveShadow = false;
const om = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: .28
});
OUTLINE_MATS.push(om);
const tO = new THREE.Mesh(new THREE.TubeGeometry(e.curve, EDGE_SEGMENTS, EDGE_RADIUS * 1.45, 10, false), om);
tO.renderOrder = 1;
e.group.add(tO);
e.tubeOutline = tO;
const ocm = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: .35
});
OUTLINE_MATS.push(ocm);
const cO = new THREE.Mesh(new THREE.ConeGeometry(EDGE_RADIUS * 2.4 * 1.25, EDGE_RADIUS * 5.2 * 1.1, 14), ocm);
cO.position.copy(e.cone.position);
cO.quaternion.copy(e.cone.quaternion);
cO.renderOrder = 1;
e.group.add(cO);
e.coneOutline = cO;
const mid = new THREE.Mesh(new THREE.ConeGeometry(EDGE_RADIUS * 2.0, EDGE_RADIUS * 4.2, 12), neonMat(col, .95));
e.group.add(mid);
e.midCone = mid;
});
verticalConnections.forEach(c => {
c.material.dispose();
c.material = neonMat(NEON.green, .9);
c.castShadow = c.receiveShadow = false
});
scene.traverse(o => {
if (!o.isMesh) return;
if (o.geometry?.type === 'BoxGeometry') {
const col = guessLayerColorByHeight(o.position.y);
o.material.dispose();
o.material = neonMat(col, .85);
o.castShadow = o.receiveShadow = false
}
if (o.geometry?.type === 'PlaneGeometry') {
o.material.transparent = true;
o.material.opacity = .05;
o.material.color.set(0xffffff);
o.castShadow = o.receiveShadow = false
}
})
}
function updateHalos() {
halos.forEach(h => h.sprite.position.copy(h.node.position))
}
let infoPanel, reopenBtn, miniHUD;
const nodeIndex = new Map();
function init() {
layers.concept.color = NEON.yellow;
layers.document.color = NEON.orange;
layers.technology.color = NEON.pink;
scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x2a2a3a, 1, 100);
perspCamera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, .1, 1000);
orthoCamera = new THREE.OrthographicCamera();
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true
});
renderer.setSize(innerWidth, innerHeight);
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.35;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.getElementById('canvas-container').appendChild(renderer.domElement);
renderer.domElement.classList.add('grab');
composer = new THREE.EffectComposer(renderer);
renderPass = new THREE.RenderPass(scene, activeCamera());
bloomPass = new THREE.UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 1.15, 0.4, 0.0);
composer.addPass(renderPass);
composer.addPass(bloomPass);
const c = document.createElement('canvas');
c.width = c.height = 256;
const x = c.getContext('2d'),
g = x.createRadialGradient(128, 128, 0, 128, 128, 128);
g.addColorStop(0, 'rgba(255,255,255,1)');
g.addColorStop(.5, 'rgba(255,255,255,.22)');
g.addColorStop(1, 'rgba(255,255,255,0)');
x.fillStyle = g;
x.fillRect(0, 0, 256, 256);
haloTexture = new THREE.CanvasTexture(c);
haloTexture.minFilter = THREE.LinearFilter;
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
scene.add(new THREE.AmbientLight(0xffffff, .45));
const dir = new THREE.DirectionalLight(0xffffff, .65);
dir.position.set(15, 25, 15);
dir.castShadow = true;
dir.shadow.camera.near = .1;
dir.shadow.camera.far = 60;
dir.shadow.camera.left = -20;
dir.shadow.camera.right = 20;
dir.shadow.camera.top = 20;
dir.shadow.camera.bottom = -20;
scene.add(dir);
const pt = new THREE.PointLight(0xffffff, .3);
pt.position.set(-10, 20, -10);
scene.add(pt);
build();
applyNeonTheme();
wireUI();
setPanelCollapsed(localStorage.getItem('infoPanelCollapsed') === 'true');
updateCameraPositionFromSpherical(true);
animate();
}
function build() {
Object.entries(layers).forEach(([name, l]) => {
layersByName[name] = new THREE.Group();
createLayerFrame(l.height, l.color);
scene.add(layersByName[name])
});
nodes.forEach(n => {
const y = layers[n.layer].height;
const m = (n.kind === 'literal') ? createLiteral(n, layers[n.layer].color, y) : createNode(n, layers[n.layer].color, y);
nodeIndex.set(n.id, m);
layersByName[n.layer].add(m);
allNodes.push(m);
nodeLabels.push(createDOMLabel(n.label, m.position))
});
triples.forEach(t => {
const s = nodeIndex.get(t.s),
o = nodeIndex.get(t.o);
if (!s || !o) return;
const col = layers[t.layer].color;
const e = createDirectedCurveEdge(s, o, col, t.p, t.layer);
layersByName[t.layer].add(e.group);
edges.push(e)
});
createVerticalConnections();
}
function createLayerFrame(h, c) {
const s = 14,
t = .2,
mat = new THREE.MeshPhongMaterial({
color: c,
emissive: c,
emissiveIntensity: .2,
transparent: true,
opacity: .8
});
[
[0, h, s / 2],
[0, h, -s / 2],
[s / 2, h, 0],
[-s / 2, h, 0]
].forEach((p, i) => {
const g = i < 2 ? new THREE.BoxGeometry(s, t, t) : new THREE.BoxGeometry(t, t, s);
const m = new THREE.Mesh(g, mat);
m.position.set(...p);
m.castShadow = true;
m.receiveShadow = true;
scene.add(m)
});
const plane = new THREE.Mesh(new THREE.PlaneGeometry(s, s), new THREE.MeshPhongMaterial({
color: 0xffffff,
transparent: true,
opacity: .05,
side: THREE.DoubleSide
}));
plane.position.y = h;
plane.rotation.x = -Math.PI / 2;
plane.receiveShadow = true;
scene.add(plane)
}
function createNode(d, c, h) {
const mat = new THREE.MeshPhongMaterial({
color: c,
emissive: c,
emissiveIntensity: .3,
shininess: 120,
transparent: true,
opacity: 1
});
const geo = new THREE.SphereGeometry(.5, 32, 32);
const m = new THREE.Mesh(geo, mat);
m.position.set(d.x, h, d.z);
m.castShadow = m.receiveShadow = true;
m.userData = d;
return m
}
function createLiteral(d, c, h) {
const geo = new THREE.OctahedronGeometry(.38, 0);
const mat = new THREE.MeshPhongMaterial({
color: 0xdddddd,
emissive: 0x888888,
emissiveIntensity: .15,
transparent: true,
opacity: .95
});
const m = new THREE.Mesh(geo, mat);
m.position.set(d.x, h, d.z);
m.castShadow = m.receiveShadow = true;
m.userData = d;
literalNodes.add(m);
return m
}
function createDOMLabel(text, vec3) {
const d = document.createElement('div');
d.className = 'label3d';
d.textContent = text;
d.userData = {
position: vec3
};
document.body.appendChild(d);
return d
}
function createEdgePill(text) {
const d = document.createElement('div');
d.className = 'label3d edge-pill';
d.innerHTML = text;
document.body.appendChild(d);
return d
}
function createDirectedCurveEdge(start, end, color, pred, layerName) {
const group = new THREE.Group(),
s = start.position,
e = end.position,
mid = new THREE.Vector3().addVectors(s, e).multiplyScalar(.5);
const dirXZ = new THREE.Vector2(e.x - s.x, e.z - s.z),
lenXZ = Math.max(.001, dirXZ.length()),
perp = new THREE.Vector2(-dirXZ.y, dirXZ.x).divideScalar(lenXZ);
const ctrl = new THREE.Vector3(mid.x + perp.x * .7, mid.y + .9, mid.z + perp.y * .7);
let curve = new THREE.QuadraticBezierCurve3(s.clone(), ctrl, e.clone());
const tube = new THREE.Mesh(new THREE.TubeGeometry(curve, EDGE_SEGMENTS, EDGE_RADIUS, 10, false), new THREE.MeshPhongMaterial({
color,
emissive: color,
emissiveIntensity: .18,
transparent: true,
opacity: .9
}));
tube.castShadow = tube.receiveShadow = true;
group.add(tube);
const tHead = .95,
headPos = curve.getPoint(tHead),
tangent = curve.getTangent(tHead).normalize();
const cone = new THREE.Mesh(new THREE.ConeGeometry(EDGE_RADIUS * 2.4, EDGE_RADIUS * 5.2, 14), new THREE.MeshPhongMaterial({
color,
emissive: color,
emissiveIntensity: .25,
transparent: true,
opacity: .98
}));
cone.position.copy(headPos);
cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), tangent);
cone.castShadow = cone.receiveShadow = true;
group.add(cone);
const src = start.userData.label || start.userData.id,
dst = end.userData.label || end.userData.id,
labelText = `<span>${src}</span> <span class="arrow">—[${pred}]→</span> <span>${dst}</span>`;
const labelDiv = createEdgePill(labelText);
return {
start,
end,
group,
tube,
cone,
curve,
ctrl,
labelDiv,
layerName,
pLabel: pred
};
}
function createVerticalConnections() {
verticalConnections = [];
sharedEntities.forEach(ent => {
const pos = [];
Object.keys(layers).forEach(L => {
const ly = layers[L],
n = nodes.find(nd => nd.layer === L && (nd.id === ent.id || nd.id === ent.connectTo));
if (n) pos.push({
x: n.x,
y: ly.height,
z: n.z
})
});
if (pos.length > 1) {
const ys = pos.map(p => p.y),
min = Math.min(...ys),
max = Math.max(...ys),
h = max - min;
const cyl = new THREE.Mesh(new THREE.CylinderGeometry(.12, .12, h, 16), new THREE.MeshPhongMaterial({
color: 0x00ff00,
emissive: 0x00ff00,
emissiveIntensity: .45,
transparent: true,
opacity: .7
}));
cyl.position.set(pos[0].x, min + h / 2, pos[0].z);
cyl.castShadow = cyl.receiveShadow = false;
scene.add(cyl);
verticalConnections.push(cyl)
}
})
}
function projectToScreen(v, c) {
v = v.clone().project(c);
return {
x: (v.x * .5 + .5) * innerWidth,
y: (-v.y * .5 + .5) * innerHeight,
visible: v.z > -1 && v.z < 1
}
}
function updateDOMLabels() {
const cam = activeCamera();
nodeLabels.forEach(l => {
const p = l.userData?.position;
if (!p || labelMode === 'none') {
l.style.display = 'none';
return
}
if (labelMode === 'smart' && cam.position.distanceTo(p) > 28) {
l.style.display = 'none';
return
}
const s = projectToScreen(new THREE.Vector3(p.x, p.y + .8, p.z), cam);
if (!s.visible) {
l.style.display = 'none';
return
}
l.style.left = s.x + 'px';
l.style.top = s.y + 'px';
l.style.display = 'block'
});
edges.forEach(e => {
if (labelMode === 'none') {
e.labelDiv.style.display = 'none';
return
}
if (labelMode === 'smart' && cam.position.distanceTo(e.curve.getPoint(EDGE_LABEL_T)) > 26) {
e.labelDiv.style.display = 'none';
return
}
const s = projectToScreen(e.curve.getPoint(EDGE_LABEL_T), cam);
if (!s.visible) {
e.labelDiv.style.display = 'none';
return
}
e.labelDiv.style.left = s.x + 'px';
e.labelDiv.style.top = s.y + 'px';
e.labelDiv.style.display = 'block'
})
}
function setLayerOpacity(name, o) {
const g = layersByName[name];
if (!g) return;
g.traverse(x => {
if (x.material) {
x.material.transparent = true;
x.material.opacity = o;
x.castShadow = false;
x.receiveShadow = false
}
});
const y = layers[name].height;
nodeLabels.forEach(nl => {
const p = nl.userData?.position;
if (p && Math.abs(p.y - y) < .6) nl.style.opacity = (o === 1 ? 1 : .2)
});
edges.forEach(e => {
const my = e.curve.getPoint(.5).y;
if (Math.abs(my - y) < .8) e.labelDiv.style.opacity = (o === 1 ? 1 : .25)
})
}
function deg2rad(d) {
return d * Math.PI / 180
}
function rad2deg(r) {
return r * 180 / Math.PI
}
function smoothstep(x) {
x = Math.min(1, Math.max(0, x));
return x * x * (3 - 2 * x)
}
function orthoBlendFactor() {
const raw = 1 - (theta / (Math.PI / 2));
return smoothstep(raw)
}
function activeCamera() {
return activeCameraType === 'persp' ? perspCamera : orthoCamera
}
function matchOrthoToPerspective() {
const d = perspCamera.position.distanceTo(lookTarget),
h = Math.tan(deg2rad(perspCamera.fov * .5)) * d,
a = innerWidth / innerHeight;
orthoCamera.top = h;
orthoCamera.bottom = -h;
orthoCamera.right = h * a;
orthoCamera.left = -h * a;
orthoCamera.near = -200;
orthoCamera.far = 200;
orthoCamera.updateProjectionMatrix()
}
function matchPerspectiveToOrtho() {
const d = perspCamera.position.distanceTo(lookTarget),
h = Math.abs(orthoCamera.top),
f = rad2deg(2 * Math.atan(h / d));
perspCamera.fov = THREE.MathUtils.clamp(f, 2, 85);
perspCamera.updateProjectionMatrix()
}
function updateProjectionBlend() {
const b = forceOrtho ? 1 : orthoBlendFactor();
perspCamera.fov = THREE.MathUtils.lerp(75, 2, b);
perspCamera.updateProjectionMatrix();
if (activeCameraType === 'persp' && b > SNAP_IN) {
orthoCamera.position.copy(perspCamera.position);
orthoCamera.up.set(0, 1, 0);
orthoCamera.lookAt(lookTarget);
matchOrthoToPerspective();
activeCameraType = 'ortho'
} else if (activeCameraType === 'ortho' && !forceOrtho && b < SNAP_OUT) {
perspCamera.position.copy(orthoCamera.position);
perspCamera.up.set(0, 1, 0);
perspCamera.lookAt(lookTarget);
matchPerspectiveToOrtho();
activeCameraType = 'persp'
}
}
function updateCameraPositionFromSpherical(skip = false) {
const c = activeCamera();
const x = radius * Math.sin(theta) * Math.cos(phi),
y = radius * Math.cos(theta),
z = radius * Math.sin(theta) * Math.sin(phi);
c.position.set(x, y, z);
c.up.set(0, 1, 0);
c.lookAt(lookTarget);
if (!skip) updateProjectionBlend()
}
function forceToggleProjection() {
forceOrtho = !forceOrtho;
if (forceOrtho && activeCameraType === 'persp') {
orthoCamera.position.copy(perspCamera.position);
orthoCamera.up.set(0, 1, 0);
orthoCamera.lookAt(lookTarget);
matchOrthoToPerspective();
activeCameraType = 'ortho'
} else if (!forceOrtho && activeCameraType === 'ortho') {
perspCamera.position.copy(orthoCamera.position);
perspCamera.up.set(0, 1, 0);
perspCamera.lookAt(lookTarget);
matchPerspectiveToOrtho();
activeCameraType = 'persp'
}
setPressed('orthoBtn', activeCameraType === 'ortho')
}
function onMouseDown(e) {
if (e.button !== 0) return;
dragging = true;
lastX = e.clientX;
lastY = e.clientY;
renderer.domElement.classList.remove('grab');
renderer.domElement.classList.add('grabbing')
}
function onMouseUp() {
dragging = false;
renderer.domElement.classList.remove('grabbing');
renderer.domElement.classList.add('grab')
}
function onMouseDragMove(e) {
if (!dragging) return;
const dx = e.clientX - lastX,
dy = e.clientY - lastY;
phi -= dx * .005;
theta -= dy * .005;
theta = Math.max(.0001, Math.min(Math.PI - .0001, theta));
lastX = e.clientX;
lastY = e.clientY;
updateCameraPositionFromSpherical()
}
function dolly(f) {
if (activeCameraType === 'persp') {
radius /= f;
radius = Math.max(8, Math.min(60, radius))
} else {
orthoCamera.left /= f;
orthoCamera.right /= f;
orthoCamera.top /= f;
orthoCamera.bottom /= f;
orthoCamera.updateProjectionMatrix();
matchPerspectiveToOrtho()
}
updateCameraPositionFromSpherical()
}
function onMouseWheel(e) {
e.preventDefault();
dolly(Math.exp(-e.deltaY * .0015))
}
function onKeyDown(e) {
const k = e.key.toLowerCase(),
rot = .06,
tilt = .06,
zoom = 1.08;
if (k === 'p') {
togglePanel();
return
}
if (k === 'arrowleft' || k === 'a' || k === 'q') phi -= rot;
if (k === 'arrowright' || k === 'd' || k === 'e') phi += rot;
if (k === 'arrowup' || k === 'w') theta -= tilt;
if (k === 'arrowdown' || k === 's') theta += tilt;
if (k === '+' || k === '=') dolly(zoom);
if (k === '-' || k === '_') dolly(1 / zoom);
if (k === 'h') topDown();
if (k === 'o') {
forceToggleProjection();
setPressed('orthoBtn', activeCameraType === 'ortho')
}
if (k === 'r') resetCamera();
if (k === 'l') cycleLabelMode();
theta = Math.max(.0001, Math.min(Math.PI - .0001, theta));
updateCameraPositionFromSpherical()
}
function onHoverMove(e) {
const cam = activeCamera();
mouse.x = (e.clientX / innerWidth) * 2 - 1;
mouse.y = -(e.clientY / innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, cam);
const hits = raycaster.intersectObjects(allNodes);
const tip = document.getElementById('tooltip');
if (hits.length > 0) {
const n = hits[0].object;
const id = n.userData.id || '';
tip.innerHTML = `<strong>${n.userData.label}</strong><br>${n.userData.kind}${id?`<br><small>${id}</small>`:''}`;
tip.style.display = 'block';
tip.style.left = (e.clientX + 10) + 'px';
tip.style.top = (e.clientY - 30) + 'px';
if (n.material.emissiveIntensity != null) n.material.emissiveIntensity = .6;
n.scale.set(1.2, 1.2, 1.2)
} else {
tip.style.display = 'none';
allNodes.forEach(n => {
if (n.material.emissiveIntensity != null) n.material.emissiveIntensity = .3;
n.scale.set(1, 1, 1)
})
}
}
function setupTouchControls(t) {
let last = [],
timer = null;
t.addEventListener('touchstart', e => {
last = [...e.touches];
const a = e.touches[0];
if (a.clientX > innerWidth - 80 && a.clientY < 80) timer = setTimeout(() => setPanelCollapsed(false), 900)
}, {
passive: true
});
t.addEventListener('touchmove', e => {
if (timer) {
clearTimeout(timer);
timer = null
}
if (e.touches.length === 1 && last.length === 1) {
const dx = e.touches[0].clientX - last[0].clientX,
dy = e.touches[0].clientY - last[0].clientY;
phi -= dx * .005;
theta -= dy * .005;
theta = Math.max(.0001, Math.min(Math.PI - .0001, theta));
updateCameraPositionFromSpherical()
} else if (e.touches.length === 2 && last.length === 2) {
const d0 = Math.hypot(last[0].clientX - last[1].clientX, last[0].clientY - last[1].clientY),
d1 = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
dolly(d1 / d0)
}
last = [...e.touches]
}, {
passive: true
})
}
function resetCamera() {
radius = 28;
theta = Math.PI / 4;
phi = Math.PI / 4;
forceOrtho = false;
activeCameraType = 'persp';
updateCameraPositionFromSpherical();
const b = document.getElementById('orthoBtn');
if (b) b.setAttribute('aria-pressed', 'false')
}
function topDown() {
theta = .0001;
radius = 35;
updateCameraPositionFromSpherical()
}
function togglePanel() {
setPanelCollapsed(!infoPanel || infoPanel.classList.contains('collapsed') ? false : true)
}
function setPanelCollapsed(c) {
const mini = document.getElementById('mini-hud');
if (c) {
infoPanel.classList.add('collapsed');
reopenBtn.style.display = 'grid';
mini.style.display = 'flex'
} else {
infoPanel.classList.remove('collapsed');
reopenBtn.style.display = 'none';
mini.style.display = 'none'
};
document.getElementById('collapseBtn').textContent = c ? '▸' : '▾';
document.getElementById('collapseBtn').setAttribute('aria-pressed', c ? 'true' : 'false');
localStorage.setItem('infoPanelCollapsed', c ? 'true' : 'false')
}
function setPressed(id, p) {
const b = document.getElementById(id);
if (b) b.setAttribute('aria-pressed', p ? 'true' : 'false')
}
function flash(id) {
setPressed(id, true);
setTimeout(() => setPressed(id, false), 180)
}
function updateEdges() {
edges.forEach(e => {
const s = e.start.position,
d = e.end.position,
mid = new THREE.Vector3().addVectors(s, d).multiplyScalar(.5);
const v = new THREE.Vector2(d.x - s.x, d.z - s.z),
len = Math.max(.001, v.length()),
perp = new THREE.Vector2(-v.y, v.x).divideScalar(len);
const ctrl = new THREE.Vector3(mid.x + perp.x * .7, mid.y + .9, mid.z + perp.y * .7);
e.curve = new THREE.QuadraticBezierCurve3(s.clone(), ctrl, d.clone());
e.tube.geometry.dispose();
e.tube.geometry = new THREE.TubeGeometry(e.curve, EDGE_SEGMENTS, EDGE_RADIUS, 10, false);
if (e.tubeOutline) {
e.tubeOutline.geometry.dispose();
e.tubeOutline.geometry = new THREE.TubeGeometry(e.curve, EDGE_SEGMENTS, EDGE_RADIUS * 1.45, 10, false)
}
const t = .95,
p = e.curve.getPoint(t),
tan = e.curve.getTangent(t).normalize();
e.cone.position.copy(p);
e.cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), tan);
if (e.coneOutline) {
e.coneOutline.position.copy(p);
e.coneOutline.quaternion.copy(e.cone.quaternion)
}
if (e.midCone) {
const tm = .6,
pm = e.curve.getPoint(tm),
tn = e.curve.getTangent(tm).normalize();
e.midCone.position.copy(pm);
e.midCone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), tn)
}
})
}
function updateTopDownContrast() {
const t = orthoBlendFactor();
if (bloomPass) {
bloomPass.strength = THREE.MathUtils.lerp(1.1, 0.32, t);
bloomPass.threshold = THREE.MathUtils.lerp(0.00, 0.60, t);
bloomPass.radius = THREE.MathUtils.lerp(0.40, 0.25, t)
}
const normal = t > 0.82;
NEON_MATS.forEach(m => {
m.opacity = THREE.MathUtils.lerp(0.95, 0.60, t);
m.blending = normal ? THREE.NormalBlending : THREE.AdditiveBlending;
m.needsUpdate = true
});
OUTLINE_MATS.forEach(m => {
m.opacity = THREE.MathUtils.lerp(0.25, 0.65, t)
})
}
function wireUI() {
infoPanel = document.getElementById('info');
reopenBtn = document.getElementById('reopen');
miniHUD = document.getElementById('mini-hud');
document.getElementById('collapseBtn').addEventListener('click', () => togglePanel());
reopenBtn.addEventListener('click', () => setPanelCollapsed(false));
document.getElementById('animBtn').addEventListener('click', () => {
animating = !animating;
setPressed('animBtn', animating)
});
document.getElementById('labelsBtn').addEventListener('click', () => cycleLabelMode());
document.getElementById('topBtn').addEventListener('click', () => {
topDown();
flash('topBtn')
});
document.getElementById('orthoBtn').addEventListener('click', () => {
forceToggleProjection();
setPressed('orthoBtn', activeCameraType === 'ortho')
});
document.getElementById('resetBtn').addEventListener('click', () => {
resetCamera();
flash('resetBtn')
});
['concept', 'document', 'technology', 'social'].forEach(n => document.getElementById('fade-' + n).addEventListener('change', e => setLayerOpacity(n, e.target.checked ? 1 : .03)));
document.getElementById('toggle-vertical').addEventListener('change', e => verticalConnections.forEach(c => c.visible = e.target.checked));
document.getElementById('miniLabels').addEventListener('click', cycleLabelMode);
document.getElementById('miniTop').addEventListener('click', topDown);
document.getElementById('miniPanel').addEventListener('click', () => setPanelCollapsed(false));
const canvas = renderer.domElement;
canvas.addEventListener('mousedown', onMouseDown);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('mousemove', onMouseDragMove);
canvas.addEventListener('wheel', onMouseWheel, {
passive: false
});
window.addEventListener('keydown', onKeyDown);
window.addEventListener('mousemove', onHoverMove);
setupTouchControls(canvas);
window.addEventListener('resize', () => {
renderer.setSize(innerWidth, innerHeight);
if (composer) composer.setSize(innerWidth, innerHeight);
if (bloomPass && bloomPass.setSize) bloomPass.setSize(innerWidth, innerHeight);
perspCamera.aspect = innerWidth / innerHeight;
perspCamera.updateProjectionMatrix();
if (activeCameraType === 'persp') matchOrthoToPerspective();
else matchPerspectiveToOrtho();
updateCameraPositionFromSpherical()
})
}
function cycleLabelMode() {
labelMode = (labelMode === 'smart') ? 'all' : (labelMode === 'all' ? 'none' : 'smart');
const b = document.getElementById('labelsBtn');
b.dataset.mode = labelMode;
b.textContent = '🏷️ Labels: ' + (labelMode === 'smart' ? 'Smart' : labelMode === 'all' ? 'All' : 'None');
b.setAttribute('aria-pressed', labelMode !== 'none');
document.getElementById('miniLabels').textContent = (labelMode === 'none' ? '🏷️×' : labelMode === 'smart' ? '🏷️✓' : '🏷️★')
}
function animate() {
requestAnimationFrame(animate);
if (animating) {
allNodes.forEach((n, i) => {
n.position.y = n.userData.originalY ?? n.position.y;
if (n.userData.originalY == null) n.userData.originalY = n.position.y;
n.position.y = n.userData.originalY + Math.sin(Date.now() * .001 + i * .5) * .05
})
}
if (uprightBias && !dragging) {
const snap = Math.round(phi / (Math.PI / 2)) * (Math.PI / 2);
phi += (snap - phi) * .03
}
updateEdges();
updateProjectionBlend();
updateDOMLabels();
updateHalos();
updateTopDownContrast();
renderPass.camera = activeCamera();
if (composer) composer.render();
else renderer.render(scene, activeCamera());
}
init();
window.__graph = {
setLayerOpacity,
cycleLabelMode,
setPanelCollapsed
};
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment