Skip to content
Make with

MapLibre GL JS globe

The Earth, as real as MapLibre can draw it

Real elevation, real imagery at every zoom, and the Sun where it is right now. A globe made with MapLibre GL JS from open data, with no API key. Fly to a mountain, search for a place, or move the Sun.

Loading the globe…

What makes it look real

Everything here is MapLibre GL JS's style spec, plus two small tile protocols. The data is open and needs no key.

  1. 01

    Real heights

    Mapterhorn's open elevation tiles give the globe its relief: 30 m worldwide, and 1 m or finer where countries publish lidar. The terrain isn't exaggerated, so mountains stand as tall as they are.

  2. 02

    Imagery for every zoom

    From space, NASA's Blue Marble. Closer in, it fades into Sentinel-2 cloudless at 10 m. In six countries, the national mapping agency's aerial photos take over from zoom 11, sharp enough to see paths on a glacier.

  3. 03

    An atmosphere lit by the Sun

    The style's light is anchored to the map and placed over the subsolar point, the spot where the Sun is overhead at that moment. MapLibre's atmosphere scatters it: a blue rim on the day side, a warm glow along the terminator, none at night.

  4. 04

    Day, dusk and night on the ground

    A small tile protocol works out the Sun's altitude at every pixel. It dims the ground under a low Sun, as seen from space, and brings up NASA's Black Marble city lights where the Sun has set.

  5. 05

    A sky for the time of day

    Tilt the map and the sky, horizon and haze take their colors from the Sun's altitude where you look: blue by day, orange at sunset, deep blue in twilight, dark at night. Far ridges fade into the haze, as they do on a clear day.

  6. 06

    Black space

    No stars. A camera exposed for the sunlit Earth can't see them, which is why photos of the Earth from space show none.

The code

One style, two tile protocols registered with maplibregl.addProtocol, and a search box. This is the code the globe above runs.

style.tsThe style: terrain, imagery, sky and light
// The style of the globe on /globe/: real elevation, real imagery at every zoom, and the
// Sun where it is at `date`. Every source is free to use with credit and needs no key.
import type {
  RasterLayerSpecification,
  SourceSpecification,
  StyleSpecification,
} from "maplibre-gl";
import { skyFor, subsolarPoint, sunAltitude, sunLight, type LngLat } from "./sun";

/** Orthophotos from national mapping agencies, sharper than any satellite. Each covers its country. */
const ORTHOPHOTOS: {
  id: string;
  tiles: string;
  bounds: [number, number, number, number];
  maxzoom: number;
  attribution: string;
}[] = [
  {
    id: "switzerland",
    tiles: "wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg",
    bounds: [5.9, 45.8, 10.5, 47.85],
    maxzoom: 19,
    attribution:
      '<a href="https://www.swisstopo.admin.ch/en/orthoimage-swissimage-10">© swisstopo</a>',
  },
  {
    id: "france",
    tiles:
      "data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTHOIMAGERY.ORTHOPHOTOS&STYLE=normal&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/jpeg",
    bounds: [-5.2, 41.3, 9.6, 51.1],
    maxzoom: 19,
    attribution: '<a href="https://geoservices.ign.fr/bdortho">© IGN</a>',
  },
  {
    id: "austria",
    tiles: "mapsneu.wien.gv.at/basemap/bmaporthofoto30cm/normal/google3857/{z}/{y}/{x}.jpeg",
    bounds: [9.5, 46.37, 17.17, 49.02],
    maxzoom: 19,
    attribution: '<a href="https://basemap.at">basemap.at</a>',
  },
  {
    id: "spain",
    tiles:
      "www.ign.es/wmts/pnoa-ma?request=GetTile&service=WMTS&VERSION=1.0.0&Layer=OI.OrthoimageCoverage&Style=default&Format=image/jpeg&TileMatrixSet=GoogleMapsCompatible&TileMatrix={z}&TileRow={y}&TileCol={x}",
    bounds: [-18.2, 27.6, 4.4, 43.9],
    maxzoom: 19,
    attribution: '<a href="https://pnoa.ign.es">PNOA © IGN España</a>',
  },
  {
    id: "united-states",
    tiles:
      "basemap.nationalmap.gov/arcgis/rest/services/USGSImageryOnly/MapServer/tile/{z}/{y}/{x}",
    bounds: [-125, 24.4, -66.9, 49.4],
    maxzoom: 16,
    attribution:
      '<a href="https://www.usgs.gov/programs/national-geospatial-program/national-map">USGS The National Map</a>',
  },
  {
    id: "japan",
    tiles: "cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg",
    bounds: [122.9, 24, 146, 45.6],
    maxzoom: 18,
    attribution: '<a href="https://maps.gsi.go.jp/development/ichiran.html">GSI Japan</a>',
  },
];

