A Reusable 3D Pipeline Dashboard: Map, Topology, and Spatial Flow in Three.js
Topology Pipeline Network 3D Visualization Dashboard: A Tech-Sense Demo Technical Breakdown (Map + Topology + 3D Space)
A tech-sense 3D visualization demo driven purely by Three.js (r160 ES Module) + ECharts. This article dissects its overall architecture and 7 core technical points, with all code snippets directly copyable and reusable.
Screenshot:
- GitHub Repository: https://github.com/weiweiweigang/topo-big-screen1
- Online Demo: https://weiweiweigang.github.io/topo-big-screen1/
I. Project Background and Positioning
This is a set of 3D data visualization dashboards with tech-sense and dazzling visuals as the primary goal: overlaying a heating pipeline network topology and 3D spatial pipelines on a real geographic base map, presenting a unified visualization effect of "map + topology + 3D".
Its positioning is clear—a reusable collection of effects, not the frontend of a specific industrial system. If you see a suitable effect (like double-layer glow pipelines, opposing particle streams, camera micro-sway, high-altitude engine scanning), just copy the corresponding code directly.
- Technical base: Real Handan city administrative division GeoJSON + 136 segments of real pipeline network topology (Web Mercator coordinates)
- Visual elements: Dark tech style, glowing pipeline network, flowing particles, high-altitude computing engine scanning, bloom post-processing
- Nature: Pure frontend demo, no backend dependencies, can be previewed locally by changing one line
II. Tech Stack and Overall Architecture
2.1 Module Responsibility Division
| Module | File | Responsibility |
|---|---|---|
| SceneManager (Orchestrator) | three-scene.js |
Stage setup + per-frame orchestration, dispatches all other business modules, writes no business logic |
| NetworkBuilder | NetworkBuilder.js |
Station nodes, real pipeline curves, flowing particles, alarm/breathing animations |
| ComputeEngine | computeEngine/index.js |
High-altitude real-time computing engine + timed scanning effects + scheme switching (orchestration class) |
| MapBuilder | map.js |
GeoJSON district extrusion, boundary glow lines, coordinate projection tools |
| Background | background.js |
Gradient background + glowing grid + floating particles |
| OverlayManager | overlay.js |
Overlay panels (metrics/alarms/charts/detail popups) |
| Data Layer | data.js + data/*.json |
Station/alarm data, topology lines, administrative division GeoJSON |
| Entry | main.js |
Initialization, async loading, animation loop |
2.2 Lifecycle and Data Flow
flowchart TD
A[main.js Entry] --> B[new SceneManager]
A --> C[OverlayManager]
B --> D[scene.init<br/>MapBuilder.load handan_geo.json]
D --> E[scene.buildNetwork networkData]
E --> F[NetworkBuilder.buildNetwork]
F --> F1[fetch topology_line.json]
F1 --> F2[Supply _chainSegments → CatmullRom]
F2 --> F3[Return _offsetCurveLateral]
F3 --> F4[_createMergedPipe Double-layer pipe]
F3 --> F5[_createPathParticles Particles]
E --> G[ComputeEngine.create High-altitude engine]
E --> H[Background Background enhancement]
B --> I[animate loop: scene.update delta]
I --> I1[Undo micro-sway → controls.update]
I1 --> I2[network/engine/background.update]
I2 --> I3[composer.render + labelRenderer.render]
Key design: SceneManager only "sets the stage + announces the acts", delegating construction (build) and per-frame advancement (update). Each sub-module receives references to scene / camera / controls, managing its own Meshes and animations, decoupled from each other.
2.3 Scene Orchestrator
SceneManager initializes the scene, camera, renderer, label renderer, controls, lights, post-processing, and picking once during construction, and injects business modules:
// three-scene.js (excerpt)
this.network = new NetworkBuilder(this.scene, this.camera, this.controls);
this.engine = new ComputeEngine(this.scene, this.controls);
Each frame does only four things—undo the previous frame's camera micro-sway, update controls, advance business animations, and render:
update(delta) {
if (this._lastSway) { /* Undo previous frame's micro-sway offset */ }
this.controls.update();
if (!this._userInteracting) { /* Overlay camera micro-sway */ }
this.network.update(delta);
this.engine.update(delta);
if (this.background) this.background.update(delta);
this.composer.render();
this.labelRenderer.render(this.scene, this.camera);
}
III. Deep Dive into Core Technical Points
3.1 Curving Real Pipeline Network Topology
The raw data consists of discrete pipeline segments (each with endpoint ids connId1 / connId2 and start/end coordinates). To draw a continuous fluid pipeline, the segments must first be "chained" into a path, then smoothed.
Supply: Chaining segments by connection + Catmull-Rom smoothing
// NetworkBuilder._chainSegments (excerpt of logic)
// 1. Use connId to build an index: which segments connect at endpoint A, endpoint B
// 2. Find starting point: connId1 does not appear in any segment's connId2
// 3. Forward tracing + backward tracing, assemble ordered segment sequence
// 4. Take each segment's endpoints to generate an ordered point list
return new THREE.CatmullRomCurve3(points, false, 'catmullrom', 0.3);
Return: Reuse supply curve, offset in a fixed direction
The return pipeline is almost parallel to the supply, but cannot be offset point-by-point using the pipeline's normal—at bends, the normal direction of adjacent segments changes abruptly, causing the return curve to "twist/self-intersect". The project uses a very stable approximation: calculate a global lateral direction using the start-to-end vector of the entire curve, and translate uniformly.
// NetworkBuilder._offsetCurveLateral
_offsetCurveLateral(curve, lateralOffset, yOffset) {
const pts = curve.points;
const up = new THREE.Vector3(0, 1, 0);
// Overall direction of the entire curve
const overallDir = new THREE.Vector3()
.subVectors(pts[pts.length - 1], pts[0]).normalize();
// Direction × up = fixed lateral normal direction
const fixedNormal = new THREE.Vector3()
.crossVectors(overallDir, up).normalize();
const offsetPoints = pts.map(p => new THREE.Vector3(
p.x + fixedNormal.x * lateralOffset, // Lateral 0.8
yOffset, // Lower Y 0.3
p.z + fixedNormal.z * lateralOffset
));
return new THREE.CatmullRomCurve3(offsetPoints, false, 'catmullrom', 0.3);
}
Experience: When "parallel offset" would self-intersect locally, step back and use a global direction offset, sacrificing physical accuracy for visual stability, which is completely sufficient for dashboards.
3.2 Double-Layer Pipe Rendering and Transparency Sorting
A pipeline needs to simultaneously have a "solid feel" and a "tech glow feel", while also allowing particles flowing inside to be visible. The final solution is three-layer overlay:
| Layer | Geometry | Material | renderOrder | Role |
|---|---|---|---|---|
| Inner layer | TubeGeometry(r=0.08) |
MeshStandardMaterial opaque, emissive, depthWrite:false |
5 | Pipe solid |
| Outer shell | TubeGeometry(r=0.3) |
MeshBasicMaterial semi-transparent(opacity 0.1), depthWrite:false, DoubleSide |
10 | Glow |
| Particles | SphereGeometry(r=0.15) |
MeshBasicMaterial, depthWrite:false |
5 | Flowing medium |
// Inner layer (pipe solid)
const innerMesh = new THREE.Mesh(
new THREE.TubeGeometry(curve, tubularSegments, 0.08, 8, false),
new THREE.MeshStandardMaterial({ color:0x1a2a44, emissive: color,
emissiveIntensity: 0.5, metalness: 0.8, roughness: 0.3,
transparent: true, opacity: 1.0, depthWrite: false })
);
innerMesh.renderOrder = 5;
// Outer shell (glow, no depth write to avoid occluding particles)
const outerMesh = new THREE.Mesh(
new THREE.TubeGeometry(curve, tubularSegments, 0.3, 12, false),
new THREE.MeshBasicMaterial({ color, transparent:true, opacity:0.1,
depthWrite:false, side: THREE.DoubleSide })
);
outerMesh.renderOrder = 10;
Why manually control renderOrder and disable depthWrite?
The default depth sorting for transparent objects inevitably fails in a scene like a pipeline network with "interpenetrating + semi-transparent shells". The project's convention is:
District faces (depthWrite:false, renderOrder:0)
→ Pipe inner layer + Particles (renderOrder:5)
→ Pipe outer shell (renderOrder:10)
That is: draw the bottom semi-transparent districts first (no depth write) → then draw pipes and particles → finally draw the outermost glow shell. With full-chain depthWrite:false, semi-transparent objects no longer "eat" each other, and the glow always wraps on the outside.
3.3 Opposing Particle Flow Animation
Supply and return must display "opposing flow". Particles use getPointAt(t) on the CatmullRomCurve3 to get their position, with t cycling in [0,1]:
// NetworkBuilder._createPathParticles + update (excerpt)
this.particles.forEach(p => {
if (p.userData.reverse) { // Return: t decrements
p.userData.t -= p.userData.speed;
if (p.userData.t < 0) p.userData.t += 1;
} else { // Supply: t increments
p.userData.t += p.userData.speed;
if (p.userData.t > 1) p.userData.t -= 1;
}
p.position.copy(p.userData.curve.getPointAt(p.userData.t));
});
Because the return curve is an offset copy of the supply curve, both share the same parameter space. Same t direction means same path; reverse means "head-on", visually forming a natural loop flow.
3.4 Smootherstep Easing for Breathing / Alarm Animations
For "soft undulations" like the main station glow and alarm pulses, simple sin is not used; instead, a layer of smootherstep is applied—zero-order, first-order, and second-order derivatives are all zero at the endpoints, making the transition more "stable" than sin, without abruptness:
// NetworkBuilder.update (excerpt)
const breathTime = performance.now() * 0.003; // ~2 second cycle
this.breathingObjects.forEach(obj => {
let t = (Math.sin(breathTime + (obj.phase || 0)) + 1) * 0.5; // 0→1
t = t * t * t * (t * (t * 6 - 15) + 10); // smootherstep
if (obj.isOpacity)
obj.mat.opacity = obj.baseOpacity + obj.amplitude * t;
else
obj.mat.emissiveIntensity = obj.baseEmissive + obj.amplitude * t;
});
Alarm pulse rings are driven directly by sin for scaling + opacity:
const time = performance.now() * 0.003;
this.alarmObjects.forEach(obj => {
if (obj.type === 'ring') {
const scale = 1 + Math.sin(time) * 0.3;
obj.mesh.scale.set(scale, scale, 1);
obj.mesh.material.opacity = obj.baseOpacity * (0.5 + Math.sin(time) * 0.5);
}
});
3.5 Symbiosis of Camera Micro-Sway and OrbitControls
When the dashboard is static, a slight "breathing" camera sway adds vitality; but the moment a user drags, it must immediately stop and must never corrupt the user's OrbitControls state.
The most ingenious part is this logic of "undo first, hand over to controls, then overlay":
// three-scene.js SceneManager.update (excerpt)
update(delta) {
// ① Undo the previous frame's micro-sway offset, so controls calculate based on "clean position"
if (this._lastSway) {
this.camera.position.x -= this._lastSway.x;
this.camera.position.y -= this._lastSway.y;
this.camera.position.z -= this._lastSway.z;
}
// ② Controls update (damping, user drag all take effect in this step)
this.controls.update();
// ③ Overlay micro-sway when not interacting; clear cache when interacting
if (!this._userInteracting) {
const t = performance.now() * 0.00105; // ~6 second cycle
const sx = Math.sin(t) * 2.0;
const sy = Math.sin(t * 0.7) * 1.0;
const sz = Math.cos(t) * 2.0;
this.camera.position.x += sx;
this.camera.position.y += sy;
this.camera.position.z += sz;
this.camera.lookAt(this.controls.target);
this._lastSway = { x: sx, y: sy, z: sz }; // Record this offset for next frame's undo
} else {
this._lastSway = null;
}
// ...
}
Why can they "coexist" without conflict?
- Each frame first subtracts the previous frame's offset, so controls always work on a "micro-sway-free" baseline pose; user drag/zoom is never polluted by the overlay amount;
- The micro-sway amount is recorded each frame and undone the next, forming a "net-zero" loop;
- Interaction detection uses
controls'start/endevents + a 2-second idle timer: during drag,_userInteracting = truedirectly skips overlay; micro-sway resumes only 2 seconds after release.
3.6 High-Altitude Computing Engine: Orchestration Pattern and Preset Switching
The "real-time computing engine" module uses combinatorial presets to manage complexity: the three elements—engine body / downward streamer / expanding ring—each have multiple implementations, switched as a whole by 1/2/3/4.
// computeEngine/index.js (excerpt)
// Three elements each have multiple schemes, switched by combinatorial presets
// 1 Stable·Status Quo → A+A+A 2 Clean·Sharp → D+D+C
// 3 Data Flow → C+C+D 4 Radar Scan Feel → E+B+B
setPreset(n) {
if (!PRESETS[n]) return;
this.scheme = PRESETS[n];
this._disposeEngine(); // Destroy old engine (dispose geometry/material/labels)
this.engineGroup = new THREE.Group();
this.engineGroup.position.set(this._cx, this._cy, this._cz);
this._buildEngine(this.scheme.engine, this.engineGroup); // Rebuild with new scheme
// Rebuild labels + clear ongoing scan effects
}
_buildEngine delegates specific construction to engines/reactor.js / points.js / fresnel.js / morph.js, and _triggerScan delegates to scan.js—the orchestration class only dispatches, never implements. When switching schemes, the entire group is destroyed and rebuilt, eliminating residual meshes/particles from the old scheme. Among them, scheme E's "polymorphic core" can even switch to the next form with each scan pulse (cubic lattice→icosahedron→octahedron→Rubik's cube cycle), using smoothstep for morphing transitions between forms.
3.7 Post-Processing Bloom and Picking Interaction
Bloom uses UnrealBloomPass to give all emissive materials a unified "glow", the main source of the tech-sense:
// three-scene.js _initPostProcessing
this.composer = new EffectComposer(this.renderer);
this.composer.addPass(new RenderPass(this.scene, this.camera));
this.composer.addPass(new UnrealBloomPass(
new THREE.Vector2(w, h), 0.6 /* strength */, 0.5 /* radius */, 0.15 /* threshold */
));
this.composer.addPass(new OutputPass());
Picking uses Raycaster to hit interaction proxies for nodes or pipes (each supply pipe segment has an invisible TubeGeometry proxy, opacity:0 for clicking):
// three-scene.js _initRaycaster (excerpt)
const targets = [...this.network.nodeMeshes, ...this.network.pipeMeshes];
const intersects = this.raycaster.intersectObjects(targets, true);
// Traverse up to the ancestor with userData.nodeData / pipeData
After a hit, the data is passed to OverlayManager via the scene.onNodeClick callback to pop up a detail panel; ECharts charts are also rendered by it.
IV. Summary and Extensible Directions
This dashboard's architecture can be condensed into one sentence: SceneManager sets the stage and announces the acts; business modules each manage their own domain, decoupling scene infrastructure from business construction via import chains.
Several patterns worth reusing:
- Orchestrator pattern: Completely separate "scene infrastructure" from "business construction/animation"; sub-modules take references and self-manage;
- Global direction offset instead of point-by-point normal: A stable solution for parallel pipelines at bends;
renderOrder+depthWrite:falsethree-stage: The iron law of sorting for semi-transparent layered scenes;- Camera micro-sway's "net-zero loop": Undo first → controls update → then overlay, coexisting with OrbitControls without conflict;
- Combinatorial presets + full destruction and rebuild: A clean approach for multi-scheme visualization switching.
Directions for further expansion:
- Connect your own data source: Replace
data/topology_line.jsonanddata/handan_geo.json, swapnetworkDatawith your topology/node data; - Use
InstancedMeshinstead of per-particleMesh, compressing 25×N particles into a single draw call; - Abstract
lngLatToSceneinto a general projection layer, supporting multi-city switching.
V. Source Code and Online Preview
- GitHub Repository: https://github.com/weiweiweigang/topo-big-screen1
- Online Demo: https://weiweiweigang.github.io/topo-big-screen1/
- Administrative GeoJSON data source (downloadable national/provincial district boundaries): https://datav.aliyun.com/portal/school/atlas/area_selector
This is a pure frontend demo with no backend dependencies. If you see a suitable effect, just copy the corresponding module's code directly.
The code repository is the essence; all examples can be found implemented in the topo-big-screen1 project.
Top 1 of 6 from juejin.cn, machine-translated. The original thread is authoritative.
6
[fist-and-palm salute][fist-and-palm salute]
Actually, the biggest problem everyone encounters with 3D isn't the tech or the implementation — it's lag, the browser just can't handle it.