feat(map): adopt DC Default scene

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 17:55:44 +03:00
parent 5a06194962
commit b91d3b913e
9 changed files with 1299 additions and 331 deletions
@@ -0,0 +1,681 @@
import {
Checker,
ColorField,
ControlRow,
Inspector,
RangeControl,
StatusBadge,
} from "@nodedc/ui-react";
import type {
MapInspectorSection,
MapViewDocument,
} from "../../core/map/mapView";
import type { useMapGatewayHealth } from "../../core/map/useMapGatewayHealth";
export const editableMapInspectorSections = new Set<MapInspectorSection>([
"base-terrain",
"atmosphere-light",
"buildings",
"grid-lod",
"camera",
"tile-cache",
]);
export function MapSettingsInspector({
draft,
updateDraft,
gatewayHealth,
}: {
draft: MapViewDocument;
updateDraft: (
update: (current: MapViewDocument) => MapViewDocument,
) => void;
gatewayHealth: ReturnType<typeof useMapGatewayHealth>;
}) {
return (
<Inspector
singleOpen
sections={createInspectorSections(draft, updateDraft, gatewayHealth)}
openSections={draft.view.inspectorOpenSections.filter(
(section) => editableMapInspectorSections.has(section),
)}
activeId={draft.view.inspectorOpenSections[0]}
onOpenSectionsChange={(sections) => {
updateDraft((current) => {
current.view.inspectorOpenSections = sections.filter(
(section): section is MapInspectorSection =>
editableMapInspectorSections.has(section as MapInspectorSection),
).slice(0, 1);
return current;
});
}}
/>
);
}
function createInspectorSections(
draft: MapViewDocument,
updateDraft: (
update: (current: MapViewDocument) => MapViewDocument,
) => void,
gatewayHealth: ReturnType<typeof useMapGatewayHealth>,
) {
const view = draft.view;
const settings = view.visualSettings;
return [
{
id: "base-terrain",
label: "Подложка и terrain",
description: "provider-neutral surface",
content: (
<div className="mission-map__inspector-controls">
<ControlRow label="Подложка">
<strong>Cesium World Imagery</strong>
</ControlRow>
<Checker
label="Спутниковая подложка"
checked={view.layerVisibility.imagery}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.imagery = checked;
return current;
})}
/>
<Checker
label="Terrain"
checked={view.layerVisibility.terrain}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.terrain = checked;
return current;
})}
/>
<RangeControl
label="Вертикальное преувеличение рельефа"
value={settings.terrainExaggeration * 100}
min={25}
max={300}
step={1}
formatValue={(value) => `${(value / 100).toFixed(2)}×`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.terrainExaggeration = value / 100;
return current;
})}
/>
<Checker
label="Монохромная поверхность"
checked={settings.monochromeEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.monochromeEnabled = checked;
return current;
})}
/>
<ControlRow label="Цвет монохрома">
<ColorField
label="Цвет монохромной поверхности"
value={settings.monochromeColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.monochromeColor = value;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Яркость"
value={settings.imageryBrightness}
min={0}
max={200}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imageryBrightness = value;
return current;
})}
/>
<RangeControl
label="Контраст"
value={settings.imageryContrast}
min={0}
max={200}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imageryContrast = value;
return current;
})}
/>
<RangeControl
label="Насыщенность"
value={settings.imagerySaturation}
min={0}
max={200}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imagerySaturation = value;
return current;
})}
/>
<RangeControl
label="Гамма"
value={settings.imageryGamma}
min={0}
max={300}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imageryGamma = value;
return current;
})}
/>
<RangeControl
label="Оттенок"
value={settings.imageryHue}
min={-180}
max={180}
formatValue={(value) => `${value}°`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imageryHue = value;
return current;
})}
/>
<RangeControl
label="Прозрачность imagery"
value={settings.imageryAlpha}
min={0}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imageryAlpha = value;
return current;
})}
/>
<ControlRow label="Цвет планеты">
<ColorField
label="Цвет terrain без imagery"
value={settings.globeColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.globeColor = value;
return current;
})}
/>
</ControlRow>
<ControlRow label="Фон сцены">
<ColorField
label="Цвет фона сцены"
value={settings.backgroundColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.backgroundColor = value;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Высота карты"
value={view.mapHeight}
min={420}
max={1080}
step={1}
formatValue={(value) => `${value} px`}
onChange={(value) => updateDraft((current) => {
current.view.mapHeight = value;
return current;
})}
/>
</div>
),
},
{
id: "atmosphere-light",
label: "Атмосфера и освещение",
description: "scene / color correction",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Показывать атмосферу"
checked={settings.atmosphereEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.atmosphereEnabled = checked;
return current;
})}
/>
<RangeControl
label="Атмосфера: оттенок"
value={settings.atmosphereHue}
min={-100}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.atmosphereHue = value;
return current;
})}
/>
<RangeControl
label="Атмосфера: насыщенность"
value={settings.atmosphereSaturation}
min={-100}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.atmosphereSaturation = value;
return current;
})}
/>
<RangeControl
label="Атмосфера: яркость"
value={settings.atmosphereBrightness}
min={-100}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.atmosphereBrightness = value;
return current;
})}
/>
<Checker
label="Туман"
checked={settings.fogEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.fogEnabled = checked;
return current;
})}
/>
<RangeControl
label="Плотность тумана"
value={settings.fogDensity}
min={0}
max={100}
formatValue={(value) => `${(value / 10_000).toFixed(4)}`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.fogDensity = value;
return current;
})}
/>
<Checker
label="Солнечное освещение"
checked={settings.sunEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.sunEnabled = checked;
return current;
})}
/>
<RangeControl
label="Час солнца"
value={settings.sunHour}
min={0}
max={24}
formatValue={(value) => `${value}:00 UTC`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.sunHour = value;
return current;
})}
/>
<RangeControl
label="Интенсивность света"
value={settings.sunIntensity}
min={0}
max={200}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.sunIntensity = value;
return current;
})}
/>
<Checker
label="Тени"
checked={settings.shadowsEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.shadowsEnabled = checked;
return current;
})}
/>
</div>
),
},
{
id: "buildings",
label: "3D здания",
description: "3D Tiles / detail",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Показывать 3D здания"
checked={view.layerVisibility.buildings}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.buildings = checked;
return current;
})}
/>
<ControlRow label="Цвет">
<ColorField
label="Цвет зданий"
value={settings.buildingsColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.buildingsColor = value;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Прозрачность"
value={Math.round(settings.buildingsOpacity * 100)}
min={0}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.buildingsOpacity = value / 100;
return current;
})}
/>
<RangeControl
label="Детализация"
value={settings.buildingsMaximumScreenSpaceError}
min={4}
max={32}
formatValue={(value) => `SSE ${value}`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.buildingsMaximumScreenSpaceError = value;
return current;
})}
/>
</div>
),
},
{
id: "grid-lod",
label: "Сетка и LOD",
description: "first adapter control",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="3D-сетка"
checked={view.layerVisibility.grid}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.grid = checked;
return current;
})}
/>
<Checker
label="LOD по высоте камеры"
checked={settings.gridLodEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.gridLodEnabled = checked;
return current;
})}
/>
<RangeControl
label="Высота над поверхностью"
value={settings.gridHeightMeters}
min={0}
max={1000}
formatValue={(value) => `${value} м`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridHeightMeters = value;
return current;
})}
/>
<RangeControl
label="LOD 1: до высоты"
value={settings.gridLod1MaxHeightKm}
min={1}
max={50}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLod1MaxHeightKm = value;
return current;
})}
/>
<RangeControl
label="LOD 1: шаг"
value={settings.gridLod1StepKm}
min={1}
max={10}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLod1StepKm = value;
return current;
})}
/>
<RangeControl
label="LOD 2: до высоты"
value={settings.gridLod2MaxHeightKm}
min={10}
max={200}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLod2MaxHeightKm = value;
return current;
})}
/>
<RangeControl
label="LOD 2: шаг"
value={settings.gridLod2StepKm}
min={1}
max={25}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLod2StepKm = value;
return current;
})}
/>
<RangeControl
label="LOD 3: шаг"
value={settings.gridLod3StepKm}
min={5}
max={100}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLod3StepKm = value;
return current;
})}
/>
<RangeControl
label="Радиус сетки"
value={settings.gridRadiusKm}
min={5}
max={150}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridRadiusKm = value;
return current;
})}
/>
<ControlRow label="Цвет линий">
<ColorField
label="Цвет линий сетки"
value={settings.gridColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridColor = value;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Толщина линий"
value={settings.gridLineWidth}
min={1}
max={8}
formatValue={(value) => `${value} px`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLineWidth = value;
return current;
})}
/>
<RangeControl
label="Прозрачность сетки"
value={settings.gridOpacity}
min={0}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridOpacity = value;
return current;
})}
/>
<Checker
label="Точки в пересечениях"
checked={settings.gridDotsEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.gridDotsEnabled = checked;
return current;
})}
/>
<RangeControl
label="Размер точки"
value={settings.gridDotsSize}
min={2}
max={28}
formatValue={(value) => `${value} px`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridDotsSize = value;
return current;
})}
/>
<ControlRow label="Цвет точек">
<ColorField
label="Цвет точек сетки"
value={settings.gridDotsColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridDotsColor = value;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Прозрачность точек"
value={settings.gridDotsOpacity}
min={0}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridDotsOpacity = value;
return current;
})}
/>
</div>
),
},
{
id: "camera",
label: "Анимация камеры",
description: "geodesic spiral survey",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Разрешить анимацию камеры"
checked={settings.cameraAnimationEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.cameraAnimationEnabled = checked;
return current;
})}
/>
<ControlRow label="Сохранённая позиция">
<StatusBadge tone={view.camera ? "success" : "neutral"}>
{view.camera ? "Есть" : "Не задана"}
</StatusBadge>
</ControlRow>
</div>
),
},
{
id: "tile-cache",
label: "TileCache",
description: "Platform Map Gateway",
content: (
<div
className="mission-map__inspector-controls"
data-health-code={gatewayHealth.code ?? undefined}
>
<Checker
label="Использовать общий TileCache"
checked={view.cacheIntent.enabled}
onChange={(checked) => updateDraft((current) => {
current.view.cacheIntent.enabled = checked;
return current;
})}
/>
<Checker
label="Не перезаписывать существующие тайлы"
checked={view.cacheIntent.noOverwrite}
disabled={!view.cacheIntent.enabled}
onChange={(checked) => updateDraft((current) => {
current.view.cacheIntent.noOverwrite = checked;
return current;
})}
/>
<CacheHealth health={gatewayHealth} />
</div>
),
},
];
}
function CacheHealth({
health,
}: {
health: ReturnType<typeof useMapGatewayHealth>;
}) {
if (health.state === "loading" || health.state === "idle") {
return (
<ControlRow label="Состояние">
<StatusBadge tone="neutral">Проверяем</StatusBadge>
</ControlRow>
);
}
if (!health.snapshot) {
return (
<ControlRow label="Состояние">
<StatusBadge tone="danger">Недоступен</StatusBadge>
</ControlRow>
);
}
const { cache } = health.snapshot;
return (
<dl className="mission-map__cache-health">
<div>
<dt>Хранилище</dt>
<dd>
<StatusBadge tone={cache.persistent ? "success" : "warning"}>
{cache.persistent ? "Постоянное" : "Временное"}
</StatusBadge>
</dd>
</div>
<div>
<dt>Объекты</dt>
<dd>{cache.entries.toLocaleString("ru-RU")}</dd>
</div>
<div>
<dt>Объём</dt>
<dd>
{formatBytes(cache.bytes)}
{cache.maxBytes === null ? "" : ` / ${formatBytes(cache.maxBytes)}`}
</dd>
</div>
<div>
<dt>Заполнение</dt>
<dd>
<StatusBadge
tone={
cache.atCapacity || health.state === "stale"
? "warning"
: "success"
}
>
{cache.atCapacity
? "Лимит"
: health.state === "stale"
? "Устарело"
: "Норма"}
</StatusBadge>
</dd>
</div>
</dl>
);
}
function formatBytes(value: number): string {
if (value < 1024) return `${value} Б`;
const units = ["КБ", "МБ", "ГБ", "ТБ"];
let amount = value / 1024;
let unit = units[0];
for (let index = 1; index < units.length && amount >= 1024; index += 1) {
amount /= 1024;
unit = units[index];
}
return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${unit}`;
}
+279 -14
View File
@@ -19,10 +19,44 @@ export interface MapCamera {
export interface MapVisualSettings { export interface MapVisualSettings {
atmosphereEnabled: boolean; atmosphereEnabled: boolean;
lightingEnabled: boolean; atmosphereHue: number;
atmosphereSaturation: number;
atmosphereBrightness: number;
fogEnabled: boolean;
fogDensity: number;
sunEnabled: boolean;
sunHour: number;
sunIntensity: number;
shadowsEnabled: boolean;
monochromeEnabled: boolean; monochromeEnabled: boolean;
monochromeColor: string;
imageryBrightness: number;
imageryContrast: number;
imagerySaturation: number;
imageryGamma: number;
imageryHue: number;
imageryAlpha: number;
globeColor: string;
backgroundColor: string;
terrainExaggeration: number; terrainExaggeration: number;
buildingsColor: string;
buildingsOpacity: number;
buildingsMaximumScreenSpaceError: number; buildingsMaximumScreenSpaceError: number;
gridLodEnabled: boolean;
gridHeightMeters: number;
gridLod1MaxHeightKm: number;
gridLod1StepKm: number;
gridLod2MaxHeightKm: number;
gridLod2StepKm: number;
gridLod3StepKm: number;
gridRadiusKm: number;
gridLineWidth: number;
gridColor: string;
gridOpacity: number;
gridDotsEnabled: boolean;
gridDotsSize: number;
gridDotsColor: string;
gridDotsOpacity: number;
cameraAnimationEnabled: boolean; cameraAnimationEnabled: boolean;
} }
@@ -69,16 +103,57 @@ export function defaultMapViewDocument(): MapViewDocument {
return { return {
revision: 0, revision: 0,
view: { view: {
camera: null, camera: {
longitude: 37.618423,
latitude: 55.751244,
height: 40_000,
heading: 0,
pitch: -51.56620156177409,
roll: 0,
},
visualSettings: { visualSettings: {
atmosphereEnabled: true, atmosphereEnabled: false,
lightingEnabled: true, atmosphereHue: 0,
atmosphereSaturation: 0,
atmosphereBrightness: 0,
fogEnabled: true,
fogDensity: 2,
sunEnabled: true,
sunHour: 12,
sunIntensity: 200,
shadowsEnabled: true,
monochromeEnabled: false, monochromeEnabled: false,
monochromeColor: "#15151b",
imageryBrightness: 118,
imageryContrast: 102,
imagerySaturation: 0,
imageryGamma: 57,
imageryHue: 13,
imageryAlpha: 27,
globeColor: "#15151b",
backgroundColor: "#08090d",
terrainExaggeration: 1, terrainExaggeration: 1,
buildingsMaximumScreenSpaceError: 16, buildingsColor: "#a27aff",
buildingsOpacity: 1,
buildingsMaximumScreenSpaceError: 4,
gridLodEnabled: true,
gridHeightMeters: 500,
gridLod1MaxHeightKm: 10,
gridLod1StepKm: 1,
gridLod2MaxHeightKm: 50,
gridLod2StepKm: 5,
gridLod3StepKm: 25,
gridRadiusKm: 40,
gridLineWidth: 4,
gridColor: "#f5f5f5",
gridOpacity: 12,
gridDotsEnabled: true,
gridDotsSize: 7,
gridDotsColor: "#ffffff",
gridDotsOpacity: 58,
cameraAnimationEnabled: true, cameraAnimationEnabled: true,
}, },
mapHeight: 720, mapHeight: 694,
inspectorOpenSections: [], inspectorOpenSections: [],
cacheIntent: { cacheIntent: {
enabled: true, enabled: true,
@@ -89,7 +164,7 @@ export function defaultMapViewDocument(): MapViewDocument {
imagery: true, imagery: true,
terrain: true, terrain: true,
buildings: true, buildings: true,
grid: false, grid: true,
targets: true, targets: true,
}, },
}, },
@@ -99,7 +174,7 @@ export function defaultMapViewDocument(): MapViewDocument {
export function decodeMapViewDocument(value: unknown): MapViewDocument { export function decodeMapViewDocument(value: unknown): MapViewDocument {
const document = requireRecord(value, "map view"); const document = requireRecord(value, "map view");
requireExactKeys(document, ["schema_version", "revision", "view"], "map view"); requireExactKeys(document, ["schema_version", "revision", "view"], "map view");
if (document.schema_version !== "missioncore.map-view/v1") { if (document.schema_version !== "missioncore.map-view/v2") {
throw new Error("Версия сохранённого состояния карты не поддерживается."); throw new Error("Версия сохранённого состояния карты не поддерживается.");
} }
return { return {
@@ -124,11 +199,45 @@ export function encodeMapViewPut(document: MapViewDocument): unknown {
: null, : null,
visual_settings: { visual_settings: {
atmosphere_enabled: document.view.visualSettings.atmosphereEnabled, atmosphere_enabled: document.view.visualSettings.atmosphereEnabled,
lighting_enabled: document.view.visualSettings.lightingEnabled, atmosphere_hue: document.view.visualSettings.atmosphereHue,
atmosphere_saturation: document.view.visualSettings.atmosphereSaturation,
atmosphere_brightness: document.view.visualSettings.atmosphereBrightness,
fog_enabled: document.view.visualSettings.fogEnabled,
fog_density: document.view.visualSettings.fogDensity,
sun_enabled: document.view.visualSettings.sunEnabled,
sun_hour: document.view.visualSettings.sunHour,
sun_intensity: document.view.visualSettings.sunIntensity,
shadows_enabled: document.view.visualSettings.shadowsEnabled,
monochrome_enabled: document.view.visualSettings.monochromeEnabled, monochrome_enabled: document.view.visualSettings.monochromeEnabled,
monochrome_color: document.view.visualSettings.monochromeColor,
imagery_brightness: document.view.visualSettings.imageryBrightness,
imagery_contrast: document.view.visualSettings.imageryContrast,
imagery_saturation: document.view.visualSettings.imagerySaturation,
imagery_gamma: document.view.visualSettings.imageryGamma,
imagery_hue: document.view.visualSettings.imageryHue,
imagery_alpha: document.view.visualSettings.imageryAlpha,
globe_color: document.view.visualSettings.globeColor,
background_color: document.view.visualSettings.backgroundColor,
terrain_exaggeration: document.view.visualSettings.terrainExaggeration, terrain_exaggeration: document.view.visualSettings.terrainExaggeration,
buildings_color: document.view.visualSettings.buildingsColor,
buildings_opacity: document.view.visualSettings.buildingsOpacity,
buildings_maximum_screen_space_error: buildings_maximum_screen_space_error:
document.view.visualSettings.buildingsMaximumScreenSpaceError, document.view.visualSettings.buildingsMaximumScreenSpaceError,
grid_lod_enabled: document.view.visualSettings.gridLodEnabled,
grid_height_meters: document.view.visualSettings.gridHeightMeters,
grid_lod_1_max_height_km: document.view.visualSettings.gridLod1MaxHeightKm,
grid_lod_1_step_km: document.view.visualSettings.gridLod1StepKm,
grid_lod_2_max_height_km: document.view.visualSettings.gridLod2MaxHeightKm,
grid_lod_2_step_km: document.view.visualSettings.gridLod2StepKm,
grid_lod_3_step_km: document.view.visualSettings.gridLod3StepKm,
grid_radius_km: document.view.visualSettings.gridRadiusKm,
grid_line_width: document.view.visualSettings.gridLineWidth,
grid_color: document.view.visualSettings.gridColor,
grid_opacity: document.view.visualSettings.gridOpacity,
grid_dots_enabled: document.view.visualSettings.gridDotsEnabled,
grid_dots_size: document.view.visualSettings.gridDotsSize,
grid_dots_color: document.view.visualSettings.gridDotsColor,
grid_dots_opacity: document.view.visualSettings.gridDotsOpacity,
camera_animation_enabled: document.view.visualSettings.cameraAnimationEnabled, camera_animation_enabled: document.view.visualSettings.cameraAnimationEnabled,
}, },
map_height: document.view.mapHeight, map_height: document.view.mapHeight,
@@ -228,29 +337,175 @@ function decodeVisualSettings(value: unknown): MapVisualSettings {
settings, settings,
[ [
"atmosphere_enabled", "atmosphere_enabled",
"lighting_enabled", "atmosphere_hue",
"atmosphere_saturation",
"atmosphere_brightness",
"fog_enabled",
"fog_density",
"sun_enabled",
"sun_hour",
"sun_intensity",
"shadows_enabled",
"monochrome_enabled", "monochrome_enabled",
"monochrome_color",
"imagery_brightness",
"imagery_contrast",
"imagery_saturation",
"imagery_gamma",
"imagery_hue",
"imagery_alpha",
"globe_color",
"background_color",
"terrain_exaggeration", "terrain_exaggeration",
"buildings_color",
"buildings_opacity",
"buildings_maximum_screen_space_error", "buildings_maximum_screen_space_error",
"grid_lod_enabled",
"grid_height_meters",
"grid_lod_1_max_height_km",
"grid_lod_1_step_km",
"grid_lod_2_max_height_km",
"grid_lod_2_step_km",
"grid_lod_3_step_km",
"grid_radius_km",
"grid_line_width",
"grid_color",
"grid_opacity",
"grid_dots_enabled",
"grid_dots_size",
"grid_dots_color",
"grid_dots_opacity",
"camera_animation_enabled", "camera_animation_enabled",
], ],
"map view.view.visual_settings", "map view.view.visual_settings",
); );
return { return {
atmosphereEnabled: requireBoolean(settings.atmosphere_enabled, "atmosphere_enabled"), atmosphereEnabled: requireBoolean(settings.atmosphere_enabled, "atmosphere_enabled"),
lightingEnabled: requireBoolean(settings.lighting_enabled, "lighting_enabled"), atmosphereHue: requireNumber(settings.atmosphere_hue, "atmosphere_hue", -100, 100),
atmosphereSaturation: requireNumber(
settings.atmosphere_saturation,
"atmosphere_saturation",
-100,
100,
),
atmosphereBrightness: requireNumber(
settings.atmosphere_brightness,
"atmosphere_brightness",
-100,
100,
),
fogEnabled: requireBoolean(settings.fog_enabled, "fog_enabled"),
fogDensity: requireNumber(settings.fog_density, "fog_density", 0, 100),
sunEnabled: requireBoolean(settings.sun_enabled, "sun_enabled"),
sunHour: requireNumber(settings.sun_hour, "sun_hour", 0, 24),
sunIntensity: requireNumber(settings.sun_intensity, "sun_intensity", 0, 200),
shadowsEnabled: requireBoolean(settings.shadows_enabled, "shadows_enabled"),
monochromeEnabled: requireBoolean(settings.monochrome_enabled, "monochrome_enabled"), monochromeEnabled: requireBoolean(settings.monochrome_enabled, "monochrome_enabled"),
monochromeColor: requireHexColor(settings.monochrome_color, "monochrome_color"),
imageryBrightness: requireNumber(
settings.imagery_brightness,
"imagery_brightness",
0,
200,
),
imageryContrast: requireNumber(
settings.imagery_contrast,
"imagery_contrast",
0,
200,
),
imagerySaturation: requireNumber(
settings.imagery_saturation,
"imagery_saturation",
0,
200,
),
imageryGamma: requireNumber(settings.imagery_gamma, "imagery_gamma", 0, 300),
imageryHue: requireNumber(settings.imagery_hue, "imagery_hue", -180, 180),
imageryAlpha: requireNumber(settings.imagery_alpha, "imagery_alpha", 0, 100),
globeColor: requireHexColor(settings.globe_color, "globe_color"),
backgroundColor: requireHexColor(settings.background_color, "background_color"),
terrainExaggeration: requireNumber( terrainExaggeration: requireNumber(
settings.terrain_exaggeration, settings.terrain_exaggeration,
"terrain_exaggeration", "terrain_exaggeration",
0.1, 0.25,
20, 3,
),
buildingsColor: requireHexColor(settings.buildings_color, "buildings_color"),
buildingsOpacity: requireNumber(
settings.buildings_opacity,
"buildings_opacity",
0,
1,
), ),
buildingsMaximumScreenSpaceError: requireNumber( buildingsMaximumScreenSpaceError: requireNumber(
settings.buildings_maximum_screen_space_error, settings.buildings_maximum_screen_space_error,
"buildings_maximum_screen_space_error", "buildings_maximum_screen_space_error",
4,
32,
),
gridLodEnabled: requireBoolean(settings.grid_lod_enabled, "grid_lod_enabled"),
gridHeightMeters: requireNumber(
settings.grid_height_meters,
"grid_height_meters",
0,
1000,
),
gridLod1MaxHeightKm: requireNumber(
settings.grid_lod_1_max_height_km,
"grid_lod_1_max_height_km",
1, 1,
64, 50,
),
gridLod1StepKm: requireNumber(
settings.grid_lod_1_step_km,
"grid_lod_1_step_km",
1,
10,
),
gridLod2MaxHeightKm: requireNumber(
settings.grid_lod_2_max_height_km,
"grid_lod_2_max_height_km",
10,
200,
),
gridLod2StepKm: requireNumber(
settings.grid_lod_2_step_km,
"grid_lod_2_step_km",
1,
25,
),
gridLod3StepKm: requireNumber(
settings.grid_lod_3_step_km,
"grid_lod_3_step_km",
5,
100,
),
gridRadiusKm: requireNumber(settings.grid_radius_km, "grid_radius_km", 5, 150),
gridLineWidth: requireNumber(
settings.grid_line_width,
"grid_line_width",
1,
8,
),
gridColor: requireHexColor(settings.grid_color, "grid_color"),
gridOpacity: requireNumber(settings.grid_opacity, "grid_opacity", 0, 100),
gridDotsEnabled: requireBoolean(
settings.grid_dots_enabled,
"grid_dots_enabled",
),
gridDotsSize: requireNumber(
settings.grid_dots_size,
"grid_dots_size",
2,
28,
),
gridDotsColor: requireHexColor(settings.grid_dots_color, "grid_dots_color"),
gridDotsOpacity: requireNumber(
settings.grid_dots_opacity,
"grid_dots_opacity",
0,
100,
), ),
cameraAnimationEnabled: requireBoolean( cameraAnimationEnabled: requireBoolean(
settings.camera_animation_enabled, settings.camera_animation_enabled,
@@ -317,6 +572,16 @@ function requireBoolean(value: unknown, path: string): boolean {
return value; return value;
} }
function requireHexColor(value: unknown, path: string): string {
if (
typeof value !== "string" ||
!/^#[0-9a-f]{6}$/i.test(value)
) {
throw new Error(`${path} должен быть цветом #RRGGBB.`);
}
return value.toLowerCase();
}
function requireNumber( function requireNumber(
value: unknown, value: unknown,
path: string, path: string,
@@ -16,36 +16,24 @@ import { mapPageTemplate } from "@nodedc/page-patterns";
import { import {
Button, Button,
Checker, Checker,
ControlRow,
Icon, Icon,
IconButton, IconButton,
Inspector,
MapGlassSurface, MapGlassSurface,
RangeControl,
StatusBadge, StatusBadge,
Window, Window,
WindowFooterActions, WindowFooterActions,
} from "@nodedc/ui-react"; } from "@nodedc/ui-react";
import { MapSettingsInspector } from "../../components/map/MapSettingsInspector";
import { import {
cloneMapViewDocument, cloneMapViewDocument,
defaultMapViewDocument, defaultMapViewDocument,
type MapInspectorSection,
type MapViewDocument, type MapViewDocument,
} from "../../core/map/mapView"; } from "../../core/map/mapView";
import { useMapGatewayHealth } from "../../core/map/useMapGatewayHealth"; import { useMapGatewayHealth } from "../../core/map/useMapGatewayHealth";
import { useMapView } from "../../core/map/useMapView"; import { useMapView } from "../../core/map/useMapView";
import type { WorkspaceDefinition } from "../../productModel"; import type { WorkspaceDefinition } from "../../productModel";
const editableInspectorSections = new Set<MapInspectorSection>([
"base-terrain",
"atmosphere-light",
"buildings",
"grid-lod",
"camera",
"tile-cache",
]);
export function WorldMapWorkspace({ export function WorldMapWorkspace({
definition, definition,
}: { }: {
@@ -174,12 +162,57 @@ export function WorldMapWorkspace({
layers={draft.view.layerVisibility} layers={draft.view.layerVisibility}
settings={{ settings={{
atmosphere_enabled: draft.view.visualSettings.atmosphereEnabled, atmosphere_enabled: draft.view.visualSettings.atmosphereEnabled,
lighting_enabled: draft.view.visualSettings.lightingEnabled, atmosphere_hue: draft.view.visualSettings.atmosphereHue,
atmosphere_saturation:
draft.view.visualSettings.atmosphereSaturation,
atmosphere_brightness:
draft.view.visualSettings.atmosphereBrightness,
fog_enabled: draft.view.visualSettings.fogEnabled,
fog_density: draft.view.visualSettings.fogDensity,
sun_enabled: draft.view.visualSettings.sunEnabled,
sun_hour: draft.view.visualSettings.sunHour,
sun_intensity: draft.view.visualSettings.sunIntensity,
shadows_enabled: draft.view.visualSettings.shadowsEnabled,
monochrome_enabled: draft.view.visualSettings.monochromeEnabled, monochrome_enabled: draft.view.visualSettings.monochromeEnabled,
terrain_exaggeration: draft.view.visualSettings.terrainExaggeration, monochrome_color: draft.view.visualSettings.monochromeColor,
imagery_brightness: draft.view.visualSettings.imageryBrightness,
imagery_contrast: draft.view.visualSettings.imageryContrast,
imagery_saturation: draft.view.visualSettings.imagerySaturation,
imagery_gamma: draft.view.visualSettings.imageryGamma,
imagery_hue: draft.view.visualSettings.imageryHue,
imagery_alpha: draft.view.visualSettings.imageryAlpha,
globe_color: draft.view.visualSettings.globeColor,
background_color: draft.view.visualSettings.backgroundColor,
terrain_exaggeration:
draft.view.visualSettings.terrainExaggeration,
buildings_color: draft.view.visualSettings.buildingsColor,
buildings_opacity: draft.view.visualSettings.buildingsOpacity,
buildings_maximum_screen_space_error: buildings_maximum_screen_space_error:
draft.view.visualSettings.buildingsMaximumScreenSpaceError, draft.view.visualSettings.buildingsMaximumScreenSpaceError,
camera_animation_enabled: draft.view.visualSettings.cameraAnimationEnabled, grid_lod_enabled: draft.view.visualSettings.gridLodEnabled,
grid_height_meters: draft.view.visualSettings.gridHeightMeters,
grid_lod_1_max_height_km:
draft.view.visualSettings.gridLod1MaxHeightKm,
grid_lod_1_step_km:
draft.view.visualSettings.gridLod1StepKm,
grid_lod_2_max_height_km:
draft.view.visualSettings.gridLod2MaxHeightKm,
grid_lod_2_step_km:
draft.view.visualSettings.gridLod2StepKm,
grid_lod_3_step_km:
draft.view.visualSettings.gridLod3StepKm,
grid_radius_km: draft.view.visualSettings.gridRadiusKm,
grid_line_width: draft.view.visualSettings.gridLineWidth,
grid_color: draft.view.visualSettings.gridColor,
grid_opacity: draft.view.visualSettings.gridOpacity,
grid_dots_enabled:
draft.view.visualSettings.gridDotsEnabled,
grid_dots_size: draft.view.visualSettings.gridDotsSize,
grid_dots_color: draft.view.visualSettings.gridDotsColor,
grid_dots_opacity:
draft.view.visualSettings.gridDotsOpacity,
camera_animation_enabled:
draft.view.visualSettings.cameraAnimationEnabled,
}} }}
cacheIntent={{ cacheIntent={{
enabled: draft.view.cacheIntent.enabled, enabled: draft.view.cacheIntent.enabled,
@@ -338,22 +371,10 @@ export function WorldMapWorkspace({
</WindowFooterActions> </WindowFooterActions>
)} )}
> >
<Inspector <MapSettingsInspector
singleOpen draft={draft}
sections={createInspectorSections(draft, updateDraft, gatewayHealth)} updateDraft={updateDraft}
openSections={draft.view.inspectorOpenSections.filter( gatewayHealth={gatewayHealth}
(section) => editableInspectorSections.has(section),
)}
activeId={draft.view.inspectorOpenSections[0]}
onOpenSectionsChange={(sections) => {
updateDraft((current) => {
current.view.inspectorOpenSections = sections.filter(
(section): section is MapInspectorSection =>
editableInspectorSections.has(section as MapInspectorSection),
).slice(0, 1);
return current;
});
}}
/> />
</Window> </Window>
</div> </div>
@@ -380,249 +401,6 @@ function LayerControl({
); );
} }
function createInspectorSections(
draft: MapViewDocument,
updateDraft: (update: (current: MapViewDocument) => MapViewDocument) => void,
gatewayHealth: ReturnType<typeof useMapGatewayHealth>,
) {
const view = draft.view;
return [
{
id: "base-terrain",
label: "Подложка и terrain",
description: "provider-neutral surface",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Спутниковая подложка"
checked={view.layerVisibility.imagery}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.imagery = checked;
return current;
})}
/>
<Checker
label="Рельеф"
checked={view.layerVisibility.terrain}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.terrain = checked;
return current;
})}
/>
<Checker
label="Монохромная подложка"
checked={view.visualSettings.monochromeEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.monochromeEnabled = checked;
return current;
})}
/>
<RangeControl
label="Высота карты"
value={view.mapHeight}
min={420}
max={1080}
step={20}
formatValue={(value) => `${value} px`}
onChange={(value) => updateDraft((current) => {
current.view.mapHeight = value;
return current;
})}
/>
</div>
),
},
{
id: "atmosphere-light",
label: "Атмосфера и освещение",
description: "scene / color correction",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Атмосфера"
checked={view.visualSettings.atmosphereEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.atmosphereEnabled = checked;
return current;
})}
/>
<Checker
label="Освещение глобуса"
checked={view.visualSettings.lightingEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.lightingEnabled = checked;
return current;
})}
/>
</div>
),
},
{
id: "buildings",
label: "3D здания",
description: "3D Tiles / detail",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Показывать 3D-здания"
checked={view.layerVisibility.buildings}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.buildings = checked;
return current;
})}
/>
<RangeControl
label="Детализация"
value={view.visualSettings.buildingsMaximumScreenSpaceError}
min={1}
max={64}
step={1}
formatValue={(value) => `${value} SSE`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.buildingsMaximumScreenSpaceError = value;
return current;
})}
/>
</div>
),
},
{
id: "grid-lod",
label: "Сетка и LOD",
description: "first adapter control",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Планетарная сетка"
checked={view.layerVisibility.grid}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.grid = checked;
return current;
})}
/>
<RangeControl
label="Масштаб рельефа"
value={view.visualSettings.terrainExaggeration}
min={0.1}
max={8}
step={0.1}
formatValue={(value) => `${value.toFixed(1)}×`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.terrainExaggeration = value;
return current;
})}
/>
</div>
),
},
{
id: "camera",
label: "Анимация камеры",
description: "geodesic spiral survey",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Разрешить анимацию камеры"
checked={view.visualSettings.cameraAnimationEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.cameraAnimationEnabled = checked;
return current;
})}
/>
<ControlRow label="Сохранённая позиция">
<StatusBadge tone={view.camera ? "success" : "neutral"}>
{view.camera ? "Есть" : "Не задана"}
</StatusBadge>
</ControlRow>
</div>
),
},
{
id: "tile-cache",
label: "TileCache",
description: "Platform Map Gateway",
content: (
<div
className="mission-map__inspector-controls"
data-health-code={gatewayHealth.code ?? undefined}
>
<Checker
label="Использовать общий TileCache"
checked={view.cacheIntent.enabled}
onChange={(checked) => updateDraft((current) => {
current.view.cacheIntent.enabled = checked;
return current;
})}
/>
<Checker
label="Не перезаписывать существующие тайлы"
checked={view.cacheIntent.noOverwrite}
disabled={!view.cacheIntent.enabled}
onChange={(checked) => updateDraft((current) => {
current.view.cacheIntent.noOverwrite = checked;
return current;
})}
/>
<CacheHealth health={gatewayHealth} />
</div>
),
},
];
}
function CacheHealth({
health,
}: {
health: ReturnType<typeof useMapGatewayHealth>;
}) {
if (health.state === "loading" || health.state === "idle") {
return (
<ControlRow label="Состояние">
<StatusBadge tone="neutral">Проверяем</StatusBadge>
</ControlRow>
);
}
if (!health.snapshot) {
return (
<ControlRow label="Состояние">
<StatusBadge tone="danger">Недоступен</StatusBadge>
</ControlRow>
);
}
const { cache } = health.snapshot;
return (
<dl className="mission-map__cache-health">
<div>
<dt>Хранилище</dt>
<dd>
<StatusBadge tone={cache.persistent ? "success" : "warning"}>
{cache.persistent ? "Постоянное" : "Временное"}
</StatusBadge>
</dd>
</div>
<div>
<dt>Объекты</dt>
<dd>{cache.entries.toLocaleString("ru-RU")}</dd>
</div>
<div>
<dt>Объём</dt>
<dd>
{formatBytes(cache.bytes)}
{cache.maxBytes === null ? "" : ` / ${formatBytes(cache.maxBytes)}`}
</dd>
</div>
<div>
<dt>Заполнение</dt>
<dd>
<StatusBadge tone={cache.atCapacity ? "warning" : health.state === "stale" ? "warning" : "success"}>
{cache.atCapacity ? "Лимит" : health.state === "stale" ? "Устарело" : "Норма"}
</StatusBadge>
</dd>
</div>
</dl>
);
}
function runtimePresentation(state: MapRuntimeState): { function runtimePresentation(state: MapRuntimeState): {
label: string; label: string;
tone: "neutral" | "success" | "warning" | "danger"; tone: "neutral" | "success" | "warning" | "danger";
@@ -650,15 +428,3 @@ function providerPresentation(
if (provider.phase === "error") return { label: "Недоступен", tone: "danger" }; if (provider.phase === "error") return { label: "Недоступен", tone: "danger" };
return { label: "Загрузка", tone: "neutral" }; return { label: "Загрузка", tone: "neutral" };
} }
function formatBytes(value: number): string {
if (value < 1024) return `${value} Б`;
const units = ["КБ", "МБ", "ГБ", "ТБ"];
let amount = value / 1024;
let unit = units[0];
for (let index = 1; index < units.length && amount >= 1024; index += 1) {
amount /= 1024;
unit = units[index];
}
return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${unit}`;
}
+53 -9
View File
@@ -22,19 +22,60 @@ after(async () => {
function serverDocument(overrides = {}) { function serverDocument(overrides = {}) {
return { return {
schema_version: "missioncore.map-view/v1", schema_version: "missioncore.map-view/v2",
revision: 0, revision: 0,
view: { view: {
camera: null, camera: {
longitude: 37.618423,
latitude: 55.751244,
height: 40000,
heading: 0,
pitch: -51.56620156177409,
roll: 0,
},
visual_settings: { visual_settings: {
atmosphere_enabled: true, atmosphere_enabled: false,
lighting_enabled: true, atmosphere_hue: 0,
atmosphere_saturation: 0,
atmosphere_brightness: 0,
fog_enabled: true,
fog_density: 2,
sun_enabled: true,
sun_hour: 12,
sun_intensity: 200,
shadows_enabled: true,
monochrome_enabled: false, monochrome_enabled: false,
monochrome_color: "#15151b",
imagery_brightness: 118,
imagery_contrast: 102,
imagery_saturation: 0,
imagery_gamma: 57,
imagery_hue: 13,
imagery_alpha: 27,
globe_color: "#15151b",
background_color: "#08090d",
terrain_exaggeration: 1, terrain_exaggeration: 1,
buildings_maximum_screen_space_error: 16, buildings_color: "#a27aff",
buildings_opacity: 1,
buildings_maximum_screen_space_error: 4,
grid_lod_enabled: true,
grid_height_meters: 500,
grid_lod_1_max_height_km: 10,
grid_lod_1_step_km: 1,
grid_lod_2_max_height_km: 50,
grid_lod_2_step_km: 5,
grid_lod_3_step_km: 25,
grid_radius_km: 40,
grid_line_width: 4,
grid_color: "#f5f5f5",
grid_opacity: 12,
grid_dots_enabled: true,
grid_dots_size: 7,
grid_dots_color: "#ffffff",
grid_dots_opacity: 58,
camera_animation_enabled: true, camera_animation_enabled: true,
}, },
map_height: 720, map_height: 694,
inspector_open_sections: [], inspector_open_sections: [],
cache_intent: { cache_intent: {
enabled: true, enabled: true,
@@ -45,7 +86,7 @@ function serverDocument(overrides = {}) {
imagery: true, imagery: true,
terrain: true, terrain: true,
buildings: true, buildings: true,
grid: false, grid: true,
targets: true, targets: true,
}, },
}, },
@@ -53,9 +94,12 @@ function serverDocument(overrides = {}) {
}; };
} }
test("map view starts without a fabricated global camera or subject", () => { test("map view starts from the canonical Foundry camera without a fabricated subject", () => {
const document = mapView.defaultMapViewDocument(); const document = mapView.defaultMapViewDocument();
assert.equal(document.view.camera, null); assert.equal(document.view.camera.longitude, 37.618423);
assert.equal(document.view.camera.latitude, 55.751244);
assert.equal(document.view.visualSettings.imagerySaturation, 0);
assert.equal(document.view.visualSettings.gridDotsOpacity, 58);
assert.equal(document.view.selectedSubjectId, null); assert.equal(document.view.selectedSubjectId, null);
assert.equal(document.view.cacheIntent.enabled, true); assert.equal(document.view.cacheIntent.enabled, true);
assert.equal(document.view.cacheIntent.noOverwrite, true); assert.equal(document.view.cacheIntent.noOverwrite, true);
@@ -194,8 +194,22 @@ the existing Gateway is reachable and reports a configured state.
## Map state contract ## Map state contract
Add a versioned Mission Core map document outside operator environment media, Mission Core stores a versioned map document outside operator environment
for example `missioncore.map-view/v1`, with optimistic revision: media. The implemented contract is `missioncore.map-view/v2`, with optimistic
revision:
- its default camera and complete visual state are the canonical Foundry
`DC Default` profile;
- black-and-white presentation is produced by zero imagery saturation while
imagery remains active;
- atmosphere, fog, sun, shadows, terrain, buildings and the elevated LOD grid
are persisted as provider-neutral values;
- saved `missioncore.map-view/v1` documents are upgraded in memory and become
v2 on the next explicit save;
- provider attribution remains registered in the renderer. The local
experiment may hide only the visual credit overlay through
`MISSIONCORE_MAP_SANDBOX_HIDE_CREDITS=1`; production defaults to visible
credits and must not enable that sandbox flag.
- camera: longitude, latitude, height, heading, pitch and roll; - camera: longitude, latitude, height, heading, pitch and roll;
- provider-neutral visual settings compatible with Map Page; - provider-neutral visual settings compatible with Map Page;
+15
View File
@@ -13,6 +13,7 @@ from fastapi import APIRouter, HTTPException, Path, Query, Request
from fastapi.responses import JSONResponse, Response, StreamingResponse from fastapi.responses import JSONResponse, Response, StreamingResponse
MAP_GATEWAY_URL_ENV: Final = "MISSIONCORE_MAP_GATEWAY_INTERNAL_URL" MAP_GATEWAY_URL_ENV: Final = "MISSIONCORE_MAP_GATEWAY_INTERNAL_URL"
MAP_SANDBOX_HIDE_CREDITS_ENV: Final = "MISSIONCORE_MAP_SANDBOX_HIDE_CREDITS"
MAP_SCHEMA_VERSION: Final = "missioncore.map-gateway/v1" MAP_SCHEMA_VERSION: Final = "missioncore.map-gateway/v1"
MAP_RUNTIME_SCHEMA_VERSION: Final = "missioncore.map-runtime/v1" MAP_RUNTIME_SCHEMA_VERSION: Final = "missioncore.map-runtime/v1"
MAP_PAGE_VERSION: Final = "0.1.0" MAP_PAGE_VERSION: Final = "0.1.0"
@@ -149,6 +150,11 @@ class MapGatewayProxy:
"terrain": 1, "terrain": 1,
"buildings": 96188, "buildings": 96188,
}, },
"sandbox": {
"hide_credit_overlay": _environment_flag(
MAP_SANDBOX_HIDE_CREDITS_ENV
)
},
}, },
headers={"Cache-Control": "no-store"}, headers={"Cache-Control": "no-store"},
) )
@@ -309,6 +315,15 @@ def _validate_internal_url(raw_url: str) -> str:
return raw_url.rstrip("/") return raw_url.rstrip("/")
def _environment_flag(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
def _validate_cache_target(target_url: str) -> None: def _validate_cache_target(target_url: str) -> None:
if len(target_url) > MAX_CACHE_TARGET_LENGTH: if len(target_url) > MAX_CACHE_TARGET_LENGTH:
raise HTTPException(status_code=422, detail="Map resource URL is too long.") raise HTTPException(status_code=422, detail="Map resource URL is too long.")
+128 -15
View File
@@ -10,7 +10,7 @@ from typing import Literal
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
MAP_VIEW_SCHEMA_VERSION: Literal["missioncore.map-view/v1"] = "missioncore.map-view/v1" MAP_VIEW_SCHEMA_VERSION: Literal["missioncore.map-view/v2"] = "missioncore.map-view/v2"
MapInspectorSection = Literal[ MapInspectorSection = Literal[
"base-terrain", "base-terrain",
"atmosphere-light", "atmosphere-light",
@@ -37,15 +37,49 @@ class MapCamera(StrictMapModel):
class MapVisualSettings(StrictMapModel): class MapVisualSettings(StrictMapModel):
atmosphere_enabled: bool = True atmosphere_enabled: bool = False
lighting_enabled: bool = True atmosphere_hue: float = Field(default=0.0, ge=-100.0, le=100.0)
atmosphere_saturation: float = Field(default=0.0, ge=-100.0, le=100.0)
atmosphere_brightness: float = Field(default=0.0, ge=-100.0, le=100.0)
fog_enabled: bool = True
fog_density: float = Field(default=2.0, ge=0.0, le=100.0)
sun_enabled: bool = True
sun_hour: float = Field(default=12.0, ge=0.0, le=24.0)
sun_intensity: float = Field(default=200.0, ge=0.0, le=200.0)
shadows_enabled: bool = True
monochrome_enabled: bool = False monochrome_enabled: bool = False
terrain_exaggeration: float = Field(default=1.0, ge=0.1, le=20.0) monochrome_color: str = Field(default="#15151b", pattern=r"^#[0-9A-Fa-f]{6}$")
imagery_brightness: float = Field(default=118.0, ge=0.0, le=200.0)
imagery_contrast: float = Field(default=102.0, ge=0.0, le=200.0)
imagery_saturation: float = Field(default=0.0, ge=0.0, le=200.0)
imagery_gamma: float = Field(default=57.0, ge=0.0, le=300.0)
imagery_hue: float = Field(default=13.0, ge=-180.0, le=180.0)
imagery_alpha: float = Field(default=27.0, ge=0.0, le=100.0)
globe_color: str = Field(default="#15151b", pattern=r"^#[0-9A-Fa-f]{6}$")
background_color: str = Field(default="#08090d", pattern=r"^#[0-9A-Fa-f]{6}$")
terrain_exaggeration: float = Field(default=1.0, ge=0.25, le=3.0)
buildings_color: str = Field(default="#a27aff", pattern=r"^#[0-9A-Fa-f]{6}$")
buildings_opacity: float = Field(default=1.0, ge=0.0, le=1.0)
buildings_maximum_screen_space_error: float = Field( buildings_maximum_screen_space_error: float = Field(
default=16.0, default=4.0,
ge=1.0, ge=4.0,
le=64.0, le=32.0,
) )
grid_lod_enabled: bool = True
grid_height_meters: float = Field(default=500.0, ge=0.0, le=1000.0)
grid_lod_1_max_height_km: float = Field(default=10.0, ge=1.0, le=50.0)
grid_lod_1_step_km: float = Field(default=1.0, ge=1.0, le=10.0)
grid_lod_2_max_height_km: float = Field(default=50.0, ge=10.0, le=200.0)
grid_lod_2_step_km: float = Field(default=5.0, ge=1.0, le=25.0)
grid_lod_3_step_km: float = Field(default=25.0, ge=5.0, le=100.0)
grid_radius_km: float = Field(default=40.0, ge=5.0, le=150.0)
grid_line_width: float = Field(default=4.0, ge=1.0, le=8.0)
grid_color: str = Field(default="#f5f5f5", pattern=r"^#[0-9A-Fa-f]{6}$")
grid_opacity: float = Field(default=12.0, ge=0.0, le=100.0)
grid_dots_enabled: bool = True
grid_dots_size: float = Field(default=7.0, ge=2.0, le=28.0)
grid_dots_color: str = Field(default="#ffffff", pattern=r"^#[0-9A-Fa-f]{6}$")
grid_dots_opacity: float = Field(default=58.0, ge=0.0, le=100.0)
camera_animation_enabled: bool = True camera_animation_enabled: bool = True
@@ -53,7 +87,7 @@ class MapLayerVisibility(StrictMapModel):
imagery: bool = True imagery: bool = True
terrain: bool = True terrain: bool = True
buildings: bool = True buildings: bool = True
grid: bool = False grid: bool = True
targets: bool = True targets: bool = True
@@ -63,9 +97,18 @@ class MapCacheIntent(StrictMapModel):
class MapViewContent(StrictMapModel): class MapViewContent(StrictMapModel):
camera: MapCamera | None = None camera: MapCamera | None = Field(
default_factory=lambda: MapCamera(
longitude=37.618423,
latitude=55.751244,
height=40_000.0,
heading=0.0,
pitch=-51.56620156177409,
roll=0.0,
)
)
visual_settings: MapVisualSettings = Field(default_factory=MapVisualSettings) visual_settings: MapVisualSettings = Field(default_factory=MapVisualSettings)
map_height: int = Field(default=720, ge=420, le=2160) map_height: int = Field(default=694, ge=420, le=2160)
inspector_open_sections: list[MapInspectorSection] = Field( inspector_open_sections: list[MapInspectorSection] = Field(
default_factory=list, default_factory=list,
max_length=1, max_length=1,
@@ -91,7 +134,43 @@ class MapViewPut(StrictMapModel):
class MapViewDocument(MapViewPut): class MapViewDocument(MapViewPut):
schema_version: Literal["missioncore.map-view/v1"] = MAP_VIEW_SCHEMA_VERSION schema_version: Literal["missioncore.map-view/v2"] = MAP_VIEW_SCHEMA_VERSION
class LegacyMapVisualSettings(StrictMapModel):
atmosphere_enabled: bool = True
lighting_enabled: bool = True
monochrome_enabled: bool = False
terrain_exaggeration: float = Field(default=1.0, ge=0.1, le=20.0)
buildings_maximum_screen_space_error: float = Field(
default=16.0,
ge=1.0,
le=64.0,
)
camera_animation_enabled: bool = True
class LegacyMapViewContent(StrictMapModel):
camera: MapCamera | None = None
visual_settings: LegacyMapVisualSettings = Field(default_factory=LegacyMapVisualSettings)
map_height: int = Field(default=720, ge=420, le=2160)
inspector_open_sections: list[MapInspectorSection] = Field(
default_factory=list,
max_length=1,
)
cache_intent: MapCacheIntent = Field(default_factory=MapCacheIntent)
selected_subject_id: str | None = Field(
default=None,
max_length=160,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$",
)
layer_visibility: MapLayerVisibility = Field(default_factory=MapLayerVisibility)
class LegacyMapViewDocument(StrictMapModel):
schema_version: Literal["missioncore.map-view/v1"]
revision: int = Field(ge=0)
view: LegacyMapViewContent
def default_map_view() -> MapViewDocument: def default_map_view() -> MapViewDocument:
@@ -101,6 +180,36 @@ def default_map_view() -> MapViewDocument:
) )
def upgrade_legacy_map_view(document: LegacyMapViewDocument) -> MapViewDocument:
legacy_settings = document.view.visual_settings
settings = MapVisualSettings(
atmosphere_enabled=legacy_settings.atmosphere_enabled,
sun_enabled=legacy_settings.lighting_enabled,
monochrome_enabled=legacy_settings.monochrome_enabled,
terrain_exaggeration=min(
3.0,
max(0.25, legacy_settings.terrain_exaggeration),
),
buildings_maximum_screen_space_error=min(
32.0,
max(4.0, legacy_settings.buildings_maximum_screen_space_error),
),
camera_animation_enabled=legacy_settings.camera_animation_enabled,
)
return MapViewDocument(
revision=document.revision,
view=MapViewContent(
camera=document.view.camera,
visual_settings=settings,
map_height=document.view.map_height,
inspector_open_sections=document.view.inspector_open_sections,
cache_intent=document.view.cache_intent,
selected_subject_id=document.view.selected_subject_id,
layer_visibility=document.view.layer_visibility,
),
)
class MapViewStore: class MapViewStore:
def __init__(self, root: Path) -> None: def __init__(self, root: Path) -> None:
self.root = root.expanduser().resolve() self.root = root.expanduser().resolve()
@@ -138,10 +247,14 @@ class MapViewStore:
if not self.document_path.is_file(): if not self.document_path.is_file():
return default_map_view() return default_map_view()
try: try:
return MapViewDocument.model_validate_json( payload = json.loads(self.document_path.read_text(encoding="utf-8"))
self.document_path.read_text(encoding="utf-8") if (
) isinstance(payload, dict)
except (OSError, ValueError) as exc: and payload.get("schema_version") == "missioncore.map-view/v1"
):
return upgrade_legacy_map_view(LegacyMapViewDocument.model_validate(payload))
return MapViewDocument.model_validate(payload)
except (OSError, ValueError, TypeError) as exc:
raise RuntimeError("map view is corrupt") from exc raise RuntimeError("map view is corrupt") from exc
+19 -1
View File
@@ -13,6 +13,7 @@ from fastapi.routing import APIRoute
from k1link.web.map_api import ( from k1link.web.map_api import (
MAP_GATEWAY_URL_ENV, MAP_GATEWAY_URL_ENV,
MAP_SANDBOX_HIDE_CREDITS_ENV,
MapGatewayConfiguration, MapGatewayConfiguration,
MapGatewayProxy, MapGatewayProxy,
build_map_router, build_map_router,
@@ -108,7 +109,10 @@ def test_configuration_is_fail_closed_and_rejects_non_origin_values(
MapGatewayConfiguration.from_environment() MapGatewayConfiguration.from_environment()
def test_runtime_config_exposes_only_same_origin_contract_and_hides_internal_origin() -> None: def test_runtime_config_exposes_only_same_origin_contract_and_hides_internal_origin(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv(MAP_SANDBOX_HIDE_CREDITS_ENV, raising=False)
service, requests = _service( service, requests = _service(
lambda request: httpx.Response( lambda request: httpx.Response(
200, 200,
@@ -133,11 +137,25 @@ def test_runtime_config_exposes_only_same_origin_contract_and_hides_internal_ori
"terrain": 1, "terrain": 1,
"buildings": 96188, "buildings": 96188,
} }
assert document["sandbox"] == {"hide_credit_overlay": False}
assert response.headers["cache-control"] == "no-store" assert response.headers["cache-control"] == "no-store"
assert not requests assert not requests
assert "map-gateway.internal" not in _response_body(response).decode() assert "map-gateway.internal" not in _response_body(response).decode()
def test_runtime_config_hides_credits_only_with_explicit_sandbox_flag(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service, _ = _service(lambda request: httpx.Response(200))
monkeypatch.setenv(MAP_SANDBOX_HIDE_CREDITS_ENV, "true")
response = service.runtime_configuration()
assert isinstance(response, JSONResponse)
document = json.loads(_response_body(response))
assert document["sandbox"] == {"hide_credit_overlay": True}
def test_runtime_config_is_fail_closed_when_gateway_is_not_configured() -> None: def test_runtime_config_is_fail_closed_when_gateway_is_not_configured() -> None:
service = MapGatewayProxy(MapGatewayConfiguration(None)) service = MapGatewayProxy(MapGatewayConfiguration(None))
response = service.runtime_configuration() response = service.runtime_configuration()
+55 -3
View File
@@ -15,15 +15,23 @@ from k1link.web.map_view_api import (
) )
def test_default_map_view_is_honestly_unlocated_and_cache_safe() -> None: def test_default_map_view_matches_the_canonical_foundry_scene_and_cache_policy() -> None:
document = default_map_view() document = default_map_view()
assert document.schema_version == "missioncore.map-view/v1" assert document.schema_version == "missioncore.map-view/v2"
assert document.revision == 0 assert document.revision == 0
assert document.view.camera is None assert document.view.camera is not None
assert document.view.camera.longitude == pytest.approx(37.618423)
assert document.view.camera.latitude == pytest.approx(55.751244)
assert document.view.camera.height == pytest.approx(40_000.0)
assert document.view.selected_subject_id is None assert document.view.selected_subject_id is None
assert document.view.layer_visibility.imagery is True assert document.view.layer_visibility.imagery is True
assert document.view.layer_visibility.grid is True
assert document.view.layer_visibility.targets is True assert document.view.layer_visibility.targets is True
assert document.view.visual_settings.imagery_saturation == 0
assert document.view.visual_settings.imagery_alpha == 27
assert document.view.visual_settings.imagery_gamma == 57
assert document.view.visual_settings.background_color == "#08090d"
assert document.view.cache_intent.enabled is True assert document.view.cache_intent.enabled is True
assert document.view.cache_intent.no_overwrite is True assert document.view.cache_intent.no_overwrite is True
@@ -98,3 +106,47 @@ def test_persisted_document_contains_no_runtime_or_credential_material(
assert "gateway" not in serialized assert "gateway" not in serialized
assert "provider" not in serialized assert "provider" not in serialized
assert "cesium" not in serialized assert "cesium" not in serialized
def test_v1_map_view_is_upgraded_without_rewriting_the_source_file(
tmp_path: Path,
) -> None:
store = MapViewStore(tmp_path / "map-view")
store.root.mkdir(parents=True)
legacy = {
"schema_version": "missioncore.map-view/v1",
"revision": 7,
"view": {
"camera": None,
"visual_settings": {
"atmosphere_enabled": True,
"lighting_enabled": False,
"monochrome_enabled": True,
"terrain_exaggeration": 2,
"buildings_maximum_screen_space_error": 12,
"camera_animation_enabled": False,
},
"map_height": 720,
"inspector_open_sections": [],
"cache_intent": {"enabled": True, "no_overwrite": True},
"selected_subject_id": None,
"layer_visibility": {
"imagery": True,
"terrain": True,
"buildings": True,
"grid": False,
"targets": True,
},
},
}
store.document_path.write_text(json.dumps(legacy), encoding="utf-8")
upgraded = store.read()
assert upgraded.schema_version == "missioncore.map-view/v2"
assert upgraded.revision == 7
assert upgraded.view.camera is None
assert upgraded.view.visual_settings.atmosphere_enabled is True
assert upgraded.view.visual_settings.sun_enabled is False
assert upgraded.view.visual_settings.monochrome_enabled is True
assert json.loads(store.document_path.read_text(encoding="utf-8")) == legacy