/** A raster layer that fades in between two zoom levels, over the one before it. */
const fadeIn = (
  id: string,
  from: number,
  to: number,
  extra: Partial<RasterLayerSpecification["paint"]> = {},
): RasterLayerSpecification => ({
  id,
  type: "raster",
  source: id,
  minzoom: from,
  paint: { "raster-opacity": ["interpolate", ["linear"], ["zoom"], from, 0, to, 1], ...extra },
});

/** Tiles of the Sun's light at one moment, drawn by daylightProtocol (protocols.ts). */
export const daylightTiles = (kind: "shade" | "night", date: Date) => [
  `daylight://${kind}/{z}/{x}/{y}?time=${date.getTime()}`,
];

export function globeStyle(date: Date, center: LngLat): StyleSpecification {
  const sun = subsolarPoint(date);
  const sources: Record<string, SourceSpecification> = {
    // Mapterhorn: open elevation tiles, 30 m worldwide and down to 1 m where countries publish lidar.
    terrain: { type: "raster-dem", url: "https://tiles.mapterhorn.com/tilejson.json" },
    // From space: NASA's Blue Marble, a cloud-free mosaic of the whole Earth at 500 m.
    "blue-marble": {
      type: "raster",
      tiles: [
        // NASA sends these with no-store, so browsers fetch them again on every visit: cached:// keeps
        // them in the site's tile cache, and asks NASA when the cache can't answer (src/tiles/client.ts).
        "cached://gibs.earthdata.nasa.gov/wmts/epsg3857/best/BlueMarble_NextGeneration/default/GoogleMapsCompatible_Level8/{z}/{y}/{x}.jpeg",
      ],
      tileSize: 256,
      maxzoom: 8,
      attribution:
        '<a href="https://earthobservatory.nasa.gov/features/BlueMarble">NASA Blue Marble</a>',
    },
    // Closer in: a cloud-free Sentinel-2 mosaic at 10 m. The 2016 edition is CC BY 4.0.
    "sentinel-2": {
      type: "raster",
      tiles: ["https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless_3857/default/g/{z}/{y}/{x}.jpg"],
      tileSize: 256,
      minzoom: 5,
      maxzoom: 14,
      attribution:
        '<a href="https://s2maps.eu">Sentinel-2 cloudless</a> by <a href="https://eox.at">EOX IT Services GmbH</a> (Contains modified Copernicus Sentinel data 2016 &amp; 2017)',
    },
    ...Object.fromEntries(
      ORTHOPHOTOS.map(({ id, tiles, bounds, maxzoom, attribution }) => [
        id,
        {
          type: "raster",
          tiles: [`ortho://${tiles}`],
          tileSize: 256,
          minzoom: 11,
          maxzoom,
          bounds,
          attribution,
        },
      ]),
    ),
    shade: { type: "raster", tiles: daylightTiles("shade", date), tileSize: 256, maxzoom: 6 },
    night: {
      type: "raster",
      tiles: daylightTiles("night", date),
      tileSize: 256,
      maxzoom: 8,
      attribution:
        '<a href="https://earthobservatory.nasa.gov/features/NightLights">NASA Black Marble</a>',
    },
  };

  return {
    version: 8,
    projection: { type: "globe" },
    sources,
    terrain: { source: "terrain", exaggeration: 1 },
    light: sunLight(sun),
    sky: skyFor(sunAltitude(center, sun)),
    layers: [
      // Under the imagery while it loads: the deep blue of Blue Marble's oceans.
      { id: "background", type: "background", paint: { "background-color": "rgb(2, 5, 20)" } },
      { id: "blue-marble", type: "raster", source: "blue-marble" },
      fadeIn("sentinel-2", 5, 7, { "raster-contrast": 0.1, "raster-saturation": 0.1 }),
      ...ORTHOPHOTOS.map(({ id }) => fadeIn(id, 11, 12.5)),
      {
        id: "shade",
        type: "raster",
        source: "shade",
        maxzoom: 7,
        // The low Sun's dimming is how the globe looks from space; up close the eye adapts.
        paint: {
          "raster-opacity": ["interpolate", ["linear"], ["zoom"], 4, 1, 7, 0],
          "raster-fade-duration": 0,
        },
      },
      { id: "night", type: "raster", source: "night", paint: { "raster-fade-duration": 0 } },
    ],
  };
}
sun.tsWhere the Sun is, and the sky it makes
// Where the Sun is, and what it does to the sky. Plain math, so it runs in node
// tests as well as on the page: the subsolar point for any moment, the Sun's
// altitude seen from any place, the light position that points MapLibre's
// atmosphere at it, and sky colors for the Sun's altitude.
import type { LightSpecification, SkySpecification } from "maplibre-gl";

