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]);
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
? `${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
+1 -1
View File
@@ -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",
+9
View File
@@ -181,6 +181,7 @@ export type MapGatewayHealth = {
maxBytes?: number;
atCapacity?: boolean;
persistent?: boolean;
byResourceKind?: Record<string, { entries?: number; bytes?: number }>;
};
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;
};
+7 -1
View File
@@ -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),
+26 -1
View File
@@ -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;
+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 () => {
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 \{(?<body>[\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 () => {
+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(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/);
});
+3 -1
View File
@@ -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"), []);
});