Skip to content

Chapter 3: 3D Geographic Visualization#

Welcome back! In Chapter 1: Application Views, we learned how our application uses different "views" or screens to manage the user experience. In Chapter 2: User Input Handling (Files & Interaction), we explored how the application receives data and commands from the user, like uploading files or clicking buttons.

Now that we can get data into the application, how do we show it to the user in a meaningful way, especially when that data is related to geographic locations and needs to be displayed in 3D? This is where 3D Geographic Visualization comes in.

What's the Problem?#

Imagine you've uploaded a photo of a building, and our app has figured out where the photo was taken and possibly the outline of the building itself. Simply showing the building outline on a flat 2D map isn't enough. You want to see:

  • The actual 3D shape of the building, maybe even extruded to its correct height.
  • Where exactly on the map the photo was taken.
  • Perhaps even see other related geographic data like a point cloud (a collection of 3D points representing a scanned area) overlaid on the scene.
  • You might want to look at this scene from different angles: a traditional map view, a straight-down view (orthographic), or even from the perspective of where the photo was taken (first-person).

To do this, we need a powerful way to render geographic data, not just as simple points or lines, but as interactive 3D objects within a map context.

What is 3D Geographic Visualization?#

In this project, 3D Geographic Visualization refers to the process of taking geographic datasets (like coordinates, building outlines, point clouds) and displaying them as interactive 3D objects on a map. It's about creating a virtual scene where you can explore spatial data with depth and perspective.

Our project uses two main libraries to achieve this:

  1. Mapbox GL JS: This library provides the interactive base map layer. It handles showing the streets, terrain, satellite imagery, etc. Think of it as the canvas or the base world onto which we'll draw our data.
  2. Deck.gl: This is a powerful data visualization library built on WebGL. It's specifically designed for rendering large, complex datasets on maps. Deck.gl draws our custom data (buildings, icons, point clouds) on top of the Mapbox base map.

Deck.gl works using the concept of Layers. You define different types of data you want to visualize (buildings, icons, point clouds), and for each type, you create a specific Deck.gl Layer. Deck.gl then takes all these layers and renders them together to create the final visualization.

How it Works in This Project: The DeckglWrapper#

The core component responsible for handling the 3D visualization is DeckglWrapper (src/components/deckgl-wrapper.tsx). This component acts as the bridge between our application's data and state (like which view is active) and the rendering power of Deck.gl and Mapbox.

The MapResultView and MapShowcaseView components (Chapter 1) are the ones that decide what to display. They gather the necessary data (like the processed GeoJSON from a photo upload or data fetched from an API) and then pass it down to DeckglWrapper to handle the actual rendering.

Here's a simplified look at how it's used in MapResultView:

// Inside MapResultView component in src/views/map-result-view.tsx (simplified)

// ... state and effect hooks ...
const [layers, setLayers] = useState<Layer[]>([]);
// ... other state and handler functions ...

useEffect(() => {
  // This effect runs when 'geo' data changes (e.g., after a photo upload)
  if (geo) {
      // Use a helper function to create Deck.gl layers from the geo data
      const buildingLayers = createBuilding(geo.geojson, geo.cameraGPSData);
      // Update the state with the new list of layers
      setLayers(buildingLayers);
      // ... logic to set the map view state based on geo data ...
  }
}, [geo]); // Dependency array: re-run this effect if 'geo' changes

return (
  // ... other UI elements ...
  <MapWrapper component="main">
    {/* ... tooltip component ... */}
    <DeckglWrapper
      parentViewState={viewState} // Pass the camera view state
      view={view}                 // Pass the desired view type ("map", "firstPerson", etc.)
      layers={layers}             // Pass the array of Deck.gl layers to render
      onHover={onHoverHandler}    // Pass a handler for mouse hover events on layers
    />
  </MapWrapper>
  // ... other UI elements ...
);