export type LngLat = [lng: number, lat: number];

const RAD = Math.PI / 180;
const J2000 = Date.UTC(2000, 0, 1, 12); // 2000-01-01 12:00 UTC, the epoch of the formulas below
const DAY = 86_400_000;

const wrap = (degrees: number) => ((((degrees + 180) % 360) + 360) % 360) - 180;

/**
 * The point where the Sun is straight overhead at `date`, as [lng, lat] in degrees. The
 * low-precision solar position of the Astronomical Almanac: good to about 0.01° for
 * centuries either side of 2000, far finer than a map can show.
 */
export function subsolarPoint(date: Date): LngLat {
  const d = (date.getTime() - J2000) / DAY;
  const meanAnomaly = (357.529 + 0.98560028 * d) * RAD;
  const meanLongitude = 280.459 + 0.98564736 * d;
  const eclipticLongitude =
    (meanLongitude + 1.915 * Math.sin(meanAnomaly) + 0.02 * Math.sin(2 * meanAnomaly)) * RAD;
  const obliquity = (23.439 - 0.00000036 * d) * RAD;
  const rightAscension = Math.atan2(
    Math.cos(obliquity) * Math.sin(eclipticLongitude),
    Math.cos(eclipticLongitude),
  );
  const declination = Math.asin(Math.sin(obliquity) * Math.sin(eclipticLongitude));
  // Greenwich mean sidereal time: how far the Earth has turned under the stars.
  const siderealTime = 280.46061837 + 360.98564736629 * d;
  return [wrap(rightAscension / RAD - siderealTime), declination / RAD];
}

/** The angle between two places, seen from the Earth's center, in degrees. */
export function arc([lng1, lat1]: LngLat, [lng2, lat2]: LngLat): number {
  const cosine =
    Math.sin(lat1 * RAD) * Math.sin(lat2 * RAD) +
    Math.cos(lat1 * RAD) * Math.cos(lat2 * RAD) * Math.cos((lng2 - lng1) * RAD);
  return Math.acos(Math.max(-1, Math.min(1, cosine))) / RAD;
}

/** The Sun's altitude above the horizon at `place`, in degrees: 90 overhead, negative at night. */
export const sunAltitude = (place: LngLat, subsolar: LngLat) => 90 - arc(place, subsolar);

const HOUR = 3_600_000;

/**
 * The moment nearest `from`, at most 12 hours either way, when the local solar time at `lng`
 * is `hour`: noon is when the Sun is highest there.
 */
export function nearestSolarTime(from: Date, hour: number, lng: number): Date {
  const local = (from.getTime() / HOUR + lng / 15) % 24;
  const ahead = ((((hour - local) % 24) + 36) % 24) - 12;
  return new Date(from.getTime() + ahead * HOUR);
}

const smoothstep = (edge0: number, edge1: number, x: number) => {
  const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
  return t * t * (3 - 2 * t);
};

/**
 * How bright the ground looks from space, 0 to 1, for `h`, the sine of the Sun's altitude.
 * Under a low Sun it is darker, roughly with the square root of `h`, as the air scatters some
 * light onto it; twilight fades out until the Sun is 6° down.
 */
