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]);
|
}, [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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"), []);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user