Frontend Development 18 min read

City Data Visualization Demo with Three.js, Vite, and TypeScript

This article presents a step‑by‑step guide for building an interactive 3D city data visualization using Vite, TypeScript, and Three.js, covering model loading, scene centering, fly‑line generation, line‑drawing, CSS2D overlays, camera presets, and custom shaders with complete code examples.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
City Data Visualization Demo with Three.js, Vite, and TypeScript

Introduction

With the rapid deployment of smart cameras in modern cities for fire monitoring, personnel tracking, and equipment verification, developers often need to create 3D visualizations of urban data. This tutorial demonstrates a complete demo built with Vite , TypeScript , and Three.js that includes building effects, fly‑lines, custom camera angles, and animations.

Model Loading

The city model is provided in GLTF format, so Three.js’s GLTFLoader is used to load it.

export function loadGltf(url: string) {
  return new Promise
((resolve, reject) => {
    gltfLoader.load(url, function (gltf) {
      console.log('gltf', gltf);
      resolve(gltf);
    });
  });
}

After loading, unnecessary meshes are removed and the remaining ones are renamed for easier reference.

loadGltf('./models/scene.gltf').then((gltf: any) => {
  const group = gltf.scene;
  const scale = 10;
  group.scale.set(scale, scale, scale);

  // Delete unused meshes
  const mesh1 = group.getObjectByName('Text_test-base_0');
  if (mesh1 && mesh1.parent) mesh1.parent.remove(mesh1);
  const mesh2 = group.getObjectByName('Text_text_0');
  if (mesh2 && mesh2.parent) mesh2.parent.remove(mesh2);

  // Rename useful meshes
  const hqjrzx = group.getObjectByName('02-huanqiujinrongzhongxin_huanqiujinrongzhongxin_0');
  if (hqjrzx) hqjrzx.name = 'hqjrzx';
  const shzx = group.getObjectByName('01-shanghaizhongxindasha_shanghaizhongxindasha_0');
  if (shzx) shzx.name = 'shzx';
  const jmds = group.getObjectByName('03-jinmaodasha_jjinmaodasha_0');
  if (jmds) jmds.name = 'jmds';
  const dfmzt = group.getObjectByName('04-dongfangmingzhu_dongfangmingzhu_0');
  if (dfmzt) dfmzt.name = 'dfmzt';

  T.scene.add(group);
  T.toSceneCenter(group);
  T.ray(group.children, (meshList) => {
    console.log('meshList', meshList);
  });
  T.animate();
});

The helper method toSceneCenter recenters the model by computing its bounding box with THREE.Box3 and moving the mesh so that its center aligns with the world origin.

getBoxInfo(mesh) {
  const box3 = new THREE.Box3();
  box3.expandByObject(mesh);
  const size = new THREE.Vector3();
  const center = new THREE.Vector3();
  box3.getCenter(center);
  box3.getSize(size);
  return { size, center };
}

toSceneCenter(mesh) {
  const { center } = this.getBoxInfo(mesh);
  mesh.position.copy(center.negate().setY(0));
}

Fly‑Line Generation

Points for fly‑lines are collected by ray‑casting on mouse clicks. The controller records whether the mouse moved to differentiate a click from a drag.

this.controls.addEventListener('start', () => {
  this.controlsStartPos.copy(this.camera.position);
});

this.controls.addEventListener('end', () => {
  this.controlsMoveFlag = this.controlsStartPos.distanceToSquared(this.camera.position) === 0;
});

Ray‑casting extracts the intersection point and stores it in an array.

ray(children, callback) {
  let mouse = new THREE.Vector2();
  var raycaster = new THREE.Raycaster();
  window.addEventListener('click', (event) => {
    mouse.x = (event.clientX / document.body.offsetWidth) * 2 - 1;
    mouse.y = -(event.clientY / document.body.offsetHeight) * 2 + 1;
    raycaster.setFromCamera(mouse, this.camera);
    const rallyist = raycaster.intersectObjects(children);
    if (this.controlsMoveFlag) {
      callback && callback(rallyist);
    }
  });
}

Collected points are interpolated with THREE.SplineCurve to obtain a dense set of vertices. Each segment is rendered as a series of small points that move forward each frame, creating the illusion of a flowing line.

flyLineData.forEach((data) => {
  const points = [];
  for (let i = 0; i < data.length / 3; i++) {
    const x = data[i * 3];
    const z = data[i * 3 + 2];
    points.push(new THREE.Vector2(x, z));
  }
  const curve = new THREE.SplineCurve(points);
  const curvePoints = curve.getPoints(100);
  const flyPoints = curvePoints.map(p => new THREE.Vector3(p.x, 0, p.y));

  const flyGroup = T._Fly.setFly({
    index: Math.random() > 0.5 ? 50 : 20,
    num: 20,
    points: flyPoints,
    spaced: 50,
    starColor: new THREE.Color(Math.random() * 0xffffff),
    endColor: new THREE.Color(Math.random() * 0xffffff),
    size: 0.5
  });
  flyLineGroup.add(flyGroup);
});

