# Mapbox Odp Maps

> Build interactive maps with Mapbox GL JS and ODP geodata in Next.js — covers setup, dynamic imports, CSS loading, geometry conversion, layer management, and common pitfalls

- Skill: `joevstaas/mapbox-odp-maps` (Agent Skill)
- Install (CLI): `npx skillmds@latest add joevstaas/mapbox-odp-maps`
- Raw SKILL.md: https://api.skillmd.com/api/skills/joevstaas/mapbox-odp-maps/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: joevstaas (https://skillmd.com/u/joevstaas)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/joevstaas/mapbox-odp-maps

---


# Mapbox GL + ODP Maps Skill

Use this skill when the user wants to build or debug interactive maps that display geodata from the Ocean Data Platform (ODP) using Mapbox GL JS in a Next.js application.

## Prerequisites

### npm Dependencies

```bash
npm install mapbox-gl wkx apache-arrow
npm install -D @types/geojson
```

| Package | Purpose |
|---------|---------|
| `mapbox-gl` | Map rendering engine |
| `wkx` | Convert WKT/WKB geometry (from ODP) to GeoJSON |
| `apache-arrow` | Parse Arrow IPC binary format from ODP tabular API |

### Environment Variables

```env
NEXT_PUBLIC_MAPBOX_TOKEN=pk.your_token_here
ODP_API_KEY=sk_your_key_here
```

`NEXT_PUBLIC_MAPBOX_TOKEN` is client-side (public). `ODP_API_KEY` is server-side only.

## Next.js Setup — Critical Steps

### 1. next.config.ts

Mapbox GL and Apache Arrow need special configuration:

```typescript
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  transpilePackages: ["mapbox-gl"],
  serverExternalPackages: ["apache-arrow"],
};
```

- `transpilePackages: ["mapbox-gl"]` — required because mapbox-gl ships untranspiled ESM
- `serverExternalPackages: ["apache-arrow"]` — Apache Arrow has native bindings that break in bundling

### 2. Mapbox GL CSS — Import in the map component

Load CSS via a direct import inside the map component file:

```tsx
// components/map/map-view.tsx
"use client";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";
```

This works reliably with the manual client-only import pattern (see step 3). **Do NOT add a `<head>` tag to layout.tsx** — Next.js 16 with Turbopack can error with "Missing `<html>` and `<body>` tags" if you add `<head>` manually in the root layout.

### 3. Client-Only Import Pattern — Do NOT use next/dynamic

**IMPORTANT (Next.js 16 / Turbopack)**: Do NOT use `next/dynamic` with `ssr: false` for the map component. In Next.js 16 with Turbopack, `dynamic()` with `ssr: false` triggers a false-positive runtime error: "Missing `<html>` and `<body>` tags in the root layout." This is caused by the SSR bailout mechanism confusing the Turbopack dev overlay.

Instead, use a **manual `useEffect` + lazy `import()` pattern**:

```tsx
// components/map/index.tsx (wrapper — client-only without next/dynamic)
"use client";
import { useEffect, useState, type ComponentType } from "react";

export function MapView() {
  const [Component, setComponent] = useState<ComponentType | null>(null);

  useEffect(() => {
    import("./map-view").then((mod) => setComponent(() => mod.default));
  }, []);

  if (!Component) {
    return (
      <div className="flex h-full w-full items-center justify-center bg-slate-100">
        <div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
      </div>
    );
  }

  return <Component />;
}
```

```tsx
// components/map/map-view.tsx (actual map)
"use client";
import { useEffect, useRef } from "react";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";

export default function MapView() {
  const mapContainer = useRef<HTMLDivElement>(null);
  const map = useRef<mapboxgl.Map | null>(null);
  // ...
}
```

Import the wrapper (not the implementation) in pages:
```tsx
import { MapView } from "@/components/map";
```

### 4. Root Layout — Keep it minimal

Do NOT add `<head>`, Google Fonts, or `<link>` tags to the root layout. Next.js 16 Turbopack is strict about the root layout structure. Keep it simple:

```tsx
// app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "My Map App",
  description: "...",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className="antialiased">{children}</body>
    </html>
  );
}
```

### 5. Map Container Sizing

The map container MUST have explicit dimensions. The most reliable pattern is CSS Grid on the page section with the map area as the `1fr` row, and `position: absolute; inset: 0` on the map component root inside a `position: relative; overflow: hidden` wrapper:

```tsx
// In the page — use CSS Grid so the map row gets a real computed height
<section style={{ height: "100dvh", display: "grid", gridTemplateRows: "auto 1fr auto" }}>
  <header>...</header>

  {/* Map wrapper: relative + overflow:hidden gives the absolute child a real size */}
  <div style={{ position: "relative", overflow: "hidden" }}>
    <MapView />
  </div>

  <footer>...</footer>
</section>
```

```tsx
// In the map component — absolute inset-0 fills the relative parent unconditionally
export default function MapView() {
  return (
    <div style={{ position: "absolute", inset: 0 }}>
      <div ref={mapContainer} style={{ width: "100%", height: "100%" }} />
      {/* Overlays go here */}
    </div>
  );
}
```

**Why not `h-full w-full`?** In Tailwind v4 with Next.js/Turbopack, `h-full` depends on every ancestor having an explicit `height` (not `min-height`). This chain breaks easily when using `flex-1` or `min-h-0` patterns — the Mapbox canvas initialises at 0×0 and the map is invisible even though data loads. `absolute inset-0` bypasses the chain entirely and always works.

**Also call `map.resize()` at the start of the `load` handler** to ensure the canvas matches the container's final rendered size:

```tsx
map.current.on("load", async () => {
  if (!map.current) return;
  map.current.resize(); // ← always do this first
  // ... add sources and layers
});
```

## ODP Vector Tiles — Preferred for Large Datasets

For datasets with thousands of features (e.g. float positions, trajectories), use ODP's vector tile API instead of fetching GeoJSON. Tiles are served on demand per zoom/tile, scale to millions of features, and require no backend proxy.

### Tile URL pattern

```typescript
const ODP_BASE_URL = "https://api.hubocean.earth";
const TILE_URL =
  `${ODP_BASE_URL}/api/table/v2/tile?` +
  `table_id=${DATASET_UUID}&z={z}&x={x}&y={y}`;
```

The ODP tile API uses the standard `{z}/{x}/{y}` template — pass it directly to the Mapbox source.

### Auth injection via `transformRequest`

Inject the ODP API key for tile requests using Mapbox's `transformRequest` option. This eliminates the need for a backend proxy and works for all tile requests automatically:

```typescript
import mapboxgl, { RequestParameters } from "mapbox-gl";

const ODP_HOSTNAME = new URL(ODP_BASE_URL).hostname; // "api.hubocean.earth"

const map = new mapboxgl.Map({
  // ...
  transformRequest: (url: string): RequestParameters => {
    try {
      const u = new URL(url);
      if (u.hostname === ODP_HOSTNAME && readKey) {
        return { url, headers: { Authorization: `ApiKey ${readKey}` } };
      }
    } catch {
      // not a parsable URL — leave it alone
    }
    return { url };
  },
});
```

The key used here must be a **read-only public key** (`NEXT_PUBLIC_ODP_API_KEY_READ`) since it's exposed client-side. Keep write keys server-side only.

### Vector source and layer setup

```typescript
map.addSource("argo-floats", {
  type: "vector",
  tiles: [TILE_URL],
  minzoom: 0,
  maxzoom: 8,
});

map.addLayer({
  id: "argo-float-positions",
  type: "circle",
  source: "argo-floats",
  "source-layer": "main",  // ODP always uses "main" as the source layer name
  paint: {
    "circle-color": [
      "match", ["get", "float_type"],
      "core", "#38bdf8",
      "bgc",  "#4ade80",
      "deep", "#fb923c",
      "#94a3b8",
    ],
    "circle-radius": [
      "interpolate", ["linear"], ["zoom"],
      0, 2, 3, 3, 6, 5, 10, 7,
    ],
  },
});
```

