Skip to main content

Overview

Been uses Mapbox GL JS through the react-map-gl wrapper to render an interactive 3D globe that visualizes visited countries. The integration combines Mapbox’s powerful mapping capabilities with React’s component model.
Mapbox GL JS is a JavaScript library that uses WebGL to render interactive maps from vector tiles. It provides high-performance rendering with smooth animations and 3D capabilities.

Globe Component

The main map component is defined in src/components/globe.tsx:
import { useAtomValue } from 'jotai';
import { Map, NavigationControl, Source, Layer } from 'react-map-gl/mapbox';
import type { MapRef } from 'react-map-gl/mapbox';
import { focusAtom, selectedCountriesAtom } from '../state/atoms';

export const Globe = memo(
  forwardRef<MapForwardedRef>((_, ref) => {
    const internalRef = useRef<MapRef>(null);
    const selectedCountries = useAtomValue(selectedCountriesAtom);
    const focus = useAtomValue(focusAtom);

    return (
      <Map
        mapboxAccessToken={apiKeyMapbox}
        mapStyle={prefersDark ? darkThemeUrl : lightThemeUrl}
        antialias
        minZoom={minZoom}
        ref={internalRef}
      >
        <NavigationControl showCompass={false} />
        <Source
          id={MapboxSourceKeys.Countries}
          type="vector"
          url="mapbox://mapbox.country-boundaries-v1"
        />
        <Layer
          id={MapboxLayerKeys.Been}
          type="fill"
          source={MapboxSourceKeys.Countries}
          source-layer="country_boundaries"
          filter={beenFilter}
          paint={beenPaint}
        />
      </Map>
    );
  })
);

Configuration

API Key

The Mapbox API key is loaded from environment variables:
const apiKeyMapbox = import.meta.env['VITE_API_KEY_MAPBOX'] as string | undefined;
Required: You need a valid Mapbox access token to use the map. Get one for free at mapbox.com.

Map Styles

Been supports both light and dark themes:
const darkThemeUrl = 'mapbox://styles/mapbox/dark-v11';
const lightThemeUrl = 'mapbox://styles/mapbox/light-v11';

const prefersDark = useMatchMedia('(prefers-color-scheme: dark)');

// In component:
<Map
  mapStyle={prefersDark ? darkThemeUrl : lightThemeUrl}
  // ...
/>
The map automatically switches between light and dark themes based on the user’s system preferences.

Zoom Constraints

const minZoom = 1.8;

<Map minZoom={minZoom} />
The minimum zoom level is set to 1.8 to ensure the entire globe is visible and prevent users from zooming out too far.

Data Sources

Mapbox data is organized into sources (data) and layers (visualization).

Country Boundaries Source

Been uses Mapbox’s built-in country boundaries dataset:
<Source
  id={MapboxSourceKeys.Countries}
  type="vector"
  url="mapbox://mapbox.country-boundaries-v1"
/>
Key details:
  • type="vector" - Uses vector tiles for better performance and scaling
  • mapbox://mapbox.country-boundaries-v1 - Mapbox’s curated country boundary dataset
  • Each feature includes an iso_3166_1 property matching ISO 3166 country codes
Vector tiles contain geometric data that’s rendered on the client, allowing for smooth zooming and rotation without pixelation.

Visualization Layers

Been uses two layers to visualize visited countries:

1. Country Fill Layer

Highlights visited countries with a colored fill:
const beenFilter: FillLayerSpecification['filter'] = useMemo(
  () => ['in', ['get', 'iso_3166_1'], ['literal', selectedCountries]],
  [selectedCountries],
);

const beenPaint: FillLayerSpecification['paint'] = useMemo(
  () => ({
    'fill-color': '#fd7e14',
    'fill-opacity': 0.6,
  }),
  [],
);

<Layer
  id={MapboxLayerKeys.Been}
  type="fill"
  source={MapboxSourceKeys.Countries}
  source-layer="country_boundaries"
  beforeId="national-park"
  filter={beenFilter}
  paint={beenPaint}
/>
How it works:
1

Filter Expression

The filter uses Mapbox’s expression syntax:
['in', ['get', 'iso_3166_1'], ['literal', selectedCountries]]
  • ['get', 'iso_3166_1'] - Gets the country code from each feature
  • ['literal', selectedCountries] - The array of selected country codes
  • ['in', ...] - Checks if the country code is in the selected array
2

Paint Properties

