description: 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 name: 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
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
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:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["mapbox-gl"],
serverExternalPackages: ["apache-arrow"],
};
transpilePackages: ["mapbox-gl"]— required because mapbox-gl ships untranspiled ESMserverExternalPackages: ["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:
// 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:
// 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 />;
}
// 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:
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:
// 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:
// 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>
// 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:
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
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:
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
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 namemaxzoom: 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.
// 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:
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.
// 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:
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
// 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}(NOTBearer) - Tabular endpoint:
POST /api/table/v2/sdk/select?table_id={uuid} - Request body uses
query,sample,columns(NOTfilter,limit) - Response is Apache Arrow IPC binary (NOT JSON)
Server-Side: Arrow Parser
// 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:
// 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
// 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
// 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:
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: #888for muted appearance - Values (right column):
color: #333for readable dark text - Title:
color: #1a1a2efor strong heading contrast - Never rely on inherited/default text color in popups
Caching Strategy
ODP data changes infrequently. Cache GeoJSON responses server-side:
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:
// 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:
// 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.
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:
{
"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
- "Missing html/body tags" false positive: Caused by using
next/dynamicwithssr: false. Use the manualuseEffect+import()pattern instead (see step 3 above). <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. Usemetadataexport or load CSS via component imports.- Stale Turbopack cache: When changing layout.tsx or env vars, always
rm -rf .nextand restart the dev server. Turbopack caches aggressively.
Map is invisible / not rendering
- 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. - Container has no height: Every ancestor must have explicit height. Check with browser DevTools → computed styles.
- Version mismatch: If using CDN CSS, version must match
npm ls mapbox-glversion.
WebGL / canvas issues
- Canvas renders at 0×0 (invisible map, but data loads fine):
h-full w-fullon the map root fails when the Tailwind/CSS height chain breaks (e.g.flex-1,min-h-0, ormin-heightinstead ofheighton an ancestor). Useposition: absolute; inset: 0inside aposition: relative; overflow: hiddenwrapper — this is independent of the height chain. See "Map Container Sizing" above. - Map.resize() needed: Call
map.current.resize()at the start of everyloadhandler, not just on container resize events. The canvas may have been sized before the container reached its final layout dimensions. - 8-digit hex colors not supported: Mapbox paint properties do not accept CSS 8-digit hex (
#00000033). Usergba(0, 0, 0, 0.2)instead. This throws a console error and silently drops the paint property.
ODP data issues
- Auth header format: Use
ApiKey {key}, notBearer {key}. - Tabular API endpoint:
POST /api/table/v2/sdk/select?table_id={uuid}, NOT/data/{uuid}. - Binary response: ODP returns Apache Arrow IPC, not JSON. Must parse with
apache-arrowlibrary. - Geometry column: Contains WKT or WKB — must convert to GeoJSON before Mapbox can render it.
- NaN/null values: Arrow data often contains NaN for missing values. Check with
typeof val === "number" && isNaN(val). - BigInt values: Arrow may return BigInt for integer columns. Convert with
Number(val)before JSON serialization.
Vector tile issues
source-layerrequired: Vector sources must specify"source-layer": "main"on every layer. Omitting it silently renders nothing.- 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"]. - Filter arrives before load: If
selectedIdstate changes before the map finishes loading,setFilterwill throw. Useif (map.loaded()) apply(); else map.once("load", apply). transformRequesttry/catch required: Mapbox passes internal non-URL strings totransformRequest. Always wrapnew URL(url)in try/catch or the map will crash on startup.
Antimeridian artifacts
- 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 aMULTILINESTRING. Single-point segments at a crossing boundary should be discarded. See_split_antimeridian()inskills/_argo_index.pyfor a reference implementation.
Popup issues
- Low contrast text: Mapbox popup default styles can make text nearly invisible. Always set explicit
coloron all text elements inside popup HTML (see Popup Styling section).
Event handler issues
- 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
useRefto always read the latest callback (see "Stale Closures" section above). - Null map after async: After any
awaitinside a mapuseEffect, checkif (!map.current) return— the map may have been destroyed. Never usemap.current!.
Vercel deployment
- Serverless timeout: Hobby plan has 10s timeout. Large ODP datasets may exceed this. Use
sampleparameter to limit rows, or switch to vector tiles which are served on demand. - Bundle size:
apache-arrowis large. Keep it server-side only withserverExternalPackages. - Security headers not applied:
next.config.ts headers()and middleware may not set headers on cached pages. Usevercel.jsonheaders instead (see "Security Headers on Vercel" section).
Debugging Checklist
When a map doesn't render, check in this order:
// 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);
});
}, []);
- Container dimensions > 0? If not → fix CSS height chain
- Map "load" event fires? If not → check token, check network for tile 401/403
- Canvas dimensions match container (2x on retina)? If not → CSS issue
- No errors in console? Check for WebGL context lost, token errors