The SetFly interface defines the parameters for each fly‑line, including start index, length, color gradient, and point spacing.

interface SetFly {
  index: number; // start index
  num: number;   // length (must be < total points)
  points: Vector3[];
  spaced: number; // segment division count, default 5
  starColor: Color;
  endColor: Color;
  size: number;
}

Line‑Drawing (Wireframe)

To highlight building edges, LineBasicMaterial and MeshLambertMaterial are combined. The helper changeModelMaterial creates an EdgesGeometry from a mesh and attaches the resulting LineSegments as a child.

export const otherBuildingMaterial = (color, opacity = 1) =>
  new THREE.MeshLambertMaterial({ color, transparent: true, opacity });

export const otherBuildingLineMaterial = (color, opacity = 1) =>
  new THREE.LineBasicMaterial({ color, depthTest: true, transparent: true, opacity });

export const changeModelMaterial = (mesh, meshMaterial, lineMaterial, deg = 1) => {
  if (mesh.isMesh) {
    if (meshMaterial) mesh.material = meshMaterial;
    const line = getLine(mesh, deg, lineMaterial);
    const name = mesh.name + '_line';
    line.name = name;
    mesh.add(line);
  }
};

export const getLine = (object, thresholdAngle = 1, lineMaterial) => {
  const edges = new THREE.EdgesGeometry(object.geometry, thresholdAngle);
  const line = new THREE.LineSegments(edges);
  if (lineMaterial) line.material = lineMaterial;
  return line;
};

Camera Presets and CSS2D Overlays

Predefined camera positions are stored as arrays of position and target vectors. A hidden div (id="css2dRender") hosts the CSS2DRenderer DOM elements. Each preset creates a li element with the position data, which is turned into a CSS2DObject and added to the scene.

export interface CameraPosInfo {
  pos: number[];   // camera position
  target: number[]; // look‑at target
  name: string;
  tagPos?: number[]; // optional 2D tag position
}

function createTag() {
  const buildTagGroup = new THREE.Group();
  T.scene.add(buildTagGroup);
  presetsCameraPos.forEach((cameraPos, i) => {
    if (cameraPos.tagPos) {
      const element = document.createElement('li');
      element.setAttribute('data-cameraPosInfo', JSON.stringify(cameraPos));
      element.classList.add('build_tag');
      element.innerText = `${i + 1}`;
      const tag = new CSS2DObject(element);
      const tagPos = new THREE.Vector3().fromArray(cameraPos.tagPos);
      tag.position.copy(tagPos);
      buildTagGroup.add(tag);
    }
  });
}

Clicking a tag reads the data-cameraPosInfo attribute, updates the OrbitControls target, and animates the camera using TWEEN for smooth motion.

if (css2dDom) {
  css2dDom.addEventListener('click', function (e) {
    if (e.target && e.target.nodeName === 'LI') {
      const cameraPosInfo = e.target.getAttribute('data-cameraPosInfo');
      if (cameraPosInfo) {
        const { pos, target } = JSON.parse(cameraPosInfo);
        T.controls.target.set(...target);
        T.handleCameraPos(pos);
      }
    }
  });
}

handleCameraPos(end) {
  const endV3 = new THREE.Vector3().fromArray(end);
  const length = this.camera.position.distanceTo(endV3);
  if (length === 0) return;
  new this._TWEEN.Tween(this.camera.position)
    .to(endV3, Math.sqrt(length) * 400)
    .start()
    .onComplete(() => {
      // optional callback after animation
    });
}

Scene Background

A canvas is used to draw a radial gradient, which is then applied as scene.background to replace the plain dark color.

createScene() {
  const drawingCanvas = document.createElement('canvas');
  const context = drawingCanvas.getContext('2d');
  if (context) {
    drawingCanvas.width = this.width;
    drawingCanvas.height = this.height;
    const gradient = context.createRadialGradient(
      this.width / 2, this.height, 0,
      this.width / 2, this.height / 2, Math.max(this.width, this.height)
    );
    gradient.addColorStop(0, '#0b171f');
    gradient.addColorStop(0.6, '#000000');
    context.fillStyle = gradient;
    context.fillRect(0, 0, drawingCanvas.width, drawingCanvas.height);
    this.scene.background = new THREE.CanvasTexture(drawingCanvas);
  }
}

Conclusion

The complete source code for each stage (model loading, fly‑lines, wireframe, camera presets, background) is referenced as versioned demo files (e.g., "城市加载白模 v2.0.1", "城市飞线 v2.0.2", etc.). This guide equips front‑end developers with a reusable pipeline for building interactive 3D city visualizations using modern web technologies.

FrontendTypeScriptThree.jsWebGLvite3D visualization
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.