From f645725e8ce2ec3b4b5f948ee68f5c317836bd9e Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 9 Aug 2026 20:27:06 +0300 Subject: [PATCH] fix(map): stabilize layers menu and offline cache UX --- apps/catalog/src/MapFixturePreview.tsx | 18 +++++++++++++- apps/catalog/src/mapReferenceStations.ts | 2 +- apps/catalog/src/mapRendererContract.ts | 9 +++++++ apps/catalog/src/useMapReferenceRuntime.ts | 8 +++++- packages/ui-react/src/Dropdown.tsx | 27 ++++++++++++++++++++- scripts/floating-position-contract.test.mjs | 11 ++++++--- scripts/map-reference-stations.test.mjs | 3 ++- scripts/map-search.test.mjs | 4 ++- 8 files changed, 72 insertions(+), 10 deletions(-) diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx index fe5f134..ba5fbdf 100644 --- a/apps/catalog/src/MapFixturePreview.tsx +++ b/apps/catalog/src/MapFixturePreview.tsx @@ -1061,8 +1061,24 @@ export const MapFixturePreview = forwardRef { + const count = liveCacheKinds[key]?.entries ?? 0; + return count > 0 ? [`${label}: ${count}`] : []; + }) + .join(" · ") + : ""; const liveCacheSummary = liveCacheStatus - ? `${liveCacheStatus.entries ?? 0} объектов · ${Math.round((liveCacheStatus.bytes ?? 0) / 1024 / 1024)} / ${Math.round((liveCacheStatus.maxBytes ?? 0) / 1024 / 1024) || "?"} MB` + ? `${liveCacheStatus.entries ?? 0} объектов · ${Math.round((liveCacheStatus.bytes ?? 0) / 1024 / 1024)} / ${Math.round((liveCacheStatus.maxBytes ?? 0) / 1024 / 1024) || "?"} MB${liveCacheBreakdown ? ` · ${liveCacheBreakdown}` : ""}` : "индекс ещё не получен"; const transportReferenceStatus = gatewayHealth?.referenceSources?.transportStations; const transportDiagnostic = transportReferenceStatus?.lastFailure diff --git a/apps/catalog/src/mapReferenceStations.ts b/apps/catalog/src/mapReferenceStations.ts index 8fb5071..fea3d08 100644 --- a/apps/catalog/src/mapReferenceStations.ts +++ b/apps/catalog/src/mapReferenceStations.ts @@ -51,7 +51,7 @@ export const defaultMapReferencePresentationProfiles: readonly MapPresentationPr semanticTypes: [...definition.semanticTypes], label: { mode: "attributes", - fields: ["name", "official_name", "local_name"], + fields: ["name", "official_name", "local_name", "alternate_names"], fontWeight: 600, sizePx: 20, color: "#cccccc", diff --git a/apps/catalog/src/mapRendererContract.ts b/apps/catalog/src/mapRendererContract.ts index f82bd5e..aac180a 100644 --- a/apps/catalog/src/mapRendererContract.ts +++ b/apps/catalog/src/mapRendererContract.ts @@ -181,6 +181,7 @@ export type MapGatewayHealth = { maxBytes?: number; atCapacity?: boolean; persistent?: boolean; + byResourceKind?: Record; }; diagnostics?: { cacheHits?: number; @@ -200,6 +201,7 @@ export type MapGatewayHealth = { fetchEnabled?: boolean; cellDegrees?: number; cachedCellCount?: number; + indexedFactCount?: number; upstreamRequests?: number; upstreamFailures?: number; searchRequests?: number; @@ -212,6 +214,13 @@ export type MapGatewayHealth = { lastFailureAt?: string | null; }; }; + providerCache?: Array<{ + assetId?: number; + type?: string; + cachedEndpoint?: boolean; + credentialUsable?: boolean; + entryPointCached?: boolean; + }>; ionConfigured?: boolean; }; diff --git a/apps/catalog/src/useMapReferenceRuntime.ts b/apps/catalog/src/useMapReferenceRuntime.ts index 94bc122..3f8456b 100644 --- a/apps/catalog/src/useMapReferenceRuntime.ts +++ b/apps/catalog/src/useMapReferenceRuntime.ts @@ -211,7 +211,13 @@ function asFact(value: unknown): MapRuntimeFact | null { || !isIso(value.observedAt) || !isIso(value.receivedAt) || !isObject(value.attributes) || !categories.has(value.attributes.category as MapReferenceStationCategory) || !isPoint(value.geometry)) return null; - const allowedAttributes = new Set(["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"]); + if (value.attributes.alternate_names !== undefined + && (!Array.isArray(value.attributes.alternate_names) + || value.attributes.alternate_names.length > 16 + || value.attributes.alternate_names.some((name) => typeof name !== "string" || !name.trim() || name.length > 256))) { + return null; + } + const allowedAttributes = new Set(["name", "category", "network", "operator", "official_name", "local_name", "alternate_names", "uic_ref", "wheelchair"]); return { sourceId: value.sourceId, semanticType: String(value.semanticType), diff --git a/packages/ui-react/src/Dropdown.tsx b/packages/ui-react/src/Dropdown.tsx index 1710893..839376a 100644 --- a/packages/ui-react/src/Dropdown.tsx +++ b/packages/ui-react/src/Dropdown.tsx @@ -107,7 +107,32 @@ export function Dropdown({ useLayoutEffect(() => { if (!isOpen) return; updatePosition(); - }, [isOpen, updatePosition]); + + const surface = surfaceRef.current; + const anchor = anchorElement ?? triggerElement; + if (!surface || typeof ResizeObserver === "undefined") { + const frame = window.requestAnimationFrame(updatePosition); + return () => window.cancelAnimationFrame(frame); + } + + let frame: number | null = null; + const schedulePositionUpdate = () => { + if (frame !== null) return; + frame = window.requestAnimationFrame(() => { + frame = null; + updatePosition(); + }); + }; + const resizeObserver = new ResizeObserver(schedulePositionUpdate); + resizeObserver.observe(surface); + if (anchor) resizeObserver.observe(anchor); + schedulePositionUpdate(); + + return () => { + resizeObserver.disconnect(); + if (frame !== null) window.cancelAnimationFrame(frame); + }; + }, [anchorElement, isOpen, triggerElement, updatePosition]); useEffect(() => { if (!isOpen) return; diff --git a/scripts/floating-position-contract.test.mjs b/scripts/floating-position-contract.test.mjs index abf0950..4a0462e 100644 --- a/scripts/floating-position-contract.test.mjs +++ b/scripts/floating-position-contract.test.mjs @@ -49,15 +49,18 @@ test("floating surface remains bounded when its anchor is below the viewport", ( }); test("dropdown scroll keeps portal geometry stable and contained", async () => { - const styles = await readFile( - new URL("../packages/ui-core/styles.css", import.meta.url), - "utf8", - ); + const [styles, dropdown] = await Promise.all([ + readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"), + readFile(new URL("../packages/ui-react/src/Dropdown.tsx", import.meta.url), "utf8"), + ]); const dropdownRule = styles.match(/\.nodedc-dropdown-surface \{(?[\s\S]*?)\n\}/)?.groups?.body ?? ""; assert.match(dropdownRule, /box-sizing:\s*border-box;/); assert.match(dropdownRule, /overflow:\s*auto;/); assert.match(dropdownRule, /overscroll-behavior:\s*contain;/); + assert.match(dropdown, /new ResizeObserver\(schedulePositionUpdate\)/); + assert.match(dropdown, /resizeObserver\.observe\(surface\)/); + assert.match(dropdown, /window\.requestAnimationFrame\(updatePosition\)/); }); test("workspace windows keep their glass rim without masked compositor overlays", async () => { diff --git a/scripts/map-reference-stations.test.mjs b/scripts/map-reference-stations.test.mjs index 4cbf54f..9477370 100644 --- a/scripts/map-reference-stations.test.mjs +++ b/scripts/map-reference-stations.test.mjs @@ -35,6 +35,7 @@ test("existing layouts upgrade reference layers on read and renderer accepts the assert.match(server, /referenceLayers: structuredClone\(canonicalMapReferenceLayers\)/); assert.match(runtimeLayers, /binding\.slotId === "reference-points"/); assert.match(runtime, /\/api\/map-gateway\/api\/map\/reference-sources\/v1\/profiles\//); - assert.match(runtime, /allowedAttributes = new Set\(\["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"\]\)/); + assert.match(runtime, /allowedAttributes = new Set\(\["name", "category", "network", "operator", "official_name", "local_name", "alternate_names", "uic_ref", "wheelchair"\]\)/); + assert.match(runtime, /alternate_names/); assert.doesNotMatch(runtime, /credential|accessToken|providerEndpoint|rawPayload/); }); diff --git a/scripts/map-search.test.mjs b/scripts/map-search.test.mjs index cfc86c9..c418c42 100644 --- a/scripts/map-search.test.mjs +++ b/scripts/map-search.test.mjs @@ -85,7 +85,7 @@ test("reference subjects remain searchable by profile labels without a provider id: "map.reference.station.v1", title: "Метро", semanticTypes: ["map.station"], - label: { mode: "attributes", fields: ["name", "official_name"] }, + label: { mode: "attributes", fields: ["name", "official_name", "alternate_names"] }, }; const index = buildMapSearchIndex({ runtimeBindings: [{ @@ -93,6 +93,7 @@ test("reference subjects remain searchable by profile labels without a provider presentationProfileId: stationProfile.id, facts: [point("osm.node.1", "map.station", { name: "Петроградская", + alternate_names: ["Petrogradskaya station"], provider_note: "not searchable", })], }], @@ -100,6 +101,7 @@ test("reference subjects remain searchable by profile labels without a provider }); assert.equal(searchMapSubjects(index, "петрог").at(0)?.title, "Петроградская"); + assert.equal(searchMapSubjects(index, "Petrogradskaya").at(0)?.sourceId, "osm.node.1"); assert.equal(searchMapSubjects(index, "osm.node.1").at(0)?.groupTitle, "Метро"); assert.deepEqual(searchMapSubjects(index, "provider_note"), []); });