export const groundLight = (h: number) =>
  h > 0 ? 0.1 + 0.9 * Math.sqrt(h) : 0.1 * smoothstep(Math.sin(-6 * RAD), 0, h);

/** How far night has fallen, 0 to 1: city lights come on from 2° below the horizon and are all on at 9°. */
export const nightfall = (h: number) => smoothstep(Math.sin(-2 * RAD), Math.sin(-9 * RAD), h);

/**
 * The style's `light`, placed so the globe's atmosphere is lit from the subsolar point.
 * With `anchor: "map"`, MapLibre turns the light with the globe: the position is an
 * [radial, azimuthal, polar] direction in a frame fixed to the Earth.
 */
export function sunLight([lng, lat]: LngLat): LightSpecification {
  // The atmosphere shader looks for the Sun opposite the light's position,
  // at (cos lat sin lng, sin lat, cos lat cos lng) in that frame.
  const x = -Math.cos(lat * RAD) * Math.sin(lng * RAD);
  const y = -Math.sin(lat * RAD);
  const z = -Math.cos(lat * RAD) * Math.cos(lng * RAD);
  const polar = Math.acos(z) / RAD;
  // MapLibre counts the azimuth from north, 90° on from the x axis.
  const azimuthal = Math.atan2(y, x) / RAD - 90;
  return { anchor: "map", position: [1.5, azimuthal, polar] };
}

type RGB = [number, number, number];

/** Sky colors at a few altitudes of the Sun, from deep night to full day. */
const SKY_STOPS: { altitude: number; sky: RGB; horizon: RGB; fog: RGB }[] = [
  { altitude: -18, sky: [2, 4, 10], horizon: [6, 9, 20], fog: [3, 5, 12] },
  { altitude: -10, sky: [8, 14, 36], horizon: [26, 32, 62], fog: [14, 18, 36] },
  { altitude: -4, sky: [28, 44, 96], horizon: [176, 104, 92], fog: [72, 62, 82] },
  { altitude: 1, sky: [56, 92, 160], horizon: [246, 162, 96], fog: [196, 150, 124] },
  { altitude: 8, sky: [70, 124, 196], horizon: [236, 214, 178], fog: [208, 196, 182] },
  { altitude: 25, sky: [64, 128, 212], horizon: [204, 222, 240], fog: [196, 212, 228] },
];

const mix = (a: RGB, b: RGB, t: number) => {
  // Blend in linear light, as light adds up, then back to sRGB.
  const toLinear = (c: number) => (c / 255) ** 2.2;
  const toSrgb = (c: number) => Math.round(255 * c ** (1 / 2.2));
  const [r, g, bl] = a.map((c, i) => toSrgb(toLinear(c) + (toLinear(b[i]) - toLinear(c)) * t));
  return `rgb(${r}, ${g}, ${bl})`;
};

/**
 * The style's `sky` for the Sun's altitude where the camera looks: blue by day, warm near
 * sunrise and sunset, deep blue through twilight, dark at night. The sky shows when the map
 * tilts toward the horizon; from space, the atmosphere shader takes over.
 */
export function skyFor(altitude: number): SkySpecification {
  const last = SKY_STOPS.length - 1;
  let i = 0;
  while (i < last - 1 && altitude > SKY_STOPS[i + 1].altitude) i++;
  const a = SKY_STOPS[i];
  const b = SKY_STOPS[i + 1];
  const t = Math.max(0, Math.min(1, (altitude - a.altitude) / (b.altitude - a.altitude)));
  return {
    "sky-color": mix(a.sky, b.sky, t),
    "horizon-color": mix(a.horizon, b.horizon, t),
    "fog-color": mix(a.fog, b.fog, t),
    // Haze thickens toward the horizon: far ridges fade into the fog, the fog into the sky.
    "fog-ground-blend": 0.6,
    "horizon-fog-blend": 0.6,
    "sky-horizon-blend": 0.7,
    "atmosphere-blend": 0.6,
  };
}
protocols.tsDay and night on the ground, and the aerial photos
// Two tile protocols for the globe, registered with maplibregl.addProtocol.
//
// daylight://{kind}/{z}/{x}/{y}?time={ms} draws the Sun's light on the Earth for one
// moment, a tile at a time: "shade" darkens the day side as the Sun sinks toward the
// horizon, "night" lays NASA's Black Marble over the night side. Each pixel gets the
// Sun's altitude at its own lng/lat, so the terminator is exact at every zoom.
//
// ortho://{url without https://} fetches a national orthophoto tile and drops it when
// the service answers with a blank tile outside its country, so the imagery below shows.
// Along a border, where a tile is part photo and part the service's white fill, the fill
// is made see-through.
import type { AddProtocolAction } from "maplibre-gl";
import { fetchTile } from "../../tiles/client";
import { groundLight, nightfall, subsolarPoint } from "./sun";

