Ctrl K
ComunidadGuías…

src/lib/basemap.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// What basemap this instance draws, and what the browser has to be allowed to
// fetch to draw it.
//
// The renderer is MapLibre GL JS, which speaks one language for both modes: a
// style. A vector style is a document naming sources, glyphs, sprites and 111
// layers of cartography; a raster tile server is the same document with one
// source and one layer, which is what `rasterStyle` builds. So the escape hatch
// is not a second renderer, it is a smaller style.
//
// See DECISIONS.md, "The basemap is a MapLibre style, and raster is the escape
// hatch".

import type { MapOptions } from "maplibre-gl";

/**
 * What MapLibre will accept as a style: a parsed document, or a URL it fetches
 * for itself.
 *
 * Derived from `MapOptions` rather than imported as `StyleSpecification`, which
 * `maplibre-gl` re-exports from `@maplibre/maplibre-gl-style-spec` but does not
 * export itself - naming that package here would be a dependency on a
 * transitive one, for a type.
 */
export type BasemapStyle = NonNullable<MapOptions["style"]>;
type StyleDocument = Exclude<BasemapStyle, string>;

/** A position in decimal degrees, which is how this app carries one everywhere. */
export interface LatLon {
  latitude: number;
  longitude: number;
}

/**
 * A rectangular extent in degrees, as a geocoder reports a place's footprint.
 *
 * `west` may be greater than `east`: a box straddling the antimeridian is not
 * malformed, and the API deliberately does not reject one. A single point is
 * the degenerate case where both pairs are equal, which is what lets a place
 * with a position but no footprint go through the same path.
 */
export interface LatLonBounds {
  south: number;
  north: number;
  west: number;
  east: number;
}

/**
 * Where a map opens with nothing on it yet, at `MIN_ZOOM`: the whole world,
 * centred a little north of the equator because that is where the land - and
 * most of the world's diving - is. Shared by the site picker and the read-only
 * map so the two open on the same view by construction, rather than on two
 * literals that agree until somebody edits one.
 */
export const WORLD_CENTER: LatLon = { latitude: 20, longitude: 0 };

// Web Mercator is undefined at the poles and conventionally cut here, which is
// what makes the projected world square: this is the latitude whose projected y
// equals the map's own width. MapLibre owns the camera now and clamps its own,
// but this is still what keeps a position this app *emits* inside the range the
// form's parser and the API both accept.
export const MAX_LATITUDE = 85.0511287798066;

/**
 * Latitude cut to the range Web Mercator can represent.
 */
export function clampLatitude(latitude: number): number {
  return Math.min(MAX_LATITUDE, Math.max(-MAX_LATITUDE, latitude));
}

/**
 * Longitude folded back into [-180, 180), so panning past the antimeridian
 * yields a coordinate a diver (and the API's `ge=-180, le=180` bound) accepts.
 *
 * Exactly 180 comes back as -180. They are the same meridian, and picking one
 * representative is what keeps the range half-open and the folding idempotent.
 */
export function wrapLongitude(longitude: number): number {
  return ((((longitude + 180) % 360) + 360) % 360) - 180;
}

/**
 * The credit the keyless raster default owed, kept as the raster escape hatch's
 * fallback: an operator who points `MAP_TILE_URL` at OpenStreetMap's own tiles
 * and sets no attribution still ships a credit rather than nothing.
 *
 * Written in the `[label](url)` syntax `parseAttribution` understands, so the
 * credit can point at the licence rather than merely naming it. The OSMF tile
 * policy asks for it plainly visible, which is also why it is not behind a
 * toggle.
 */
export const DEFAULT_TILE_ATTRIBUTION =
  "[© OpenStreetMap contributors](https://www.openstreetmap.org/copyright)";

