Skip to content

Basic Visualization

This tutorial builds on the project from Getting Started. Follow its setup first: scaffold a Vite TypeScript project, style the <body>, and install the Navara packages and their peer dependencies.

In addition, this tutorial uses TileJsonPlugin from @navaramap/three-plugins, so install that package as well:

Terminal window
npm install @navaramap/three-plugins

Replace the contents of src/main.ts with the following:

import ThreeView from "@navaramap/three";
import { DefaultPlugin, type DefaultDescriptions } from "@navaramap/three-default-plugin";
import { TileJsonPlugin } from "@navaramap/three-plugins";
const plugin = new DefaultPlugin();
const view = new ThreeView<DefaultDescriptions>({});
view.addPlugin(plugin);
const tilejson = new TileJsonPlugin();
view.addPlugin(tilejson);
await view.init();

view.init() builds the 3D scene and camera, and plugins can only be registered before it, and addPlugin() throws afterwards. Registering DefaultPlugin is what makes built-in Descriptors such as ambient valid in the next step.

Add the following code to main.ts:

view.addLight({
ambient: {},
});
const basemapSource = await tilejson.addSource({
type: "raster-tile",
url: "https://papers.reearth.land/styles/protomaps-light/tilejson.json",
});
view.addLayer({
type: "raster",
source: basemapSource,
});

Ambient light alone is all a raster basemap needs. The basemap comes from a Re:Earth Papers TileJSON document. TileJsonPlugin.addSource() fetches it and derives the tile URL template, zoom range, and attribution. See TileJsonPlugin for the full mapping.

A base map will be displayed on the globe in the scene.

Basic Map

import ThreeView from "@navaramap/three";
import { DefaultPlugin, type DefaultDescriptions } from "@navaramap/three-default-plugin";
import { TileJsonPlugin } from "@navaramap/three-plugins";
const plugin = new DefaultPlugin();
const view = new ThreeView<DefaultDescriptions>({});
view.addPlugin(plugin);
const tilejson = new TileJsonPlugin();
view.addPlugin(tilejson);
await view.init();
view.addLight({
ambient: {},
});
const basemapSource = await tilejson.addSource({
type: "raster-tile",
url: "https://papers.reearth.land/styles/protomaps-light/tilejson.json",
});
view.addLayer({
type: "raster",
source: basemapSource,
});

To display the map at a specific location, set the camera position. Add the following to main.ts:

view.setCamera({
lng: 139.7671,
lat: 35.6812,
height: 1000,
heading: 0, // -180 to 180
pitch: -30, // -180 to 0
roll: 0, // -180 to 180
});

For the full parameter list, see ThreeView Functions.

The camera position is set to the area around Tokyo.

Camera Map

import ThreeView from "@navaramap/three";
import { DefaultPlugin, type DefaultDescriptions } from "@navaramap/three-default-plugin";
import { TileJsonPlugin } from "@navaramap/three-plugins";
const plugin = new DefaultPlugin();
const view = new ThreeView<DefaultDescriptions>({});
view.addPlugin(plugin);
const tilejson = new TileJsonPlugin();
view.addPlugin(tilejson);
await view.init();
view.addLight({
ambient: {},
});
const basemapSource = await tilejson.addSource({
type: "raster-tile",
url: "https://papers.reearth.land/styles/protomaps-light/tilejson.json",
});
view.addLayer({
type: "raster",
source: basemapSource,
});
view.setCamera({
lng: 139.7671,
lat: 35.6812,
height: 1000,
heading: 0, // -180 to 180
pitch: -30, // -180 to 0
roll: 0, // -180 to 180
});

Terrain comes from a quantized-mesh source: pre-meshed tiles that carry their own geometry, so there is no elevation encoding to decode. Requesting per-vertex normals lets the mesh be lit directly, which removes the need for a separate hillshade layer.

Add the terrain layer before the raster tile layer (layers are rendered in the order they are added):

const terrainSource = view.addSource({
type: "quantized-mesh",
url: "https://terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain",
requestVertexNormals: true,
maxZoom: 18,
});
view.addLayer({
type: "terrain",
source: terrainSource,
terrain: {
castShadow: true,
receiveShadow: true,
},
});

The data is the global quantized-mesh dataset published by Re:Earth Terrain.

  • requestVertexNormals asks the server for vertex normals, which the sun light added in the next step uses to shade the slopes.
  • Fetch settings such as maxZoom live on the source. terrain holds only how the mesh is rendered (shadows, skirts).

For details, see Terrain Layer.

Replacing the Ambient Light with a Sun Light

Section titled “Replacing the Ambient Light with a Sun Light”

Ambient light shades every surface uniformly, so the terrain relief stays invisible under it. Replace the view.addLight({ ambient: {} }) call with a sun light, which shades the slopes using the vertex normals requested above:

view.addLight({
sun: {},
});
view.atmosphere.date = new Date("2026-07-16T01:00:00Z");