const SIZE = 256;
const RAD = Math.PI / 180;

/** VIIRS city lights, 2016, from NASA GIBS. Its tiles go to zoom 8 (500 m a pixel). */
const BLACK_MARBLE =
  "https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_Black_Marble/default/2016-01-01/GoogleMapsCompatible_Level8/{z}/{y}/{x}.png";

/** The sine of the Sun's altitude at every pixel of a tile, row by row. */
function sunHeights(z: number, x: number, y: number, time: number): Float32Array {
  const [sunLng, sunLat] = subsolarPoint(new Date(time));
  const sinSun = Math.sin(sunLat * RAD);
  const cosSun = Math.cos(sunLat * RAD);
  const tiles = 2 ** z;
  const cosHourAngle = new Float32Array(SIZE);
  for (let col = 0; col < SIZE; col++) {
    const lng = ((x + (col + 0.5) / SIZE) / tiles) * 360 - 180;
    cosHourAngle[col] = Math.cos((lng - sunLng) * RAD);
  }
  const heights = new Float32Array(SIZE * SIZE);
  for (let row = 0; row < SIZE; row++) {
    const lat = Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + (row + 0.5) / SIZE)) / tiles)));
    const a = Math.sin(lat) * sinSun;
    const b = Math.cos(lat) * cosSun;
    for (let col = 0; col < SIZE; col++) heights[row * SIZE + col] = a + b * cosHourAngle[col];
  }
  return heights;
}

/** Dims the ground under a low Sun, as it looks from space. The warm glow along the terminator is MapLibre's atmosphere. */
function shade(heights: Float32Array): Uint8ClampedArray {
  const pixels = new Uint8ClampedArray(SIZE * SIZE * 4);
  for (let i = 0; i < heights.length; i++) pixels[i * 4 + 3] = 255 * (1 - groundLight(heights[i]));
  return pixels;
}

/** Black Marble where night has fallen. Its moonlit ground is darkened more than its lights, so the night stays dark. */
function night(heights: Float32Array, lights: Uint8ClampedArray): Uint8ClampedArray {
  const pixels = new Uint8ClampedArray(lights.length);
  for (let i = 0; i < heights.length; i++) {
    const r = lights[i * 4];
    const g = lights[i * 4 + 1];
    const b = lights[i * 4 + 2];
    const keep = (Math.max(r, g, b) / 255) ** 0.8;
    pixels[i * 4] = r * keep;
    pixels[i * 4 + 1] = g * keep;
    pixels[i * 4 + 2] = b * keep;
    pixels[i * 4 + 3] = 255 * nightfall(heights[i]);
  }
  return pixels;
}

async function decode(blob: Blob): Promise<Uint8ClampedArray> {
  const bitmap = await createImageBitmap(blob);
  const canvas = new OffscreenCanvas(SIZE, SIZE);
  const context = canvas.getContext("2d", { willReadFrequently: true })!;
  context.drawImage(bitmap, 0, 0, SIZE, SIZE);
  bitmap.close();
  return context.getImageData(0, 0, SIZE, SIZE).data;
}

// GIBS tells browsers not to keep its tiles, so the page keeps the last few itself:
// moving the Sun redraws every tile, but the lights under them stay the same.
const lightTiles = new Map<string, Promise<Uint8ClampedArray>>();
const KEEP = 64;

