diff --git a/.changeset/map-camera-fits-queried-records.md b/.changeset/map-camera-fits-queried-records.md
new file mode 100644
index 0000000000..5268440bb5
--- /dev/null
+++ b/.changeset/map-camera-fits-queried-records.md
@@ -0,0 +1,39 @@
+---
+'@object-ui/plugin-map': patch
+---
+
+A map view now fits its camera to the records it queried, so a view with data never first-paints an empty viewport.
+
+The initial camera was never derived from the data. The zoom came from
+`getMapConfig`'s default branch — the branch reached precisely when the author
+declared nothing — which synthesized `zoom: 10` at the origin, and the fabricated
+value was indistinguishable from a declared one at the read site. An unconfigured
+object list view of continent-wide records therefore opened on a ~30km-wide
+viewport centred on the set's midpoint: no markers anywhere on screen, the
+records reachable only by zooming out and panning by hand (objectui#4941, seen on
+the showcase `task` map view, whose ten seeded US-city locations span ~4000km).
+
+The camera is now the marker set's bounding box, handed to MapLibre as
+`initialViewState.bounds` so the fit happens against the real container size,
+with padding and a city-scale zoom ceiling (a single record, or several at one
+address, fits a zero-width box — unbounded, that answers with a rooftop view of a
+style whose tiles stop far short of it).
+
+The box is measured along the **shortest arc** containing every marker, not
+between the naive longitude extremes. Read as a line rather than a circle, two
+records two degrees apart across the antimeridian (179 and -179) describe a
+358-degree box whose centre is their antipode; MapLibre then places the markers in
+whichever copy of the world is nearest their previous screen position, which is
+how a fitted-looking camera ends up showing empty ocean with the records sitting
+on a neighbouring copy.
+
+Unchanged on purpose: record coordinates are not rescued. The platform's
+`location` value bounds longitude to [-180, 180], so an out-of-range coordinate is
+a producer-side defect — such records keep being rejected and counted in the
+view's "invalid coordinates" notice, and the normalization above is camera
+arithmetic over already-valid values. No new configuration key was added either:
+the documented `zoom` / `center` pair of the `map` block is still the only camera
+declaration, it still wins outright, and declaring one half keeps the other
+derived (`zoom` alone applies at the records' centre; `center` alone at a
+continental zoom). An empty result set is not fitted — it opens on the whole
+world rather than a zoom-10 patch of sea.
diff --git a/content/docs/plugins/plugin-map.mdx b/content/docs/plugins/plugin-map.mdx
index fd05ac1784..10d939c4f5 100644
--- a/content/docs/plugins/plugin-map.mdx
+++ b/content/docs/plugins/plugin-map.mdx
@@ -119,8 +119,8 @@ const schema = {
locationField?: string, // Field with combined location (alternative)
titleField?: string, // Field to use as marker title
descriptionField?: string, // Field for marker description
- zoom?: number, // Default zoom level (1-20)
- center?: [number, number] // Center coordinates [lat, lng]
+ zoom?: number, // Zoom level (1-20) — opts out of the auto-fit
+ center?: [number, number] // Center coordinates [lat, lng] — opts out of the auto-fit
}
```
@@ -159,9 +159,24 @@ When your data has a combined location field:
}
```
+### Initial Camera
+
+By default the map has no fixed camera: on load it **fits the records it queried**.
+The marker set's bounding box is measured along the shortest arc that contains
+every marker — so a set straddling the antimeridian is framed across the line
+rather than around the far side of the planet — and the map fits that box with
+padding, up to a city-scale zoom ceiling (a single record does not become a
+rooftop view). A view with data therefore never opens on an empty viewport.
+
+Two cases sit outside the fit:
+
+- **No records** (empty result, or every record missing coordinates): nothing to
+ fit, so the map opens on the whole world.
+- **A declared camera** (below): the declaration wins and the fit is skipped.
+
### Zoom and Center
-Control the initial map view:
+Declare either one to take the camera over and opt this view out of the auto-fit:
```tsx
{
@@ -177,6 +192,9 @@ Control the initial map view:
}
```
+Declaring only one half keeps the other derived: `zoom` on its own is applied at
+the centre of the records, `center` on its own at a continental zoom.
+
## Data Providers
### Object Provider (Database)
diff --git a/packages/plugin-map/src/ObjectMap.camera.test.tsx b/packages/plugin-map/src/ObjectMap.camera.test.tsx
new file mode 100644
index 0000000000..3f80141f10
--- /dev/null
+++ b/packages/plugin-map/src/ObjectMap.camera.test.tsx
@@ -0,0 +1,195 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * Regression (objectui#4941): a map list view with records first-painted a
+ * viewport that did not contain them. The camera never fitted the data — the
+ * zoom came from a synthesized default (10, a city-block scale) and the centre
+ * from the naive longitude extremes — so the showcase `task` map opened on empty
+ * sea and the records had to be found by hand.
+ *
+ * These pin what `MapGL` is actually mounted with. The derivation itself is
+ * pinned in `camera.test.ts`.
+ */
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { ObjectMap } from './ObjectMap';
+import { FIT_MAX_ZOOM, FIT_PADDING_PX, EMPTY_VIEW_ZOOM, UNFITTED_CENTER_ZOOM } from './camera';
+
+let capturedProps: any = null;
+
+vi.mock('react-map-gl/maplibre', () => ({
+ default: (props: any) => {
+ capturedProps = props;
+ return
{props.children}
;
+ },
+ Map: ({ children }: any) => {children}
,
+ NavigationControl: () => ,
+ Marker: ({ children, longitude, latitude }: any) => (
+
+ {children}
+
+ ),
+ Popup: ({ children }: any) => {children}
,
+}));
+
+/**
+ * Showcase-shaped records: the platform's `location` value is `{ lat, lng }`,
+ * and the showcase map view declares no map config at all — the exact pair of
+ * facts that produced the reported empty first paint.
+ */
+const usCities = [
+ { id: '1', name: 'Seattle', location: { lat: 47.6062, lng: -122.3321 } },
+ { id: '2', name: 'San Francisco', location: { lat: 37.7749, lng: -122.4194 } },
+ { id: '3', name: 'New York', location: { lat: 40.7128, lng: -74.006 } },
+ { id: '4', name: 'Austin', location: { lat: 30.2672, lng: -97.7431 } },
+];
+
+const valueView = (items: any[], map?: Record): any => ({
+ type: 'map',
+ ...(map ? { map } : {}),
+ data: { provider: 'value', items },
+});
+
+const renderMap = async (schema: any) => {
+ render();
+ await waitFor(() => expect(screen.queryByText('Loading map...')).toBeNull());
+ await waitFor(() => expect(capturedProps).not.toBeNull());
+};
+
+describe('ObjectMap — initial camera', () => {
+ beforeEach(() => {
+ capturedProps = null;
+ });
+
+ it('fits the queried records when no camera is declared', async () => {
+ await renderMap(valueView(usCities));
+
+ const { bounds, fitBoundsOptions, zoom, longitude, latitude } = capturedProps.initialViewState;
+
+ expect(bounds).toEqual([
+ [-122.4194, 30.2672],
+ [-74.006, 47.6062],
+ ]);
+ expect(fitBoundsOptions).toEqual({ padding: FIT_PADDING_PX, maxZoom: FIT_MAX_ZOOM });
+ // The fit owns the camera: no synthesized centre/zoom rides along. A zoom of
+ // 10 here is the bug — a viewport ~30km wide, on a set spanning ~4000km.
+ expect(zoom).toBeUndefined();
+ expect(longitude).toBeUndefined();
+ expect(latitude).toBeUndefined();
+
+ expect(screen.getAllByTestId('map-marker')).toHaveLength(4);
+ });
+
+ it('fits across the antimeridian along the short arc', async () => {
+ await renderMap(
+ valueView([
+ { id: '1', name: 'west of the line', location: { lat: -16, lng: 179 } },
+ { id: '2', name: 'east of the line', location: { lat: -18, lng: -179 } },
+ ]),
+ );
+
+ // 179 -> 181 keeps the box on one world copy. The naive extremes would give
+ // [-179, 179]: a box centred on 0, half a planet from both records.
+ expect(capturedProps.initialViewState.bounds).toEqual([
+ [179, -18],
+ [181, -16],
+ ]);
+ });
+
+ it('fits a single record without zooming to rooftop scale', async () => {
+ await renderMap(valueView([usCities[1]]));
+
+ const { bounds, fitBoundsOptions } = capturedProps.initialViewState;
+ expect(bounds).toEqual([
+ [-122.4194, 37.7749],
+ [-122.4194, 37.7749],
+ ]);
+ expect(fitBoundsOptions.maxZoom).toBe(FIT_MAX_ZOOM);
+ });
+
+ it('does not fit an empty record set — the world, not a random sea', async () => {
+ await renderMap(valueView([]));
+
+ expect(capturedProps.initialViewState).toEqual({
+ longitude: 0,
+ latitude: 0,
+ zoom: EMPTY_VIEW_ZOOM,
+ });
+ expect(capturedProps.initialViewState.bounds).toBeUndefined();
+ });
+
+ it('lets a declared camera win over the fit', async () => {
+ await renderMap(
+ valueView(usCities, {
+ latitudeField: 'latitude',
+ longitudeField: 'longitude',
+ locationField: 'location',
+ titleField: 'name',
+ // Declared as [lat, lng], the documented order for this block.
+ center: [51.5074, -0.1278],
+ zoom: 12,
+ }),
+ );
+
+ expect(capturedProps.initialViewState.bounds).toBeUndefined();
+ expect(capturedProps.initialViewState).toEqual({
+ longitude: -0.1278,
+ latitude: 51.5074,
+ zoom: 12,
+ });
+ });
+
+ it('keeps a declared zoom and takes the centre from the records', async () => {
+ await renderMap(
+ valueView(usCities, { locationField: 'location', titleField: 'name', zoom: 6 }),
+ );
+
+ const { bounds, longitude, latitude, zoom } = capturedProps.initialViewState;
+ expect(bounds).toBeUndefined();
+ expect(zoom).toBe(6);
+ // Centre of the fitted box, not the origin.
+ expect(longitude).toBeCloseTo((-122.4194 + -74.006) / 2, 6);
+ expect(latitude).toBeCloseTo((30.2672 + 47.6062) / 2, 6);
+ });
+
+ it('keeps a declared centre and falls back to a continental zoom', async () => {
+ await renderMap(
+ valueView(usCities, {
+ locationField: 'location',
+ titleField: 'name',
+ center: [51.5074, -0.1278],
+ }),
+ );
+
+ expect(capturedProps.initialViewState).toEqual({
+ longitude: -0.1278,
+ latitude: 51.5074,
+ zoom: UNFITTED_CENTER_ZOOM,
+ });
+ });
+
+ it('still fits when the declared camera cannot be read', async () => {
+ // The shape a reader of the package README would write. It is diagnosed by
+ // `MapConfigSchema`, not adapted — and it must not cost the view its fit.
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ try {
+ await renderMap(
+ valueView(usCities, {
+ locationField: 'location',
+ titleField: 'name',
+ center: { lat: 51.5074, lng: -0.1278 } as unknown as [number, number],
+ }),
+ );
+
+ expect(capturedProps.initialViewState.bounds).toEqual([
+ [-122.4194, 30.2672],
+ [-74.006, 47.6062],
+ ]);
+ expect(warn).toHaveBeenCalled();
+ } finally {
+ warn.mockRestore();
+ }
+ });
+});
diff --git a/packages/plugin-map/src/ObjectMap.tsx b/packages/plugin-map/src/ObjectMap.tsx
index 613c444fb3..8db3dad1a1 100644
--- a/packages/plugin-map/src/ObjectMap.tsx
+++ b/packages/plugin-map/src/ObjectMap.tsx
@@ -29,6 +29,14 @@ import { z } from 'zod';
import MapGL, { NavigationControl, Marker, Popup } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';
+import {
+ computeMarkerBounds,
+ boundsCenter,
+ EMPTY_VIEW_ZOOM,
+ FIT_MAX_ZOOM,
+ FIT_PADDING_PX,
+ UNFITTED_CENTER_ZOOM,
+} from './camera';
const MapConfigSchema = z.object({
latitudeField: z.string().optional(),
@@ -74,9 +82,16 @@ interface MapConfig {
titleField?: string;
/** Field to use for marker description */
descriptionField?: string;
- /** Default zoom level (1-20) */
+ /**
+ * Zoom level (1-20). Declaring it opts the view OUT of fitting the camera to
+ * its records — the declaration wins (objectui#4941).
+ */
zoom?: number;
- /** Center coordinates [lat, lng] */
+ /**
+ * Center coordinates [lat, lng] — latitude first, as documented and as the
+ * `map` block's shape has always been read. Declaring it opts the view OUT of
+ * fitting the camera to its records.
+ */
center?: [number, number];
/** MapLibre style URL/spec (overrides the public demo default) */
style?: string;
@@ -211,15 +226,20 @@ function getMapConfig(schema: ObjectGridSchema | any): MapConfig {
return { ...config, style: config.style || style };
}
- // Default configuration
+ // Default configuration — field names only. No camera is synthesized here
+ // (objectui#4941): this branch is reached precisely when the author declared
+ // nothing, and a fabricated `zoom` / `center` is indistinguishable from a
+ // declared one at the read site. The old defaults (zoom 10 at the origin)
+ // therefore SUPPRESSED the fit for exactly the views that need it most — an
+ // unconfigured object list view of continent-wide records first-painted a
+ // city-block viewport centred on the set's midpoint, showing no markers at
+ // all. With no camera declared, the camera comes from the data.
return {
latitudeField: 'latitude',
longitudeField: 'longitude',
locationField: 'location',
titleField: 'name',
descriptionField: 'description',
- zoom: 10,
- center: [0, 0],
style,
};
}
@@ -559,31 +579,55 @@ export const ObjectMap: React.FC = ({
return clusterMarkers(filteredMarkers, currentZoom, clusterRadius);
}, [filteredMarkers, currentZoom, enableClustering, clusterRadius, schema]);
- // Calculate map bounds
+ /**
+ * The box the records occupy, along the shortest arc containing them
+ * (see `./camera`). `null` when there is nothing to fit.
+ */
+ const markerBounds = useMemo(
+ () => computeMarkerBounds(filteredMarkers.map(m => m.coordinates)),
+ [filteredMarkers],
+ );
+
+ /**
+ * A camera the author declared, read from the documented `map` block. Only a
+ * READABLE declaration counts: `MapConfigSchema` already warns about a
+ * malformed one, and a shape whose numbers cannot be read is not a camera —
+ * letting it suppress the fit would first-paint an empty viewport, which is
+ * the defect this whole path exists to prevent (objectui#4941). Nothing is
+ * coerced: an unreadable declaration is diagnosed and ignored, never adapted.
+ */
+ const declaredLatitude = typeof mapConfig.center?.[0] === 'number' ? mapConfig.center[0] : undefined;
+ const declaredLongitude = typeof mapConfig.center?.[1] === 'number' ? mapConfig.center[1] : undefined;
+ const declaredZoom = typeof mapConfig.zoom === 'number' ? mapConfig.zoom : undefined;
+ const hasDeclaredCamera =
+ declaredLatitude !== undefined || declaredLongitude !== undefined || declaredZoom !== undefined;
+
+ /**
+ * Initial camera. Read once, when `MapGL` mounts — which is also every time
+ * the record set changes, because the `loading` gate below unmounts the map
+ * for the duration of each fetch. So the one-shot camera always reflects the
+ * records currently in hand, and nothing here ever yanks a camera the user
+ * has since panned.
+ */
const initialViewState = useMemo(() => {
- if (!filteredMarkers.length) {
+ // Records, no declared camera: hand MapLibre the box and let it fit at the
+ // real container size. `bounds` overrides center/zoom on the constructor.
+ if (markerBounds && !hasDeclaredCamera) {
return {
- longitude: mapConfig.center?.[1] || 0,
- latitude: mapConfig.center?.[0] || 0,
- zoom: mapConfig.zoom || 2
+ bounds: markerBounds,
+ fitBoundsOptions: { padding: FIT_PADDING_PX, maxZoom: FIT_MAX_ZOOM },
};
}
- // Simple bounds calculation
- const lngs = filteredMarkers.map(m => m.coordinates[0]);
- const lats = filteredMarkers.map(m => m.coordinates[1]);
-
- const minLng = Math.min(...lngs);
- const maxLng = Math.max(...lngs);
- const minLat = Math.min(...lats);
- const maxLat = Math.max(...lats);
-
+ // Otherwise the declared halves win and the rest falls back: to the box's
+ // centre when there are records, to the world when there are none.
+ const fallback = markerBounds ? boundsCenter(markerBounds) : { longitude: 0, latitude: 0 };
return {
- longitude: (minLng + maxLng) / 2,
- latitude: (minLat + maxLat) / 2,
- zoom: mapConfig.zoom || 3, // Auto-zoom logic could be improved here
+ longitude: declaredLongitude ?? fallback.longitude,
+ latitude: declaredLatitude ?? fallback.latitude,
+ zoom: declaredZoom ?? (markerBounds ? UNFITTED_CENTER_ZOOM : EMPTY_VIEW_ZOOM),
};
- }, [filteredMarkers, mapConfig]);
+ }, [markerBounds, hasDeclaredCamera, declaredLongitude, declaredLatitude, declaredZoom]);
if (loading) {
return (
diff --git a/packages/plugin-map/src/camera.test.ts b/packages/plugin-map/src/camera.test.ts
new file mode 100644
index 0000000000..fa8fa608fa
--- /dev/null
+++ b/packages/plugin-map/src/camera.test.ts
@@ -0,0 +1,143 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * Regression (objectui#4941): the initial camera of a map view with records was
+ * never derived from those records — the zoom came from a synthesized default
+ * and the centre from the naive longitude extremes, so a view with data could
+ * first-paint an empty viewport. These pin the derivation itself; the component
+ * wiring is pinned in `ObjectMap.camera.test.tsx`.
+ */
+import { describe, it, expect } from 'vitest';
+import {
+ computeMarkerBounds,
+ boundsCenter,
+ normalizeLongitude,
+ MAX_FIT_LATITUDE,
+} from './camera';
+
+describe('normalizeLongitude', () => {
+ it('leaves in-range longitudes untouched', () => {
+ expect(normalizeLongitude(0)).toBe(0);
+ expect(normalizeLongitude(-122.3321)).toBeCloseTo(-122.3321, 10);
+ expect(normalizeLongitude(179.5)).toBeCloseTo(179.5, 10);
+ });
+
+ it('folds wrapped longitudes back onto the circle', () => {
+ expect(normalizeLongitude(190)).toBeCloseTo(-170, 10);
+ expect(normalizeLongitude(-190)).toBeCloseTo(170, 10);
+ expect(normalizeLongitude(540)).toBeCloseTo(180 - 360, 10);
+ expect(normalizeLongitude(180)).toBe(-180);
+ });
+});
+
+describe('computeMarkerBounds', () => {
+ it('returns null for an empty record set — nothing to fit', () => {
+ expect(computeMarkerBounds([])).toBeNull();
+ });
+
+ it('fits a continent-wide set to its own extent', () => {
+ // The showcase `task` seed: US cities, [lng, lat] as markers carry them.
+ const bounds = computeMarkerBounds([
+ [-122.3321, 47.6062], // Seattle
+ [-122.4194, 37.7749], // San Francisco
+ [-74.006, 40.7128], // New York
+ [-97.7431, 30.2672], // Austin
+ [-71.0589, 42.3601], // Boston
+ ]);
+
+ expect(bounds).not.toBeNull();
+ const [[west, south], [east, north]] = bounds!;
+ expect(west).toBeCloseTo(-122.4194, 6);
+ expect(east).toBeCloseTo(-71.0589, 6);
+ expect(south).toBeCloseTo(30.2672, 6);
+ expect(north).toBeCloseTo(47.6062, 6);
+
+ // The box is the data's own extent, so its centre is inside it — not the
+ // fabricated zoom-10 origin the component used to open on.
+ const { longitude, latitude } = boundsCenter(bounds!);
+ expect(longitude).toBeGreaterThan(west);
+ expect(longitude).toBeLessThan(east);
+ expect(latitude).toBeGreaterThan(south);
+ expect(latitude).toBeLessThan(north);
+ });
+
+ it('takes the SHORT arc across the antimeridian, not the naive extremes', () => {
+ // Two markers two degrees apart, straddling 180. Naive min/max longitude
+ // would describe a 358-degree box centred on 0 — the antipode of the data,
+ // which is how markers end up on a neighbouring world copy.
+ const bounds = computeMarkerBounds([
+ [179, -16], // Fiji side
+ [-179, -18], // just east of the line
+ ]);
+
+ const [[west, south], [east, north]] = bounds!;
+ expect(west).toBeCloseTo(179, 6);
+ expect(east).toBeCloseTo(181, 6); // past 180 on purpose: MapLibre's own spelling
+ expect(east - west).toBeCloseTo(2, 6);
+ expect(south).toBeCloseTo(-18, 6);
+ expect(north).toBeCloseTo(-16, 6);
+
+ // ...and the centre lands ON the data, folded back onto the circle.
+ expect(boundsCenter(bounds!).longitude).toBeCloseTo(-180, 6);
+ });
+
+ it('keeps a genuinely global set on the long span', () => {
+ // Nothing to shorten here: the widest empty gap is the 120 degrees between
+ // 120 and -120, so the box spans the other 240.
+ const bounds = computeMarkerBounds([
+ [-120, 0],
+ [0, 10],
+ [120, -10],
+ ]);
+
+ const [[west], [east]] = bounds!;
+ expect(west).toBeCloseTo(-120, 6);
+ expect(east).toBeCloseTo(120, 6);
+ });
+
+ it('yields a degenerate box for a single record', () => {
+ // Zero-width: the fit's maxZoom is what stops this becoming a rooftop view.
+ const bounds = computeMarkerBounds([[-122.4194, 37.7749]]);
+ expect(bounds).toEqual([
+ [-122.4194, 37.7749],
+ [-122.4194, 37.7749],
+ ]);
+ expect(boundsCenter(bounds!)).toEqual({ longitude: -122.4194, latitude: 37.7749 });
+ });
+
+ it('yields a degenerate box when several records share one address', () => {
+ const bounds = computeMarkerBounds([
+ [8.5417, 47.3769],
+ [8.5417, 47.3769],
+ [8.5417, 47.3769],
+ ]);
+ expect(bounds).toEqual([
+ [8.5417, 47.3769],
+ [8.5417, 47.3769],
+ ]);
+ });
+
+ it('folds an out-of-range longitude onto the circle for the camera only', () => {
+ // `ObjectMap` rejects such a record before it can become a marker (the
+ // platform bounds longitude to [-180, 180]); this pins the arithmetic, not
+ // an intake path.
+ const bounds = computeMarkerBounds([
+ [190, 10],
+ [-170, 12],
+ ]);
+ const [[west], [east]] = bounds!;
+ expect(west).toBeCloseTo(-170, 6);
+ expect(east).toBeCloseTo(-170, 6);
+ });
+
+ it('clamps the box to the Mercator-safe latitude band', () => {
+ const bounds = computeMarkerBounds([
+ [10, 89.9],
+ [12, -89.9],
+ ]);
+ const [[, south], [, north]] = bounds!;
+ expect(south).toBe(-MAX_FIT_LATITUDE);
+ expect(north).toBe(MAX_FIT_LATITUDE);
+ });
+});
diff --git a/packages/plugin-map/src/camera.ts b/packages/plugin-map/src/camera.ts
new file mode 100644
index 0000000000..b3bbdf8382
--- /dev/null
+++ b/packages/plugin-map/src/camera.ts
@@ -0,0 +1,152 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * Initial-camera derivation for ObjectMap (objectui#4941).
+ *
+ * A map view whose query returned records must never first-paint an empty
+ * viewport. The camera is therefore derived from the records themselves: the
+ * marker set's bounding box, handed to MapLibre so it fits the box with the
+ * real container size (`initialViewState.bounds` -> the `bounds` /
+ * `fitBoundsOptions` pair on the MapLibre `Map` constructor, which overrides
+ * `center` / `zoom`).
+ *
+ * Two things this module deliberately does NOT do:
+ *
+ * 1. It does not rescue out-of-range record coordinates. The platform's
+ * `location` value is `{ lat, lng }` with longitude bounded to [-180, 180]
+ * by `@objectstack/spec` (`data/field-value.zod.ts`), so a longitude outside
+ * that range is a producer-side defect, not a dialect this renderer should
+ * learn to read. `ObjectMap` keeps rejecting such records and counting them
+ * in its "invalid coordinates" notice. The normalization here is projection
+ * arithmetic over already-valid values, applied to the CAMERA only.
+ * 2. It does not introduce any authoring surface. The only camera declaration
+ * read anywhere is the documented `zoom` / `center` pair of the map config
+ * block (`content/docs/plugins/plugin-map.mdx`), and an author who declares
+ * it keeps winning over everything computed here.
+ */
+
+/**
+ * `[[west, south], [east, north]]` — the two-corner form MapLibre accepts as
+ * `LngLatBoundsLike`. `east` may exceed 180: that is how MapLibre expects a box
+ * crossing the antimeridian to be spelled (its own `adjustAntiMeridian` produces
+ * exactly this shape), and it is what keeps the fitted camera on the same world
+ * copy as the markers.
+ */
+export type MarkerBounds = [[number, number], [number, number]];
+
+/** Breathing room around the fitted box, in pixels. */
+export const FIT_PADDING_PX = 48;
+
+/**
+ * Ceiling for the fitted zoom. A single record — or several records at one
+ * address — fits a zero-width box, and an unbounded fit answers that with the
+ * map's maximum zoom (22): a building-level view of a style whose tiles usually
+ * stop far short of it. City scale is the useful answer instead.
+ */
+export const FIT_MAX_ZOOM = 12;
+
+/** Zoom used when there is nothing to fit: the whole world, not a random sea. */
+export const EMPTY_VIEW_ZOOM = 2;
+
+/**
+ * Zoom used when a partially declared camera pins the position but not the
+ * scale, so the box cannot drive the fit. Same value the component used before
+ * the fit existed.
+ */
+export const UNFITTED_CENTER_ZOOM = 3;
+
+/**
+ * Latitude ceiling for the fitted box. Web Mercator is unbounded at the poles,
+ * so a record sitting exactly at +/-90 projects to infinity and takes the whole
+ * camera with it. Markers themselves stay at their real latitude.
+ */
+export const MAX_FIT_LATITUDE = 85;
+
+/**
+ * Fold any longitude onto the [-180, 180) circle.
+ *
+ * In-range values return unchanged rather than through the modulo, which would
+ * round-trip them into a neighbouring float (-74.006 came back as
+ * -74.00599999999997) and put that drift into every fitted box.
+ */
+export function normalizeLongitude(lng: number): number {
+ if (lng >= -180 && lng < 180) return lng;
+ return ((((lng + 180) % 360) + 360) % 360) - 180;
+}
+
+const clampLatitude = (lat: number): number =>
+ Math.min(MAX_FIT_LATITUDE, Math.max(-MAX_FIT_LATITUDE, lat));
+
+/**
+ * Bounding box of a marker set, along the SHORTEST arc that contains every
+ * marker.
+ *
+ * Why the shortest arc and not `[min(lng), max(lng)]`: the naive extremes read
+ * the circle as a line, so a set straddling the antimeridian (say 179 and -179,
+ * two degrees apart) yields a 358-degree-wide box whose midpoint is 0 — the
+ * antipode of the data, half a planet away. MapLibre then places the markers in
+ * whichever world copy is nearest their previous screen position (`smartWrap`),
+ * which is how a fitted-looking camera ends up showing an empty ocean with the
+ * records sitting on a neighbouring copy of the world.
+ *
+ * The widest EMPTY gap between adjacent longitudes is the seam to cut; what
+ * remains is the shortest containing arc. `west` is the first marker east of
+ * that seam, and the span is measured eastward from it, so the box may extend
+ * past 180.
+ *
+ * @param coordinates marker coordinates in MapLibre order, `[lng, lat]`
+ * @returns the box, or `null` when there is nothing to fit
+ */
+export function computeMarkerBounds(
+ coordinates: ReadonlyArray,
+): MarkerBounds | null {
+ if (coordinates.length === 0) return null;
+
+ const lngs = coordinates.map(([lng]) => normalizeLongitude(lng)).sort((a, b) => a - b);
+ const lats = coordinates.map(([, lat]) => clampLatitude(lat));
+
+ const count = lngs.length;
+ // Start from the seam that closes the circle (last -> first), then look for a
+ // wider gap between neighbours.
+ let widestGap = lngs[0] + 360 - lngs[count - 1];
+ let firstAfterGap = 0;
+ for (let i = 1; i < count; i++) {
+ const gap = lngs[i] - lngs[i - 1];
+ if (gap > widestGap) {
+ widestGap = gap;
+ firstAfterGap = i;
+ }
+ }
+
+ // Both edges are marker longitudes, so the box carries the data's own values
+ // instead of a sum of spans. The eastern edge is shifted a full turn only when
+ // the arc runs over the antimeridian.
+ const west = lngs[firstAfterGap];
+ const easternmost = lngs[(firstAfterGap + count - 1) % count];
+ const east = firstAfterGap === 0 ? easternmost : easternmost + 360;
+
+ return [
+ [west, Math.min(...lats)],
+ [east, Math.max(...lats)],
+ ];
+}
+
+/**
+ * Centre of a box from {@link computeMarkerBounds}, for the paths that cannot
+ * hand the box to a fit (a declared zoom with no declared centre). The
+ * longitude is folded back onto the circle so a box crossing the antimeridian
+ * still yields a real centre rather than a value past 180.
+ */
+export function boundsCenter(bounds: MarkerBounds): { longitude: number; latitude: number } {
+ const [[west, south], [east, north]] = bounds;
+ return {
+ longitude: normalizeLongitude((west + east) / 2),
+ latitude: (south + north) / 2,
+ };
+}
diff --git a/packages/plugin-map/src/index.registration.test.tsx b/packages/plugin-map/src/index.registration.test.tsx
index e2a6b0c141..71eb386852 100644
--- a/packages/plugin-map/src/index.registration.test.tsx
+++ b/packages/plugin-map/src/index.registration.test.tsx
@@ -19,6 +19,20 @@ vi.mock('react-map-gl/maplibre', () => ({
Popup: () => null,
}));
+// AGENTS.md §9 测试纪律 — the assertion below re-imports this module after
+// `vi.resetModules()`, and that import drags in the whole component graph
+// (`@object-ui/components`, `@object-ui/react`, the map bindings). Loading it
+// for the FIRST time inside the test put an unbounded transform inside the 5s
+// test budget: this file timed out when run on its own and stayed green only
+// while a sibling file happened to warm the transform cache first — an order
+// dependency that broke as soon as the package gained another test file
+// (objectui#4941). Importing the same specifiers at module scope moves the cost
+// into the import phase, which no test/hook timeout bounds, and leaves the
+// in-test import to re-EXECUTE an already-transformed graph. The specifiers
+// must match the in-test ones exactly for the cache to serve them.
+import './index';
+import '@object-ui/core';
+
/**
* Regression pin for objectstack#7139: a leftover
* `console.log('Registering object-map...')` sat at this module's top level, so