refactor(debug): float the coverage inspector over a full-width map

The two-column grid held half the width empty until a point was picked, and
reflowing the map on selection moved the very dot the user had just clicked.
From lg the inspector now floats over the map's right edge; a phone has
neither the width to float into nor the height to split, so there it takes the
surface and its close button brings the map back.

The map keeps a 16rem floor below lg: the page chrome takes 225px, so a purely
flexed surface collapsed to 98px on a landscape phone. Escape closes the
inspector unless a select owns the keypress, a ResizeObserver re-measures the
map when it reappears, and the tile attribution moves to the corner the
inspector does not cover.
This commit is contained in:
tiennm99 committed 2026-09-02 09:53:22 +07:00
1 parent 23ee9d47ff
commit 26dd5f384e
3 files changed
+77 -12

No files matched your search

+26 -1
View File
@@ -44,6 +44,10 @@ export default function CoverageMap({
if (!containerRef.current || mapRef.current) return;
const map = L.map(containerRef.current, { preferCanvas: true });
// Bottom-left: from lg the panorama inspector floats over the bottom-right
// corner, and tile attribution has to stay visible. The map option is a
// boolean, so the position is set on the control itself.
map.attributionControl.setPosition('bottomleft');
const tiles = getTileConfig();
L.tileLayer(tiles.url, tiles.options).addTo(map);
map.setView([16.0, 107.0], 6);
@@ -84,10 +88,25 @@ export default function CoverageMap({
};
map.on('click', pick);
// Leaflet caches the container size and only re-reads it on a window
// resize, so opening the inspector -- which shrinks the map on phones,
// where it stacks rather than floats -- would otherwise leave the map
// drawing at its old height.
// Zero size means the map is display:none -- the phone layout swaps it out
// for the inspector. Measuring then would hand the loader a degenerate
// bbox and wipe the point count, so wait until it is on screen again.
const resizeObserver = new ResizeObserver(() => {
const el = containerRef.current;
if (!el || !el.clientWidth || !el.clientHeight) return;
map.invalidateSize();
});
resizeObserver.observe(containerRef.current);
mapRef.current = map;
return () => {
clearTimeout(moveTimer);
resizeObserver.disconnect();
map.off('moveend', report);
map.off('click', pick);
map.remove();
@@ -173,5 +192,11 @@ export default function CoverageMap({
}).addTo(map);
}, [selectedId, panos]);
return <div ref={containerRef} className="h-full w-full rounded-lg bg-muted" />;
// Sized by insets, so the caller must position its wrapper. A percentage
// height would collapse: the wrapper is a flex item, whose flexed main-axis
// height is indefinite for percentage resolution.
// `isolate` keeps Leaflet's own ladder (panes at 400, controls at 800-1000)
// inside this element; without it those values escape to the caller's
// stacking context and paint over anything layered on top of the map.
return <div ref={containerRef} className="absolute inset-0 isolate bg-muted" />;
}
+45 -7
View File
@@ -15,7 +15,7 @@ const CoverageMap = dynamic(() => import('./CoverageMap'), {
<div
role="status"
aria-live="polite"
className="flex h-full w-full items-center justify-center rounded-lg bg-muted text-muted-foreground"
className="absolute inset-0 flex items-center justify-center bg-muted text-muted-foreground"
>
Loading map...
</div>
@@ -125,6 +125,21 @@ export default function CoveragePage() {
setPanoLoading(false);
}, []);
// The inspector covers part of the map, so Escape closes it -- the X in its
// header is the only other way out.
useEffect(() => {
if (!selected) return undefined;
const handleKeyDown = (event) => {
if (event.key !== 'Escape') return;
// An open select owns Escape; closing the inspector behind it would make
// one keypress do two things.
if (document.querySelector('[data-radix-popper-content-wrapper]')) return;
closePano();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selected, closePano]);
const handleBoundsChange = useCallback(
(nextBbox) => {
load(region, nextBbox);
@@ -133,7 +148,7 @@ export default function CoveragePage() {
);
return (
<div className="flex h-full flex-col">
<div className="flex min-h-0 flex-1 flex-col">
{/* Same title pattern as the other debug pages, with the tool's own
controls beside it. The shared debug layout owns the app chrome. */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-card/60 px-4 py-2">
@@ -185,8 +200,25 @@ export default function CoveragePage() {
{loading && <span className="text-xs text-muted-foreground">loading…</span>}
</div>
<div className="grid min-h-0 flex-1 gap-3 p-3 lg:grid-cols-2">
<div className="min-h-[40dvh] lg:min-h-0">
{/* The map is the tool, so it gets the whole surface. From lg the
inspector floats over its right edge: the old two-column grid held
half the width empty until a point was picked, and reflowing the map
on selection moved the very dot the user had just clicked. A phone has
neither the width to float into nor the height to split -- 112px of
map is not a map -- so there the inspector takes the surface instead
and its close button brings the map back.
min-h-64 is the floor that keeps that promise: the page chrome takes
225px, so on a landscape phone a purely flexed surface collapsed to
98px. The floor makes <main> scroll instead of shrinking the map
below the point of being one. */}
<div className="flex min-h-64 flex-1 flex-col lg:relative lg:block lg:min-h-0">
{/* Kept mounted while hidden, so coming back costs no reload; the
map's ResizeObserver re-measures it when it reappears. */}
<div
className={`relative min-h-0 flex-1 lg:absolute lg:inset-0 lg:block ${
selected ? 'hidden' : ''
}`}
>
<CoverageMap
boundary={boundary}
panos={panos}
@@ -197,7 +229,13 @@ export default function CoveragePage() {
</div>
{selected && (
<div className="flex min-h-[40dvh] flex-col overflow-hidden rounded-lg border border-border bg-card lg:min-h-0">
<aside
aria-label={`Panorama ${selected.id}`}
// Bottom sheet on phones, side panel from lg: covering the map
// from the edge is what map tools do, and it leaves the clicked
// dot exactly where it was.
className="flex min-h-0 flex-1 flex-col overflow-hidden border-t border-border bg-card lg:absolute lg:inset-y-3 lg:right-3 lg:z-(--z-floating) lg:h-auto lg:w-[26rem] lg:rounded-xl lg:border lg:shadow-xl"
>
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{selected.id}</p>
@@ -226,7 +264,7 @@ export default function CoveragePage() {
</div>
</div>
<div className="min-h-0 flex-1 bg-neutral-900">
<div className="relative min-h-0 flex-1 bg-neutral-900">
{panoError ? (
<div role="alert" className="flex h-full items-center justify-center p-4 text-center text-sm text-destructive">
{panoError}
@@ -243,7 +281,7 @@ export default function CoveragePage() {
</div>
)}
</div>
</div>
</aside>
)}
</div>
</div>
+6 -4
View File
@@ -7,9 +7,11 @@ import DebugNav from './DebugNav';
// One shell for every debug page, styled like the game screen's app bar, so
// the hub and both tools share identical chrome and only differ in content.
// The column fills the body's sticky-footer layout (viewport minus the global
// footer strip) with the scroll INSIDE <main>: document-style
// pages (hub, bbox) scroll there, while the coverage map takes h-full and
// manages its own panes.
// footer strip) with the scroll INSIDE <main>: document-style pages (hub,
// bbox) scroll there, while the coverage map fills it and manages its own
// panes. <main> is itself a flex column so a full-height page can claim the
// space with flex-1 -- `h-full` cannot, because main's height comes from
// flexing inside a min-height container and so is indefinite.
export default function DebugLayout({ children }) {
return (
<div className="flex flex-1 min-h-0 flex-col vn-surface">
@@ -35,7 +37,7 @@ export default function DebugLayout({ children }) {
<ThemeToggle />
</header>
<main className="min-h-0 flex-1 overflow-y-auto">{children}</main>
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</main>
</div>
);
}