/**
 * The styles this app ships, served from `public/basemap/`.
 *
 * OpenFreeMap's Liberty, vendored rather than fetched from
 * `tiles.openfreemap.org/styles/...` on purpose: the vendored copy is what keeps
 * the map looking the same after an upstream restyle, and a basemap that changes
 * appearance under the app is a regression nobody committed.
 * `scripts/generate-basemaps.mjs` recolours its water teal and derives the dark
 * style from it by recolouring alone, so both themes label a place the same
 * way. They are also the one part of this an operator can replace without a
 * rebuild - they are static files, not bundle contents.
 */
export const DEFAULT_STYLE_URL = "/basemap/liberty.json";
export const DEFAULT_STYLE_URL_DARK = "/basemap/dark.json";

/**
 * The one host the vendored pair fetches from.
 *
 * Every asset both styles name - the vector source, the Natural Earth raster
 * underlay, the glyph ranges - is on this origin; only the sprite set travels
 * with us. `basemap.test.ts` reads the shipped JSON and fails if a re-vendor
 * ever introduces a second host, which is the check that keeps this constant
 * honest rather than merely current.
 */
export const DEFAULT_BASEMAP_ORIGIN = "https://tiles.openfreemap.org";

/**
 * The credit the vendored pair owes.
 *
 * OpenMapTiles and OpenStreetMap are the required half; OpenFreeMap's own name
 * is optional and named anyway, because an operator reading their own map's
 * corner should be able to tell where the tiles come from. Written in the
 * `[label](url)` syntax `parseAttribution` understands - MapLibre's own
 * `AttributionControl` is switched off, since it renders HTML and
 * `react/no-danger` is an error here.
 */
export const DEFAULT_BASEMAP_ATTRIBUTION =
  "[OpenFreeMap](https://openfreemap.org/) " +
  "© [OpenMapTiles](https://openmaptiles.org/) " +
  "/ [OpenStreetMap](https://www.openstreetmap.org/copyright)";

/**
 * Zoom, in MapLibre's units.
 *
 * MapLibre measures zoom against a **512 px** tile - its transform's `worldSize`
 * is `512 * 2 ** zoom` - while Leaflet and the whole slippy convention measure
 * against 256, as this app's own hand-rolled renderer did. The same view is
 * therefore one number *lower* here than in the numbers this app used before the
 * renderer changed, which is why these are not the constants they replace:
 *
 * | view              | slippy | MapLibre |
 * | ----------------- | ------ | -------- |
 * | widest useful     | 1      | 0        |
 * | street level      | 18     | 17       |
 * | a lone place      | 10     | 9        |
 * | a placed pin      | 12     | 11       |
 *
 * Carried across as the same integers they would each open one level too deep.
 * `basemap.test.ts` pins the relationship against the slippy figures, which are
 * written out there now that the module holding them is gone, so that moving one
 * without the other fails rather than merely looking odd. The fourth row is
 * `PLACED_ZOOM` in `components/map/map-picker.tsx`, which is the one that never
 * lived here.
 */
export const MIN_ZOOM = 0;
export const MAX_ZOOM = 17;

/**
 * How far in a fit is allowed to go for a lone place, which would otherwise fit
 * at any zoom you like and open at the deepest.
 *
 * A single pin opens where the surrounding coast is recognisable rather than at
 * street level, where a lone marker on a grid of house numbers says nothing -
 * and, for the half of dive sites that are offshore, on nothing but open water.
 */
export const MAX_FIT_ZOOM = 9;

/** What an operator configured about the basemap, before defaults are applied. */
export interface BasemapConfig {
  /** A complete MapLibre style. Wins over the raster template outright. */
  styleUrl?: string;
  /** Falls back to `styleUrl`, never to the style this app ships. */
  styleUrlDark?: string;
  /** A `{z}/{x}/{y}` raster template - the escape hatch. */
  tileUrl?: string;
  /** Falls back to `tileUrl`, never to a stranger's. */
  tileUrlDark?: string;
  /**
   * Rendered over whichever basemap is active. Deliberately not part of either
   * mode's group of variables: an operator who configures a style must be able
   * to credit it, and there is exactly one credit on screen at a time.
   */
  attribution?: string;
  /** Substituted for `{key}` in either raster template. */
  apiKey?: string;
}

