Skip to content

Commit 45c5c58

Browse files
Fix #2325: Restrict zooming and panning on the data inspector
- Fetch TileJSON when inspector dialog opens - Extract bounds, minzoom, maxzoom, and center from TileJSON - Configure map with initialViewState and restrictions - Apply bounds restrictions to prevent panning outside dataset extent - Set min/max zoom limits based on TileJSON data - Fit map to bounds when available, otherwise use center - Update tests to mock TileJSON fetch calls
1 parent 9c49ffd commit 45c5c58

File tree

2 files changed

+194
-6
lines changed

2 files changed

+194
-6
lines changed

martin/martin-ui/__tests__/components/dialogs/tile-inspect.test.tsx

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
1-
import { cleanup, render } from '@testing-library/react';
1+
import { cleanup, render, waitFor } from '@testing-library/react';
22
import type { ReactNode } from 'react';
33
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
44
import { TileInspectDialog } from '@/components/dialogs/tile-inspect';
55
import type { TileSource } from '@/lib/types';
6+
import { buildMartinUrl } from '@/lib/api';
67

78
interface MockComponentProps {
89
children?: ReactNode;
910
className?: string;
1011
[key: string]: unknown;
1112
}
1213

14+
// Mock the buildMartinUrl function
15+
vi.mock('@/lib/api', () => ({
16+
buildMartinUrl: vi.fn((path: string) => `http://localhost:3000${path}`),
17+
}));
18+
19+
// Mock fetch globally
20+
global.fetch = vi.fn();
21+
1322
// Mock the UI dialog components
1423
vi.mock('@/components/ui/dialog', () => ({
1524
Dialog: ({
@@ -64,18 +73,28 @@ describe('TileInspectDialog', () => {
6473

6574
beforeEach(() => {
6675
vi.clearAllMocks();
76+
// Default mock for fetch - return empty TileJSON
77+
vi.mocked(global.fetch).mockResolvedValue({
78+
ok: true,
79+
json: async () => ({
80+
tiles: [],
81+
}),
82+
} as Response);
6783
});
6884

6985
afterEach(() => {
7086
cleanup();
7187
});
7288

73-
it('renders dialog with correct title and source information', () => {
89+
it('renders dialog with correct title and source information', async () => {
7490
const { container } = render(
7591
<TileInspectDialog name="test-tiles" onCloseAction={mockOnClose} source={mockTileSource} />,
7692
);
7793

78-
expect(container.textContent).toContain('Inspect Tile Source:');
94+
await waitFor(() => {
95+
expect(container.textContent).toContain('Inspect Tile Source:');
96+
});
97+
7998
expect(container.textContent).toContain('test-tiles');
8099
expect(container.textContent).toContain('Source Information');
81100
expect(container.textContent).toContain('image/png');
@@ -165,6 +184,31 @@ describe('TileInspectDialog', () => {
165184
expect(container.textContent).toContain('Encoding:');
166185
});
167186

187+
it('fetches TileJSON when dialog opens', async () => {
188+
const mockTileJSON = {
189+
tiles: ['http://localhost:3000/test-tiles/{z}/{x}/{y}'],
190+
minzoom: 5,
191+
maxzoom: 15,
192+
bounds: [-180, -85, 180, 85],
193+
center: [0, 0, 10],
194+
};
195+
196+
vi.mocked(global.fetch).mockResolvedValueOnce({
197+
ok: true,
198+
json: async () => mockTileJSON,
199+
} as Response);
200+
201+
render(
202+
<TileInspectDialog name="test-tiles" onCloseAction={mockOnClose} source={mockTileSource} />,
203+
);
204+
205+
await waitFor(() => {
206+
expect(global.fetch).toHaveBeenCalledWith(
207+
buildMartinUrl('/test-tiles'),
208+
);
209+
});
210+
});
211+
168212
it('conditionally renders optional fields', () => {
169213
const sourceWithoutOptionals: TileSource = {
170214
content_type: 'image/png',

martin/martin-ui/src/components/dialogs/tile-inspect.tsx

Lines changed: 147 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { useCallback, useId, useRef } from 'react';
3+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
44
import {
55
Dialog,
66
DialogContent,
@@ -17,6 +17,23 @@ import { Database } from 'lucide-react';
1717
import { Popup } from 'maplibre-gl';
1818
import { buildMartinUrl } from '@/lib/api';
1919

20+
interface TileJSON {
21+
tilejson?: string;
22+
name?: string;
23+
description?: string;
24+
version?: string;
25+
attribution?: string;
26+
scheme?: string;
27+
tiles: string[];
28+
grids?: string[];
29+
data?: string[];
30+
minzoom?: number;
31+
maxzoom?: number;
32+
bounds?: [number, number, number, number]; // [west, south, east, north]
33+
center?: [number, number, number]; // [longitude, latitude, zoom]
34+
vector_layers?: unknown[];
35+
}
36+
2037
interface TileInspectDialogProps {
2138
name: string;
2239
source: TileSource;
@@ -27,6 +44,86 @@ export function TileInspectDialog({ name, source, onCloseAction }: TileInspectDi
2744
const id = useId();
2845
const mapRef = useRef<MapRef>(null);
2946
const inspectControlRef = useRef<MaplibreInspect>(null);
47+
const [tileJSON, setTileJSON] = useState<TileJSON | null>(null);
48+
49+
const configureMapBounds = useCallback(() => {
50+
if (!mapRef.current || !tileJSON) {
51+
return;
52+
}
53+
54+
const map = mapRef.current.getMap();
55+
56+
// Set minzoom and maxzoom restrictions
57+
if (tileJSON.minzoom !== undefined) {
58+
map.setMinZoom(tileJSON.minzoom);
59+
}
60+
if (tileJSON.maxzoom !== undefined) {
61+
map.setMaxZoom(tileJSON.maxzoom);
62+
}
63+
64+
// Set bounds restrictions if available
65+
if (tileJSON.bounds) {
66+
const [west, south, east, north] = tileJSON.bounds;
67+
map.setMaxBounds([
68+
[west, south],
69+
[east, north],
70+
]);
71+
}
72+
73+
// Fit to bounds or center if available
74+
if (tileJSON.bounds) {
75+
const [west, south, east, north] = tileJSON.bounds;
76+
map.fitBounds(
77+
[
78+
[west, south],
79+
[east, north],
80+
],
81+
{
82+
padding: 50,
83+
maxZoom: tileJSON.maxzoom,
84+
},
85+
);
86+
} else if (tileJSON.center) {
87+
const [lng, lat, zoom] = tileJSON.center;
88+
map.setCenter([lng, lat]);
89+
if (zoom !== undefined) {
90+
map.setZoom(zoom);
91+
}
92+
}
93+
}, [tileJSON]);
94+
95+
// Fetch TileJSON when dialog opens
96+
useEffect(() => {
97+
let cancelled = false;
98+
99+
fetch(buildMartinUrl(`/${name}`))
100+
.then((response) => {
101+
if (!response.ok) {
102+
throw new Error(`Failed to fetch TileJSON: ${response.statusText}`);
103+
}
104+
return response.json();
105+
})
106+
.then((data: TileJSON) => {
107+
if (!cancelled) {
108+
setTileJSON(data);
109+
}
110+
})
111+
.catch((error) => {
112+
console.error('Error fetching TileJSON:', error);
113+
// Continue without TileJSON restrictions if fetch fails
114+
});
115+
116+
return () => {
117+
cancelled = true;
118+
};
119+
}, [name]);
120+
121+
// Reconfigure bounds when TileJSON loads after map is already initialized
122+
useEffect(() => {
123+
if (tileJSON && mapRef.current) {
124+
configureMapBounds();
125+
}
126+
}, [tileJSON, configureMapBounds]);
30127

31128
const addInspectorToMap = useCallback(() => {
32129
if (!mapRef.current) {
@@ -54,12 +151,15 @@ export function TileInspectDialog({ name, source, onCloseAction }: TileInspectDi
54151
});
55152

56153
map.addControl(inspectControlRef.current);
57-
}, [name]);
154+
155+
// Configure bounds after adding inspector
156+
configureMapBounds();
157+
}, [name, configureMapBounds]);
58158
const isImageSource = ['image/gif', 'image/jpeg', 'image/png', 'image/webp'].includes(
59159
source.content_type,
60160
);
61161
return (
62-
<Dialog onOpenChange={(v) => !v && onCloseAction()} open={true}>
162+
<Dialog onOpenChange={(v: boolean) => !v && onCloseAction()} open={true}>
63163
<DialogContent className="max-w-6xl w-full p-6 max-h-[90vh] overflow-auto">
64164
<DialogHeader className="mb-6">
65165
<DialogTitle className="text-2xl flex items-center justify-between">
@@ -78,6 +178,31 @@ export function TileInspectDialog({ name, source, onCloseAction }: TileInspectDi
78178
<MapLibreMap
79179
ref={mapRef}
80180
reuseMaps={false}
181+
onLoad={() => {
182+
// Configure bounds for raster sources after map loads
183+
if (tileJSON) {
184+
configureMapBounds();
185+
}
186+
}}
187+
initialViewState={
188+
tileJSON?.center
189+
? {
190+
longitude: tileJSON.center[0],
191+
latitude: tileJSON.center[1],
192+
zoom: tileJSON.center[2] ?? 0,
193+
}
194+
: undefined
195+
}
196+
minZoom={tileJSON?.minzoom}
197+
maxZoom={tileJSON?.maxzoom}
198+
maxBounds={
199+
tileJSON?.bounds
200+
? [
201+
[tileJSON.bounds[0], tileJSON.bounds[1]],
202+
[tileJSON.bounds[2], tileJSON.bounds[3]],
203+
]
204+
: undefined
205+
}
81206
style={{
82207
height: '500px',
83208
width: '100%',
@@ -91,6 +216,25 @@ export function TileInspectDialog({ name, source, onCloseAction }: TileInspectDi
91216
onLoad={addInspectorToMap}
92217
ref={mapRef}
93218
reuseMaps={false}
219+
initialViewState={
220+
tileJSON?.center
221+
? {
222+
longitude: tileJSON.center[0],
223+
latitude: tileJSON.center[1],
224+
zoom: tileJSON.center[2] ?? 0,
225+
}
226+
: undefined
227+
}
228+
minZoom={tileJSON?.minzoom}
229+
maxZoom={tileJSON?.maxzoom}
230+
maxBounds={
231+
tileJSON?.bounds
232+
? [
233+
[tileJSON.bounds[0], tileJSON.bounds[1]],
234+
[tileJSON.bounds[2], tileJSON.bounds[3]],
235+
]
236+
: undefined
237+
}
94238
style={{
95239
height: '500px',
96240
width: '100%',

0 commit comments

Comments
 (0)