diff --git a/apps/control-station/src/components/map/GhostPinSettingsPanel.tsx b/apps/control-station/src/components/map/GhostPinSettingsPanel.tsx new file mode 100644 index 0000000..4505200 --- /dev/null +++ b/apps/control-station/src/components/map/GhostPinSettingsPanel.tsx @@ -0,0 +1,360 @@ +import { + Button, + Checker, + ColorField, + ControlRow, + InspectorSelectField, + RangeControl, + StatusBadge, +} from "@nodedc/ui-react"; + +import type { + MapGhostPinLabelMode, + MapViewDocument, +} from "../../core/map/mapView"; + +export function GhostPinSettingsPanel({ + draft, + updateDraft, + onGenerate, + onClear, +}: { + draft: MapViewDocument; + updateDraft: ( + update: (current: MapViewDocument) => MapViewDocument, + ) => void; + onGenerate: () => void; + onClear: () => void; +}) { + const configuration = draft.view.ghostPins; + const profile = configuration.presentation; + return ( +
+ + Временная симуляция + + `${value}`} + onChange={(count) => updateDraft((current) => { + current.view.ghostPins.count = count; + return current; + })} + /> + value >= 1_000 + ? `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)} км` + : `${value} м`} + onChange={(radiusMeters) => updateDraft((current) => { + current.view.ghostPins.radiusMeters = radiusMeters; + return current; + })} + /> + updateDraft((current) => { + current.view.ghostPins.simulationEnabled = simulationEnabled; + return current; + })} + /> + + + {configuration.positions.length + ? `${configuration.positions.length} на карте` + : "Не созданы"} + + + + + + updateDraft((current) => { + current.view.layerVisibility.targets = checked; + return current; + })} + /> + + updateDraft((current) => { + current.view.ghostPins.presentation.color = color; + return current; + })} + /> + + `${value}%`} + onChange={(value) => updateDraft((current) => { + current.view.ghostPins.presentation.opacity = value / 100; + return current; + })} + /> + `${value} м`} + onChange={(stemHeightMeters) => updateDraft((current) => { + current.view.ghostPins.presentation.stemHeightMeters = stemHeightMeters; + return current; + })} + /> + `${value} px`} + onChange={(headSizePx) => updateDraft((current) => { + current.view.ghostPins.presentation.headSizePx = headSizePx; + return current; + })} + /> + `${value} px`} + onChange={(stemWidthPx) => updateDraft((current) => { + current.view.ghostPins.presentation.stemWidthPx = stemWidthPx; + return current; + })} + /> + + updateDraft((current) => { + current.view.ghostPins.presentation.outlineColor = outlineColor; + return current; + })} + /> + + `${value}%`} + onChange={(value) => updateDraft((current) => { + current.view.ghostPins.presentation.outlineOpacity = value / 100; + return current; + })} + /> + `${value} px`} + onChange={(outlineWidthPx) => updateDraft((current) => { + current.view.ghostPins.presentation.outlineWidthPx = outlineWidthPx; + return current; + })} + /> + + label="Подпись" + value={profile.labelMode} + options={[ + { + value: "attributes", + label: "Имя", + description: "Случайное числовое имя Ghost Pin", + }, + { + value: "subject_id", + label: "ID", + description: "Стабильный синтетический идентификатор", + }, + { + value: "none", + label: "Нет", + description: "Не показывать плашку", + }, + ]} + onChange={(labelMode) => updateDraft((current) => { + current.view.ghostPins.presentation.labelMode = labelMode; + return current; + })} + /> + `${value}`} + onChange={(labelFontWeight) => updateDraft((current) => { + current.view.ghostPins.presentation.labelFontWeight = labelFontWeight; + return current; + })} + /> + `${value} px`} + onChange={(labelSizePx) => updateDraft((current) => { + current.view.ghostPins.presentation.labelSizePx = labelSizePx; + return current; + })} + /> + + updateDraft((current) => { + current.view.ghostPins.presentation.labelColor = labelColor; + return current; + })} + /> + + + updateDraft((current) => { + current.view.ghostPins.presentation.labelBackgroundColor = + labelBackgroundColor; + return current; + })} + /> + + `${value}%`} + onChange={(value) => updateDraft((current) => { + current.view.ghostPins.presentation.labelBackgroundOpacity = + value / 100; + return current; + })} + /> + `${value} px`} + onChange={(labelPaddingX) => updateDraft((current) => { + current.view.ghostPins.presentation.labelPaddingX = labelPaddingX; + return current; + })} + /> + `${value} px`} + onChange={(labelPaddingY) => updateDraft((current) => { + current.view.ghostPins.presentation.labelPaddingY = labelPaddingY; + return current; + })} + /> + `${value} px`} + onChange={(labelOffsetX) => updateDraft((current) => { + current.view.ghostPins.presentation.labelOffsetX = labelOffsetX; + return current; + })} + /> + `${value} px`} + onChange={(labelOffsetY) => updateDraft((current) => { + current.view.ghostPins.presentation.labelOffsetY = labelOffsetY; + return current; + })} + /> + `${value}`} + onChange={(labelMaxLength) => updateDraft((current) => { + current.view.ghostPins.presentation.labelMaxLength = labelMaxLength; + return current; + })} + /> + `${Math.round(value / 1_000)} км`} + onChange={(labelHideCameraHeightMeters) => updateDraft((current) => { + current.view.ghostPins.presentation.labelHideCameraHeightMeters = + labelHideCameraHeightMeters; + return current; + })} + /> + `${Math.round(value / 1_000)} км`} + onChange={(targetHideCameraHeightMeters) => updateDraft((current) => { + current.view.ghostPins.presentation.targetHideCameraHeightMeters = + targetHideCameraHeightMeters; + return current; + })} + /> +
+ ); +} diff --git a/apps/control-station/src/components/map/MapSettingsInspector.tsx b/apps/control-station/src/components/map/MapSettingsInspector.tsx index f3cf5f0..65b92ff 100644 --- a/apps/control-station/src/components/map/MapSettingsInspector.tsx +++ b/apps/control-station/src/components/map/MapSettingsInspector.tsx @@ -12,11 +12,13 @@ import type { MapViewDocument, } from "../../core/map/mapView"; import type { useMapGatewayHealth } from "../../core/map/useMapGatewayHealth"; +import { GhostPinSettingsPanel } from "./GhostPinSettingsPanel"; export const editableMapInspectorSections = new Set([ "base-terrain", "atmosphere-light", "buildings", + "ghost-pins", "grid-lod", "camera", "tile-cache", @@ -26,17 +28,27 @@ export function MapSettingsInspector({ draft, updateDraft, gatewayHealth, + onGenerateGhostPins, + onClearGhostPins, }: { draft: MapViewDocument; updateDraft: ( update: (current: MapViewDocument) => MapViewDocument, ) => void; gatewayHealth: ReturnType; + onGenerateGhostPins: () => void; + onClearGhostPins: () => void; }) { return ( editableMapInspectorSections.has(section), )} @@ -60,6 +72,8 @@ function createInspectorSections( update: (current: MapViewDocument) => MapViewDocument, ) => void, gatewayHealth: ReturnType, + onGenerateGhostPins: () => void, + onClearGhostPins: () => void, ) { const view = draft.view; const settings = view.visualSettings; @@ -376,6 +390,19 @@ function createInspectorSections( ), }, + { + id: "ghost-pins", + label: "Гост-пин", + description: "временная симуляция", + content: ( + + ), + }, { id: "grid-lod", label: "Сетка и LOD", diff --git a/apps/control-station/src/core/map/ghostPins.ts b/apps/control-station/src/core/map/ghostPins.ts new file mode 100644 index 0000000..c3d6d08 --- /dev/null +++ b/apps/control-station/src/core/map/ghostPins.ts @@ -0,0 +1,92 @@ +import type { + MapGeoPoint, + MapGhostPin, +} from "./mapView"; + +const EARTH_RADIUS_METERS = 6_371_008.8; + +export function generateGhostPins({ + anchor, + count, + radiusMeters, + random = Math.random, + timestamp = Date.now(), +}: { + anchor: MapGeoPoint; + count: number; + radiusMeters: number; + random?: () => number; + timestamp?: number; +}): MapGhostPin[] { + const safeCount = Math.min(100, Math.max(1, Math.round(count))); + const safeRadius = Math.min(100_000, Math.max(100, radiusMeters)); + const labels = new Set(); + return Array.from({ length: safeCount }, (_, index) => { + const distance = safeRadius * Math.sqrt(clampUnit(random())); + const bearing = clampUnit(random()) * 360; + const position = destinationPoint(anchor, bearing, distance); + const label = nextNumericLabel(labels, random); + return { + id: [ + "ghost", + Math.max(0, Math.round(timestamp)).toString(36), + index.toString(36), + Math.floor(clampUnit(random()) * 0xff_ffff).toString(36), + ].join("-"), + label, + ...position, + headingDegrees: clampUnit(random()) * 360, + speedMetersPerSecond: 3 + clampUnit(random()) * 15, + }; + }); +} + +function nextNumericLabel( + labels: Set, + random: () => number, +): string { + let label = String(100_000 + Math.floor(clampUnit(random()) * 900_000)); + for (let attempt = 0; labels.has(label) && attempt < 16; attempt += 1) { + label = String(100_000 + Math.floor(clampUnit(random()) * 900_000)); + } + if (labels.has(label)) { + label = String(100_000 + (labels.size % 900_000)); + } + labels.add(label); + return label; +} + +function destinationPoint( + origin: MapGeoPoint, + headingDegrees: number, + distanceMeters: number, +): MapGeoPoint { + const angularDistance = distanceMeters / EARTH_RADIUS_METERS; + const bearing = toRadians(headingDegrees); + const latitude = toRadians(origin.latitude); + const longitude = toRadians(origin.longitude); + const nextLatitude = Math.asin( + Math.sin(latitude) * Math.cos(angularDistance) + + Math.cos(latitude) * Math.sin(angularDistance) * Math.cos(bearing), + ); + const nextLongitude = longitude + Math.atan2( + Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latitude), + Math.cos(angularDistance) - Math.sin(latitude) * Math.sin(nextLatitude), + ); + return { + longitude: ((toDegrees(nextLongitude) + 540) % 360) - 180, + latitude: Math.min(90, Math.max(-90, toDegrees(nextLatitude))), + }; +} + +function clampUnit(value: number): number { + return Math.min(0.999_999_999, Math.max(0, Number.isFinite(value) ? value : 0)); +} + +function toRadians(value: number): number { + return value * Math.PI / 180; +} + +function toDegrees(value: number): number { + return value * 180 / Math.PI; +} diff --git a/apps/control-station/src/core/map/mapView.ts b/apps/control-station/src/core/map/mapView.ts index 82ebedc..c66d52c 100644 --- a/apps/control-station/src/core/map/mapView.ts +++ b/apps/control-station/src/core/map/mapView.ts @@ -3,6 +3,7 @@ export type MapInspectorSection = | "atmosphere-light" | "buildings" | "targets" + | "ghost-pins" | "grid-lod" | "camera" | "tile-cache" @@ -17,6 +18,53 @@ export interface MapCamera { roll: number; } +export interface MapGeoPoint { + longitude: number; + latitude: number; +} + +export interface MapGhostPin extends MapGeoPoint { + id: string; + label: string; + headingDegrees: number; + speedMetersPerSecond: number; +} + +export type MapGhostPinLabelMode = "subject_id" | "attributes" | "none"; + +export interface MapGhostPinPresentation { + color: string; + opacity: number; + stemHeightMeters: number; + headSizePx: number; + stemWidthPx: number; + outlineColor: string; + outlineOpacity: number; + outlineWidthPx: number; + labelMode: MapGhostPinLabelMode; + labelFontWeight: number; + labelSizePx: number; + labelColor: string; + labelBackgroundColor: string; + labelBackgroundOpacity: number; + labelPaddingX: number; + labelPaddingY: number; + labelOffsetX: number; + labelOffsetY: number; + labelMaxLength: number; + labelHideCameraHeightMeters: number; + targetHideCameraHeightMeters: number; +} + +export interface MapGhostPinConfiguration { + count: number; + radiusMeters: number; + simulationEnabled: boolean; + anchor: MapGeoPoint | null; + positions: MapGhostPin[]; + presentation: MapGhostPinPresentation; +} + export interface MapVisualSettings { atmosphereEnabled: boolean; atmosphereHue: number; @@ -81,6 +129,7 @@ export interface MapView { cacheIntent: MapCacheIntent; selectedSubjectId: string | null; layerVisibility: MapLayerVisibility; + ghostPins: MapGhostPinConfiguration; } export interface MapViewDocument { @@ -93,6 +142,7 @@ const inspectorSections = new Set([ "atmosphere-light", "buildings", "targets", + "ghost-pins", "grid-lod", "camera", "tile-cache", @@ -167,6 +217,40 @@ export function defaultMapViewDocument(): MapViewDocument { grid: true, targets: true, }, + ghostPins: defaultGhostPinConfiguration(), + }, + }; +} + +export function defaultGhostPinConfiguration(): MapGhostPinConfiguration { + return { + count: 12, + radiusMeters: 5_000, + simulationEnabled: false, + anchor: null, + positions: [], + presentation: { + color: "#6fb5fb", + opacity: 0.9, + stemHeightMeters: 1_500, + headSizePx: 6, + stemWidthPx: 3, + outlineColor: "#0c0d12", + outlineOpacity: 0.6, + outlineWidthPx: 1, + labelMode: "attributes", + labelFontWeight: 600, + labelSizePx: 13, + labelColor: "#f5f5f5", + labelBackgroundColor: "#0c0d12", + labelBackgroundOpacity: 0.86, + labelPaddingX: 8, + labelPaddingY: 5, + labelOffsetX: 15, + labelOffsetY: 10, + labelMaxLength: 48, + labelHideCameraHeightMeters: 70_000, + targetHideCameraHeightMeters: 100_000, }, }; } @@ -174,7 +258,7 @@ export function defaultMapViewDocument(): MapViewDocument { export function decodeMapViewDocument(value: unknown): MapViewDocument { const document = requireRecord(value, "map view"); requireExactKeys(document, ["schema_version", "revision", "view"], "map view"); - if (document.schema_version !== "missioncore.map-view/v2") { + if (document.schema_version !== "missioncore.map-view/v3") { throw new Error("Версия сохранённого состояния карты не поддерживается."); } return { @@ -254,6 +338,57 @@ export function encodeMapViewPut(document: MapViewDocument): unknown { grid: document.view.layerVisibility.grid, targets: document.view.layerVisibility.targets, }, + ghost_pins: { + count: document.view.ghostPins.count, + radius_meters: document.view.ghostPins.radiusMeters, + simulation_enabled: document.view.ghostPins.simulationEnabled, + anchor: document.view.ghostPins.anchor + ? { + longitude: document.view.ghostPins.anchor.longitude, + latitude: document.view.ghostPins.anchor.latitude, + } + : null, + positions: document.view.ghostPins.positions.map((pin) => ({ + id: pin.id, + label: pin.label, + longitude: pin.longitude, + latitude: pin.latitude, + heading_degrees: pin.headingDegrees, + speed_meters_per_second: pin.speedMetersPerSecond, + })), + presentation: { + color: document.view.ghostPins.presentation.color, + opacity: document.view.ghostPins.presentation.opacity, + stem_height_meters: + document.view.ghostPins.presentation.stemHeightMeters, + head_size_px: document.view.ghostPins.presentation.headSizePx, + stem_width_px: document.view.ghostPins.presentation.stemWidthPx, + outline_color: document.view.ghostPins.presentation.outlineColor, + outline_opacity: + document.view.ghostPins.presentation.outlineOpacity, + outline_width_px: + document.view.ghostPins.presentation.outlineWidthPx, + label_mode: document.view.ghostPins.presentation.labelMode, + label_font_weight: + document.view.ghostPins.presentation.labelFontWeight, + label_size_px: document.view.ghostPins.presentation.labelSizePx, + label_color: document.view.ghostPins.presentation.labelColor, + label_background_color: + document.view.ghostPins.presentation.labelBackgroundColor, + label_background_opacity: + document.view.ghostPins.presentation.labelBackgroundOpacity, + label_padding_x: document.view.ghostPins.presentation.labelPaddingX, + label_padding_y: document.view.ghostPins.presentation.labelPaddingY, + label_offset_x: document.view.ghostPins.presentation.labelOffsetX, + label_offset_y: document.view.ghostPins.presentation.labelOffsetY, + label_max_length: + document.view.ghostPins.presentation.labelMaxLength, + label_hide_camera_height_meters: + document.view.ghostPins.presentation.labelHideCameraHeightMeters, + target_hide_camera_height_meters: + document.view.ghostPins.presentation.targetHideCameraHeightMeters, + }, + }, }, }; } @@ -268,6 +403,14 @@ export function cloneMapViewDocument(document: MapViewDocument): MapViewDocument inspectorOpenSections: [...document.view.inspectorOpenSections], cacheIntent: { ...document.view.cacheIntent }, layerVisibility: { ...document.view.layerVisibility }, + ghostPins: { + ...document.view.ghostPins, + anchor: document.view.ghostPins.anchor + ? { ...document.view.ghostPins.anchor } + : null, + positions: document.view.ghostPins.positions.map((pin) => ({ ...pin })), + presentation: { ...document.view.ghostPins.presentation }, + }, }, }; } @@ -284,6 +427,7 @@ function decodeMapView(value: unknown): MapView { "cache_intent", "selected_subject_id", "layer_visibility", + "ghost_pins", ], "map view.view", ); @@ -311,6 +455,247 @@ function decodeMapView(value: unknown): MapView { cacheIntent: decodeCacheIntent(view.cache_intent), selectedSubjectId, layerVisibility: decodeLayerVisibility(view.layer_visibility), + ghostPins: decodeGhostPinConfiguration(view.ghost_pins), + }; +} + +function decodeGhostPinConfiguration(value: unknown): MapGhostPinConfiguration { + const configuration = requireRecord(value, "map view.view.ghost_pins"); + requireExactKeys( + configuration, + [ + "count", + "radius_meters", + "simulation_enabled", + "anchor", + "positions", + "presentation", + ], + "map view.view.ghost_pins", + ); + const anchor = configuration.anchor === null + ? null + : decodeGeoPoint(configuration.anchor, "ghost_pins.anchor"); + const positions = requireArray( + configuration.positions, + "ghost_pins.positions", + ); + if (positions.length > 100) { + throw new Error("ghost_pins.positions превышает лимит."); + } + return { + count: requireInteger(configuration.count, "ghost_pins.count", 1, 100), + radiusMeters: requireNumber( + configuration.radius_meters, + "ghost_pins.radius_meters", + 100, + 100_000, + ), + simulationEnabled: requireBoolean( + configuration.simulation_enabled, + "ghost_pins.simulation_enabled", + ), + anchor, + positions: positions.map((pin, index) => decodeGhostPin( + pin, + `ghost_pins.positions[${index}]`, + )), + presentation: decodeGhostPinPresentation(configuration.presentation), + }; +} + +function decodeGeoPoint(value: unknown, path: string): MapGeoPoint { + const point = requireRecord(value, path); + requireExactKeys(point, ["longitude", "latitude"], path); + return { + longitude: requireNumber(point.longitude, `${path}.longitude`, -180, 180), + latitude: requireNumber(point.latitude, `${path}.latitude`, -90, 90), + }; +} + +function decodeGhostPin(value: unknown, path: string): MapGhostPin { + const pin = requireRecord(value, path); + requireExactKeys( + pin, + [ + "id", + "label", + "longitude", + "latitude", + "heading_degrees", + "speed_meters_per_second", + ], + path, + ); + if (typeof pin.id !== "string" || !/^ghost-[a-z0-9-]{1,80}$/i.test(pin.id)) { + throw new Error(`${path}.id некорректен.`); + } + if (typeof pin.label !== "string" || !/^\d{1,12}$/.test(pin.label)) { + throw new Error(`${path}.label должен содержать только цифры.`); + } + return { + id: pin.id, + label: pin.label, + longitude: requireNumber(pin.longitude, `${path}.longitude`, -180, 180), + latitude: requireNumber(pin.latitude, `${path}.latitude`, -90, 90), + headingDegrees: requireNumber( + pin.heading_degrees, + `${path}.heading_degrees`, + 0, + 360, + ), + speedMetersPerSecond: requireNumber( + pin.speed_meters_per_second, + `${path}.speed_meters_per_second`, + 0.1, + 100, + ), + }; +} + +function decodeGhostPinPresentation(value: unknown): MapGhostPinPresentation { + const profile = requireRecord(value, "ghost_pins.presentation"); + requireExactKeys( + profile, + [ + "color", + "opacity", + "stem_height_meters", + "head_size_px", + "stem_width_px", + "outline_color", + "outline_opacity", + "outline_width_px", + "label_mode", + "label_font_weight", + "label_size_px", + "label_color", + "label_background_color", + "label_background_opacity", + "label_padding_x", + "label_padding_y", + "label_offset_x", + "label_offset_y", + "label_max_length", + "label_hide_camera_height_meters", + "target_hide_camera_height_meters", + ], + "ghost_pins.presentation", + ); + const labelMode = profile.label_mode; + if ( + labelMode !== "subject_id" + && labelMode !== "attributes" + && labelMode !== "none" + ) { + throw new Error("ghost_pins.presentation.label_mode некорректен."); + } + return { + color: requireHexColor(profile.color, "ghost_pins.presentation.color"), + opacity: requireNumber(profile.opacity, "ghost_pins.presentation.opacity", 0, 1), + stemHeightMeters: requireNumber( + profile.stem_height_meters, + "ghost_pins.presentation.stem_height_meters", + 100, + 10_000, + ), + headSizePx: requireNumber( + profile.head_size_px, + "ghost_pins.presentation.head_size_px", + 1, + 32, + ), + stemWidthPx: requireNumber( + profile.stem_width_px, + "ghost_pins.presentation.stem_width_px", + 0.25, + 12, + ), + outlineColor: requireHexColor( + profile.outline_color, + "ghost_pins.presentation.outline_color", + ), + outlineOpacity: requireNumber( + profile.outline_opacity, + "ghost_pins.presentation.outline_opacity", + 0, + 1, + ), + outlineWidthPx: requireNumber( + profile.outline_width_px, + "ghost_pins.presentation.outline_width_px", + 0, + 8, + ), + labelMode, + labelFontWeight: requireInteger( + profile.label_font_weight, + "ghost_pins.presentation.label_font_weight", + 400, + 700, + ), + labelSizePx: requireNumber( + profile.label_size_px, + "ghost_pins.presentation.label_size_px", + 8, + 32, + ), + labelColor: requireHexColor( + profile.label_color, + "ghost_pins.presentation.label_color", + ), + labelBackgroundColor: requireHexColor( + profile.label_background_color, + "ghost_pins.presentation.label_background_color", + ), + labelBackgroundOpacity: requireNumber( + profile.label_background_opacity, + "ghost_pins.presentation.label_background_opacity", + 0, + 1, + ), + labelPaddingX: requireNumber( + profile.label_padding_x, + "ghost_pins.presentation.label_padding_x", + 0, + 32, + ), + labelPaddingY: requireNumber( + profile.label_padding_y, + "ghost_pins.presentation.label_padding_y", + 0, + 32, + ), + labelOffsetX: requireNumber( + profile.label_offset_x, + "ghost_pins.presentation.label_offset_x", + -100, + 100, + ), + labelOffsetY: requireNumber( + profile.label_offset_y, + "ghost_pins.presentation.label_offset_y", + -100, + 100, + ), + labelMaxLength: requireInteger( + profile.label_max_length, + "ghost_pins.presentation.label_max_length", + 1, + 64, + ), + labelHideCameraHeightMeters: requireNumber( + profile.label_hide_camera_height_meters, + "ghost_pins.presentation.label_hide_camera_height_meters", + 1_000, + 500_000, + ), + targetHideCameraHeightMeters: requireNumber( + profile.target_hide_camera_height_meters, + "ghost_pins.presentation.target_hide_camera_height_meters", + 1_000, + 500_000, + ), }; } diff --git a/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx b/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx index 39191e5..ec81c26 100644 --- a/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx +++ b/apps/control-station/src/workspaces/map/WorldMapWorkspace.tsx @@ -8,6 +8,7 @@ import { import { CesiumMapRenderer, initialMapRuntimeState, + type CesiumMapRendererHandle, type MapCamera, type MapProviderState, type MapRuntimeState, @@ -25,6 +26,7 @@ import { } from "@nodedc/ui-react"; import { MapSettingsInspector } from "../../components/map/MapSettingsInspector"; +import { generateGhostPins } from "../../core/map/ghostPins"; import { cloneMapViewDocument, defaultMapViewDocument, @@ -51,6 +53,7 @@ export function WorldMapWorkspace({ const [layersOpen, setLayersOpen] = useState(false); const [rendererGeneration, setRendererGeneration] = useState(0); const operatorCameraInteraction = useRef(false); + const rendererRef = useRef(null); const gatewayHealth = useMapGatewayHealth(settingsOpen || layersOpen); useEffect(() => { @@ -82,13 +85,54 @@ export function WorldMapWorkspace({ const save = useCallback(async () => { try { - const accepted = await controller.save(draft); + const next = cloneMapViewDocument(draft); + const snapshot = rendererRef.current?.snapshotGhostPins(); + if (snapshot) { + next.view.ghostPins.positions = snapshot.map((pin) => ({ + id: pin.id, + label: pin.label, + longitude: pin.longitude, + latitude: pin.latitude, + headingDegrees: pin.heading_degrees, + speedMetersPerSecond: pin.speed_meters_per_second, + })); + } + const accepted = await controller.save(next); setDraft(cloneMapViewDocument(accepted)); } catch { // The controller exposes stable product copy and leaves the current draft intact. } }, [controller, draft]); + const generatePins = useCallback(() => { + const anchor = rendererRef.current?.getViewAnchor() + ?? (draft.view.camera + ? { + longitude: draft.view.camera.longitude, + latitude: draft.view.camera.latitude, + } + : null); + if (!anchor) return; + updateDraft((current) => { + current.view.ghostPins.anchor = anchor; + current.view.ghostPins.positions = generateGhostPins({ + anchor, + count: current.view.ghostPins.count, + radiusMeters: current.view.ghostPins.radiusMeters, + }); + current.view.layerVisibility.targets = true; + return current; + }); + }, [draft.view.camera, updateDraft]); + + const clearPins = useCallback(() => { + updateDraft((current) => { + current.view.ghostPins.positions = []; + current.view.ghostPins.anchor = null; + return current; + }); + }, [updateDraft]); + const retryRenderer = useCallback(() => { operatorCameraInteraction.current = false; setRendererGeneration((generation) => generation + 1); @@ -157,6 +201,7 @@ export function WorldMapWorkspace({ > {viewInitialized ? ( ({ + id: pin.id, + label: pin.label, + longitude: pin.longitude, + latitude: pin.latitude, + heading_degrees: pin.headingDegrees, + speed_meters_per_second: pin.speedMetersPerSecond, + })), + presentation: { + color: draft.view.ghostPins.presentation.color, + opacity: draft.view.ghostPins.presentation.opacity, + stem_height_meters: + draft.view.ghostPins.presentation.stemHeightMeters, + head_size_px: draft.view.ghostPins.presentation.headSizePx, + stem_width_px: draft.view.ghostPins.presentation.stemWidthPx, + outline_color: + draft.view.ghostPins.presentation.outlineColor, + outline_opacity: + draft.view.ghostPins.presentation.outlineOpacity, + outline_width_px: + draft.view.ghostPins.presentation.outlineWidthPx, + label_mode: draft.view.ghostPins.presentation.labelMode, + label_font_weight: + draft.view.ghostPins.presentation.labelFontWeight, + label_size_px: + draft.view.ghostPins.presentation.labelSizePx, + label_color: draft.view.ghostPins.presentation.labelColor, + label_background_color: + draft.view.ghostPins.presentation.labelBackgroundColor, + label_background_opacity: + draft.view.ghostPins.presentation.labelBackgroundOpacity, + label_padding_x: + draft.view.ghostPins.presentation.labelPaddingX, + label_padding_y: + draft.view.ghostPins.presentation.labelPaddingY, + label_offset_x: + draft.view.ghostPins.presentation.labelOffsetX, + label_offset_y: + draft.view.ghostPins.presentation.labelOffsetY, + label_max_length: + draft.view.ghostPins.presentation.labelMaxLength, + label_hide_camera_height_meters: + draft.view.ghostPins.presentation + .labelHideCameraHeightMeters, + target_hide_camera_height_meters: + draft.view.ghostPins.presentation + .targetHideCameraHeightMeters, + }, + }} rendererGeneration={rendererGeneration} className="mission-map__cesium" onRuntimeStateChange={setRuntimeState} @@ -293,6 +391,20 @@ export function WorldMapWorkspace({ {draft.view.layerVisibility.grid ? "Включена" : "Выключена"} +
+ updateLayer("targets", checked)} + /> + + {draft.view.ghostPins.positions.length + ? `${draft.view.ghostPins.positions.length} позиций` + : "Пусто"} + +
) : null} @@ -375,6 +487,8 @@ export function WorldMapWorkspace({ draft={draft} updateDraft={updateDraft} gatewayHealth={gatewayHealth} + onGenerateGhostPins={generatePins} + onClearGhostPins={clearPins} /> diff --git a/apps/control-station/test/ghostPins.test.mjs b/apps/control-station/test/ghostPins.test.mjs new file mode 100644 index 0000000..0da3ae5 --- /dev/null +++ b/apps/control-station/test/ghostPins.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { createServer } from "vite"; + +let server; +let ghostPins; + +before(async () => { + server = await createServer({ + appType: "custom", + logLevel: "silent", + server: { middlewareMode: true }, + }); + ghostPins = await server.ssrLoadModule("/src/core/map/ghostPins.ts"); +}); + +after(async () => { + await server?.close(); +}); + +test("ghost pins are generated inside the requested current-view radius", () => { + const samples = [0, 0.25, 0.5, 0.75, 0.1, 0.9]; + let index = 0; + const pins = ghostPins.generateGhostPins({ + anchor: { longitude: 37.618423, latitude: 55.751244 }, + count: 3, + radiusMeters: 1_000, + random: () => samples[index++ % samples.length], + timestamp: 42, + }); + + assert.equal(pins.length, 3); + assert.equal(new Set(pins.map((pin) => pin.label)).size, 3); + assert.ok(pins.every((pin) => /^\d{6}$/.test(pin.label))); + assert.ok(pins.every((pin) => pin.id.startsWith("ghost-16-"))); + assert.ok(pins.every((pin) => pin.speedMetersPerSecond >= 3)); +}); + +test("numeric pin names remain unique with a degenerate random source", () => { + const pins = ghostPins.generateGhostPins({ + anchor: { longitude: 37.618423, latitude: 55.751244 }, + count: 4, + radiusMeters: 1_000, + random: () => 0, + timestamp: 42, + }); + + assert.equal(new Set(pins.map((pin) => pin.label)).size, 4); +}); diff --git a/apps/control-station/test/mapView.test.mjs b/apps/control-station/test/mapView.test.mjs index 3660782..c9edc74 100644 --- a/apps/control-station/test/mapView.test.mjs +++ b/apps/control-station/test/mapView.test.mjs @@ -22,7 +22,7 @@ after(async () => { function serverDocument(overrides = {}) { return { - schema_version: "missioncore.map-view/v2", + schema_version: "missioncore.map-view/v3", revision: 0, view: { camera: { @@ -89,6 +89,36 @@ function serverDocument(overrides = {}) { grid: true, targets: true, }, + ghost_pins: { + count: 12, + radius_meters: 5000, + simulation_enabled: false, + anchor: null, + positions: [], + presentation: { + color: "#6fb5fb", + opacity: 0.9, + stem_height_meters: 1500, + head_size_px: 6, + stem_width_px: 3, + outline_color: "#0c0d12", + outline_opacity: 0.6, + outline_width_px: 1, + label_mode: "attributes", + label_font_weight: 600, + label_size_px: 13, + label_color: "#f5f5f5", + label_background_color: "#0c0d12", + label_background_opacity: 0.86, + label_padding_x: 8, + label_padding_y: 5, + label_offset_x: 15, + label_offset_y: 10, + label_max_length: 48, + label_hide_camera_height_meters: 70000, + target_hide_camera_height_meters: 100000, + }, + }, }, ...overrides, }; @@ -103,6 +133,8 @@ test("map view starts from the canonical Foundry camera without a fabricated sub assert.equal(document.view.selectedSubjectId, null); assert.equal(document.view.cacheIntent.enabled, true); assert.equal(document.view.cacheIntent.noOverwrite, true); + assert.equal(document.view.ghostPins.count, 12); + assert.equal(document.view.ghostPins.positions.length, 0); }); test("map view decodes and re-encodes the complete versioned contract", () => { @@ -128,6 +160,7 @@ test("map view decodes and re-encodes the complete versioned contract", () => { assert.equal(decoded.view.inspectorOpenSections[0], "camera"); assert.equal(encoded.view.camera.latitude, 55.7558); assert.equal(encoded.view.cache_intent.no_overwrite, true); + assert.equal(encoded.view.ghost_pins.presentation.stem_height_meters, 1500); assert.equal("schema_version" in encoded, false); }); diff --git a/docs/21_MISSION_CORE_MAP_PAGE_INTEGRATION_PLAN.md b/docs/21_MISSION_CORE_MAP_PAGE_INTEGRATION_PLAN.md index d8d426a..3ab0c3b 100644 --- a/docs/21_MISSION_CORE_MAP_PAGE_INTEGRATION_PLAN.md +++ b/docs/21_MISSION_CORE_MAP_PAGE_INTEGRATION_PLAN.md @@ -195,7 +195,7 @@ the existing Gateway is reachable and reports a configured state. ## Map state contract Mission Core stores a versioned map document outside operator environment -media. The implemented contract is `missioncore.map-view/v2`, with optimistic +media. The implemented contract is `missioncore.map-view/v3`, with optimistic revision: - its default camera and complete visual state are the canonical Foundry @@ -204,8 +204,8 @@ revision: 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; +- saved `missioncore.map-view/v1` and `missioncore.map-view/v2` documents are + upgraded in memory and become v3 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 @@ -218,6 +218,9 @@ revision: - cache intent (`enabled`, `noOverwrite`), never server-level cache mode; - selected stable subject id, or `null`; - layer visibility; +- a bounded temporary Ghost Pin sandbox: generation settings, actual + coordinates, motion state and provider-neutral `elevated-spike` + presentation; - no credentials, upstream URLs, cache objects or transient Cesium ids. Resolution order: @@ -256,6 +259,14 @@ A real Mission Core map subject requires: Until that contract exists, the basemap remains valid with an honest empty subject state and the selected-entity section remains absent. +The temporary `Гост-пин` Inspector section is an explicit visual sandbox and +does not relax this boundary. It creates numeric synthetic labels around the +centre of the current Cesium view, can move them inside a bounded radius and +persists only its declared positions on explicit map save. It never claims +scanner evidence, `map.moving_object` authority, Data Product provenance or an +Engine command. `Очистить позиции на карте` removes the complete synthetic +layer from the draft; the next explicit save makes that removal durable. + ## State grammar The UI must distinguish: @@ -318,6 +329,18 @@ unbounded reconnect loops. - implement bounded renderer recreation for explicit viewport refresh; - keep provider credits available for any non-sandbox release. +### M3.1 — temporary Ghost Pin visual sandbox + +- reuse the canonical Foundry moving-object `elevated-spike` profile without + reading or mutating Foundry source; +- generate at most 100 numeric synthetic pins around the actual current view + centre; +- keep one local animation loop and bounce motion back inside its saved radius; +- save a snapshot of current positions together with camera and map state; +- expose explicit clear and the complete pin/label presentation controls in the + existing map Inspector; +- keep the feature visibly marked as temporary and non-authoritative. + ### M4 — secure API/key control plane - add authenticated Mission Core admin authority; diff --git a/src/k1link/web/map_view_api.py b/src/k1link/web/map_view_api.py index de50e32..2530c61 100644 --- a/src/k1link/web/map_view_api.py +++ b/src/k1link/web/map_view_api.py @@ -10,12 +10,13 @@ from typing import Literal from fastapi import APIRouter, HTTPException from pydantic import BaseModel, ConfigDict, Field, model_validator -MAP_VIEW_SCHEMA_VERSION: Literal["missioncore.map-view/v2"] = "missioncore.map-view/v2" +MAP_VIEW_SCHEMA_VERSION: Literal["missioncore.map-view/v3"] = "missioncore.map-view/v3" MapInspectorSection = Literal[ "base-terrain", "atmosphere-light", "buildings", "targets", + "ghost-pins", "grid-lod", "camera", "tile-cache", @@ -96,7 +97,76 @@ class MapCacheIntent(StrictMapModel): no_overwrite: bool = True -class MapViewContent(StrictMapModel): +class MapGeoPoint(StrictMapModel): + longitude: float = Field(ge=-180.0, le=180.0) + latitude: float = Field(ge=-90.0, le=90.0) + + +class MapGhostPin(MapGeoPoint): + id: str = Field( + min_length=7, + max_length=86, + pattern=r"^ghost-[A-Za-z0-9-]{1,80}$", + ) + label: str = Field(min_length=1, max_length=12, pattern=r"^\d{1,12}$") + heading_degrees: float = Field(ge=0.0, le=360.0) + speed_meters_per_second: float = Field(ge=0.1, le=100.0) + + +class MapGhostPinPresentation(StrictMapModel): + color: str = Field(default="#6fb5fb", pattern=r"^#[0-9A-Fa-f]{6}$") + opacity: float = Field(default=0.9, ge=0.0, le=1.0) + stem_height_meters: float = Field(default=1_500.0, ge=100.0, le=10_000.0) + head_size_px: float = Field(default=6.0, ge=1.0, le=32.0) + stem_width_px: float = Field(default=3.0, ge=0.25, le=12.0) + outline_color: str = Field(default="#0c0d12", pattern=r"^#[0-9A-Fa-f]{6}$") + outline_opacity: float = Field(default=0.6, ge=0.0, le=1.0) + outline_width_px: float = Field(default=1.0, ge=0.0, le=8.0) + label_mode: Literal["subject_id", "attributes", "none"] = "attributes" + label_font_weight: int = Field(default=600, ge=400, le=700, multiple_of=100) + label_size_px: float = Field(default=13.0, ge=8.0, le=32.0) + label_color: str = Field(default="#f5f5f5", pattern=r"^#[0-9A-Fa-f]{6}$") + label_background_color: str = Field( + default="#0c0d12", + pattern=r"^#[0-9A-Fa-f]{6}$", + ) + label_background_opacity: float = Field(default=0.86, ge=0.0, le=1.0) + label_padding_x: float = Field(default=8.0, ge=0.0, le=32.0) + label_padding_y: float = Field(default=5.0, ge=0.0, le=32.0) + label_offset_x: float = Field(default=15.0, ge=-100.0, le=100.0) + label_offset_y: float = Field(default=10.0, ge=-100.0, le=100.0) + label_max_length: int = Field(default=48, ge=1, le=64) + label_hide_camera_height_meters: float = Field( + default=70_000.0, + ge=1_000.0, + le=500_000.0, + ) + target_hide_camera_height_meters: float = Field( + default=100_000.0, + ge=1_000.0, + le=500_000.0, + ) + + +class MapGhostPinConfiguration(StrictMapModel): + count: int = Field(default=12, ge=1, le=100) + radius_meters: float = Field(default=5_000.0, ge=100.0, le=100_000.0) + simulation_enabled: bool = False + anchor: MapGeoPoint | None = None + positions: list[MapGhostPin] = Field(default_factory=list, max_length=100) + presentation: MapGhostPinPresentation = Field( + default_factory=MapGhostPinPresentation, + ) + + @model_validator(mode="after") + def validate_unique_positions(self) -> MapGhostPinConfiguration: + ids = [pin.id for pin in self.positions] + if len(ids) != len(set(ids)): + raise ValueError("ghost pin ids must be unique") + return self + + +class MapViewContentBase(StrictMapModel): camera: MapCamera | None = Field( default_factory=lambda: MapCamera( longitude=37.618423, @@ -122,19 +192,35 @@ class MapViewContent(StrictMapModel): layer_visibility: MapLayerVisibility = Field(default_factory=MapLayerVisibility) @model_validator(mode="after") - def validate_selection_section(self) -> MapViewContent: + def validate_selection_section(self) -> MapViewContentBase: if self.selected_subject_id is None and "selection" in self.inspector_open_sections: raise ValueError("selection section requires a selected stable subject") return self +class MapViewContent(MapViewContentBase): + ghost_pins: MapGhostPinConfiguration = Field( + default_factory=MapGhostPinConfiguration, + ) + + class MapViewPut(StrictMapModel): revision: int = Field(ge=0) view: MapViewContent class MapViewDocument(MapViewPut): - schema_version: Literal["missioncore.map-view/v2"] = MAP_VIEW_SCHEMA_VERSION + schema_version: Literal["missioncore.map-view/v3"] = MAP_VIEW_SCHEMA_VERSION + + +class LegacyV2MapViewContent(MapViewContentBase): + pass + + +class LegacyV2MapViewDocument(StrictMapModel): + schema_version: Literal["missioncore.map-view/v2"] + revision: int = Field(ge=0) + view: LegacyV2MapViewContent class LegacyMapVisualSettings(StrictMapModel): @@ -210,6 +296,16 @@ def upgrade_legacy_map_view(document: LegacyMapViewDocument) -> MapViewDocument: ) +def upgrade_v2_map_view(document: LegacyV2MapViewDocument) -> MapViewDocument: + return MapViewDocument( + revision=document.revision, + view=MapViewContent( + **document.view.model_dump(mode="python"), + ghost_pins=MapGhostPinConfiguration(), + ), + ) + + class MapViewStore: def __init__(self, root: Path) -> None: self.root = root.expanduser().resolve() @@ -253,6 +349,13 @@ class MapViewStore: and payload.get("schema_version") == "missioncore.map-view/v1" ): return upgrade_legacy_map_view(LegacyMapViewDocument.model_validate(payload)) + if ( + isinstance(payload, dict) + and payload.get("schema_version") == "missioncore.map-view/v2" + ): + return upgrade_v2_map_view( + LegacyV2MapViewDocument.model_validate(payload) + ) return MapViewDocument.model_validate(payload) except (OSError, ValueError, TypeError) as exc: raise RuntimeError("map view is corrupt") from exc diff --git a/tests/test_map_view_api.py b/tests/test_map_view_api.py index ef456a4..5b5551f 100644 --- a/tests/test_map_view_api.py +++ b/tests/test_map_view_api.py @@ -18,7 +18,7 @@ from k1link.web.map_view_api import ( def test_default_map_view_matches_the_canonical_foundry_scene_and_cache_policy() -> None: document = default_map_view() - assert document.schema_version == "missioncore.map-view/v2" + assert document.schema_version == "missioncore.map-view/v3" assert document.revision == 0 assert document.view.camera is not None assert document.view.camera.longitude == pytest.approx(37.618423) @@ -34,6 +34,9 @@ def test_default_map_view_matches_the_canonical_foundry_scene_and_cache_policy() assert document.view.visual_settings.background_color == "#08090d" assert document.view.cache_intent.enabled is True assert document.view.cache_intent.no_overwrite is True + assert document.view.ghost_pins.count == 12 + assert document.view.ghost_pins.positions == [] + assert document.view.ghost_pins.presentation.stem_height_meters == 1_500 def test_map_view_round_trip_uses_optimistic_revision(tmp_path: Path) -> None: @@ -143,10 +146,29 @@ def test_v1_map_view_is_upgraded_without_rewriting_the_source_file( upgraded = store.read() - assert upgraded.schema_version == "missioncore.map-view/v2" + assert upgraded.schema_version == "missioncore.map-view/v3" 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 upgraded.view.ghost_pins.positions == [] assert json.loads(store.document_path.read_text(encoding="utf-8")) == legacy + + +def test_v2_map_view_receives_the_default_ghost_pin_layer_without_rewrite( + tmp_path: Path, +) -> None: + store = MapViewStore(tmp_path / "map-view") + store.root.mkdir(parents=True) + current = default_map_view().model_dump(mode="json") + current["schema_version"] = "missioncore.map-view/v2" + current["view"].pop("ghost_pins") + store.document_path.write_text(json.dumps(current), encoding="utf-8") + + upgraded = store.read() + + assert upgraded.schema_version == "missioncore.map-view/v3" + assert upgraded.view.ghost_pins.count == 12 + assert upgraded.view.ghost_pins.presentation.label_mode == "attributes" + assert json.loads(store.document_path.read_text(encoding="utf-8")) == current