function blackMarble(z: number, x: number, y: number) {
  const key = `${z}/${x}/${y}`;
  let tile = lightTiles.get(key);
  if (tile) {
    // Most recently used goes last; the oldest is first out.
    lightTiles.delete(key);
  } else {
    const url = BLACK_MARBLE.replace("{z}", `${z}`).replace("{x}", `${x}`).replace("{y}", `${y}`);
    // Not aborted with the tile that asked first: other tiles may be waiting for it too.
    tile = fetchTile(url).then(async (response) => {
      if (!response.ok) throw new Error(`Black Marble ${key}: ${response.status}`);
      return decode(await response.blob());
    });
    tile.catch(() => lightTiles.delete(key));
  }
  lightTiles.set(key, tile);
  if (lightTiles.size > KEEP) lightTiles.delete(lightTiles.keys().next().value!);
  return tile;
}

export const daylightProtocol: AddProtocolAction = async (request) => {
  const match = /^daylight:\/\/(shade|night)\/(\d+)\/(\d+)\/(\d+)\?time=(\d+)$/.exec(request.url);
  if (!match) throw new Error(`Not a daylight tile: ${request.url}`);
  const [, kind, z, x, y, time] = match;
  const heights = sunHeights(+z, +x, +y, +time);
  const pixels = kind === "shade" ? shade(heights) : night(heights, await blackMarble(+z, +x, +y));
  // MapLibre blends raster tiles with premultiplied alpha, and an ImageBitmap isn't premultiplied for it on upload.
  const image = new ImageData(pixels as ImageDataArray, SIZE, SIZE);
  return { data: await createImageBitmap(image, { premultiplyAlpha: "premultiply" }) };
};

/** True when a tile is all white or all transparent: what services send outside their coverage. */
async function isBlank(blob: Blob): Promise<boolean> {
  const bitmap = await createImageBitmap(blob, { resizeWidth: 16, resizeHeight: 16 });
  const canvas = new OffscreenCanvas(16, 16);
  const context = canvas.getContext("2d")!;
  context.drawImage(bitmap, 0, 0);
  bitmap.close();
  const data = context.getImageData(0, 0, 16, 16).data;
  for (let i = 0; i < data.length; i += 4) {
    const transparent = data[i + 3] < 8;
    const white = data[i] > 250 && data[i + 1] > 250 && data[i + 2] > 250;
    if (!transparent && !white) return false;
  }
  return true;
}

/** Near-white, as the fill outside a country is; a little below 255 for the JPEG's blur along its edge. */
const FILL = 248;

/**
 * The tile with the service's white fill made see-through, or null when it has none. Snow as bright
 * as the fill goes too, which leaves the satellite's snow below to show through.
 */
async function withoutFill(blob: Blob): Promise<ImageBitmap | null> {
  const bitmap = await createImageBitmap(blob);
  const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
  const context = canvas.getContext("2d", { willReadFrequently: true })!;
  context.drawImage(bitmap, 0, 0);
  bitmap.close();
  const image = context.getImageData(0, 0, canvas.width, canvas.height);
  const pixels = image.data;
  let cleared = 0;
  for (let i = 0; i < pixels.length; i += 4) {
    if (pixels[i] >= FILL && pixels[i + 1] >= FILL && pixels[i + 2] >= FILL) {
      pixels[i + 3] = 0;
      cleared++;
    }
  }
  if (!cleared) return null;
  // MapLibre blends raster tiles with premultiplied alpha, and an ImageBitmap isn't premultiplied for it on upload.
  return createImageBitmap(image, { premultiplyAlpha: "premultiply" });
}

export const orthoProtocol: AddProtocolAction = async (request, abortController) => {
  const response = await fetch(request.url.replace(/^ortho:/, "https:"), {
    signal: abortController.signal,
  });
  // Outside its country, a service answers 404 or with a blank tile: either way, no tile.
  if (!response.ok) return { data: new ArrayBuffer(0) };
  const blob = await response.blob();
  // Blank tiles compress to a few hundred bytes; only small ones are worth a look.
  if (blob.size < 4096 && (await isBlank(blob))) return { data: new ArrayBuffer(0) };
  return { data: (await withoutFill(blob)) ?? (await blob.arrayBuffer()) };
};
search.tsThe search box: MapLibre's geocoder plugin, asking Photon
// The search box on the globe: MapLibre's geocoder plugin, asking Photon, komoot's free place search
// over OpenStreetMap data, which is made for searching as you type and needs no key.
import MaplibreGeocoder from "@maplibre/maplibre-gl-geocoder";
import "@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css";
import "./search.css";