**Key details:**
- `source-layer: "main"` — ODP vector tiles always use this name
- `maxzoom: 8` — ODP tiles are generated up to zoom 8; Mapbox overzooms beyond that
- Add trajectory layers before point layers so points render on top

## Dim + Highlight Selected Feature Pattern

For selecting one feature out of many (e.g. one float out of 4000), use **two layers on the same source**: a dim base layer and a bright highlight layer filtered to the selected ID. This avoids re-fetching data and gives instant visual feedback.

```typescript
// Base layer — low opacity for all features
map.addLayer({
  id: "float-trajectories",
  type: "line",
  source: "argo-trajectories",
  "source-layer": "main",
  paint: {
    "line-color": ["match", ["get", "float_type"], "bgc", "#4ade80", "#38bdf8"],
    "line-width": 1.0,
    "line-opacity": 0.12,  // very dim — selected float will stand out
  },
});

// Highlight layer — starts with a filter that matches nothing
map.addLayer({
  id: "float-trajectory-selected",
  type: "line",
  source: "argo-trajectories",
  "source-layer": "main",
  filter: ["==", ["to-string", ["get", "wmo"]], ""],  // empty string → no match
  paint: {
    "line-width": 2.5,
    "line-opacity": 0.9,
  },
});
```

### Updating the filter — handle pre-load arrival

Selection state changes may arrive before or after the map loads. Use `loaded()` + `once("load", ...)` to cover both:

```typescript
useEffect(() => {
  const map = mapRef.current;
  if (!map) return;
  const apply = () => {
    if (!map.getLayer("float-trajectory-selected")) return;
    const filter = ["==", ["to-string", ["get", "wmo"]], selectedWmoId ?? ""] as FilterSpecification;
    map.setFilter("float-trajectory-selected", filter);
    map.setFilter("argo-float-selected", filter);
  };
  if (map.loaded()) apply();
  else map.once("load", apply);
}, [selectedWmoId]);
```

**Critical**: Vector tile properties that are stored as integers (like WMO numbers) come back as numbers from Mapbox. Comparing a number to a string always fails. Wrap with `["to-string", ["get", "prop"]]` before comparing to a string value — or store IDs as strings in the dataset.

## Layer Controls — Visibility, Filter, Projection

Use Mapbox's imperative API to update map state in response to React UI. Pass the `map` instance via a ref or prop.

```typescript
// Toggle layer visibility
map.setLayoutProperty("float-trajectories", "visibility", checked ? "visible" : "none");

// Filter by attribute (null = no filter = show all)
const expr: FilterSpecification | null =
  value === "all" ? null : ["==", ["get", "float_type"], value];
map.setFilter("argo-float-positions", expr);

// Switch projection
map.setProjection(isGlobe ? "globe" : "mercator");
```

**Filtering multiple layers together**: When a type filter applies to both trajectories and point positions, call `setFilter` on each layer separately — there's no group filter API.

## BBox Draw Mode

To let users drag a bounding box on the map, disable `dragPan` and attach raw mouse events to the container element:

```typescript
useEffect(() => {
  if (!drawBboxActive) {
    map.dragPan.enable();
    return;
  }
  map.dragPan.disable();

  function onMouseDown(e: MouseEvent) {
    const rect = container.getBoundingClientRect();
    dragStart.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
  }
  function onMouseUp(e: MouseEvent) {
    if (!dragStart.current) return;
    const rect = container.getBoundingClientRect();
    const x2 = e.clientX - rect.left;
    const y2 = e.clientY - rect.top;
    const sw = map.unproject([Math.min(dragStart.current.x, x2), Math.max(dragStart.current.y, y2)]);
    const ne = map.unproject([Math.max(dragStart.current.x, x2), Math.min(dragStart.current.y, y2)]);
    onBboxChange([sw.lng, sw.lat, ne.lng, ne.lat]);
    dragStart.current = null;
  }

  container.addEventListener("mousedown", onMouseDown);
  container.addEventListener("mouseup", onMouseUp);
  return () => {
    container.removeEventListener("mousedown", onMouseDown);
    container.removeEventListener("mouseup", onMouseUp);
    map.dragPan.enable();
  };
}, [drawBboxActive]);
```