The sun direction is derived from view.atmosphere.date. Use a UTC string ("...Z") so the scene looks the same on every machine. For details, see SunLightDesc.

The sun also casts shadows onto the terrain through the castShadow and receiveShadow options set on the terrain layer. Shadow mapping must be enabled when the view is constructed (shadow is an initialization-only option), so change the ThreeView constructor call:

const view = new ThreeView<DefaultDescriptions>({ shadow: true });

Move the camera to Mount Fuji, where the relief stands out. Change the view.setCamera() call:

view.setCamera({
lng: 138.75,
lat: 35.5,
height: 6000,
heading: 185, // -180 to 180
pitch: -26, // -180 to 0
roll: 0, // -180 to 180
});

The sunlight shades the slopes of Mount Fuji, making the terrain relief clearly visible.

Terrain Map

import ThreeView from "@navaramap/three";
import { DefaultPlugin, type DefaultDescriptions } from "@navaramap/three-default-plugin";
import { TileJsonPlugin } from "@navaramap/three-plugins";
const plugin = new DefaultPlugin();
const view = new ThreeView<DefaultDescriptions>({ shadow: true });
view.addPlugin(plugin);
const tilejson = new TileJsonPlugin();
view.addPlugin(tilejson);
await view.init();
view.addLight({
sun: {},
});
view.atmosphere.date = new Date("2026-07-16T01:00:00Z");
const terrainSource = view.addSource({
type: "quantized-mesh",
url: "https://terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain",
requestVertexNormals: true,
maxZoom: 18,
});
view.addLayer({
type: "terrain",
source: terrainSource,
terrain: {
castShadow: true,
receiveShadow: true,
},
});
const basemapSource = await tilejson.addSource({
type: "raster-tile",
url: "https://papers.reearth.land/styles/protomaps-light/tilejson.json",
});
view.addLayer({
type: "raster",
source: basemapSource,
});
view.setCamera({
lng: 138.75,
lat: 35.5,
height: 6000,
heading: 185, // -180 to 180
pitch: -26, // -180 to 0
roll: 0, // -180 to 180
});

Add a polygon covering the northern slope of Mount Fuji, the area the camera is now looking at:

const geojsonSource = view.addSource({
type: "geojson",
data: {
type: "Feature",
properties: { name: "Area" },
geometry: {
type: "Polygon",
coordinates: [
[
[138.7, 35.425],
[138.7, 35.4],
[138.745, 35.4],
[138.745, 35.425],
[138.7, 35.425],
],
],
},
},
});
view.addLayer({
type: "vector",
source: geojsonSource,
polygon: {
color: new Color().setHex(0xff3b30),
clampToGround: true,
opacity: 0.75,
transparent: true,
},
});

Register the GeoJSON as a source with view.addSource({ type: "geojson", ... }), then draw it with view.addLayer({ type: "vector", source, ... }). The polygon material carries the styling such as colour and transparency. clampToGround: true drapes the polygon onto the terrain surface, so it follows the slope instead of sinking into the mountain at a fixed height. For details, see Vector Layer.

GeoJSON Map

import ThreeView, { Color } from "@navaramap/three";
import { DefaultPlugin, type DefaultDescriptions } from "@navaramap/three-default-plugin";
import { TileJsonPlugin } from "@navaramap/three-plugins";
const plugin = new DefaultPlugin();
const view = new ThreeView<DefaultDescriptions>({ shadow: true });
view.addPlugin(plugin);
const tilejson = new TileJsonPlugin();
view.addPlugin(tilejson);
await view.init();
view.addLight({
sun: {},
});
view.atmosphere.date = new Date("2026-07-16T01:00:00Z");
const terrainSource = view.addSource({
type: "quantized-mesh",
url: "https://terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain",
requestVertexNormals: true,
maxZoom: 18,
});
view.addLayer({
type: "terrain",
source: terrainSource,
terrain: {
castShadow: true,
receiveShadow: true,
},
});
const basemapSource = await tilejson.addSource({
type: "raster-tile",
url: "https://papers.reearth.land/styles/protomaps-light/tilejson.json",
});
view.addLayer({
type: "raster",
source: basemapSource,
});
const geojsonSource = view.addSource({
type: "geojson",
data: {
type: "Feature",
properties: { name: "Area" },
geometry: {
type: "Polygon",
coordinates: [
[
[138.7, 35.425],
[138.7, 35.4],
[138.745, 35.4],
[138.745, 35.425],
[138.7, 35.425],
],
],
},
},
});
view.addLayer({
type: "vector",
source: geojsonSource,
polygon: {
color: new Color().setHex(0xff3b30),
opacity: 0.75,
transparent: true,
},
});
view.setCamera({
lng: 138.75,
lat: 35.5,
height: 6000,
heading: 185, // -180 to 180
pitch: -26, // -180 to 0
roll: 0, // -180 to 180
});