From eaecce9f56e673ca5c619783c5eeb082842b0cb2 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 25 Jul 2026 13:12:24 +0300 Subject: [PATCH] feat(map): add reference layers and stabilize workspace controls --- apps/catalog/src/CatalogApp.tsx | 120 +-------- apps/catalog/src/CesiumMapRenderer.tsx | 126 ++++++++-- .../src/FoundryMapProviderSettings.tsx | 132 ++++++++++ apps/catalog/src/FoundrySettingsModal.tsx | 30 ++- apps/catalog/src/MapFixturePreview.tsx | 227 ++++++++++++++---- apps/catalog/src/mapReferenceStations.ts | 117 +++++++++ apps/catalog/src/styles.css | 108 +++++++-- apps/catalog/src/useMapReferenceRuntime.ts | 126 ++++++++++ docs/COMPONENTS.md | 21 +- docs/MAP_TEMPLATE.md | 64 +++++ docs/MODULE_FOUNDRY_MCP.md | 5 +- package.json | 7 +- packages/page-patterns/src/index.ts | 4 +- packages/ui-core/styles.css | 49 ++++ packages/ui-react/src/Inspector.tsx | 21 +- packages/ui-react/src/RangeControl.tsx | 128 +++++++++- packages/ui-react/src/Window.tsx | 6 +- packages/ui-react/src/WorkspaceWindow.tsx | 60 ++++- registry/components.json | 13 +- registry/map-presentation-profiles.json | 164 ++++++++++++- registry/pages.json | 1 + .../application-manifest-v0.1.schema.json | 6 + scripts/map-inspector-overlay-state.test.mjs | 56 +++++ scripts/map-object-layers.test.mjs | 59 ++++- scripts/map-reference-stations.test.mjs | 40 +++ scripts/range-control-contract.test.mjs | 50 ++++ server/catalog-server.mjs | 89 ++++++- server/foundry-agent-gateway.test.mjs | 4 +- server/foundry-mcp.mjs | 7 + 29 files changed, 1593 insertions(+), 247 deletions(-) create mode 100644 apps/catalog/src/FoundryMapProviderSettings.tsx create mode 100644 apps/catalog/src/mapReferenceStations.ts create mode 100644 apps/catalog/src/useMapReferenceRuntime.ts create mode 100644 scripts/map-inspector-overlay-state.test.mjs create mode 100644 scripts/map-reference-stations.test.mjs create mode 100644 scripts/range-control-contract.test.mjs diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx index 0f696b3..53582ec 100644 --- a/apps/catalog/src/CatalogApp.tsx +++ b/apps/catalog/src/CatalogApp.tsx @@ -118,13 +118,6 @@ interface FoundrySessionProfile { }; } -interface CesiumIonSecretStatus { - configured: boolean; - updatedAt: string | null; - updatedBy: string | null; - verification?: "verified" | "failed" | "not-configured"; -} - const materialDefaults: Record = { dark: { panelHex: "#151517", @@ -320,12 +313,7 @@ export function CatalogApp() { const [toasts, setToasts] = useState([]); const toastSequenceRef = useRef(0); const [sessionProfile, setSessionProfile] = useState(null); - const [platformSettingsOpen, setPlatformSettingsOpen] = useState(false); const [foundrySettingsOpen, setFoundrySettingsOpen] = useState(false); - const [cesiumIonToken, setCesiumIonToken] = useState(""); - const [cesiumIonStatus, setCesiumIonStatus] = useState(null); - const [cesiumIonSaveState, setCesiumIonSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("idle"); - const [cesiumIonError, setCesiumIonError] = useState(""); const [mapTemplateLayout, setMapTemplateLayout] = useState(null); const [mapTemplateSaveState, setMapTemplateSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading"); const mapTemplatePreviewRef = useRef(null); @@ -527,60 +515,6 @@ export function CatalogApp() { const isFoundryAdmin = sessionProfile?.access?.role === "admin"; - const openPlatformSettings = () => { - setPlatformSettingsOpen(true); - setCesiumIonToken(""); - setCesiumIonError(""); - setCesiumIonSaveState("loading"); - void fetch("/api/platform-settings/cesium-ion", { cache: "no-store" }) - .then(async (response) => { - if (!response.ok) throw new Error("platform_settings_load_failed"); - return await response.json() as CesiumIonSecretStatus; - }) - .then((status) => { - setCesiumIonStatus(status); - setCesiumIonSaveState("idle"); - }) - .catch(() => setCesiumIonSaveState("error")); - }; - - const closePlatformSettings = () => { - setPlatformSettingsOpen(false); - setCesiumIonToken(""); - setCesiumIonError(""); - }; - - const saveCesiumIonToken = async () => { - const token = cesiumIonToken.trim(); - if (token.length < 16) return; - setCesiumIonSaveState("saving"); - setCesiumIonError(""); - try { - const response = await fetch("/api/platform-settings/cesium-ion", { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ token }), - }); - if (!response.ok) { - const result = await response.json().catch(() => null) as { error?: string } | null; - throw new Error(result?.error || "platform_settings_save_failed"); - } - setCesiumIonStatus(await response.json() as CesiumIonSecretStatus); - setCesiumIonToken(""); - setCesiumIonSaveState("saved"); - } catch (error) { - const code = error instanceof Error ? error.message : "platform_settings_save_failed"; - setCesiumIonError(code === "cesium_ion_token_verification_failed" - ? "Token не прошёл проверку Cesium Ion для terrain, imagery или 3D Buildings. Значение не сохранено." - : code === "map_gateway_admin_unauthorized" - ? "Внутренний signing profile Gateway не совпадает с Foundry. Это исправляется только runner-managed deployment, не вводом значений в интерфейс." - : code === "map_gateway_admin_not_configured" - ? "Runner-managed signing file Gateway недоступен." - : "Не удалось применить token. Проверьте права администратора и доступность Gateway."); - setCesiumIonSaveState("error"); - } - }; - useEffect(() => { let active = true; fetch("/api/layout", { cache: "no-store" }) @@ -2019,8 +1953,6 @@ export function CatalogApp() { onClose={closeGuideline} headerActions={studioContext === "applications" ? ( setCreateModuleOpen(true)}> - ) : studioContext === "pages" && isFoundryAdmin ? ( - ) : undefined} contextSlot={studioContext === "applications" ? ( onChange(Number(event.target.value))} - {...props} /> {label} - {formatValue(value)} + {displayValue} - + { + if (!editing) { + setDraft(editableNumber(value)); + setEditing(true); + } + }} + onFocus={() => { + if (!editing) { + setDraft(editableNumber(value)); + setEditing(true); + } + }} + onChange={(event) => setDraft(event.target.value)} + onKeyDown={handleEditorKeyDown} + onBlur={() => { + if (cancelNextBlurRef.current) { + cancelNextBlurRef.current = false; + return; + } + finishEditing(true, false); + }} + /> + ); } diff --git a/packages/ui-react/src/Window.tsx b/packages/ui-react/src/Window.tsx index eef5b18..6f9dd8b 100644 --- a/packages/ui-react/src/Window.tsx +++ b/packages/ui-react/src/Window.tsx @@ -63,10 +63,12 @@ export function Window({ const titleId = useId(); const descriptionId = useId(); const dialogRef = useRef(null); + const onCloseRef = useRef(onClose); const dragRef = useRef<{ pointerId: number; offsetX: number; offsetY: number } | null>(null); const [dragPosition, setDragPosition] = useState<{ left: number; top: number } | null>(null); const shouldLockBodyScroll = lockBodyScroll ?? placement === "center"; const shouldTrapFocus = trapFocus ?? placement === "center"; + onCloseRef.current = onClose; useEffect(() => { if (!open || typeof document === "undefined") return; @@ -82,7 +84,7 @@ export function Window({ const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && closeOnEscape) { event.preventDefault(); - onClose(); + onCloseRef.current(); return; } if (event.key !== "Tab" || !shouldTrapFocus || !dialogRef.current) return; @@ -110,7 +112,7 @@ export function Window({ if (shouldLockBodyScroll) document.body.style.overflow = previousOverflow; previousActiveElement?.focus(); }; - }, [closeOnEscape, onClose, open, shouldLockBodyScroll, shouldTrapFocus]); + }, [closeOnEscape, open, shouldLockBodyScroll, shouldTrapFocus]); useEffect(() => { if (!open) setDragPosition(null); diff --git a/packages/ui-react/src/WorkspaceWindow.tsx b/packages/ui-react/src/WorkspaceWindow.tsx index 79b1f8f..3cf706c 100644 --- a/packages/ui-react/src/WorkspaceWindow.tsx +++ b/packages/ui-react/src/WorkspaceWindow.tsx @@ -35,6 +35,7 @@ export interface WorkspaceWindowProps extends Omit(null); const rectRef = useRef(rect); const onRectChangeRef = useRef(onRectChange); + const headRef = useRef(null); + const bodyRef = useRef(null); + const bodyContentRef = useRef(null); + const footerRef = useRef(null); const [interactionKind, setInteractionKind] = useState(null); rectRef.current = rect; @@ -175,6 +181,52 @@ export function WorkspaceWindow({ }; }, [boundsRef, maximized, minHeight, minWidth, rect.height, rect.width, rect.x, rect.y]); + useEffect(() => { + if (!autoHeight || maximized) return; + const bounds = boundsRef.current; + const head = headRef.current; + const body = bodyRef.current; + const content = bodyContentRef.current; + if (!bounds || !head || !body || !content) return; + let frame = 0; + + const fitContent = () => { + frame = 0; + const nextBounds = readBounds(); + if (!nextBounds) return; + const normalized = normalizeRect(rectRef.current, nextBounds, minWidth, minHeight); + const bodyStyle = window.getComputedStyle(body); + const bodyPadding = Number.parseFloat(bodyStyle.paddingTop || "0") + + Number.parseFloat(bodyStyle.paddingBottom || "0"); + const desiredHeight = Math.ceil( + head.offsetHeight + + content.scrollHeight + + bodyPadding + + (footerRef.current?.offsetHeight ?? 0), + ); + const availableHeight = Math.max(0, nextBounds.height - normalized.y); + emitRect({ + ...normalized, + height: clampDimension(desiredHeight, minHeight, availableHeight), + }); + }; + + const scheduleFit = () => { + if (frame) cancelAnimationFrame(frame); + frame = requestAnimationFrame(fitContent); + }; + + scheduleFit(); + const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleFit); + observer?.observe(content); + observer?.observe(head); + if (footerRef.current) observer?.observe(footerRef.current); + return () => { + if (frame) cancelAnimationFrame(frame); + observer?.disconnect(); + }; + }, [autoHeight, boundsRef, maximized, minHeight, minWidth, rect.width]); + useEffect(() => { const handlePointerMove = (event: globalThis.PointerEvent) => { const interaction = interactionRef.current; @@ -273,6 +325,7 @@ export function WorkspaceWindow({ data-active={active ? "true" : undefined} data-maximized={maximized ? "true" : undefined} data-resizable={resizable ? "true" : undefined} + data-auto-height={autoHeight ? "true" : undefined} data-interaction={interactionKind ?? undefined} role="dialog" aria-modal="false" @@ -289,6 +342,7 @@ export function WorkspaceWindow({ }} >
-
{children}
- {footer ?
{footer}
: null} +
+ {autoHeight ?
{children}
: children} +
+ {footer ?
{footer}
: null} {resizable && !maximized ? (