Render the live rectangle as an absolutely-positioned `div` with `pointer-events-none` so it doesn't interfere with mouse events on the container.

## ODP → GeoJSON Pipeline

### Architecture Overview

```
ODP Tabular API (Arrow IPC binary)
  → apache-arrow (parse to rows)
  → wkx (WKT/WKB geometry → GeoJSON)
  → GeoJSON FeatureCollection
  → Next.js API route (serves JSON)
  → Mapbox GL (renders on map)
```

### Server-Side: ODP Client

```typescript
// lib/odp-client.ts
const ODP_BASE_URL = "https://api.hubocean.earth";

class ODPClient {
  private apiKey: string;

  constructor(apiKey: string) {
    this.apiKey = apiKey;
  }

  async queryTabularData(datasetId: string, options: {
    query?: string;
    sample?: number;
    columns?: string[];
  } = {}): Promise<ArrayBuffer> {
    const body: Record<string, unknown> = {};
    if (options.query) body.query = options.query;
    if (options.sample) body.sample = options.sample;
    if (options.columns) body.columns = options.columns;

    const response = await fetch(
      `${ODP_BASE_URL}/api/table/v2/sdk/select?table_id=${datasetId}`,
      {
        method: "POST",
        headers: {
          Authorization: `ApiKey ${this.apiKey}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(body),
      }
    );

    if (!response.ok) {
      const text = await response.text().catch(() => "Unknown error");
      throw new Error(`ODP API error: ${response.status} - ${text}`);
    }

    return response.arrayBuffer();
  }
}
```

**Key details**:
- Auth header: `ApiKey {key}` (NOT `Bearer`)
- Tabular endpoint: `POST /api/table/v2/sdk/select?table_id={uuid}`
- Request body uses `query`, `sample`, `columns` (NOT `filter`, `limit`)
- Response is Apache Arrow IPC binary (NOT JSON)

### Server-Side: Arrow Parser

```typescript
// lib/arrow-parser.ts
import * as Arrow from "apache-arrow";

export function parseArrowIPC(buffer: ArrayBuffer): Record<string, unknown>[] {
  const table = Arrow.tableFromIPC(buffer);

  const data: Record<string, unknown>[] = [];
  for (let i = 0; i < table.numRows; i++) {
    const row: Record<string, unknown> = {};
    for (const field of table.schema.fields) {
      const col = table.getChild(field.name);
      let val = col ? col.get(i) ?? null : null;
      // Convert BigInt to Number for JSON serialization
      if (typeof val === "bigint") val = Number(val);
      // Convert NaN to null
      if (typeof val === "number" && isNaN(val)) val = null;
      row[field.name] = val;
    }
    data.push(row);
  }

  return data;
}
```

**Important**: Arrow may return `BigInt` for integer columns (breaks `JSON.stringify`) and `NaN` for missing values. Always convert both.

### Server-Side: Geometry Conversion

ODP stores geometry as WKT or WKB strings. Convert to GeoJSON:

```typescript
// lib/geometry-utils.ts
import wkx from "wkx";

