fix(map): stabilize layers menu and offline cache UX

This commit is contained in:
Codex
2026-08-09 20:27:06 +03:00
parent b70e165910
commit f645725e8c
8 changed files with 72 additions and 10 deletions
+17 -1
View File
@@ -1061,8 +1061,24 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
}, [animationModeEnabled, inspectorOpen, layersOpen, verifyGateway]); }, [animationModeEnabled, inspectorOpen, layersOpen, verifyGateway]);
const liveCacheStatus = gatewayHealth?.cache; const liveCacheStatus = gatewayHealth?.cache;
const liveCacheKinds = liveCacheStatus?.byResourceKind;
const liveCacheBreakdown = liveCacheKinds
? [
["imagery", "imagery"],
["terrain", "terrain"],
["buildings", "buildings"],
["3d-binary", "3D/binary"],
["metadata", "metadata"],
["unknown", "unknown"],
]
.flatMap(([key, label]) => {
const count = liveCacheKinds[key]?.entries ?? 0;
return count > 0 ? [`${label}: ${count}`] : [];
})
.join(" · ")
: "";
const liveCacheSummary = liveCacheStatus 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 transportReferenceStatus = gatewayHealth?.referenceSources?.transportStations;
const transportDiagnostic = transportReferenceStatus?.lastFailure const transportDiagnostic = transportReferenceStatus?.lastFailure
+1 -1
View File
@@ -51,7 +51,7 @@ export const defaultMapReferencePresentationProfiles: readonly MapPresentationPr
semanticTypes: [...definition.semanticTypes], semanticTypes: [...definition.semanticTypes],
label: { label: {
mode: "attributes", mode: "attributes",
fields: ["name", "official_name", "local_name"], fields: ["name", "official_name", "local_name", "alternate_names"],
fontWeight: 600, fontWeight: 600,
sizePx: 20, sizePx: 20,
color: "#cccccc", color: "#cccccc",
+9
View File
@@ -181,6 +181,7 @@ export type MapGatewayHealth = {
maxBytes?: number; maxBytes?: number;
atCapacity?: boolean; atCapacity?: boolean;
persistent?: boolean; persistent?: boolean;
byResourceKind?: Record<string, { entries?: number; bytes?: number }>;
}; };
diagnostics?: { diagnostics?: {
cacheHits?: number; cacheHits?: number;
@@ -200,6 +201,7 @@ export type MapGatewayHealth = {
fetchEnabled?: boolean; fetchEnabled?: boolean;
cellDegrees?: number; cellDegrees?: number;
cachedCellCount?: number; cachedCellCount?: number;
indexedFactCount?: number;
upstreamRequests?: number; upstreamRequests?: number;
upstreamFailures?: number; upstreamFailures?: number;
searchRequests?: number; searchRequests?: number;
@@ -212,6 +214,13 @@ export type MapGatewayHealth = {
lastFailureAt?: string | null; lastFailureAt?: string | null;
}; };
}; };
providerCache?: Array<{
assetId?: number;
type?: string;
cachedEndpoint?: boolean;
credentialUsable?: boolean;
entryPointCached?: boolean;
}>;
ionConfigured?: boolean; ionConfigured?: boolean;
}; };
+7 -1
View File
@@ -211,7 +211,13 @@ function asFact(value: unknown): MapRuntimeFact | null {
|| !isIso(value.observedAt) || !isIso(value.receivedAt) || !isIso(value.observedAt) || !isIso(value.receivedAt)
|| !isObject(value.attributes) || !categories.has(value.attributes.category as MapReferenceStationCategory) || !isObject(value.attributes) || !categories.has(value.attributes.category as MapReferenceStationCategory)
|| !isPoint(value.geometry)) return null; || !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 { return {
sourceId: value.sourceId, sourceId: value.sourceId,
semanticType: String(value.semanticType), semanticType: String(value.semanticType),
+26 -1
View File
@@ -107,7 +107,32 @@ export function Dropdown({
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isOpen) return; if (!isOpen) return;
updatePosition(); 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(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
+7 -4
View File
@@ -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 () => { test("dropdown scroll keeps portal geometry stable and contained", async () => {
const styles = await readFile( const [styles, dropdown] = await Promise.all([
new URL("../packages/ui-core/styles.css", import.meta.url), readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
"utf8", readFile(new URL("../packages/ui-react/src/Dropdown.tsx", import.meta.url), "utf8"),
); ]);
const dropdownRule = styles.match(/\.nodedc-dropdown-surface \{(?<body>[\s\S]*?)\n\}/)?.groups?.body ?? ""; const dropdownRule = styles.match(/\.nodedc-dropdown-surface \{(?<body>[\s\S]*?)\n\}/)?.groups?.body ?? "";
assert.match(dropdownRule, /box-sizing:\s*border-box;/); assert.match(dropdownRule, /box-sizing:\s*border-box;/);
assert.match(dropdownRule, /overflow:\s*auto;/); assert.match(dropdownRule, /overflow:\s*auto;/);
assert.match(dropdownRule, /overscroll-behavior:\s*contain;/); 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 () => { test("workspace windows keep their glass rim without masked compositor overlays", async () => {
+2 -1
View File
@@ -35,6 +35,7 @@ test("existing layouts upgrade reference layers on read and renderer accepts the
assert.match(server, /referenceLayers: structuredClone\(canonicalMapReferenceLayers\)/); assert.match(server, /referenceLayers: structuredClone\(canonicalMapReferenceLayers\)/);
assert.match(runtimeLayers, /binding\.slotId === "reference-points"/); assert.match(runtimeLayers, /binding\.slotId === "reference-points"/);
assert.match(runtime, /\/api\/map-gateway\/api\/map\/reference-sources\/v1\/profiles\//); 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/); assert.doesNotMatch(runtime, /credential|accessToken|providerEndpoint|rawPayload/);
}); });
+3 -1
View File
@@ -85,7 +85,7 @@ test("reference subjects remain searchable by profile labels without a provider
id: "map.reference.station.v1", id: "map.reference.station.v1",
title: "Метро", title: "Метро",
semanticTypes: ["map.station"], semanticTypes: ["map.station"],
label: { mode: "attributes", fields: ["name", "official_name"] }, label: { mode: "attributes", fields: ["name", "official_name", "alternate_names"] },
}; };
const index = buildMapSearchIndex({ const index = buildMapSearchIndex({
runtimeBindings: [{ runtimeBindings: [{
@@ -93,6 +93,7 @@ test("reference subjects remain searchable by profile labels without a provider
presentationProfileId: stationProfile.id, presentationProfileId: stationProfile.id,
facts: [point("osm.node.1", "map.station", { facts: [point("osm.node.1", "map.station", {
name: "Петроградская", name: "Петроградская",
alternate_names: ["Petrogradskaya station"],
provider_note: "not searchable", 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, "петрог").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.equal(searchMapSubjects(index, "osm.node.1").at(0)?.groupTitle, "Метро");
assert.deepEqual(searchMapSubjects(index, "provider_note"), []); assert.deepEqual(searchMapSubjects(index, "provider_note"), []);
}); });