/** The basemap this instance draws, with the defaults applied. */
export interface Basemap {
  /**
   * `vector` means `light`/`dark` are MapLibre style URLs; `raster` means they
   * are `{z}/{x}/{y}` templates that `rasterStyle` wraps into one.
   */
  mode: "vector" | "raster";
  /**
   * Whether `light`/`dark` name the styles this app ships. It decides two
   * things a URL alone cannot: that the style JSON needs its relative `sprite`
   * resolved before MapLibre sees it, and that `DEFAULT_BASEMAP_ORIGIN` is what
   * the policy has to allow.
   */
  vendored: boolean;
  light: string;
  dark: string;
  attribution: string;
}

// The placeholder a keyed provider's template carries. Every one of them spells
// the parameter differently - CARTO `?key=`, Stadia `?api_key=` - so the query
// string belongs to the operator's template and only the value comes from here.
const API_KEY = "{key}";

function withApiKey(template: string, apiKey?: string): string {
  if (!template.includes(API_KEY)) return template;
  if (!apiKey) {
    console.warn(
      `[basemap] Tile template wants an API key but MAP_TILE_API_KEY is ` +
        `unset: ${template}. Tiles will be requested with an empty key.`,
    );
  }
  // split/join rather than a `/g` regex: `.test()` on a global regex advances
  // its own lastIndex, so the same template can answer differently on the
  // second call.
  return template.split(API_KEY).join(apiKey ?? "");
}

/**
 * The message a style configured without a credit fails with.
 *
 * Exported so the test asserts the text an operator actually reads rather than
 * a substring of it - this is the whole of the diagnostic for a startup that
 * refuses to serve.
 */
export const MISSING_ATTRIBUTION_MESSAGE =
  "[basemap] MAP_STYLE_URL is set but MAP_ATTRIBUTION is not. Every basemap " +
  "worth pointing at carries a licence condition, and this app renders the " +
  "credit itself rather than the style's own HTML - so it cannot work out " +
  "what yours requires. Set MAP_ATTRIBUTION to the credit your style asks " +
  "for, or unset MAP_STYLE_URL to use the basemap this app ships.";

/**
 * The basemap to draw, or a thrown configuration error.
 *
 * Three states, and the precedence between them is documented rather than
 * emergent: a style URL wins outright and leaves the raster variables inert, a
 * raster template without one is the escape hatch, and neither is the pair this
 * app ships.
 *
 * **A style URL without an attribution throws**, and that is the only
 * combination that does. It cannot default to "whatever the active basemap
 * requires", because both ways of learning that are shut: this runs in the
 * request path and may not fetch and parse a remote style, and the app renders
 * the credit as text rather than as the style's HTML. Of the three answers
 * available, defaulting to this app's own credit ships a false statement and a
 * licence breach for any style that is not OpenFreeMap's, and rendering nothing
 * breaches the licence of essentially every OSM-derived source. Refusing is the
 * only one that is neither wrong nor silent.
 *
 * It throws here - once, at config resolution - rather than per map. Note what
 * that does *not* mean: `runtimeConfig()` is lazy and memoized, so the process
 * starts normally and the throw lands on the first request that renders a
 * layout or passes through middleware. Every page then 500s while `/healthz`,
 * which sits outside the middleware matcher and reads no configuration, goes on
 * answering 200 - so the container's own healthcheck reports it healthy. Loud,
 * but only to somebody opening a page.
 */