type GeocoderApi = ConstructorParameters<typeof MaplibreGeocoder>[0];
type Results = Awaited<ReturnType<GeocoderApi["forwardGeocode"]>>;
export type SearchResult = Results["features"][number];

interface PhotonFeature {
  geometry: { type: "Point"; coordinates: [number, number] };
  properties: {
    name?: string;
    city?: string;
    state?: string;
    country?: string;
    type?: string;
    /** West, north, east, south. */
    extent?: [number, number, number, number];
  };
}

/** Photon's answers as the plugin lists them: a name, where it is, and how far it reaches. */
const photon: GeocoderApi = {
  async forwardGeocode({ query, limit = 5 }) {
    const q = encodeURIComponent(String(query ?? "").trim());
    const response = await fetch(`https://photon.komoot.io/api/?q=${q}&limit=${limit}&lang=en`);
    const { features }: { features: PhotonFeature[] } = await response.json();
    const listed: SearchResult[] = features.map(({ geometry, properties: p }) => ({
      type: "Feature",
      geometry,
      properties: p,
      text: p.name ?? "",
      // Where it is, without repeating its own name: "Iceland", not "Iceland, Iceland".
      place_name: [
        p.name,
        ...[p.city, p.state, p.country].filter((part) => part && part !== p.name),
      ]
        .filter(Boolean)
        .join(", "),
      place_type: [p.type ?? "place"],
      center: geometry.coordinates,
      bbox: p.extent && [p.extent[0], p.extent[3], p.extent[2], p.extent[1]],
    }));
    // A town and a building in it can share a name: list each name once, the first, which ranks higher.
    return {
      type: "FeatureCollection",
      features: listed.filter(
        (f, i) => listed.findIndex((g) => g.place_name === f.place_name) === i,
      ),
    };
  },
};

/** The search box, for the map's top-left corner. The globe flies to a pick itself, see mount.ts. */
export function searchControl(onPick: (result: SearchResult) => void) {
  const geocoder = new MaplibreGeocoder(photon, {
    placeholder: "Search for a place",
    showResultsWhileTyping: true,
    debounceSearch: 300,
    flyTo: false,
    marker: false,
    showResultMarkers: false,
    enableEventLogging: false,
  });
  geocoder.on("result", ({ result }: { result: SearchResult }) => onPick(result));
  return geocoder;
}

Good to know

  • The photos keep the shadows of the hour they were taken in, mostly mid-morning. MapLibre doesn't light the terrain itself, and a hillshade would fight those shadows, so there is none.
  • Aerial photos stop at borders, so the colors change where a country's photos meet the satellite mosaic.
  • No clouds, and the city lights are those of 2016.
  • The light on the ground is drawn as tiles, so it redraws a few times a second while the Sun moves. The atmosphere and the sky follow every frame.

Go further

The data

All free to use with credit, which the map gives in its corner.

SourceWhat it gives the globeTerms
MapterhornElevation, from open national and global dataFree, with credit to Mapterhorn and its sources
NASA Blue MarbleThe whole Earth at 500 m, through NASA GIBSFree to use, with credit to NASA
Sentinel-2 cloudless 2016The whole Earth at 10 m, by EOXCC BY 4.0 (later editions are for non-commercial use only)
NASA Black MarbleCity lights in 2016, through NASA GIBSFree to use, with credit to NASA
swisstopo SWISSIMAGEAerial photos of SwitzerlandFree, with credit to swisstopo
IGN BD ORTHOAerial photos of FranceLicence Ouverte 2.0, with credit to IGN
basemap.atAerial photos of AustriaCC BY 4.0
PNOAAerial photos of Spain, by IGN EspañaCC BY 4.0
USGS The National MapAerial photos of the United StatesPublic domain
GSI JapanAerial photos of JapanFree, with credit to GSI
PhotonPlace search, from OpenStreetMap data, by komootFree for fair use, with credit to OpenStreetMap contributors