export function toGeoJsonGeometry(value: unknown): GeoJSON.Geometry | null {
  if (!value) return null;

  try {
    if (value instanceof Uint8Array || value instanceof Buffer) {
      const geom = wkx.Geometry.parse(Buffer.from(value));
      return geom.toGeoJSON() as GeoJSON.Geometry;
    }

    if (typeof value === "string") {
      // Try WKT first
      if (value.startsWith("POINT") || value.startsWith("POLYGON") ||
          value.startsWith("LINE") || value.startsWith("MULTI") ||
          value.startsWith("GEOMETRY")) {
        const geom = wkx.Geometry.parse(value);
        return geom.toGeoJSON() as GeoJSON.Geometry;
      }
      // Try hex-encoded WKB
      const geom = wkx.Geometry.parse(Buffer.from(value, "hex"));
      return geom.toGeoJSON() as GeoJSON.Geometry;
    }
  } catch {
    return null;
  }

  return null;
}
```

### Server-Side: API Route

```typescript
// app/api/datasets/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;

  const odpUuid = DATASET_UUIDS[id];
  if (!odpUuid) return NextResponse.json({ error: "Unknown dataset" }, { status: 404 });

  const client = getODPClient();
  const buffer = await client.queryTabularData(odpUuid, { sample: 50000 });
  const parsed = parseArrowIPC(buffer);

  const features = parsed.data.map((row) => ({
    type: "Feature" as const,
    geometry: toGeoJsonGeometry(row.geometry),
    properties: Object.fromEntries(
      Object.entries(row).filter(([k]) => k !== "id" && k !== "geometry")
    ),
  }));

  return NextResponse.json({ type: "FeatureCollection", features });
}
```

### Client-Side: Loading Data into Mapbox

```typescript
// In the map component, inside map.on("load", async () => { ... })
const res = await fetch(`/api/datasets/${datasetId}`);
const geojson = await res.json();

map.current.addSource("observations", {
  type: "geojson",
  data: geojson,
});

const geomType = geojson.features[0]?.geometry?.type;

if (geomType === "Point" || geomType === "MultiPoint") {
  map.current.addLayer({
    id: "points",
    type: "circle",
    source: "observations",
    paint: {
      "circle-color": color,
      "circle-radius": 7,
      "circle-stroke-width": 2,
      "circle-stroke-color": "#fff",
    },
  });
} else {
  // Polygons
  map.current.addLayer({
    id: "polygons",
    type: "fill",
    source: "observations",
    paint: {
      "fill-color": color,
      "fill-opacity": 0.5,
    },
  });
  map.current.addLayer({
    id: "polygons-outline",
    type: "line",
    source: "observations",
    paint: {
      "line-color": color,
      "line-width": 1.5,
    },
  });
}
```

## Popup Styling

When using `mapboxgl.Popup` with `.setHTML()`, Mapbox applies its own default styles which can result in very low contrast text (light gray on white). **Always set explicit text colors on popup content**:

```typescript
const html = `
  <div style="font-family: system-ui, sans-serif; max-width: 260px;">
    <h3 style="margin: 0 0 4px; font-size: 15px; color: #1a1a2e;">
      ${title}
    </h3>
    <p style="margin: 0 0 8px; font-size: 12px; color: #666; font-style: italic;">
      ${subtitle}
    </p>
    <table style="font-size: 12px; border-collapse: collapse; width: 100%;">
      <tr>
        <td style="padding: 2px 8px 2px 0; color: #888;">Label</td>
        <td style="color: #333;">Value</td>
      </tr>
    </table>
  </div>
`;
```

Key rules:
- Labels (left column): `color: #888` for muted appearance
- Values (right column): `color: #333` for readable dark text
- Title: `color: #1a1a2e` for strong heading contrast
- Never rely on inherited/default text color in popups

## Caching Strategy

ODP data changes infrequently. Cache GeoJSON responses server-side:

```typescript
const cache = new Map<string, { data: unknown; expires: number }>();
const TTL = 60 * 60 * 1000; // 1 hour

function getCached<T>(key: string): T | null {
  const entry = cache.get(key);
  if (entry && Date.now() < entry.expires) return entry.data as T;
  cache.delete(key);
  return null;
}

function setCache(key: string, data: unknown) {
  cache.set(key, { data, expires: Date.now() + TTL });
}
```

**Note**: In-memory cache works for dev and serverless (within a single invocation). For Vercel serverless, consider that each cold start resets the cache. For persistent caching, use Vercel KV or write to `/tmp`.

## Stale Closures in Map Event Handlers

