Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/map-camera-fits-queried-records.md
Original file line numberDiff line numberDiff line change
@@ -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.
24 changes: 21 additions & 3 deletions content/docs/plugins/plugin-map.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
}
```

Expand DownExpand Up@@ -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
{
Expand All@@ -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)
Expand Down
195 changes: 195 additions & 0 deletions packages/plugin-map/src/ObjectMap.camera.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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 <div aria-label="Map">{props.children}</div>;
},
Map: ({ children }: any) => <div aria-label="Map">{children}</div>,
NavigationControl: () => <div data-testid="nav-control" />,
Marker: ({ children, longitude, latitude }: any) => (
<div data-testid="map-marker" data-lng={longitude} data-lat={latitude}>
{children}
</div>
),
Popup: ({ children }: any) => <div data-testid="map-popup">{children}</div>,
}));

/**
* 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<string, unknown>): any => ({
type: 'map',
...(map ? { map } : {}),
data: { provider: 'value', items },
});

const renderMap = async (schema: any) => {
render(<ObjectMap schema={schema} />);
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();
}
});
});
Loading
Loading