export function resolveBasemap(config: BasemapConfig = {}): Basemap {
  const { styleUrl, styleUrlDark, tileUrl, tileUrlDark, attribution, apiKey } =
    config;

  if (styleUrl) {
    if (!attribution) throw new Error(MISSING_ATTRIBUTION_MESSAGE);
    return {
      mode: "vector",
      vendored: false,
      light: styleUrl,
      // "Use my style", not "use mine by day and a stranger's by night" - the
      // same rule the raster templates have always followed.
      dark: styleUrlDark || styleUrl,
      attribution,
    };
  }

  // A dark style with no light one to belong to would mix this app's
  // cartography with somebody else's on a theme toggle, and would credit one of
  // them for both. Ignored rather than promoted, and said out loud because a
  // variable that silently does nothing is worse than one that errors.
  if (styleUrlDark) {
    console.warn(
      `[basemap] Ignoring MAP_STYLE_URL_DARK, which is set while ` +
        `MAP_STYLE_URL is not. Set both, or neither.`,
    );
  }

  if (tileUrl) {
    const light = withApiKey(tileUrl, apiKey);
    return {
      mode: "raster",
      vendored: false,
      light,
      dark: tileUrlDark ? withApiKey(tileUrlDark, apiKey) : light,
      attribution: attribution || DEFAULT_TILE_ATTRIBUTION,
    };
  }

  return {
    mode: "vector",
    vendored: true,
    light: DEFAULT_STYLE_URL,
    dark: DEFAULT_STYLE_URL_DARK,
    attribution: attribution || DEFAULT_BASEMAP_ATTRIBUTION,
  };
}

/**
 * What MapLibre spells the high-density placeholder.
 *
 * This app's own templates have always written it `{r}`, as Leaflet and
 * OpenLayers do and as `.env.example`'s two keyed blocks still say. MapLibre
 * resolves `{ratio}` instead, from `map.getPixelRatio()`, to exactly the same
 * `@2x`/empty pair - so the escape hatch is a rename here and no density
 * plumbing of our own. Without it a keyed template would request a literal
 * `{r}` and 404, which is precisely the two configurations this mode exists to
 * preserve.
 */
export function withRatioToken(template: string): string {
  return template.replace(/\{r\}/g, "{ratio}");
}

/**
 * A minimal MapLibre style around one raster tile template.
 *
 * `tileSize` is stated rather than left to the spec, whose default is 512: every
 * template this app documents serves 256 px tiles, and taking the default would
 * draw each of them over four tiles' worth of ground.
 */
export function rasterStyle(template: string): StyleDocument {
  return {
    version: 8,
    sources: {
      basemap: {
        type: "raster",
        tiles: [withRatioToken(template)],
        tileSize: 256,
      },
    },
    layers: [{ id: "basemap", type: "raster", source: "basemap" }],
  };
}

/**
 * The style to hand MapLibre for one theme.
 *
 * Three shapes, and only one of them needs us in the middle. A configured style
 * URL is handed over as a URL, because MapLibre fetching it itself is both
 * fewer moving parts and how a remote style's own relative references resolve. A
 * raster template becomes the generated document above. The vendored pair is
 * fetched *here*, for one reason:
 *
 * **MapLibre will not resolve a relative `sprite`.** `normalizeSpriteURL` runs
 * the value through `new URL(value)` with no base and throws "must be absolute"
 * on anything else - unlike a style's *sources*, which do go through
 * `browser.resolveURL`. So a vendored style that also vendors its sprite cannot
 * express that relationship in the file, and the absolute form cannot be baked
 * in either: a published image runs on whatever domain a self-hoster gives it.
 * Resolving it against this document's own origin at load time is what is left,
 * and it is one line.
 */
