import type { Dispatch, ReactNode, SetStateAction } from "react"; import { Button, Checker, ColorField, ControlRow, Icon, InspectorSelectField, RangeControl, } from "@nodedc/ui-react"; import type { SelectOption } from "@nodedc/ui-react"; import { OSM_BUILDINGS_OBSERVED_BAND_COUNT, cameraSurveySpiralDistance, type CameraSurveySelection, } from "./mapCameraPresets.js"; import { isMapReferencePresentationProfile, type MapReferenceLayer, } from "./mapReferenceStations.js"; import type { MapPageSettings } from "./mapPageContract.js"; import type { MapGatewayHealth, MapProviderStatus } from "./mapRendererContract.js"; import type { MapPresentationProfile } from "./mapPresentationProfile.js"; import type { MapSelectableEntity } from "./mapWorkspaceModel.mjs"; export type MapInspectorSection = { id: string; label: string; description: string; group: string; icon: ReactNode; content: ReactNode; }; type UpdateMapSettings = (patch: Partial) => void; type UpdatePresentationProfile = ( profileId: string, updater: (profile: MapPresentationProfile) => MapPresentationProfile, ) => void; type UpdatePresentationStyle = ( profileId: string, styleId: string, patch: Partial, ) => void; type GatewayCheckState = "idle" | "checking" | "ready" | "stale" | "error"; const providerStateLabel: Record = { loading: "загружается", ready: "готов", error: "недоступен", "not-configured": "не настроен", }; const logarithmicControlValue = (value: number) => Math.log10(Math.max(Number.MIN_VALUE, value)); const valueFromLogarithmicControl = (value: number) => Math.max(1, Math.round(10 ** value)); export const formatMetricDistance = (value: number) => value >= 1000 ? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: value >= 10_000 ? 0 : 1 })} км` : `${Math.round(value)} м`; const formatMetricSpeed = (value: number) => value >= 1000 ? `${(value / 1000).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} км/с` : `${Math.round(value)} м/с`; const formatDuration = (seconds: number) => { if (seconds >= 86_400) return `${(seconds / 86_400).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} сут`; if (seconds >= 3_600) return `${(seconds / 3_600).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} ч`; if (seconds >= 60) return `${Math.round(seconds / 60)} мин`; return `${Math.max(1, Math.round(seconds))} с`; }; export function buildMapSurfaceInspectorSections({ mapSettings, providerStatus, updateMapSettings, }: { mapSettings: MapPageSettings; providerStatus: MapProviderStatus; updateMapSettings: UpdateMapSettings; }): MapInspectorSection[] { return [ { id: "map-base", label: "Подложка и terrain", description: "provider-neutral surface", group: "Карта", icon: , content: <> Cesium World Imagery Текущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform. Imagery: {providerStateLabel[providerStatus.imagery]} · Terrain: {providerStateLabel[providerStatus.terrain]} · 3D: {providerStateLabel[providerStatus.buildings]} {Object.entries(providerStatus.errors).map(([provider, error]) => {provider}: {error})} Рельеф — отдельный слой под imagery. updateMapSettings({ terrainEnabled })} /> `${(value / 100).toFixed(2)}×`} onChange={(value) => updateMapSettings({ terrainExaggeration: value / 100 })} /> updateMapSettings({ monochrome })} /> updateMapSettings({ monochromeColor })} /> `${value}%`} onChange={(imageryBrightness) => updateMapSettings({ imageryBrightness })} /> `${value}%`} onChange={(imageryContrast) => updateMapSettings({ imageryContrast })} /> `${value}%`} onChange={(imagerySaturation) => updateMapSettings({ imagerySaturation })} /> `${value}%`} onChange={(imageryGamma) => updateMapSettings({ imageryGamma })} /> `${value}°`} onChange={(imageryHue) => updateMapSettings({ imageryHue })} /> `${value}%`} onChange={(imageryAlpha) => updateMapSettings({ imageryAlpha })} /> updateMapSettings({ globeColor })} /> updateMapSettings({ backgroundColor })} /> , }, { id: "map-atmosphere", label: "Атмосфера и освещение", description: "scene / color correction", group: "Карта", icon: , content: <> updateMapSettings({ atmosphereEnabled })} /> `${value}%`} onChange={(atmosphereHue) => updateMapSettings({ atmosphereHue })} /> `${value}%`} onChange={(atmosphereSaturation) => updateMapSettings({ atmosphereSaturation })} /> `${value}%`} onChange={(atmosphereBrightness) => updateMapSettings({ atmosphereBrightness })} /> updateMapSettings({ fogEnabled })} /> `${(value / 10000).toFixed(4)}`} onChange={(fogDensity) => updateMapSettings({ fogDensity })} /> updateMapSettings({ sunEnabled })} /> `${value}:00 UTC`} onChange={(sunHour) => updateMapSettings({ sunHour })} /> `${value}%`} onChange={(sunIntensity) => updateMapSettings({ sunIntensity })} /> updateMapSettings({ shadowsEnabled })} /> , }, { id: "map-buildings", label: "3D здания", description: "3D Tiles / detail", group: "Карта", icon: , content: <> updateMapSettings({ buildingsVisible })} /> updateMapSettings({ buildingsColor })} /> `${value}%`} onChange={(value) => updateMapSettings({ buildingsOpacity: value / 100 })} /> `SSE ${value}`} onChange={(buildingsDetail) => updateMapSettings({ buildingsDetail })} /> , }, ]; } export function buildMapPresentationInspectorSections({ presentationProfiles, referenceLayers, setReferenceLayers, updatePresentationProfile, updatePresentationStyle, }: { presentationProfiles: MapPresentationProfile[]; referenceLayers: MapReferenceLayer[]; setReferenceLayers: Dispatch>; updatePresentationProfile: UpdatePresentationProfile; updatePresentationStyle: UpdatePresentationStyle; }): MapInspectorSection[] { return [ ...presentationProfiles.flatMap((profile) => { const referenceProfile = isMapReferencePresentationProfile(profile); const referenceLayer = referenceLayers.find((layer) => layer.presentationProfileId === profile.id); return [ { id: `map-target-${profile.id}`, label: referenceProfile ? profile.title : profile.target.variant === "surface-fill" ? "HGeoZone" : "Таргет", description: profile.target.variant === "surface-fill" ? `проекция · ${profile.title}` : profile.title, group: referenceProfile ? "Станции" : profile.target.variant === "surface-fill" ? "Слои" : "Таргеты", icon: , content: <> Профиль принадлежит этой странице Application и управляется тем же provider-neutral MCP-контрактом. Исходный API в настройках отсутствует. {referenceLayer ? ( setReferenceLayers((current) => current.map((layer) => ( layer.id === referenceLayer.id ? { ...layer, visible } : layer )))} /> ) : null} {profile.target.variant === "surface-fill" && <> HGeoZone · ground projection {profile.styles.map((style) => { const classLabels = profile.classes.filter((item) => item.styleId === style.id).map((item) => item.label); const label = classLabels.length ? classLabels.join(" · ") : style.id; return
updatePresentationStyle(profile.id, style.id, { color })} /> `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} />
; })} updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineColor } }) : current)} /> `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineOpacity: value / 100 } }) : current)} /> `${value} px`} onChange={(outlineWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "surface-fill" ? ({ ...current, target: { ...current.target, outlineWidthPx } }) : current)} /> } {profile.target.variant === "elevated-spike" && <> `${value} м`} onChange={(stemHeightMeters) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemHeightMeters } }) : current)} /> `${value} px`} onChange={(headSizePx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, headSizePx } }) : current)} /> `${value} px`} onChange={(stemWidthPx) => updatePresentationProfile(profile.id, (current) => current.target.variant === "elevated-spike" ? ({ ...current, target: { ...current.target, stemWidthPx } }) : current)} /> } updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, mode } }))} /> `${value} px`} onChange={(sizePx) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, sizePx } }))} /> `${value} px`} onChange={(offsetX) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetX } }))} /> `${value} px`} onChange={(offsetY) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, offsetY } }))} /> `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, hideCameraHeightMeters } }))} /> `${Math.round(value / 1_000)} км`} onChange={(hideCameraHeightMeters) => updatePresentationProfile(profile.id, (current) => ({ ...current, target: { ...current.target, hideCameraHeightMeters } }))} /> updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundColor } }))} /> `${value}%`} onChange={(value) => updatePresentationProfile(profile.id, (current) => ({ ...current, label: { ...current.label, backgroundOpacity: value / 100 } }))} /> , }, ...(profile.target.variant === "surface-fill" || referenceProfile ? [] : [{ id: `map-state-classes-${profile.id}`, label: "Классы состояния", description: "нормализованные фасеты онтологии", group: "Таргеты", icon: , content: <> Цвета назначены семантическим классам после нормализации данных. Здесь нет названий provider-статусов и привязки к транспорту. {profile.styles.map((style) => { const classLabels = profile.classes.filter((item) => item.styleId === style.id).map((item) => item.label); const label = classLabels.length ? classLabels.join(" · ") : style.id; return
updatePresentationStyle(profile.id, style.id, { color })} /> `${value}%`} onChange={(value) => updatePresentationStyle(profile.id, style.id, { opacity: value / 100 })} />
; })} , }]), ]; }), ]; } export function buildMapRuntimeInspectorSections({ animationModeEnabled, setAnimationMode, spiralPresetId, spiralPresetOptions, spiralRunning, selectSpiralPreset, spiralHeightMeters, setSpiralHeightMeters, spiralSpeedMetersPerSecond, setSpiralSpeedMetersPerSecond, spiralPitchMetersPerTurn, setSpiralPitchMetersPerTurn, spiralTargetRadiusMeters, setSpiralTargetRadiusMeters, setSpiralPresetId, spiralCanStart, spiralTileCacheReady, providerStatus, gatewayHealth, gatewayCheckState, toggleSpiralAnimation, spiralMessage, mapSettings, setCacheEnabled, setCacheNoOverwrite, gatewayEndpoint, liveCacheSummary, refreshCurrentViewport, cacheRefresh, verifyGateway, transportDiagnostic, gatewayHealthAge, gatewayCheckError, selected, }: { animationModeEnabled: boolean; setAnimationMode: (enabled: boolean) => void; spiralPresetId: CameraSurveySelection; spiralPresetOptions: Array>; spiralRunning: boolean; selectSpiralPreset: (presetId: CameraSurveySelection) => void; spiralHeightMeters: number; setSpiralHeightMeters: Dispatch>; spiralSpeedMetersPerSecond: number; setSpiralSpeedMetersPerSecond: Dispatch>; spiralPitchMetersPerTurn: number; setSpiralPitchMetersPerTurn: Dispatch>; spiralTargetRadiusMeters: number; setSpiralTargetRadiusMeters: Dispatch>; setSpiralPresetId: Dispatch>; spiralCanStart: boolean; spiralTileCacheReady: boolean; providerStatus: MapProviderStatus; gatewayHealth: MapGatewayHealth | null; gatewayCheckState: GatewayCheckState; toggleSpiralAnimation: () => void; spiralMessage: string | null; mapSettings: MapPageSettings; setCacheEnabled: (enabled: boolean) => void; setCacheNoOverwrite: (enabled: boolean) => void; gatewayEndpoint: string | null; liveCacheSummary: string; refreshCurrentViewport: () => void; cacheRefresh: boolean; verifyGateway: () => void | Promise; transportDiagnostic: string | null; gatewayHealthAge: string | null; gatewayCheckError: string | null; selected: MapSelectableEntity | undefined; }): MapInspectorSection[] { return [ { id: "map-camera-animation", label: "Анимация камеры", description: "geodesic spiral survey", group: "Камера", icon: , content: <> {animationModeEnabled ? <> Стартовая точка берётся из текущей позиции камеры. Камера смотрит почти в надир, а маршрут ждёт текущие tiles перед продолжением. Движение идёт по региональной геодезической спирали WGS84 до выбранного радиуса. У текущего OSM Buildings подтверждено {OSM_BUILDINGS_OBSERVED_BAND_COUNT} иерархических bands. Десять профилей управляют высотой и покрытием; фактический LOD Cesium выбирает по SSE, viewport и расстоянию. Imagery · Terrain · OSM Buildings formatMetricDistance(10 ** value)} onChange={(value) => { setSpiralPresetId("custom"); setSpiralHeightMeters(valueFromLogarithmicControl(value)); }} /> formatMetricSpeed(10 ** value)} onChange={(value) => { setSpiralPresetId("custom"); setSpiralSpeedMetersPerSecond(valueFromLogarithmicControl(value)); }} /> formatMetricDistance(10 ** value)} onChange={(value) => { setSpiralPresetId("custom"); setSpiralPitchMetersPerTurn(valueFromLogarithmicControl(value)); }} /> formatMetricDistance(10 ** value)} onChange={(value) => { setSpiralPresetId("custom"); setSpiralTargetRadiusMeters(valueFromLogarithmicControl(value)); }} /> Расчётное движение без ожидания сети: {formatDuration(cameraSurveySpiralDistance(spiralTargetRadiusMeters, spiralPitchMetersPerTurn) / spiralSpeedMetersPerSecond)}. Tile waits и автоматическое сужение шага под viewport увеличат фактическое время. {!spiralCanStart && !spiralRunning ? Подготовка: imagery — {providerStateLabel[providerStatus.imagery]}, terrain — {providerStateLabel[providerStatus.terrain]}, OSM Buildings — {providerStateLabel[providerStatus.buildings]}, TileCache — {spiralTileCacheReady ? "готов" : gatewayHealth?.cache?.atCapacity ? "заполнен" : gatewayCheckState === "checking" ? "проверяется" : "недоступен для записи"}. : null} {spiralRunning ? Камера движется от исходной точки. Выключение режима, уход со страницы или reload остановят сессию. : null} {spiralMessage ? {spiralMessage} : null} : null} , }, { id: "map-cache", label: "TileCache", description: "Platform Map Gateway", group: "Хранение", icon: , content: <> Общий persistent cache Platform: он не принадлежит приложению, странице или пользователю. Cache hit отдаётся как есть; новый tile записывается только при miss. {mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Live + Cache · append-only" : "Live + Cache · обновление разрешено" : "Live без persistent cache"} Platform Map Gateway {gatewayEndpoint ?? "runtime profile · не проверено"} {liveCacheSummary} {gatewayHealth?.cache?.writePolicy ?? "append-only · проверяется"} Live Cache: {liveCacheSummary} · {gatewayHealth?.cache?.mode ?? "проверяется"} {transportDiagnostic ? {transportDiagnostic} : null} {gatewayHealthAge ? {gatewayHealthAge} : null} {mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Новые miss дописываются; при заполнении объёма Gateway продолжит live-маршрут без удаления прежних tiles." : "Новые запросы этого Application могут явно обновлять уже записанные tiles." : "Real-time: provider остаётся официальным, чтение и запись persistent cache выключены."} {gatewayHealth?.cache?.atCapacity ? TileCache заполнен: новые tiles показываются live, но не записываются. Существующий cache не удаляется. : null} {gatewayCheckState === "error" || gatewayCheckState === "stale" ? {gatewayCheckError} : null} , }, { id: "map-selection", label: "Выбранная сущность", description: "selection contract", group: "Данные", icon: , content: <> {selected?.title ?? "Нет выбора"} {selected?.kind ?? "—"}{selected?.status ? ` · ${selected.status}` : ""} , }, ]; }