**Critical bug pattern**: Mapbox event handlers are registered once during map initialization and capture the closure at that point. If a callback prop changes (e.g., due to React state updates), the map handler still calls the stale version.

**Problem:**
```tsx
// BAD — click handler captures initial onStationSelect and never updates
map.current.on("click", layerId, (e) => {
  onStationSelect(e.features[0]); // stale closure!
});
```

**Solution — use a ref:**
```tsx
// Keep a stable ref that always points to the latest callback
const onStationSelectRef = useRef(onStationSelect);
onStationSelectRef.current = onStationSelect;

// In the map init effect, read from the ref
map.current.on("click", layerId, (e) => {
  onStationSelectRef.current(e.features[0]); // always fresh
});
```

This is especially important when the callback depends on changing state (e.g., a "compare mode" toggle).

## Null Safety After Async Operations

When loading data inside `useEffect` with `async/await`, always check `map.current` after the await — the component may have unmounted and the map destroyed while waiting.

```tsx
const loadData = async () => {
  const res = await fetch("/api/data");  // async gap
  const geojson = await res.json();

  if (!map.current) return;  // map may be gone!

  map.current.addSource("data", { type: "geojson", data: geojson });
};
```

**Avoid `map.current!`** — non-null assertions hide this bug. Use null checks instead.

## Security Headers on Vercel

`next.config.ts headers()` and Next.js middleware may NOT reliably set response headers on Vercel for cached/statically prerendered pages. Use `vercel.json` instead:

```json
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "X-Frame-Options", "value": "DENY" },
        { "key": "X-Content-Type-Options", "value": "nosniff" },
        { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
        { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" },
        {
          "key": "Content-Security-Policy",
          "value": "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline' https://api.mapbox.com; img-src 'self' data: blob: https://*.mapbox.com; connect-src 'self' https://*.mapbox.com https://events.mapbox.com; worker-src 'self' blob:; child-src blob:; frame-src 'none'; object-src 'none'"
        }
      ]
    }
  ]
}
```

The CSP must whitelist Mapbox domains for tiles, styles, workers, and telemetry.

## Common Pitfalls

### Next.js 16 / Turbopack issues
1. **"Missing html/body tags" false positive**: Caused by using `next/dynamic` with `ssr: false`. Use the manual `useEffect` + `import()` pattern instead (see step 3 above).
2. **`<head>` in root layout**: Do NOT add an explicit `<head>` tag in the root layout — Turbopack can misparse it and throw the "Missing html/body" error. Use `metadata` export or load CSS via component imports.
3. **Stale Turbopack cache**: When changing layout.tsx or env vars, always `rm -rf .next` and restart the dev server. Turbopack caches aggressively.

### Map is invisible / not rendering
4. **CSS not loaded**: With the manual import pattern, `import "mapbox-gl/dist/mapbox-gl.css"` in map-view.tsx works reliably. Verify with browser DevTools that mapbox styles are present.
5. **Container has no height**: Every ancestor must have explicit height. Check with browser DevTools → computed styles.
6. **Version mismatch**: If using CDN CSS, version must match `npm ls mapbox-gl` version.

### WebGL / canvas issues
7. **Canvas renders at 0×0 (invisible map, but data loads fine)**: `h-full w-full` on the map root fails when the Tailwind/CSS height chain breaks (e.g. `flex-1`, `min-h-0`, or `min-height` instead of `height` on an ancestor). Use `position: absolute; inset: 0` inside a `position: relative; overflow: hidden` wrapper — this is independent of the height chain. See "Map Container Sizing" above.
8. **Map.resize() needed**: Call `map.current.resize()` at the start of every `load` handler, not just on container resize events. The canvas may have been sized before the container reached its final layout dimensions.
9. **8-digit hex colors not supported**: Mapbox paint properties do not accept CSS 8-digit hex (`#00000033`). Use `rgba(0, 0, 0, 0.2)` instead. This throws a console error and silently drops the paint property.

