fix(map): stabilize layers menu and offline cache UX
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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"), []);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user