A Pen by Dan Brickley on CodePen.
Created
August 31, 2025 18:31
-
-
Save danbri/8485a7b4e7b588091106d631d501e672 to your computer and use it in GitHub Desktop.
layerfake2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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