Skip to content
Open
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
35 changes: 35 additions & 0 deletions apps/web/components/GuessMap.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { MapContainer, Marker, Polyline, TileLayer, useMap, useMapEvents } from 'react-leaflet';
import { useEffect, useMemo, useRef, useState } from 'react';
import L from 'leaflet';
import { playRegionCorners, type PlayRegionBounds } from './guess-map-bounds';

type RoundPlayerResult = {
userId: string;
Expand All @@ -20,6 +21,7 @@ type Props = {
onGuess?: (lat: number, lng: number) => void;
guess?: { lat: number; lng: number };
mode?: 'guess' | 'result';
autoZoomBounds?: PlayRegionBounds;
result?: RoundResult;
results?: RoundResult[];
interactiveInResult?: boolean;
Expand Down Expand Up @@ -140,6 +142,37 @@ function FitToResult({ result }: { result: RoundResult }) {
return null;
}

// FitToPlayRegion zooms the guess minimap to the map's precomputed play-region
// bounds once, when the map author enabled auto-zoom for the current round.
function FitToPlayRegion({ bounds }: { bounds: PlayRegionBounds }) {
const map = useMap();
const fittedRef = useRef(false);

useEffect(() => {
if (fittedRef.current) return;
const corners = playRegionCorners(bounds);
let cancelled = false;
const timer = window.setTimeout(() => {
if (cancelled) return;
const container = map.getContainer();
if (!container || !container.isConnected) return;
try {
map.invalidateSize(false);
map.fitBounds(corners, { padding: [24, 24], maxZoom: 12, animate: false });
fittedRef.current = true;
} catch {
// Ignore transient map lifecycle races during UI transitions.
}
}, 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [bounds, map]);

return null;
}

function FitToResults({ results }: { results: RoundResult[] }) {
const map = useMap();
const fittedKeyRef = useRef<string>('');
Expand Down Expand Up @@ -493,6 +526,7 @@ export default function GuessMap({
onGuess,
guess,
mode = 'guess',
autoZoomBounds,
result,
results,
interactiveInResult = false,
Expand Down Expand Up @@ -539,6 +573,7 @@ export default function GuessMap({
/>
<SafeMapUnmount />
<InvalidateOnResize />
{mode === 'guess' && autoZoomBounds ? <FitToPlayRegion bounds={autoZoomBounds} /> : null}
{mode === 'guess' && onGuess ? <ClickCapture onGuess={onGuess} /> : null}
{mode === 'guess' && guess ? <Marker position={[guess.lat, guess.lng]} icon={guessMarkerIcon} /> : null}
{mode === 'result' && result ? <FitToResult result={result} /> : null}
Expand Down
19 changes: 19 additions & 0 deletions apps/web/components/guess-map-bounds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { playRegionCorners } from './guess-map-bounds';

describe('playRegionCorners', () => {
it('passes through a non-crossing region unchanged', () => {
expect(playRegionCorners({ minLat: 10, maxLat: 20, minLng: 30, maxLng: 40 })).toEqual([
[10, 30],
[20, 40],
]);
});

it('unwraps the eastern edge for an antimeridian-crossing region', () => {
// Region runs east from +177 to -178, i.e. a narrow 5-degree band.
expect(playRegionCorners({ minLat: -18, maxLat: -17, minLng: 177, maxLng: -178 })).toEqual([
[-18, 177],
[-17, 182],
]);
});
});
22 changes: 22 additions & 0 deletions apps/web/components/guess-map-bounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export type PlayRegionBounds = {
minLat: number;
maxLat: number;
minLng: number;
maxLng: number;
};

/**
* Converts stored play-region bounds into Leaflet corners.
*
* Longitude bounds follow the shortest-circular-interval convention: the region
* runs eastward from `minLng` to `maxLng`, and `maxLng < minLng` signals that it
* crosses the antimeridian. In that case the eastern edge is unwrapped past
* +180 so Leaflet frames the narrow region instead of the whole globe.
*/
export function playRegionCorners(bounds: PlayRegionBounds): [[number, number], [number, number]] {
const east = bounds.maxLng < bounds.minLng ? bounds.maxLng + 360 : bounds.maxLng;
return [
[bounds.minLat, bounds.minLng],
[bounds.maxLat, east],
];
}
7 changes: 7 additions & 0 deletions apps/web/components/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ export type Snapshot = {
roundTimerMode?: "none" | "pressure" | "fixed";
roundTimeLimitMs?: number;
pressureTimeLimitMs?: number;
autoZoomPlayRegion?: boolean;
playRegionBounds?: {
minLat: number;
maxLat: number;
minLng: number;
maxLng: number;
};
};
unranked?: boolean;
state: string;
Expand Down
43 changes: 43 additions & 0 deletions apps/web/features/home/model/derive-home-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,49 @@ describe('deriveHomeModel', () => {
expect(model.meta.appVersion).toBe('dev');
});

it('exposes play-region bounds only when auto-zoom is enabled with bounds', () => {
const bounds = { minLat: 10, maxLat: 20, minLng: 30, maxLng: 40 };
const model = deriveHomeModel({
auth: createAuthState(),
match: createMatchState(createSnapshot({
config: { autoZoomPlayRegion: true, playRegionBounds: bounds }
})),
game: createGameState(),
config,
routeMatchId: 'match-1'
});

expect(model.game.autoZoomBounds).toEqual(bounds);
});

it('omits play-region bounds when auto-zoom is disabled', () => {
const model = deriveHomeModel({
auth: createAuthState(),
match: createMatchState(createSnapshot({
config: { autoZoomPlayRegion: false, playRegionBounds: { minLat: 10, maxLat: 20, minLng: 30, maxLng: 40 } }
})),
game: createGameState(),
config,
routeMatchId: 'match-1'
});

expect(model.game.autoZoomBounds).toBeUndefined();
});

it('omits play-region bounds when the config provides no bounds', () => {
const model = deriveHomeModel({
auth: createAuthState(),
match: createMatchState(createSnapshot({
config: { autoZoomPlayRegion: true }
})),
game: createGameState(),
config,
routeMatchId: 'match-1'
});

expect(model.game.autoZoomBounds).toBeUndefined();
});

it('uses a question mark avatar fallback for users without a linked account', () => {
const snapshot = createSnapshot({
players: {
Expand Down
4 changes: 4 additions & 0 deletions apps/web/features/home/model/derive-home-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,10 @@ export function deriveHomeModel({
? "No Move"
: "Moving",
mapName: match.snapshot?.config?.mapName || (ruleset === "nmpz" ? "A Location World" : "A Source World"),
autoZoomBounds:
matchConfig.autoZoomPlayRegion && matchConfig.playRegionBounds
? matchConfig.playRegionBounds
: undefined,
backLabel: match.sourcePartyInviteCode
? "Back to party"
: "Back to lobby",
Expand Down
1 change: 1 addition & 0 deletions apps/web/features/home/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export type HomeGameView = {
connectionIssue: string;
modeName: string;
mapName: string;
autoZoomBounds?: { minLat: number; maxLat: number; minLng: number; maxLng: number };
backLabel?: "Back to lobby" | "Back to party";
streetViewInteractive: boolean;
ruleset: GameRuleset;
Expand Down
1 change: 1 addition & 0 deletions apps/web/features/home/page/HomePageGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export default function HomePageGame({
onGuess={actions.placeGuess}
guess={game.guess}
mode="guess"
autoZoomBounds={game.autoZoomBounds}
guessAvatarUrl={game.userAvatar}
guessAvatarFallback={
game.participantsById[game.selfUserId]?.avatarFallback || "?"
Expand Down
20 changes: 20 additions & 0 deletions apps/web/features/lobby/components/MapMetadataFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ type MapMetadataFieldsProps = {
setMapThumbnailCategory: (value: ThumbnailCategory) => void;
mapThumbnailSearch: string;
setMapThumbnailSearch: (value: string) => void;
autoZoomPlayRegion?: boolean;
setAutoZoomPlayRegion?: (value: boolean) => void;
};

export function thumbnailCategoryFromKey(key: string): ThumbnailCategory {
Expand All @@ -50,6 +52,8 @@ export function MapMetadataFields({
setMapThumbnailCategory,
mapThumbnailSearch,
setMapThumbnailSearch,
autoZoomPlayRegion,
setAutoZoomPlayRegion,
}: MapMetadataFieldsProps) {
const [thumbnailPickerOpen, setThumbnailPickerOpen] = useState(false);
const selectedThumbnail =
Expand Down Expand Up @@ -96,6 +100,22 @@ export function MapMetadataFields({
className={disabled ? "pointer-events-none opacity-50" : undefined}
/>
</div>

{setAutoZoomPlayRegion ? (
<label className={`flex items-start gap-3 rounded-xl border border-white/10 bg-black/20 p-3 ${disabled ? "pointer-events-none opacity-50" : "cursor-pointer"}`}>
<input
type="checkbox"
checked={!!autoZoomPlayRegion}
disabled={disabled}
onChange={(event) => setAutoZoomPlayRegion(event.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0 accent-[#2ad18f]"
/>
<span className="grid gap-1">
<span className="text-sm font-bold text-white">Auto-zoom minimap to play region</span>
<span className="text-xs font-semibold text-[#6b8b80]">Zooms the guess minimap to this map&apos;s location bounds when a round starts.</span>
</span>
</label>
) : null}
</div>

<div className="grid content-start gap-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export function MapEditMetadataModal({ map, onClose, onSave }: MapEditMetadataMo
const [mapThumbnailKey, setMapThumbnailKey] = useState(map.thumbnailKey);
const [mapThumbnailCategory, setMapThumbnailCategory] = useState(thumbnailCategoryFromKey(map.thumbnailKey));
const [mapThumbnailSearch, setMapThumbnailSearch] = useState("");
const [autoZoomPlayRegion, setAutoZoomPlayRegion] = useState(!!map.autoZoomPlayRegion);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const saveDisabled = saving || !mapName.trim();
Expand All @@ -35,6 +36,7 @@ export function MapEditMetadataModal({ map, onClose, onSave }: MapEditMetadataMo
visibility: mapVisibility,
thumbnailKey: mapThumbnailKey,
thumbnailVariant: map.thumbnailVariant,
autoZoomPlayRegion,
});
onClose();
} catch (err) {
Expand Down Expand Up @@ -74,6 +76,8 @@ export function MapEditMetadataModal({ map, onClose, onSave }: MapEditMetadataMo
setMapThumbnailCategory={setMapThumbnailCategory}
mapThumbnailSearch={mapThumbnailSearch}
setMapThumbnailSearch={setMapThumbnailSearch}
autoZoomPlayRegion={autoZoomPlayRegion}
setAutoZoomPlayRegion={setAutoZoomPlayRegion}
/>
{error ? <p className="text-xs font-semibold text-red-300">{error}</p> : null}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
Expand Down
35 changes: 35 additions & 0 deletions apps/web/features/maps/lib/maps-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ describe("updateMap", () => {
difficulty: "hard",
thumbnailKey: countryThumbnailRef,
thumbnailVariant: 1,
autoZoomPlayRegion: false,
})).resolves.toEqual(map);

expect(fetchMock).toHaveBeenCalledWith(
Expand All @@ -153,6 +154,40 @@ describe("updateMap", () => {
difficulty: "hard",
thumbnailKey: countryThumbnailRef,
thumbnailVariant: 1,
autoZoomPlayRegion: false,
}),
}),
);
});

it("sends autoZoomPlayRegion true when enabled", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({}),
} as unknown as Response);

await updateMap(config, "token", "map-1", {
displayName: "Better Corners",
description: "Updated route notes",
visibility: "public",
difficulty: "hard",
thumbnailKey: countryThumbnailRef,
thumbnailVariant: 1,
autoZoomPlayRegion: true,
});

expect(fetchMock).toHaveBeenCalledWith(
"https://api.test/v1/maps/map-1",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
displayName: "Better Corners",
description: "Updated route notes",
visibility: "public",
difficulty: "hard",
thumbnailKey: countryThumbnailRef,
thumbnailVariant: 1,
autoZoomPlayRegion: true,
}),
}),
);
Expand Down
3 changes: 3 additions & 0 deletions apps/web/features/maps/lib/maps-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type CustomMap = {
trendingScore: number;
favorited?: boolean;
officialRegion?: string;
autoZoomPlayRegion?: boolean;
rankedMoving?: boolean;
rankedNmpz?: boolean;
defaultMoving?: boolean;
Expand Down Expand Up @@ -64,6 +65,7 @@ export type MapUpdateInput = {
difficulty: CustomMap["difficulty"];
thumbnailKey: string;
thumbnailVariant?: number;
autoZoomPlayRegion: boolean;
};
export type MapUploadQuota = {
tier: "base" | "trusted" | "established";
Expand Down Expand Up @@ -129,6 +131,7 @@ export async function updateMap(config: RuntimeConfig, accessToken: string, mapI
difficulty: input.difficulty,
thumbnailKey: input.thumbnailKey,
thumbnailVariant: input.thumbnailVariant || 1,
autoZoomPlayRegion: input.autoZoomPlayRegion,
}),
}), "Map request failed");
}
Expand Down
5 changes: 5 additions & 0 deletions db/migrations/000055_map_auto_zoom_play_region.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
alter table maps drop column if exists auto_zoom_play_region;
alter table maps drop column if exists bounds_min_lat_e7;
alter table maps drop column if exists bounds_max_lat_e7;
alter table maps drop column if exists bounds_min_lng_e7;
alter table maps drop column if exists bounds_max_lng_e7;
47 changes: 47 additions & 0 deletions db/migrations/000055_map_auto_zoom_play_region.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
alter table maps add column if not exists auto_zoom_play_region boolean not null default false;
alter table maps add column if not exists bounds_min_lat_e7 integer;
alter table maps add column if not exists bounds_max_lat_e7 integer;
alter table maps add column if not exists bounds_min_lng_e7 integer;
alter table maps add column if not exists bounds_max_lng_e7 integer;

-- Latitude bounds are a plain min/max.
update maps m
set bounds_min_lat_e7 = b.min_lat,
bounds_max_lat_e7 = b.max_lat
from (
select map_storage_id, min(lat_e7) as min_lat, max(lat_e7) as max_lat
from locations
group by map_storage_id
) b
where b.map_storage_id = m.storage_id;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

-- Longitude bounds use the shortest circular interval (the complement of the
-- largest empty gap between adjacent longitudes) so antimeridian-crossing maps
-- produce a narrow region. Stored as [min_lng_e7, max_lng_e7] traversed east;
-- min_lng_e7 > max_lng_e7 when the interval crosses the antimeridian.
with ordered as (
select map_storage_id, lng_e7,
lead(lng_e7) over (partition by map_storage_id order by lng_e7) as next_lng,
min(lng_e7) over (partition by map_storage_id) as min_lng,
max(lng_e7) over (partition by map_storage_id) as max_lng
from (select distinct map_storage_id, lng_e7 from locations) d
),
gaps as (
select map_storage_id, (next_lng::bigint - lng_e7) as gap, next_lng as start_lng, lng_e7 as end_lng
from ordered
where next_lng is not null
union all
select map_storage_id, (min_lng::bigint + 3600000000 - max_lng) as gap, min_lng as start_lng, max_lng as end_lng
from ordered
where next_lng is null
),
best as (
select distinct on (map_storage_id) map_storage_id, start_lng, end_lng
from gaps
order by map_storage_id, gap desc, start_lng
)
update maps m
set bounds_min_lng_e7 = b.start_lng,
bounds_max_lng_e7 = b.end_lng
from best b
where b.map_storage_id = m.storage_id;
Loading
Loading