From 07ba650e03532365cfca1e6633be9d5a3297dbd9 Mon Sep 17 00:00:00 2001 From: emelianov Date: Mon, 15 Jun 2026 11:58:49 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=82=D0=BE=D1=82=D0=B8?= =?UTF-8?q?=D0=BF=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=87=D0=B5=D1=82=D0=B0=20=D0=9F=D0=A3=D0=A3?= =?UTF-8?q?=D0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AngularMotionCalculatorSmokeTest.kt | 77 +++++ .../angular/AngularMotionController.kt | 117 ++++++- .../angular/AngularMotionService.kt | 6 +- .../repository/PDCMRepository.kt | 18 ++ .../service/SatelliteService.kt | 6 +- .../angular/AngularMotionRequestMapperTest.kt | 70 +++++ .../repository/PDCMRepositoryTest.kt | 19 ++ .../service/DynamicPlanService.kt | 5 +- .../static/angular_motion_scripts.js | 293 +++++++++++++----- .../resources/static/css/angular-motion.css | 32 +- .../resources/templates/angular-motion.html | 4 +- 11 files changed, 550 insertions(+), 97 deletions(-) create mode 100644 services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionRequestMapperTest.kt diff --git a/libs/angular-motion-lib/src/test/kotlin/space/nstart/pcp/angularmotion/AngularMotionCalculatorSmokeTest.kt b/libs/angular-motion-lib/src/test/kotlin/space/nstart/pcp/angularmotion/AngularMotionCalculatorSmokeTest.kt index 96443bb..22c88eb 100644 --- a/libs/angular-motion-lib/src/test/kotlin/space/nstart/pcp/angularmotion/AngularMotionCalculatorSmokeTest.kt +++ b/libs/angular-motion-lib/src/test/kotlin/space/nstart/pcp/angularmotion/AngularMotionCalculatorSmokeTest.kt @@ -163,6 +163,83 @@ class AngularMotionCalculatorSmokeTest { } + @Test + fun chekSDI(){ + val ballistics = Ballistics() + val t = LocalDateTime.of(2023, 4, 12, 17, 41, 18, 0) + val ic = InitialConditions( + OrbitalPoint( + fromDateTime(t), + 1, + Vector3D( + -3101926.630, + 6018678.8, + 0.0, + ), + Vector3D( + 1287.651, + 663.634, + 7612.951, + ), + ), + 0.005, + 160.0 + ) + val result = ballistics.calculateOrbPoints( + ic, + ic.point.t, + ic.point.t + 86400 * 10 + ) + assertEquals(result, BallisticsError.OK) + if (result != BallisticsError.OK) + return + val stepper = ballistics.getStepper() + assert(stepper != null) + if (stepper == null) + return + val calculator = SmoothSDIPUUD(stepper, EarthType.PZ90d02) + val id = SurveyId( + oep = listOf(false, false, false, true), + t = fromDateTime(LocalDateTime.of(2023, 4, 16, 3, 48, 22, 0)), + b = 49.25824 * PI / 180, + l = 153.65914 * PI / 180, + h = 0.0, + duration = 69.0, + sdi = listOf(30.0), + azimuth = 25.567 * PI / 180, + uprAngle = 6.0 * PI / 180, + pointInCenter = false + ) + val rc = calculator.calculate(id) + + println(rc.mode) + println(rc.points.size) + println(rc.startTime) + + for (v in rc.points) { + println( + String.format( + Locale.US, + "%s %.3f %.3f %.3f , %s %s , %.7f %.7f %.7f , %.7f %.7f %.7f , %.3f", + toDateTime(v.t).format(outputTimeFormatter), + v.orientation.tang * 180 / PI, + v.orientation.kren * 180 / PI, + v.orientation.risk * 180 / PI, + v.groundPoint?.let { String.format(Locale.US, "%.3f", it.lat * 180 / PI) }, + v.groundPoint?.let { String.format(Locale.US, "%.3f", it.long * 180 / PI) }, + v.omega.x, + v.omega.y, + v.omega.z, + v.wd.x, + v.wd.y, + v.wd.z, + v.sdi + ) + ) + } + } + + @Test fun chekAzimuth(){ val ballistics = Ballistics() diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionController.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionController.kt index 73350c1..7e38397 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionController.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionController.kt @@ -1,6 +1,5 @@ package space.nstart.pcp.pcp_request_service.angular -import org.springframework.format.annotation.DateTimeFormat import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -8,6 +7,10 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import space.nstart.pcp.angularmotion.AngularMotionMode +import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException +import java.net.URLDecoder +import java.nio.charset.StandardCharsets import java.time.LocalDateTime @RestController @@ -17,20 +20,112 @@ class AngularMotionController( ) { @GetMapping("/satellites") fun availableSatellites( - @RequestParam(required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - time: LocalDateTime?, - ): List = angularMotionService.availableSatellites(time) + @RequestParam(required = false) time: String?, + ): List = angularMotionService.availableSatellites(parseTime(time, required = false)) @GetMapping("/satellites/{satelliteId}/flight-line") fun flightLine( @PathVariable satelliteId: Long, - @RequestParam - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - time: LocalDateTime, - ): List = angularMotionService.flightLine(satelliteId, time) + @RequestParam time: String, + ): List = angularMotionService.flightLine( + satelliteId, + parseTime(time, required = true) ?: throw CustomValidationException("Не задано время для построения трассы"), + ) @PostMapping("/calculate") - fun calculate(@RequestBody request: AngularMotionCalculationRequestDTO): AngularMotionCalculationResultDTO = - angularMotionService.calculate(request) + fun calculate(@RequestBody request: Map): AngularMotionCalculationResultDTO = + angularMotionService.calculate(AngularMotionRequestMapper.calculationRequest(request)) + + private fun parseTime(value: String?, required: Boolean): LocalDateTime? { + val raw = value?.trim().orEmpty() + if (raw.isEmpty()) { + if (required) throw CustomValidationException("Не задан параметр time") + return null + } + val decoded = URLDecoder.decode(raw, StandardCharsets.UTF_8) + val normalized = if (decoded.length == 16) "$decoded:00" else decoded + return runCatching { LocalDateTime.parse(normalized) } + .getOrElse { + throw CustomValidationException( + "Некорректный формат времени '$decoded'. Ожидается yyyy-MM-dd'T'HH:mm или yyyy-MM-dd'T'HH:mm:ss", + ) + } + } +} + +internal object AngularMotionRequestMapper { + fun calculationRequest(payload: Map): AngularMotionCalculationRequestDTO = + AngularMotionCalculationRequestDTO( + satelliteId = payload.requiredLong("satelliteId"), + approximateTime = parseLocalDateTime(payload.requiredString("approximateTime"), "approximateTime"), + mode = payload.optionalEnum("mode") ?: AngularMotionMode.CONST_ORIENT, + latitudeDeg = payload.requiredDouble("latitudeDeg"), + longitudeDeg = payload.requiredDouble("longitudeDeg"), + heightM = payload.optionalDouble("heightM") ?: 0.0, + durationSec = payload.requiredDouble("durationSec"), + leadAngleDeg = payload.optionalDouble("leadAngleDeg") ?: 0.0, + azimuthDeg = payload.optionalDouble("azimuthDeg") ?: 0.0, + sdi = payload.optionalDouble("sdi"), + pointInCenter = payload.optionalBoolean("pointInCenter") ?: false, + focusMm = payload.optionalDouble("focusMm"), + stepPuudSec = payload.optionalDouble("stepPuudSec"), + stepSdiSec = payload.optionalDouble("stepSdiSec"), + ) + + private fun Map.requiredString(field: String): String { + val value = this[field]?.toString()?.trim().orEmpty() + if (value.isEmpty()) { + throw CustomValidationException("Не задан параметр $field") + } + return value + } + + private fun Map.requiredLong(field: String): Long = + optionalLong(field) ?: throw CustomValidationException("Не задан параметр $field") + + private fun Map.requiredDouble(field: String): Double = + optionalDouble(field) ?: throw CustomValidationException("Не задан параметр $field") + + private fun Map.optionalLong(field: String): Long? { + val value = this[field] ?: return null + return when (value) { + is Number -> value.toLong() + is String -> value.trim().takeIf { it.isNotEmpty() }?.toLongOrNull() + else -> null + } ?: throw CustomValidationException("Параметр $field должен быть целым числом") + } + + private fun Map.optionalDouble(field: String): Double? { + val value = this[field] ?: return null + return when (value) { + is Number -> value.toDouble() + is String -> value.trim().takeIf { it.isNotEmpty() }?.toDoubleOrNull() + else -> null + }?.takeIf { it.isFinite() } ?: throw CustomValidationException("Параметр $field должен быть числом") + } + + private fun Map.optionalBoolean(field: String): Boolean? { + val value = this[field] ?: return null + return when (value) { + is Boolean -> value + is String -> value.trim().takeIf { it.isNotEmpty() }?.toBooleanStrictOrNull() + else -> null + } ?: throw CustomValidationException("Параметр $field должен быть true или false") + } + + private fun Map.optionalEnum(field: String): AngularMotionMode? { + val value = this[field]?.toString()?.trim()?.takeIf { it.isNotEmpty() } ?: return null + return runCatching { AngularMotionMode.valueOf(value) } + .getOrElse { throw CustomValidationException("Некорректный режим ПУУД '$value'") } + } + + private fun parseLocalDateTime(value: String, field: String): LocalDateTime { + val normalized = if (value.length == 16) "$value:00" else value + return runCatching { LocalDateTime.parse(normalized) } + .getOrElse { + throw CustomValidationException( + "Некорректный формат времени $field='$value'. Ожидается yyyy-MM-dd'T'HH:mm или yyyy-MM-dd'T'HH:mm:ss", + ) + } + } } diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionService.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionService.kt index 750b009..bdd5929 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionService.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionService.kt @@ -35,7 +35,11 @@ class AngularMotionService( ) } .toMap() - return satelliteService.getOrbitAvailability(time) + val availability = time + ?.let { satelliteService.getOrbitAvailabilityForDate(it) } + ?: satelliteService.getOrbitAvailability(null) + + return availability .map { availability -> val satellite = satellitesByBallisticsId[availability.satelliteId] AngularMotionSatelliteDTO( diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/PDCMRepository.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/PDCMRepository.kt index 766a9bd..7c5d5ce 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/PDCMRepository.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/repository/PDCMRepository.kt @@ -46,4 +46,22 @@ interface PDCMRepository : JpaRepository{ """ ) fun findSatelliteOrbitAvailabilityAtTime(@Param("time") time: LocalDateTime): List + + @Query( + """ + select new space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO( + p.satelliteId, + min(p.time), + max(p.time) + ) + from PDCMEntity p + where p.time >= :dayStart and p.time < :dayEnd + group by p.satelliteId + order by p.satelliteId + """ + ) + fun findSatelliteOrbitAvailabilityForDate( + @Param("dayStart") dayStart: LocalDateTime, + @Param("dayEnd") dayEnd: LocalDateTime + ): List } diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt index 74d027b..015437a 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt @@ -773,6 +773,11 @@ class SatelliteService { pdcmRepository.findSatelliteOrbitAvailabilityAtTime(time) } + fun getOrbitAvailabilityForDate(time: LocalDateTime): List { + val dayStart = time.toLocalDate().atStartOfDay() + return pdcmRepository.findSatelliteOrbitAvailabilityForDate(dayStart, dayStart.plusDays(1)) + } + fun getFL(id : Long, tn : LocalDateTime?, tk : LocalDateTime?, step : Double?) = flightLineRepository.findBySatelliteIdAndTimeBetween(id, tn?: LocalDateTime.now(), @@ -924,4 +929,3 @@ data class SatelliteBallisticsDeletionSummary( - diff --git a/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionRequestMapperTest.kt b/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionRequestMapperTest.kt new file mode 100644 index 0000000..0cd6423 --- /dev/null +++ b/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionRequestMapperTest.kt @@ -0,0 +1,70 @@ +package space.nstart.pcp.pcp_request_service.angular + +import org.junit.jupiter.api.assertThrows +import space.nstart.pcp.angularmotion.AngularMotionMode +import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException +import java.time.LocalDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class AngularMotionRequestMapperTest { + @Test + fun `maps calculation payload sent by UI`() { + val request = AngularMotionRequestMapper.calculationRequest( + mapOf( + "satelliteId" to 123L, + "approximateTime" to "2026-06-15T10:30:00", + "mode" to "CONST_ORIENT", + "latitudeDeg" to 55.75, + "longitudeDeg" to 37.61, + "heightM" to 0, + "durationSec" to 60, + "leadAngleDeg" to 0, + "azimuthDeg" to 0, + "focusMm" to 5500, + "stepPuudSec" to 0.125, + "stepSdiSec" to 20, + ) + ) + + assertEquals(123L, request.satelliteId) + assertEquals(LocalDateTime.of(2026, 6, 15, 10, 30), request.approximateTime) + assertEquals(AngularMotionMode.CONST_ORIENT, request.mode) + assertEquals(55.75, request.latitudeDeg) + assertEquals(37.61, request.longitudeDeg) + assertFalse(request.pointInCenter) + } + + @Test + fun `maps datetime without seconds`() { + val request = AngularMotionRequestMapper.calculationRequest( + mapOf( + "satelliteId" to 123L, + "approximateTime" to "2026-06-15T10:30", + "latitudeDeg" to 55.75, + "longitudeDeg" to 37.61, + "durationSec" to 60, + ) + ) + + assertEquals(LocalDateTime.of(2026, 6, 15, 10, 30), request.approximateTime) + assertEquals(AngularMotionMode.CONST_ORIENT, request.mode) + } + + @Test + fun `rejects unknown calculation mode with validation error`() { + assertThrows { + AngularMotionRequestMapper.calculationRequest( + mapOf( + "satelliteId" to 123L, + "approximateTime" to "2026-06-15T10:30:00", + "mode" to "UNKNOWN", + "latitudeDeg" to 55.75, + "longitudeDeg" to 37.61, + "durationSec" to 60, + ) + ) + } + } +} diff --git a/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/repository/PDCMRepositoryTest.kt b/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/repository/PDCMRepositoryTest.kt index 6e6d2fc..8df1b40 100644 --- a/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/repository/PDCMRepositoryTest.kt +++ b/services/pcp-ballistics-service/src/test/kotlin/space/nstart/pcp/pcp_request_service/repository/PDCMRepositoryTest.kt @@ -82,6 +82,25 @@ class PDCMRepositoryTest { assertEquals(LocalDateTime.of(2026, 4, 14, 12, 0), actual.single().timeStop) } + @Test + fun `find satellite orbit availability for date returns satellites with points on requested date`() { + persistPoint(11L, LocalDateTime.of(2026, 4, 14, 10, 0)) + persistPoint(11L, LocalDateTime.of(2026, 4, 14, 12, 0)) + persistPoint(22L, LocalDateTime.of(2026, 4, 14, 23, 30)) + persistPoint(33L, LocalDateTime.of(2026, 4, 15, 9, 30)) + + val actual = repository.findSatelliteOrbitAvailabilityForDate( + LocalDateTime.of(2026, 4, 14, 0, 0), + LocalDateTime.of(2026, 4, 15, 0, 0) + ) + + assertEquals(listOf(11L, 22L), actual.map { it.satelliteId }) + assertEquals(LocalDateTime.of(2026, 4, 14, 10, 0), actual[0].timeStart) + assertEquals(LocalDateTime.of(2026, 4, 14, 12, 0), actual[0].timeStop) + assertEquals(LocalDateTime.of(2026, 4, 14, 23, 30), actual[1].timeStart) + assertEquals(LocalDateTime.of(2026, 4, 14, 23, 30), actual[1].timeStop) + } + @Test fun `satellite initial conditions repository returns last calculated condition`() { val oldCondition = satelliteInitialConditionRepository.save(sampleInitialCondition(11L, 1L)) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/DynamicPlanService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/DynamicPlanService.kt index 5bd5ba4..e737cca 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/DynamicPlanService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/DynamicPlanService.kt @@ -9,6 +9,7 @@ import space.nstart.pcp.slots_service.configuration.CustomErrorException import space.nstart.pcp.slots_service.dto.dynamicplan.DynamicPlanCalculationRequestDTO import tools.jackson.databind.JsonNode import tools.jackson.databind.ObjectMapper +import java.net.URI import java.net.URLEncoder import java.nio.charset.StandardCharsets import java.util.UUID @@ -118,7 +119,7 @@ class DynamicPlanService(webClientBuilderProvider: ObjectProvider status.isError }) { response -> response.bodyToMono(String::class.java) @@ -132,7 +133,7 @@ class DynamicPlanService(webClientBuilderProvider: ObjectProvider status.isError }) { response -> response.bodyToMono(String::class.java) diff --git a/services/pcp-ui-service/src/main/resources/static/angular_motion_scripts.js b/services/pcp-ui-service/src/main/resources/static/angular_motion_scripts.js index d49f67d..3aa99e0 100644 --- a/services/pcp-ui-service/src/main/resources/static/angular_motion_scripts.js +++ b/services/pcp-ui-service/src/main/resources/static/angular_motion_scripts.js @@ -5,6 +5,10 @@ flightLine: [], picking: false, result: null, + viewer: null, + mapDataSource: null, + pickHandler: null, + cursorCoordsPanel: null, }; function el(id) { @@ -56,6 +60,15 @@ return value && value.length === 16 ? `${value}:00` : value; } + function toDateTimeInputValue(value) { + return text(value).slice(0, 16); + } + + function normalizeDateTimeSeconds(value) { + const textValue = text(value); + return textValue && textValue.length === 16 ? `${textValue}:00` : textValue; + } + function formatDateTime(value) { return text(value, '-').replace('T', ' '); } @@ -96,9 +109,22 @@ select.value = previous; } state.selectedSatellite = satellites.find(item => String(item.satelliteId) === select.value) || satellites[0]; + ensureTimeInsideSelectedSatelliteAvailability(); renderSatelliteMeta(); } + function ensureTimeInsideSelectedSatelliteAvailability() { + const sat = state.selectedSatellite; + if (!sat?.availabilityStart || !sat?.availabilityStop) return; + + const current = normalizeDateTimeSeconds(localDateTimeValue()); + const start = normalizeDateTimeSeconds(sat.availabilityStart); + const stop = normalizeDateTimeSeconds(sat.availabilityStop); + if (!current || current < start || current > stop) { + el('angular-motion-time').value = toDateTimeInputValue(start); + } + } + function renderSatelliteMeta() { const sat = state.selectedSatellite; el('angular-motion-satellite-meta').textContent = sat @@ -118,98 +144,211 @@ el('angular-motion-map-meta').textContent = state.flightLine.length > 0 ? `Трасса и полосы обзора на интервале ${formatDateTime(time)} ± 5 минут.` : 'Нет данных трассы полета для выбранного КА и времени.'; - drawMap(); + drawMap(true); } - function lonToX(lon, width) { - return (Number(lon) + 180) / 360 * width; + function initMap() { + if (typeof Cesium === 'undefined') { + el('angular-motion-map-meta').textContent = 'Библиотека Cesium не загружена.'; + return; + } + + Cesium.Camera.DEFAULT_VIEW_RECTANGLE = Cesium.Rectangle.fromDegrees(20, 35, 190, 80); + state.viewer = new Cesium.Viewer('angular-motion-map', { + animation: false, + timeline: false, + fullscreenButton: false, + geocoder: false, + homeButton: false, + infoBox: false, + navigationHelpButton: false, + sceneModePicker: false, + selectionIndicator: false, + baseLayerPicker: false, + sceneMode: Cesium.SceneMode.SCENE2D, + ...naturalEarthLayerOptions(), + }); + state.viewer.scene.globe.enableLighting = false; + state.viewer.scene.requestRenderMode = true; + state.mapDataSource = new Cesium.CustomDataSource('angular-motion-map-data'); + state.viewer.dataSources.add(state.mapDataSource); + initCursorCoordinates(); + state.pickHandler = new Cesium.ScreenSpaceEventHandler(state.viewer.scene.canvas); + state.pickHandler.setInputAction(function (movement) { + if (!state.picking) return; + const cartesian = pickGlobePoint(movement.position); + if (!cartesian) return; + const cartographic = Cesium.Cartographic.fromCartesian(cartesian); + el('angular-motion-lat').value = Cesium.Math.toDegrees(cartographic.latitude).toFixed(6); + el('angular-motion-lon').value = Cesium.Math.toDegrees(cartographic.longitude).toFixed(6); + setPicking(false); + drawMap(false); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + drawMap(false); } - function latToY(lat, height) { - return (90 - Number(lat)) / 180 * height; + function initCursorCoordinates() { + state.cursorCoordsPanel = document.createElement('div'); + state.cursorCoordsPanel.className = 'angular-motion-cursor-coords-panel'; + state.cursorCoordsPanel.textContent = 'Координаты: наведите курсор на карту'; + state.viewer.cesiumWidget.container.appendChild(state.cursorCoordsPanel); + + state.viewer.scene.canvas.addEventListener('mousemove', event => { + const rect = state.viewer.scene.canvas.getBoundingClientRect(); + const screenPosition = new Cesium.Cartesian2( + event.clientX - rect.left, + event.clientY - rect.top + ); + state.cursorCoordsPanel.textContent = formatCursorCoordinates(screenPosition); + }); + + state.viewer.scene.canvas.addEventListener('mouseleave', () => { + state.cursorCoordsPanel.textContent = 'Координаты: курсор вне карты'; + }); } - function canvasToLonLat(event) { - const canvas = el('angular-motion-map'); - const rect = canvas.getBoundingClientRect(); - const scaleX = canvas.width / rect.width; - const scaleY = canvas.height / rect.height; - const x = (event.clientX - rect.left) * scaleX; - const y = (event.clientY - rect.top) * scaleY; + function naturalEarthLayerOptions() { + const url = '/webjars/cesium/1.107.2/Build/Cesium/Assets/Textures/NaturalEarthII'; + if (Cesium.ImageryLayer?.fromProviderAsync && Cesium.TileMapServiceImageryProvider?.fromUrl) { + return { + baseLayer: Cesium.ImageryLayer.fromProviderAsync( + Cesium.TileMapServiceImageryProvider.fromUrl(url) + ), + }; + } + return { - lon: x / canvas.width * 360 - 180, - lat: 90 - y / canvas.height * 180, + imageryProvider: new Cesium.TileMapServiceImageryProvider({ url }), }; } - function drawGrid(ctx, width, height) { - ctx.clearRect(0, 0, width, height); - ctx.fillStyle = '#f8fbff'; - ctx.fillRect(0, 0, width, height); - ctx.strokeStyle = '#e2e8f0'; - ctx.lineWidth = 1; - for (let lon = -180; lon <= 180; lon += 30) { - const x = lonToX(lon, width); - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, height); - ctx.stroke(); + function pickGlobePoint(position) { + if (!state.viewer || !position) return null; + const scene = state.viewer.scene; + if (scene.pickPositionSupported) { + const picked = scene.pickPosition(position); + if (Cesium.defined(picked)) return picked; } - for (let lat = -60; lat <= 60; lat += 30) { - const y = latToY(lat, height); - ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(width, y); - ctx.stroke(); - } - ctx.fillStyle = '#94a3b8'; - ctx.font = '12px sans-serif'; - ctx.fillText('Широта / долгота, схематичная проекция', 12, 20); + return state.viewer.camera.pickEllipsoid(position, scene.globe.ellipsoid); } - function drawLine(ctx, points, latKey, lonKey, color, width) { - const valid = points.filter(point => Number.isFinite(Number(point[latKey])) && Number.isFinite(Number(point[lonKey]))); - if (valid.length < 2) return; - ctx.strokeStyle = color; - ctx.lineWidth = width; - ctx.beginPath(); - valid.forEach((point, index) => { - const x = lonToX(point[lonKey], ctx.canvas.width); - const y = latToY(point[latKey], ctx.canvas.height); - if (index === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + function formatDegree(value, positiveSuffix, negativeSuffix) { + if (!Number.isFinite(value)) { + return '-'; + } + + const suffix = value >= 0 ? positiveSuffix : negativeSuffix; + return `${Math.abs(value).toFixed(6)}° ${suffix}`; + } + + function formatCursorCoordinates(screenPosition) { + const point = pickGlobePoint(screenPosition); + if (!Cesium.defined(point)) { + return 'Координаты: -'; + } + + const cartographic = state.viewer.scene.globe.ellipsoid.cartesianToCartographic(point); + if (!Cesium.defined(cartographic)) { + return 'Координаты: -'; + } + + const lon = Cesium.Math.toDegrees(cartographic.longitude); + const lat = Cesium.Math.toDegrees(cartographic.latitude); + return `Lon: ${formatDegree(lon, 'E', 'W')} | Lat: ${formatDegree(lat, 'N', 'S')}`; + } + + function validDegreesArray(points, latKey, lonKey) { + const result = []; + (Array.isArray(points) ? points : []).forEach(point => { + const lat = Number(point[latKey]); + const lon = Number(point[lonKey]); + if (Number.isFinite(lat) && Number.isFinite(lon)) { + result.push(lon, lat); + } }); - ctx.stroke(); + return result; } - function drawTarget(ctx) { + function addPolyline(points, latKey, lonKey, color, width, name) { + if (!state.mapDataSource) return; + const degrees = validDegreesArray(points, latKey, lonKey); + if (degrees.length < 4) return; + state.mapDataSource.entities.add({ + name, + polyline: { + positions: Cesium.Cartesian3.fromDegreesArray(degrees), + width, + material: color, + clampToGround: true, + }, + }); + } + + function addTarget() { + if (!state.mapDataSource) return; const lat = Number(el('angular-motion-lat').value); const lon = Number(el('angular-motion-lon').value); if (!Number.isFinite(lat) || !Number.isFinite(lon)) return; - const x = lonToX(lon, ctx.canvas.width); - const y = latToY(lat, ctx.canvas.height); - ctx.strokeStyle = '#dc2626'; - ctx.fillStyle = '#dc2626'; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.arc(x, y, 5, 0, Math.PI * 2); - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(x - 9, y); - ctx.lineTo(x + 9, y); - ctx.moveTo(x, y - 9); - ctx.lineTo(x, y + 9); - ctx.stroke(); + state.mapDataSource.entities.add({ + name: 'Точка съемки', + position: Cesium.Cartesian3.fromDegrees(lon, lat, 0), + point: { + pixelSize: 10, + color: Cesium.Color.RED, + outlineColor: Cesium.Color.WHITE, + outlineWidth: 2, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + }, + label: { + text: 'ЦЛМ', + font: '12px sans-serif', + fillColor: Cesium.Color.RED, + outlineColor: Cesium.Color.WHITE, + outlineWidth: 2, + style: Cesium.LabelStyle.FILL_AND_OUTLINE, + pixelOffset: new Cesium.Cartesian2(0, -18), + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + }, + }); } - function drawMap() { - const canvas = el('angular-motion-map'); - const ctx = canvas.getContext('2d'); - drawGrid(ctx, canvas.width, canvas.height); + function fitMapToData() { + if (!state.viewer) return; const points = Array.isArray(state.flightLine) ? state.flightLine : []; - drawLine(ctx, points, 'leftLatitudeDeg', 'leftLongitudeDeg', '#93c5fd', 1.5); - drawLine(ctx, points, 'rightLatitudeDeg', 'rightLongitudeDeg', '#93c5fd', 1.5); - drawLine(ctx, points, 'latitudeDeg', 'longitudeDeg', '#0f172a', 2.5); - drawTarget(ctx); + const lats = []; + const lons = []; + points.forEach(point => { + ['latitudeDeg', 'leftLatitudeDeg', 'rightLatitudeDeg'].forEach(key => { + const value = Number(point[key]); + if (Number.isFinite(value)) lats.push(value); + }); + ['longitudeDeg', 'leftLongitudeDeg', 'rightLongitudeDeg'].forEach(key => { + const value = Number(point[key]); + if (Number.isFinite(value)) lons.push(value); + }); + }); + if (lats.length === 0 || lons.length === 0) return; + const minLat = Math.max(-89.0, Math.min(...lats) - 1.0); + const maxLat = Math.min(89.0, Math.max(...lats) + 1.0); + const minLon = Math.max(-180.0, Math.min(...lons) - 1.0); + const maxLon = Math.min(180.0, Math.max(...lons) + 1.0); + if (maxLon - minLon > 180.0) return; + state.viewer.camera.flyTo({ + destination: Cesium.Rectangle.fromDegrees(minLon, minLat, maxLon, maxLat), + duration: 0.4, + }); + } + + function drawMap(fit = false) { + if (!state.mapDataSource || typeof Cesium === 'undefined') return; + state.mapDataSource.entities.removeAll(); + const points = Array.isArray(state.flightLine) ? state.flightLine : []; + addPolyline(points, 'leftLatitudeDeg', 'leftLongitudeDeg', Cesium.Color.CORNFLOWERBLUE.withAlpha(0.85), 2, 'Левая граница полосы'); + addPolyline(points, 'rightLatitudeDeg', 'rightLongitudeDeg', Cesium.Color.CORNFLOWERBLUE.withAlpha(0.85), 2, 'Правая граница полосы'); + addPolyline(points, 'latitudeDeg', 'longitudeDeg', Cesium.Color.YELLOW, 3, 'Трасса полета'); + addTarget(); + if (fit) fitMapToData(); + state.viewer.scene.requestRender(); } function setPicking(value) { @@ -228,6 +367,7 @@ } function payloadFromForm() { + ensureTimeInsideSelectedSatelliteAvailability(); const mode = el('angular-motion-mode').value; const payload = { satelliteId: Number(el('angular-motion-satellite').value), @@ -322,28 +462,21 @@ function init() { el('angular-motion-time').value = nowLocalInput(); + initMap(); el('angular-motion-form').addEventListener('submit', calculate); el('angular-motion-refresh-satellites').addEventListener('click', () => loadSatellites().catch(error => showAlert(error.message || String(error)))); el('angular-motion-time').addEventListener('change', () => loadSatellites().catch(error => showAlert(error.message || String(error)))); el('angular-motion-satellite').addEventListener('change', () => { state.selectedSatellite = state.satellites.find(item => String(item.satelliteId) === el('angular-motion-satellite').value) || null; + ensureTimeInsideSelectedSatelliteAvailability(); renderSatelliteMeta(); loadFlightLine().catch(error => showAlert(error.message || String(error))); }); el('angular-motion-mode').addEventListener('change', modeChanged); el('angular-motion-pick-from-map').addEventListener('click', () => setPicking(!state.picking)); - el('angular-motion-map').addEventListener('click', event => { - if (!state.picking) return; - const coord = canvasToLonLat(event); - el('angular-motion-lat').value = coord.lat.toFixed(6); - el('angular-motion-lon').value = coord.lon.toFixed(6); - setPicking(false); - drawMap(); - }); - ['angular-motion-lat', 'angular-motion-lon'].forEach(id => el(id).addEventListener('input', drawMap)); + ['angular-motion-lat', 'angular-motion-lon'].forEach(id => el(id).addEventListener('input', () => drawMap(false))); el('angular-motion-export-csv').addEventListener('click', exportCsv); modeChanged(); - drawMap(); loadSatellites().catch(error => showAlert(error.message || String(error))); } diff --git a/services/pcp-ui-service/src/main/resources/static/css/angular-motion.css b/services/pcp-ui-service/src/main/resources/static/css/angular-motion.css index 18f83d3..ad19eb3 100644 --- a/services/pcp-ui-service/src/main/resources/static/css/angular-motion.css +++ b/services/pcp-ui-service/src/main/resources/static/css/angular-motion.css @@ -23,18 +23,48 @@ } .angular-motion-map { + position: relative; width: 100%; + height: 420px; min-height: 320px; border: 1px solid #dbe4f0; border-radius: 0.5rem; background: #f8fbff; - cursor: crosshair; + overflow: hidden; } .angular-motion-map.angular-motion-pick-active { outline: 3px solid rgba(13, 110, 253, 0.25); } +.angular-motion-map .cesium-viewer, +.angular-motion-map .cesium-widget, +.angular-motion-map .cesium-widget canvas { + width: 100%; + height: 100%; +} + +.angular-motion-map.angular-motion-pick-active, +.angular-motion-map.angular-motion-pick-active canvas { + cursor: crosshair; +} + +.angular-motion-cursor-coords-panel { + position: absolute; + top: 10px; + right: 10px; + z-index: 1000; + max-width: calc(100% - 20px); + padding: 6px 10px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.75); + color: #fff; + font-size: 12px; + line-height: 1.3; + pointer-events: none; + white-space: nowrap; +} + .angular-motion-result-card { min-height: 360px; } diff --git a/services/pcp-ui-service/src/main/resources/templates/angular-motion.html b/services/pcp-ui-service/src/main/resources/templates/angular-motion.html index 30ee73c..974b243 100644 --- a/services/pcp-ui-service/src/main/resources/templates/angular-motion.html +++ b/services/pcp-ui-service/src/main/resources/templates/angular-motion.html @@ -4,6 +4,7 @@ layout:decorate="~{layouts/main-layout}"> ПУУД + @@ -123,7 +124,7 @@
- +
@@ -166,6 +167,7 @@ +