In this snippet:

  1. MapResultView keeps track of the Deck.gl layers it needs to display using component state (useState).
  2. When the geo prop (containing processed geographic data) changes, a useEffect hook runs.
  3. Inside the effect, it calls a helper function createBuilding (more on this later) with the GeoJSON and camera data. This function returns an array of Deck.gl Layer objects.
  4. MapResultView updates its layers state with the array received from createBuilding.
  5. Crucially, in its return block, MapResultView renders the DeckglWrapper component and passes the layers state array, the current view type (Chapter 1), and the camera viewState (which determines the camera's position, zoom, pitch, etc.) as props.

This shows the pattern: Get data -> Create layers -> Pass layers and view settings to DeckglWrapper.

Inside the DeckglWrapper#

Now, let's peek inside DeckglWrapper to see how it uses these props to render the 3D scene.

// Inside DeckglWrapper component in src/components/deckgl-wrapper.tsx (simplified)
import { DeckGL } from "@deck.gl/react";
import { MapView, FirstPersonView, MapController, FirstPersonController, Layer } from "@deck.gl/core";
import { TerrainLayer } from "@deck.gl/geo-layers";
// ... other imports and state ...

export const DeckglWrapper = ({
  parentViewState, // View state passed from parent (MapResultView/MapShowcaseView)
  view,            // Current view type ("map", "firstPerson", etc.)
  layers,          // Array of custom data layers (buildings, icons, etc.)
  onHover,         // Hover handler
}: DeckglWrapperProps) => {

  // State to manage the camera view
  const [viewState, setViewState] = useState<MultiviewMapViewState>(/* initial view state */);

  // Effect to update internal viewState when parentViewState changes
  useEffect(() => {
    if (parentViewState !== null) {
      setViewState(parentViewState);
    }
  }, [parentViewState]);

  // Effect to adjust pitch/maxPitch based on the selected view type
  useEffect(() => {
    // Logic to update viewState.mapView based on 'view' prop
    // For example, set pitch to 0 and maxPitch to 60 for "orthographic" view
    // ... simplified logic ...
    setViewState(/* updated view state */);
  }, [view]); // Re-run effect when 'view' prop changes

  // Define the available camera views (Map view and First Person view)
  const VIEWS = useMemo(
    () =>
      view === "map" || view === "orthographic"
        ? [new MapView({ id: "mapView", controller: MapController })] // Standard map controls
        : [new FirstPersonView({ id: "firstPersonView", controller: FirstPersonController })], // First-person controls
    [view] // Recreate views if the 'view' type changes
  );

  // Common layers like the base terrain
  const [commonLayers, setCommonLayers] = useState<Layer[]>([]);
  useEffect(() => {
      // Add a TerrainLayer for base elevation
      const deckglTerrainLayer = new TerrainLayer({ /* ... terrain layer config ... */ });
      setCommonLayers([deckglTerrainLayer]);
  }, []); // Empty dependency array: run only once on mount

  // Handler for view state changes (user interaction like panning/zooming)
  const onViewStateChangeHandler = (parameters: ViewStateChangeParameters) => {
    const { viewState: deckViewState } = parameters;
    // Update the corresponding part of the internal view state (mapView or firstPersonView)
    // ... simplified logic ...
    setViewState(/* updated view state */);
  };

  return (
    <DeckGL
      // Pass the currently active view state (mapView or firstPersonView) to DeckGL
      viewState={
        view === "map" || view === "orthographic"
          ? viewState.mapView
          : viewState.firstPersonView
      }
      onViewStateChange={onViewStateChangeHandler} // Listen for user view changes
      onHover={onHover} // Pass the hover handler
      // Combine common layers (like terrain) with custom data layers (buildings, icons, etc.)
      layers={[...commonLayers, ...layers]}
      views={VIEWS} // Tell DeckGL which view types are available
      // ... widgets like zoom, fullscreen, compass ...
      // ... lighting effects ...
    />
  );
};

Key things happening in DeckglWrapper:

  1. It uses the main <DeckGL> component provided by @deck.gl/react. This is the component that sets up the WebGL context and handles the rendering loop.
  2. It maintains its own state (viewState) for the camera, but initializes it and keeps it in sync with parentViewState passed from the parent component (MapResultView or MapShowcaseView). This allows the parent to control the initial view (e.g., centering on a specific building) while still letting the user interact with the map (panning, zooming).
  3. It defines different VIEWS using MapView and FirstPersonView classes from @deck.gl/core. These objects tell Deck.gl how the camera should behave for different view types (e.g., MapView uses standard map controls, FirstPersonView uses controls suitable for walking around).
  4. It includes a base TerrainLayer to add realistic elevation to the map background. This is part of the commonLayers.
  5. It passes the combined array of commonLayers and the layers prop (our custom data layers) to the layers prop of the <DeckGL> component. This tells Deck.gl what to draw.
  6. It passes the appropriate viewState (either viewState.mapView or viewState.firstPersonView based on the view prop) to the <DeckGL> component. This tells Deck.gl where the camera is and which way it's looking.
  7. The onViewStateChange prop on <DeckGL> is connected to our onViewStateChangeHandler function. Deck.gl calls this function whenever the user interacts with the map controls (pans, zooms). Our handler updates the internal viewState so the camera position persists.
  8. The onHover prop allows us to react when the user's mouse hovers over a pickable layer (like the building or camera icons).

Essentially, DeckglWrapper is the conductor: it gets the data layers and desired perspective from the parent and uses Deck.gl to render them correctly on the Mapbox base map.

Creating the Layers: createBuilding#

How do we turn raw geographic data, like a GeoJSON outline of a building, into Deck.gl Layer objects? This is handled by helper functions like createBuilding (src/utils/deckgl-utils.ts).

Let's look at a simplified version of how createBuilding might create just the extruded building polygon layer:

// Inside src/utils/deckgl-utils.ts (simplified)
import { PolygonLayer } from "@deck.gl/layers";
// ... other imports ...

export const createBuilding = (
  buildingGeojson: any, // GeoJSON data for the building
  cameraGPSData: any,   // Data about the photo location
  nameSuffix?: string   // Optional suffix for layer IDs
): Layer[] => {

  const layers: Layer[] = [];

  // Get the building coordinates and height from the GeoJSON
  const buildingCoordinates = buildingGeojson.features[0].geometry.coordinates[0];
  const buildingHeight = parseFloat(buildingGeojson.features[0].properties?.relativeheightmaximum);
  const baseElevation = parseFloat(buildingGeojson.features[0].properties!.absoluteheightminimum);

  // Prepare the polygon data format expected by PolygonLayer
  const polygonData = [
    {
      contour: buildingCoordinates.map((coord: any) => {
        // Add base elevation to each coordinate
        return [...coord, baseElevation];
      }),
    },
  ];

  // Create a PolygonLayer for the extruded building
  const storey = new PolygonLayer({
    id: nameSuffix ? `geojson-storey-building-${nameSuffix}` : "geojson-storey-building",
    data: polygonData, // The polygon data
    extruded: true,    // Enable 3D extrusion
    wireframe: true,   // Show the wireframe outline
    getPolygon: (d) => d.contour, // How to get the polygon coordinates from the data object
    getFillColor: [249, 180, 45, 255], // Color of the extruded polygon
    getElevation: buildingHeight, // How high to extrude it
    opacity: 1,
  });

  layers.push(storey);

  // ... create other layers like ground footprint, camera icon, marker ...

  return layers; // Return the array of created layers
};

This function takes the processed buildingGeojson data. It extracts the coordinates and height. It then creates a PolygonLayer instance from @deck.gl/layers.

  • id: A unique identifier for the layer.
  • data: The data array that the layer will visualize.
  • extruded: true: This is the key part that makes the polygon 3D. Instead of a flat shape, it will be extended vertically.
  • getPolygon: A function that tells the layer how to find the polygon coordinates within each data object.
  • getElevation: A function or value that tells the layer how high to extrude the polygon. We use the buildingHeight derived from the GeoJSON.

The function createBuilding actually creates several layers (ground footprint, camera icon, marker) and returns them all in an array, which is then passed to DeckglWrapper.

Similarly, other types of data are visualized using different layer types:

  • Photo locations are shown using an IconLayer (@deck.gl/layers) in MapShowcaseView. The getIcon function is configured to use the photo URL itself as the icon source.
  • Point cloud data from LAZ files is visualized using a PointCloudLayer (@deck.gl/layers) also in MapShowcaseView.

Camera Perspectives (Views)#

As mentioned, the application supports different ways of looking at the 3D scene. This is controlled by the view prop passed to DeckglWrapper and how DeckglWrapper uses MapView and FirstPersonView.

View Type Description Controller Typical Use Case
map Traditional map view, can pan, zoom, orbit, and tilt. MapController General exploration, overview.
orthographic Straight-down, parallel projection (no perspective distortion). Can pan/zoom. MapController Measuring, architectural plans, top view.
firstPerson View from a point within the scene, like walking around or from a camera. FirstPersonController Immersive view, simulating photo perspective.

The DeckglWrapper switches between using MapView or FirstPersonView based on the view prop, changing how user interaction (like mouse drag) affects the camera movement and how the scene is projected. The keyboard hook (Chapter 2) is an example of how the user can trigger these view changes.

Flow of Visualization#

Here's a simplified sequence diagram showing how data flows to become a 3D visualization after, say, processing geo data for a building:

sequenceDiagram
    participant MapResultView
    participant createBuilding
    participant DeckglWrapper
    participant DeckGL

    MapResultView->MapResultView: Receives 'geo' data update
    MapResultView->createBuilding: Calls createBuilding(geo.geojson, geo.cameraGPSData)
    createBuilding->createBuilding: Creates PolygonLayer, IconLayer, etc.
    createBuilding-->MapResultView: Returns array of Deck.gl Layers
    MapResultView->MapResultView: Updates 'layers' state
    MapResultView->DeckglWrapper: Renders DeckglWrapper with layers prop
    DeckglWrapper->DeckglWrapper: Combines parent layers with common layers (terrain)
    DeckglWrapper->DeckGL: Passes combined layers and active viewState to DeckGL component
    DeckGL->Browser: Renders the 3D scene on the map
    Note over DeckglWrapper, DeckGL: User interaction updates DeckGL view state, DeckglWrapper updates its state accordingly

This shows how MapResultView prepares the data for visualization, createBuilding translates that data into Deck.gl layers, and DeckglWrapper uses the core DeckGL component to display everything.

Conclusion#

In this chapter, we explored the concept of 3D Geographic Visualization in our project. We learned how Deck.gl is used to render complex data layers on top of a Mapbox GL JS base map. We saw that visualization is built using Layers (like PolygonLayer for buildings, IconLayer for photos, PointCloudLayer for LAZ data) and how these layers are created from processed geographic data using helper functions like createBuilding. Finally, we understood how the DeckglWrapper component acts as the central orchestrator, managing the rendering and switching between different camera Views (map, orthographic, firstPerson) based on user selection.

With the ability to handle user input and visualize geographic data in 3D, the next logical step is to display more detailed information about the buildings or objects being visualized.

Next Chapter: Building Information Display


Generated by AI Codebase Knowledge Builder. References: 1(https://github.com/buildvoc/mapbox-gl_deck.gl_turf.js-ts/blob/3d8a4a53d878db3324af6466e0f99e5fb072bbe7/src/components/deckgl-wrapper.tsx), 2(https://github.com/buildvoc/mapbox-gl_deck.gl_turf.js-ts/blob/3d8a4a53d878db3324af6466e0f99e5fb072bbe7/src/types/map-view-state.ts), 3(https://github.com/buildvoc/mapbox-gl_deck.gl_turf.js-ts/blob/3d8a4a53d878db3324af6466e0f99e5fb072bbe7/src/utils/deckgl-utils.ts), 4(https://github.com/buildvoc/mapbox-gl_deck.gl_turf.js-ts/blob/3d8a4a53d878db3324af6466e0f99e5fb072bbe7/src/views/map-result-view.tsx), 5(https://github.com/buildvoc/mapbox-gl_deck.gl_turf.js-ts/blob/3d8a4a53d878db3324af6466e0f99e5fb072bbe7/src/views/map-showcase-view.tsx)