export async function basemapStyle(
  basemap: Basemap,
  theme: "light" | "dark",
): Promise<BasemapStyle> {
  const value = theme === "dark" ? basemap.dark : basemap.light;
  if (basemap.mode === "raster") return rasterStyle(value);
  if (!basemap.vendored) return value;

  const response = await fetch(value);
  if (!response.ok) {
    throw new Error(
      `[basemap] Could not load the bundled style ${value}: ` +
        `${response.status} ${response.statusText}. It is served from ` +
        `public/basemap/, so a 404 here means the file did not reach the image.`,
    );
  }
  const style = (await response.json()) as StyleDocument;
  if (typeof style.sprite === "string") {
    style.sprite = new URL(style.sprite, window.location.origin).toString();
  }
  return style;
}

/**
 * The English line each name label of the vendored pair draws: the English
 * name, else the latin-script one, else the local one. Mirrored by
 * `scripts/generate-basemaps.mjs`, which writes it into both files.
 */
export const ENGLISH_NAME = [
  "coalesce",
  ["get", "name_en"],
  ["get", "name:latin"],
  ["get", "name"],
];

/**
 * `style` with each name label given `language`'s name as a second line, where
 * a place has one that differs from its English line - beside it along a line,
 * as a road name runs, and under it at a point. Without a language, `style`
 * itself; with one the tiles carry no name in, a style that draws English.
 *
 * Only labels drawing exactly `ENGLISH_NAME` are touched, so an operator's own
 * style is drawn as written, whatever is asked.
 */
export function withLabelLanguage(
  style: StyleDocument,
  language: string | undefined,
): StyleDocument {
  // A bare subtag, so a value nobody checked against a list cannot reach an
  // expression as anything but a field name the tiles may or may not have.
  if (!language || !/^[a-z]{2,3}$/.test(language)) return style;
  const english = JSON.stringify(ENGLISH_NAME);
  const local = ["get", `name:${language}`];
  return {
    ...style,
    layers: style.layers.map((layer) => {
      if (
        layer.type !== "symbol" ||
        JSON.stringify(layer.layout?.["text-field"]) !== english
      ) {
        return layer;
      }
      const separator =
        layer.layout?.["symbol-placement"] === "line" ? " " : "\n";
      const field = [
        "let",
        "english",
        ENGLISH_NAME,
        [
          "case",
          [
            "all",
            ["has", `name:${language}`],
            ["!=", local, ["var", "english"]],
          ],
          ["concat", ["var", "english"], separator, local],
          ["var", "english"],
        ],
      ];
      return {
        ...layer,
        layout: { ...layer.layout, "text-field": field },
      } as typeof layer;
    }),
  };
}

/**
 * The language a map labels in beside English, from a BCP 47 tag such as
 * `navigator.language`: its primary subtag, lowercased, with Norwegian's two
 * written forms read as the `no` the tiles carry. English, or no tag, is none.
 *
 * Not checked against the renderer's list: a live map stores nothing, and a
 * language the tiles hold no name in draws English by itself.
 */
export function labelLanguage(tag: string | undefined): string | undefined {
  const primary = tag?.split(/[-_]/)[0].toLowerCase();
  if (!primary || primary === "en") return undefined;
  return primary === "nb" || primary === "nn" ? "no" : primary;
}

/**
 * The origins the browser will contact to draw this basemap, for the CSP
 * `connect-src` in `proxy.ts`.
 *
 * `connect-src` and not `img-src`, in both modes, and that is not a choice:
 * MapLibre's image decoder takes an `ArrayBuffer` and goes `Blob` ->
 * `createImageBitmap`, so even raster tile *bytes* arrive by `fetch`. It holds
 * because `refreshExpiredTiles` defaults to `true` - reading the cache header
 * requires the fetch path - which is why nothing here sets that option. Treat it
 * as part of the policy rather than as a tuning knob.
 *
 * A malformed value yields no origin rather than throwing. This runs in
 * middleware, in the request path, so a typo'd variable must not take the site
 * down over the map.
 */
