跪拜 Guibai
← Back to the summary

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:

2026-07-21-14-02-22_high-compression.awebp

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.


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?

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.jsthe 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:

  1. Orchestrator pattern: Completely separate "scene infrastructure" from "business construction/animation"; sub-modules take references and self-manage;
  2. Global direction offset instead of point-by-point normal: A stable solution for parallel pipelines at bends;
  3. renderOrder + depthWrite:false three-stage: The iron law of sorting for semi-transparent layered scenes;
  4. Camera micro-sway's "net-zero loop": Undo first → controls update → then overlay, coexisting with OrbitControls without conflict;
  5. Combinatorial presets + full destruction and rebuild: A clean approach for multi-scheme visualization switching.

Directions for further expansion:


V. Source Code and Online Preview

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.

Comments

Top 1 of 6 from juejin.cn, machine-translated. The original thread is authoritative.

summer777

6

Strayer

[fist-and-palm salute][fist-and-palm salute]

summer777  → Strayer

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.