### ODP data issues
9. **Auth header format**: Use `ApiKey {key}`, not `Bearer {key}`.
10. **Tabular API endpoint**: `POST /api/table/v2/sdk/select?table_id={uuid}`, NOT `/data/{uuid}`.
11. **Binary response**: ODP returns Apache Arrow IPC, not JSON. Must parse with `apache-arrow` library.
12. **Geometry column**: Contains WKT or WKB — must convert to GeoJSON before Mapbox can render it.
13. **NaN/null values**: Arrow data often contains NaN for missing values. Check with `typeof val === "number" && isNaN(val)`.
14. **BigInt values**: Arrow may return BigInt for integer columns. Convert with `Number(val)` before JSON serialization.

### Vector tile issues
15. **`source-layer` required**: Vector sources must specify `"source-layer": "main"` on every layer. Omitting it silently renders nothing.
16. **Numeric ID filter mismatch**: Properties stored as integers (e.g. WMO numbers) arrive as numbers from vector tiles. `["==", ["get", "wmo"], "1234567"]` will never match. Use `["==", ["to-string", ["get", "wmo"]], "1234567"]`.
17. **Filter arrives before load**: If `selectedId` state changes before the map finishes loading, `setFilter` will throw. Use `if (map.loaded()) apply(); else map.once("load", apply)`.
18. **`transformRequest` try/catch required**: Mapbox passes internal non-URL strings to `transformRequest`. Always wrap `new URL(url)` in try/catch or the map will crash on startup.

### Antimeridian artifacts
19. **Horizontal lines across map from trajectories**: Floats that drift across the ±180° longitude boundary produce a line connecting the last point before the crossing to the first point after — spanning the entire map horizontally. Mapbox cannot fix this at render time. **Must be fixed at ingestion**: detect `|Δlon| > 180°` between consecutive points and split the coordinate sequence into a `MULTILINESTRING`. Single-point segments at a crossing boundary should be discarded. See `_split_antimeridian()` in `skills/_argo_index.py` for a reference implementation.

### Popup issues
20. **Low contrast text**: Mapbox popup default styles can make text nearly invisible. Always set explicit `color` on all text elements inside popup HTML (see Popup Styling section).

### Event handler issues
21. **Stale closure in click/hover handlers**: Map event handlers capture the closure at registration time. If callback props change later, handlers use stale values. Use a `useRef` to always read the latest callback (see "Stale Closures" section above).
22. **Null map after async**: After any `await` inside a map `useEffect`, check `if (!map.current) return` — the map may have been destroyed. Never use `map.current!`.

### Vercel deployment
23. **Serverless timeout**: Hobby plan has 10s timeout. Large ODP datasets may exceed this. Use `sample` parameter to limit rows, or switch to vector tiles which are served on demand.
24. **Bundle size**: `apache-arrow` is large. Keep it server-side only with `serverExternalPackages`.
25. **Security headers not applied**: `next.config.ts headers()` and middleware may not set headers on cached pages. Use `vercel.json` headers instead (see "Security Headers on Vercel" section).

## Debugging Checklist

When a map doesn't render, check in this order:

```typescript
// Add to map init useEffect:
useEffect(() => {
  if (!mapContainer.current) {
    console.error("[Map] Container ref is null");
    return;
  }
  const rect = mapContainer.current.getBoundingClientRect();
  console.log("[Map] Container:", rect.width, "x", rect.height);
  // Both must be > 0

  map.current.on("load", () => {
    console.log("[Map] Loaded OK");
    const canvas = mapContainer.current?.querySelector("canvas");
    if (canvas) {
      console.log("[Map] Canvas:", canvas.width, "x", canvas.height);
    }
  });

  map.current.on("error", (e) => {
    console.error("[Map] Error:", e.error?.message || e);
  });
}, []);
```

1. Container dimensions > 0? If not → fix CSS height chain
2. Map "load" event fires? If not → check token, check network for tile 401/403
3. Canvas dimensions match container (2x on retina)? If not → CSS issue
4. No errors in console? Check for WebGL context lost, token errors

