Ctrl K
ComunidadGuías…

src/lib/map-frame.ts

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

Archivo original ↗Preguntar a KiBot
// What every map this app draws has to agree on: how a place becomes an extent,
// the band of a frame its pins are kept to, and the canvas a page head's map is
// drawn across.

import type { LatLonBounds } from "@/lib/basemap";

// How wide a detail page's hero draws its map, in CSS pixels: a canvas this wide
// centred in the frame, which a window wider than it shows dissolving into the
// page at its sides, and a narrower one shows the middle of. The header's
// `max-w-6xl` column, so the map spans the menu bar above it.
export const HERO_CANVAS_WIDTH = 1152;

// How far in from each side a hero's canvas dissolves into the page. A quarter
// of it: wide enough that the eye finds no edge, and leaves the middle half for
// the pins.
export const SIDE_FADE_WIDTH = HERO_CANVAS_WIDTH / 4;

// Breathing room between the outermost place and the edge of the frame, so a
// pin never sits on the border where half of its context is cropped away.
export const FIT_PADDING = 24;

// Where the places have to land, as padding from a frame's top and bottom:
// clear of whatever the caller covers the foot with, and of a backdrop's credit
// over the top edge. Never so small that nothing fits: a fit then has no room
// and gives up.
export function bandIn(
  height: number,
  creditBottom: number,
  coveredBottom: number,
): { top: number; bottom: number } {
  const top = FIT_PADDING + creditBottom;
  return {
    top,
    bottom: Math.min(FIT_PADDING + coveredBottom, height - top - FIT_PADDING),
  };
}

/**
 * The part of a frame its map is drawn across, as an offset and a width, and
 * how far in from the frame's sides its places are kept.
 *
 * A card's map is the whole frame, its places `FIT_PADDING` in. A hero's is a
 * canvas `HERO_CANVAS_WIDTH` wide centred in the frame - wider than a phone's
 * frame, which shows its middle, and narrower than a desktop window's - with
 * its places kept out of the side fades as well as in from the frame's edges.
 */
export function mapCanvas(
  frameWidth: number,
  hero: boolean,
): { left: number; width: number; inset: number } {
  if (!hero) return { left: 0, width: frameWidth, inset: FIT_PADDING };
  const left = (frameWidth - HERO_CANVAS_WIDTH) / 2;
  return {
    left,
    width: HERO_CANVAS_WIDTH,
    inset: Math.max(FIT_PADDING, left + SIDE_FADE_WIDTH),
  };
}

/**
 * As much of a place as a map needs: its position, its footprint where it has
 * one, and a name for a pointer and a screen reader. Loose enough to take a trip
 * location - both what the API returns and what the form holds while it is
 * being edited - as well as a dive site, which carries the same fields under the
 * same names.
 */
export interface MappableLocation {
  name?: string;
  latitude?: number | null;
  longitude?: number | null;
  bbox_south?: number | null;
  bbox_north?: number | null;
  bbox_west?: number | null;
  bbox_east?: number | null;
  /**
   * How the marker is drawn: the default solid dot for a place somebody chose,
   * or a hollow ring for a `"fix"` - a position a device recorded, which is not
   * the same claim at all. A dive's entry and exit fixes are the only ones so
   * far, and a mis-pinned site or a fix a kilometre off the site is the thing
   * the two shapes make visible at a glance.
   *
   * Deliberately not `kind: "site" | "gps"` or anything else domain-shaped:
   * a map knows about positions and names, and one optional styling field is
   * what keeps it that way.
   */
  variant?: "pin" | "fix";
}

export interface PlacedLocation {
  // Blank where the place had none, which every reader of it already skips.
  name: string;
  latitude: number;
  longitude: number;
  variant: "pin" | "fix";
  bounds: LatLonBounds;
}

// A geocoder sends a place across the antimeridian as a box nearly or wholly
// round the world: Fiji at -180 to 180, New Zealand 358 degrees wide. From this
// wide up, the box says nothing about longitude. The iOS app holds the same
// threshold, so a place frames alike on both.
const WORLD_BOX_SPAN = 350;

/**
 * What a place's box asks a map to fit. A box that circles the world keeps its
 * south and north and takes a width equal to that height, centred on the place's
 * own longitude, so a place really that wide shows around its point and not
 * whole. The extent may pass 180, which `unionBounds` takes. Its span is
 * `unionBounds`' own, repeated here because the renderer's bundle hashes
 * `basemap.ts` whole and any edit there has every stored tile drawn again.
 */
function boxExtent(box: LatLonBounds, longitude: number): LatLonBounds {
  const signed = box.east - box.west;
  const span = signed >= 0 ? Math.min(signed, 360) : signed + 360;
  if (span < WORLD_BOX_SPAN) return box;

  // Absolute because form state reaches here unvalidated, as in `unionBounds`.
  const half = Math.abs(box.north - box.south) / 2;
  return {
    south: box.south,
    north: box.north,
    west: longitude - half,
    east: longitude + half,
  };
}

/**
 * The places that can actually be drawn, with the extent each one asks for.
 *
 * A place typed in by hand has no position and is skipped - the trip form's
 * part rows say so ("Not on the map") rather than leaving its absence here
 * unexplained.
 * A place the geocoder gave a footprint for is fitted by that footprint, which
 * is what keeps a country from opening at the zoom of its centroid; anything
 * else is fitted as the degenerate box of its own point. The exception is a box
 * that circles the world, which `boxExtent` reads around the place's point.
 */
export function placedLocations(
  locations: readonly MappableLocation[],
): PlacedLocation[] {
  const placed: PlacedLocation[] = [];
  for (const location of locations) {
    const { latitude, longitude } = location;
    if (latitude == null || longitude == null) continue;

    const { bbox_south, bbox_north, bbox_west, bbox_east } = location;
    // All four or none: the API validates that, and half a box is not an
    // extent, so the point is the safer reading of a broken one.
    const hasBox =
      bbox_south != null &&
      bbox_north != null &&
      bbox_west != null &&
      bbox_east != null;

    placed.push({
      name: location.name ?? "",
      latitude,
      longitude,
      variant: location.variant ?? "pin",
      bounds: hasBox
        ? boxExtent(
            {
              south: bbox_south,
              north: bbox_north,
              west: bbox_west,
              east: bbox_east,
            },
            longitude,
          )
        : {
            south: latitude,
            north: latitude,
            west: longitude,
            east: longitude,
          },
    });
  }
  return placed;
}