export function basemapOrigins(basemap: Basemap): string[] {
  // The vendored styles are same-origin documents, but everything they name -
  // tiles, glyphs, the Natural Earth underlay - is not.
  if (basemap.vendored) return [DEFAULT_BASEMAP_ORIGIN];

  const origins = [basemap.light, basemap.dark].flatMap((value) => {
    try {
      const { origin, protocol } = new URL(value);
      // `new URL` only throws when there is no parseable scheme at all. A typo
      // like "htp://" parses happily as a non-special scheme and yields the
      // opaque origin "null", which reaches the policy as the literal token
      // `null` - discarded by browsers as an invalid source with nothing said
      // about why.
      if (protocol !== "http:" && protocol !== "https:")
        throw new Error(protocol);
      return [origin];
    } catch {
      // A relative value is the one shape that is *not* a mistake: an operator
      // may point MAP_STYLE_URL at a style they serve themselves, which `'self'`
      // already covers. Everything else earns the diagnostic, because the
      // failure it prevents is a blocked request with a console line nobody
      // reads rather than a visibly wrong URL.
      if (!value.startsWith("/")) {
        console.warn(
          `[basemap] Ignoring malformed basemap URL: ${value}. Give an ` +
            `absolute URL, or the map will be blocked by the ` +
            `Content-Security-Policy.`,
        );
      }
      return [];
    }
  });
  return [...new Set(origins)];
}

/**
 * The extent covering every one of `boxes`, with longitudes unwrapped so a union
 * straddling the antimeridian stays the short way round.
 *
 * Each box after the first is moved to whichever copy of the repeating world
 * sits nearest the first, the way `nearestWrappedX` picks a marker's nearest
 * copy. A trip to Fiji and Samoa spans about six degrees across the
 * antimeridian, and unioning the raw coordinates instead describes the 354
 * degrees of ocean going the other way round the planet - which fits at exactly
 * one zoom: the whole world, with both pins at opposite edges of it.
 *
 * `west` and `east` may come back outside [-180, 180]. That is the point: the
 * pair describes one interval, and MapLibre's `Map.fitBounds` reads it as one.
 *
 * **MapLibre does not do this for you, and checking it with two places will not
 * reveal that.** `LngLatBounds.extend()` unions with plain `Math.min`/`Math.max`
 * on raw longitude, and `Map.fitBounds` documents that the caller owns the
 * ordering. What rescues the two-place case is `cameraForBounds` calling
 * `adjustAntiMeridian()` on the *finished* box, which cannot see that the union
 * was built the long way round on the way there - so a two-point check passes
 * whether or not this function survived. Hence three places in its tests.
 */
export function unionBounds(boxes: LatLonBounds[]): LatLonBounds | null {
  if (boxes.length === 0) return null;

  let south = MAX_LATITUDE;
  let north = -MAX_LATITUDE;
  let west = Infinity;
  let east = -Infinity;
  // The first box's west edge, which every later box is unwrapped against.
  let reference = 0;

  boxes.forEach((box, index) => {
    // Min/max rather than trusting the order: this also renders form state on
    // its way to the API, so it meets boxes the API's validation has not seen.
    south = Math.min(south, clampLatitude(Math.min(box.south, box.north)));
    north = Math.max(north, clampLatitude(Math.max(box.south, box.north)));

    // The box's width taken as a signed span first, so the antimeridian case
    // (east folding back behind west) stays one interval instead of becoming a
    // negative one, while a genuinely zero-width point stays a point.
    const signed = box.east - box.west;
    const span = signed >= 0 ? Math.min(signed, 360) : signed + 360;
    let boxWest = wrapLongitude(box.west);

    if (index === 0) {
      reference = boxWest;
    } else {
      boxWest += Math.round((reference - boxWest) / 360) * 360;
    }
    west = Math.min(west, boxWest);
    east = Math.max(east, boxWest + span);
  });

  // Past a full turn there is no short way round left to preserve, and an extent
  // wider than the world is not one any camera can be asked to fit.
  if (east - west >= 360) {
    west = -180;
    east = 180;
  }

  return { south, north, west, east };
}