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