improve query param handling, add support for linking too coords

This commit is contained in:
Brian Beck 2025-12-16 17:31:13 -08:00
parent 2a36c18f92
commit 55c1067682
39 changed files with 939 additions and 678 deletions

View file

@ -1,4 +1,5 @@
import { ReactNode } from "react";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import "./style.css";
export const metadata = {
@ -9,7 +10,15 @@ export const metadata = {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
<body>
<NuqsAdapter
defaultOptions={{
clearOnDefault: false,
}}
>
{children}
</NuqsAdapter>
</body>
</html>
);
}

View file

@ -1,8 +1,7 @@
"use client";
import { useState, useEffect, useCallback, Suspense, useMemo } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useState, useEffect, useCallback, Suspense, useRef } from "react";
import { Canvas, GLProps } from "@react-three/fiber";
import { NoToneMapping, SRGBColorSpace, PCFShadowMap } from "three";
import { NoToneMapping, SRGBColorSpace, PCFShadowMap, Camera } from "three";
import { Mission } from "@/src/components/Mission";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ObserverControls } from "@/src/components/ObserverControls";
@ -13,8 +12,9 @@ import { AudioProvider } from "@/src/components/AudioContext";
import { DebugElements } from "@/src/components/DebugElements";
import { CamerasProvider } from "@/src/components/CamerasProvider";
import { getMissionList, getMissionInfo } from "@/src/manifest";
import { createParser, useQueryState } from "nuqs";
// three.js has its own loaders for textures and models, but we need to load other
// Three.js has its own loaders for textures and models, but we need to load other
// stuff too, e.g. missions, terrains, and more. This client is used for those.
const queryClient = new QueryClient();
@ -26,23 +26,52 @@ const glSettings: GLProps = {
outputColorSpace: SRGBColorSpace,
};
type CurrentMission = {
missionName: string;
missionType?: string;
};
const defaultMission: CurrentMission = {
missionName: "RiverDance",
missionType: "CTF",
};
const parseAsMissionWithType = createParser<CurrentMission>({
parse(query: string) {
let [missionName, missionType] = query.split("~");
const availableMissionTypes = getMissionInfo(missionName).missionTypes;
if (!missionType || !availableMissionTypes.includes(missionType)) {
missionType = availableMissionTypes[0];
}
return { missionName, missionType };
},
serialize({ missionName, missionType }): string {
const availableMissionTypes = getMissionInfo(missionName).missionTypes;
if (availableMissionTypes.length === 1) {
return missionName;
}
return `${missionName}~${missionType}`;
},
eq(a, b) {
return a.missionName === b.missionName && a.missionType === b.missionType;
},
}).withDefault(defaultMission);
function MapInspector() {
const searchParams = useSearchParams();
const router = useRouter();
const [initialMissionName, initialMissionType] = useMemo(
() => (searchParams.get("mission") || "RiverDance~CTF").split("~"),
[],
const [currentMission, setCurrentMission] = useQueryState(
"mission",
parseAsMissionWithType,
);
const [missionName, setMissionName] = useState(initialMissionName);
const availableMissionTypes = getMissionInfo(missionName).missionTypes;
const [missionType, setMissionType] = useState(() =>
initialMissionType && availableMissionTypes.includes(initialMissionType)
? initialMissionType
: availableMissionTypes[0],
);
const isOnlyMissionType = availableMissionTypes.length === 1;
const changeMission = useCallback(
(mission: CurrentMission) => {
window.location.hash = "";
setCurrentMission(mission);
},
[setCurrentMission],
);
const { missionName, missionType } = currentMission;
const [loadingProgress, setLoadingProgress] = useState(0);
const [showLoadingIndicator, setShowLoadingIndicator] = useState(true);
const isLoading = loadingProgress < 1;
@ -59,7 +88,13 @@ function MapInspector() {
useEffect(() => {
// For automation, like the t2-maps app!
window.setMissionName = setMissionName;
window.setMissionName = (missionName: string) => {
const availableMissionTypes = getMissionInfo(missionName).missionTypes;
changeMission({
missionName,
missionType: availableMissionTypes[0],
});
};
window.getMissionList = getMissionList;
window.getMissionInfo = getMissionInfo;
@ -68,17 +103,7 @@ function MapInspector() {
delete window.getMissionList;
delete window.getMissionInfo;
};
}, []);
// Update query params when state changes
useEffect(() => {
const params = new URLSearchParams();
const value = isOnlyMissionType
? missionName
: `${missionName}~${missionType}`;
params.set("mission", value);
router.replace(`?${params.toString()}`, { scroll: false });
}, [missionName, missionType, isOnlyMissionType, router]);
}, [changeMission]);
const handleLoadingChange = useCallback(
(_loading: boolean, progress: number = 0) => {
@ -87,6 +112,8 @@ function MapInspector() {
[],
);
const cameraRef = useRef<Camera | null>(null);
return (
<QueryClientProvider client={queryClient}>
<main>
@ -110,6 +137,9 @@ function MapInspector() {
frameloop="always"
gl={glSettings}
shadows={{ type: PCFShadowMap }}
onCreated={(state) => {
cameraRef.current = state.camera;
}}
>
<CamerasProvider>
<AudioProvider>
@ -118,7 +148,6 @@ function MapInspector() {
name={missionName}
missionType={missionType}
onLoadingChange={handleLoadingChange}
setMissionType={setMissionType}
/>
<ObserverCamera />
<DebugElements />
@ -130,10 +159,8 @@ function MapInspector() {
<InspectorControls
missionName={missionName}
missionType={missionType}
onChangeMission={({ missionName, missionType }) => {
setMissionName(missionName);
setMissionType(missionType);
}}
onChangeMission={changeMission}
cameraRef={cameraRef}
/>
</SettingsProvider>
</main>

View file

@ -77,6 +77,42 @@ input[type="range"] {
gap: 6px;
}
.IconButton {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin: 0 0 0 -12px;
font-size: 15px;
padding: 0;
border-top: 1px solid rgba(255, 255, 255, 0.3);
border-left: 1px solid rgba(255, 255, 255, 0.3);
border-right: 1px solid rgba(200, 200, 200, 0.3);
border-bottom: 1px solid rgba(200, 200, 200, 0.3);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
border-radius: 4px;
background: rgba(3, 82, 147, 0.6);
color: #fff;
cursor: pointer;
transform: translate(0, 0);
transition:
background 0.1s,
border-color 0.1s;
}
.IconButton:hover {
background: rgba(0, 98, 179, 0.8);
border-color: rgba(255, 255, 255, 0.4);
}
.IconButton:active {
background: rgba(0, 98, 179, 0.7);
border-color: rgba(255, 255, 255, 0.3);
transform: translate(0, 1px);
}
.StaticShapeLabel {
background: rgba(0, 0, 0, 0.5);
color: #fff;
@ -353,3 +389,26 @@ input[type="range"] {
color: rgba(255, 255, 255, 0.7);
font-variant-numeric: tabular-nums;
}
.CopyCoordinatesButton .ClipboardCheck {
display: none;
opacity: 1;
}
@keyframes showClipboardCheck {
0% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}
.CopyCoordinatesButton[data-copied="true"] .ClipboardCheck {
display: block;
animation: showClipboardCheck 300ms linear infinite;
}
.CopyCoordinatesButton[data-copied="true"] .MapPin {
display: none;
}