From 71c1528b833ac1d97b4b900c03986fec74aab0db Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 9 Aug 2026 15:06:11 +0300 Subject: [PATCH] fix(map): restore subject flight and search inset --- apps/catalog/src/CesiumMapRenderer.tsx | 23 +++++++++++++++++++- apps/catalog/src/MapFixturePreview.tsx | 30 ++++++++++++++++++++------ apps/catalog/src/styles.css | 1 - scripts/map-object-layers.test.mjs | 9 +++++++- scripts/map-search.test.mjs | 11 ++++++++++ 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/apps/catalog/src/CesiumMapRenderer.tsx b/apps/catalog/src/CesiumMapRenderer.tsx index b6e0aa4..fb4fdb8 100644 --- a/apps/catalog/src/CesiumMapRenderer.tsx +++ b/apps/catalog/src/CesiumMapRenderer.tsx @@ -375,6 +375,7 @@ export type CesiumMapRendererHandle = { getCameraView: () => MapCameraView | null; fitRuntimeEntities: (entityIds?: string[]) => boolean; focusRuntimeEntity: (entityId: string) => boolean; + focusSubjectCoordinates: (longitude: number, latitude: number) => boolean; focusCoordinates: (longitude: number, latitude: number) => boolean; focusGridSector: (sector: GridSectorSelection) => boolean; focusGridMajorTile: (tile: GridMajorTileSelection) => boolean; @@ -2744,6 +2745,25 @@ export const CesiumMapRenderer = forwardRef { + const viewer = viewerRef.current; + if (!viewer || viewer.isDestroyed() + || !Number.isFinite(longitude) || longitude < -180 || longitude > 180 + || !Number.isFinite(latitude) || latitude < -90 || latitude > 90) return false; + + // Selection can make a filtered subject visible and request navigation in + // the same React tick. In that case Cesium has not rebuilt its data source + // yet, so entity lookup cannot be the only way to fly to the subject. + // Coordinates from the authorized search/runtime fact provide the same + // explicit, inspectable camera composition without clearing user filters. + const target = Cartesian3.fromDegrees(longitude, latitude, 0, viewer.scene.globe.ellipsoid); + viewer.camera.flyToBoundingSphere(new BoundingSphere(target, 1), { + duration: 0.45, + offset: new HeadingPitchRange(0, -0.9, 8_000), + }); + return true; + }, []); + useImperativeHandle(ref, () => ({ startSpiralAnimation, stopSpiralAnimation, @@ -2754,9 +2774,10 @@ export const CesiumMapRenderer = forwardRef { const stopForPageLeave = () => stopSpiralAnimation("stopped"); diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx index abc5983..b5a9e45 100644 --- a/apps/catalog/src/MapFixturePreview.tsx +++ b/apps/catalog/src/MapFixturePreview.tsx @@ -332,6 +332,15 @@ function mapFactSectorScopeValue(fact: MapRuntimeFact, field: string) { return normalizedSectorScopeValue(fact.attributes[field]) ?? MAP_SCOPE_MISSING_VALUE; } +function mapFactPointCoordinates(fact: MapRuntimeFact | undefined): [number, number] | null { + if (fact?.geometry?.type !== "Point") return null; + const [longitude, latitude] = fact.geometry.coordinates; + return Number.isFinite(longitude) && longitude >= -180 && longitude <= 180 + && Number.isFinite(latitude) && latitude >= -90 && latitude <= 90 + ? [longitude, latitude] + : null; +} + function mapFactInsideGridSector( fact: MapRuntimeFact, selection: GridSectorSelection, @@ -1477,10 +1486,21 @@ export const MapFixturePreview = forwardRef { + const renderer = mapRendererRef.current; + if (!renderer) return false; + if (renderer.focusRuntimeEntity(entityId)) return true; + const fallbackCoordinates = coordinates + ?? mapFactPointCoordinates(selectable.find((entity) => entity.id === entityId)?.fact); + return fallbackCoordinates + ? renderer.focusSubjectCoordinates(fallbackCoordinates[0], fallbackCoordinates[1]) + : false; + }, [selectable]); + const handleSelectAndFocus = useCallback((entityId: string) => { handleSelect(entityId); - mapRendererRef.current?.focusRuntimeEntity(entityId); - }, [handleSelect]); + focusSubject(entityId); + }, [focusSubject, handleSelect]); const handleSearchResult = useCallback((result: (typeof mapSearchResults)[number]) => { if (result.selectable) { @@ -1494,14 +1514,12 @@ export const MapFixturePreview = forwardRef) => { if (event.key === "Escape") { diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index 76cecea..1f8a39d 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -842,7 +842,6 @@ textarea { .catalog-map-fixture__toolbar[data-search-open] { width: min(42rem, calc(100% - 2rem)); - padding-right: 0; } .catalog-map-search { diff --git a/scripts/map-object-layers.test.mjs b/scripts/map-object-layers.test.mjs index c94ccfc..2f1934c 100644 --- a/scripts/map-object-layers.test.mjs +++ b/scripts/map-object-layers.test.mjs @@ -108,11 +108,18 @@ test("facet tree selection focuses the subject without resetting a compatible de assert.match(preview, /const \[expandedFacetRows, setExpandedFacetRows\]/); assert.match(preview, /const handleSelectAndFocus = useCallback/); - assert.match(preview, /mapRendererRef\.current\?\.focusRuntimeEntity\(entityId\)/); + assert.match(preview, /const focusSubject = useCallback/); + assert.match(preview, /if \(renderer\.focusRuntimeEntity\(entityId\)\) return true/); + assert.match(preview, /mapFactPointCoordinates\(selectable\.find/); + assert.match(preview, /renderer\.focusSubjectCoordinates\(fallbackCoordinates\[0\], fallbackCoordinates\[1\]\)/); + assert.match(preview, /focusSubject\(entityId\)/); + assert.match(preview, /focusSubject\(result\.entityId, result\.coordinates\)/); assert.match(preview, /setSubjectCardTabId\(\(current\) =>/); assert.match(preview, /profile\?\.tabs\.some\(\(tab\) => tab\.id === current\)/); assert.match(preview, /\(profile\?\.defaultTabId \?\? "overview"\)/); assert.match(renderer, /void viewer\.flyTo\(entity, \{/); + assert.match(renderer, /const focusSubjectCoordinates = useCallback/); + assert.match(renderer, /viewer\.camera\.flyToBoundingSphere\(new BoundingSphere\(target, 1\), \{/); assert.match(renderer, /duration: 0\.45/); assert.match(renderer, /offset: new HeadingPitchRange\(0, -0\.9, 8_000\)/); }); diff --git a/scripts/map-search.test.mjs b/scripts/map-search.test.mjs index 348f585..cfc86c9 100644 --- a/scripts/map-search.test.mjs +++ b/scripts/map-search.test.mjs @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { readFile } from "node:fs/promises"; + import { buildMapSearchIndex, searchMapSubjects } from "../apps/catalog/src/mapSearch.mjs"; const now = "2026-07-25T08:00:00.000Z"; @@ -101,3 +103,12 @@ test("reference subjects remain searchable by profile labels without a provider assert.equal(searchMapSubjects(index, "osm.node.1").at(0)?.groupTitle, "Метро"); assert.deepEqual(searchMapSubjects(index, "provider_note"), []); }); + +test("expanded search keeps the canonical toolbar inset on every edge", async () => { + const styles = await readFile(new URL("../apps/catalog/src/styles.css", import.meta.url), "utf8"); + const baseToolbar = styles.match(/\.catalog-map-fixture__toolbar\s*\{(?[\s\S]*?)\n\}/)?.groups?.body ?? ""; + const expandedToolbar = styles.match(/\.catalog-map-fixture__toolbar\[data-search-open\]\s*\{(?[\s\S]*?)\n\}/)?.groups?.body ?? ""; + + assert.match(baseToolbar, /padding: 0\.4rem/); + assert.doesNotMatch(expandedToolbar, /padding-right:\s*0/); +});