Only countries matching the filter are rendered with:
  • Orange fill color (#fd7e14)
  • 60% opacity for a subtle effect
3

Layer Ordering

beforeId="national-park" ensures the layer appears below park labels but above the base map

2. Building Extrusion Layer

Adds 3D building visualizations in visited countries:
const buildingsFilter: FillExtrusionLayerSpecification['filter'] =
  useMemo(() => ['==', 'extrude', 'true'], []);

const buildingsPaint: FillExtrusionLayerSpecification['paint'] =
  useMemo(
    () => ({
      'fill-extrusion-base': [
        'interpolate',
        ['linear'],
        ['zoom'],
        15,
        0,
        15.05,
        ['get', 'min_height'],
      ],
      'fill-extrusion-color': [
        'case',
        ['in', ['get', 'iso_3166_1'], ['literal', selectedCountries]],
        '#fd7e14',
        '#fd7e14',
      ],
      'fill-extrusion-height': [
        'interpolate',
        ['linear'],
        ['zoom'],
        15,
        0,
        15.05,
        ['get', 'height'],
      ],
      'fill-extrusion-opacity': 0.6,
    }),
    [selectedCountries],
  );

<Layer
  id={MapboxLayerKeys.Buildings}
  type="fill-extrusion"
  source="composite"
  source-layer="building"
  minzoom={15}
  filter={buildingsFilter}
  paint={buildingsPaint}
/>
3D Building Features:
Buildings only appear at zoom level 15+:
minzoom={15}
This prevents performance issues and visual clutter at lower zoom levels.
Building heights interpolate smoothly as you zoom:
['interpolate', ['linear'], ['zoom'], 15, 0, 15.05, ['get', 'height']]
  • At zoom 15.0: height = 0 (flat)
  • At zoom 15.05: height = actual building height
  • Creates a smooth “rising” animation
Buildings in visited countries are highlighted with the same orange color:
['case',
  ['in', ['get', 'iso_3166_1'], ['literal', selectedCountries]],
  '#fd7e14',  // Visited countries
  '#fd7e14',  // Other countries (same color in this case)
]

Map Navigation

Focus on Countries

When a user selects a country, the map automatically pans and zooms to it:
const focus = useAtomValue(focusAtom);

useEffect(() => {
  if (!focus?.bounds) {
    return;
  }
  const { current: map } = internalRef;
  map?.fitBounds(focus.bounds);
}, [focus]);
How it works:
  1. When a country is selected, addCountryAtom sets the focusAtom
  2. The Globe component’s effect hook detects the change
  3. fitBounds() animates the map to show the country’s bounding box
  4. The bounds are stored in the country data as [minLng, minLat, maxLng, maxLat]
User Experience: This automatic navigation helps users immediately see where their selected countries are located on the globe.
The map includes built-in navigation controls:
<NavigationControl showCompass={false} />
This adds zoom in/out buttons. The compass is hidden since the globe projection doesn’t support rotation in the same way flat maps do.

Performance Optimization

Memoized Filters and Paint

Mapbox layer configurations are memoized to prevent unnecessary recalculations:
const beenFilter = useMemo(
  () => ['in', ['get', 'iso_3166_1'], ['literal', selectedCountries]],
  [selectedCountries],  // Only recalculate when selections change
);

const beenPaint = useMemo(
  () => ({
    'fill-color': '#fd7e14',
    'fill-opacity': 0.6,
  }),
  [],  // Never changes, compute once
);

Component Memoization

The entire Globe component is wrapped in memo() to prevent re-renders:
export const Globe = memo(
  forwardRef<MapForwardedRef>((_, ref) => {
    // Component implementation
  })
);
This ensures the map only re-renders when its props or consumed atoms actually change.

Ref Forwarding

The component forwards refs to expose map methods:
export interface MapForwardedRef {
  isSourceLoaded: ForwardedRefFunction<MapRef['isSourceLoaded']>;
  querySourceFeatures: ForwardedRefFunction<MapRef['querySourceFeatures']>;
}

useImperativeHandle(
  ref,
  () => ({
    isSourceLoaded: (...params: Parameters<MapRef['isSourceLoaded']>) => {
      return internalRef.current?.isSourceLoaded(...params);
    },
    querySourceFeatures: (...params: Parameters<MapRef['querySourceFeatures']>) => {
      return internalRef.current?.querySourceFeatures(...params);
    },
  }),
  [],
);
This allows parent components or tests to query the map state programmatically.

State Synchronization

The map stays synchronized with application state through Jotai atoms:
const selectedCountries = useAtomValue(selectedCountriesAtom);
const focus = useAtomValue(focusAtom);
Data flow:

Responsive Design

The Globe component adapts to different screen sizes through CSS Grid:
// In App component
<div className="grid size-full grid-rows-[auto,1fr,auto] md:grid-cols-3 md:grid-rows-[auto,1fr]">
  <div className="order-2 min-h-[60vh] md:col-span-2 md:row-span-2">
    <Globe />
  </div>
</div>
Layout behavior:
  • Mobile: Globe takes 60% of viewport height, menu below
  • Desktop: Globe spans 2/3 of screen width, menu sidebar on left

Testing Considerations

The Globe component includes a test mode:
const testMode = import.meta.env.MODE === 'test';

<Map testMode={testMode} />
When testMode is enabled, react-map-gl uses a mock implementation that doesn’t require WebGL, making it possible to run tests in Node.js environments.

Mapbox GL JS

Official Mapbox GL JS documentation

react-map-gl

React wrapper documentation

State Management

Learn how state drives the map

Architecture

Overall application architecture

Build docs developers (and LLMs) love