From ce32a0307297d2e1e47cb92dd1039a1596657874 Mon Sep 17 00:00:00 2001 From: emelianov Date: Mon, 15 Jun 2026 10:13:27 +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 --- config-repo/pcp-ballistics-service.yaml | 2 + .../pcp-ballistics-service/build.gradle.kts | 1 + .../angular/AngularMotionController.kt | 36 ++ .../angular/AngularMotionDto.kt | 67 ++++ .../angular/AngularMotionService.kt | 182 +++++++++ .../service/SatelliteCatalogClient.kt | 34 ++ .../pcp/complan/service/BallTaskServce.kt | 15 + .../controller/DynamicPlanController.kt | 33 ++ .../service/DynamicPlanService.kt | 51 +++ .../static/angular_motion_scripts.js | 351 ++++++++++++++++++ .../resources/static/css/angular-motion.css | 70 ++++ .../resources/templates/angular-motion.html | 172 +++++++++ .../templates/fragments/base/menu.html | 3 + 13 files changed, 1017 insertions(+) create mode 100644 services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionController.kt create mode 100644 services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionDto.kt create mode 100644 services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionService.kt create mode 100644 services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteCatalogClient.kt create mode 100644 services/pcp-ui-service/src/main/resources/static/angular_motion_scripts.js create mode 100644 services/pcp-ui-service/src/main/resources/static/css/angular-motion.css create mode 100644 services/pcp-ui-service/src/main/resources/templates/angular-motion.html diff --git a/config-repo/pcp-ballistics-service.yaml b/config-repo/pcp-ballistics-service.yaml index f08748b..ff8c69e 100644 --- a/config-repo/pcp-ballistics-service.yaml +++ b/config-repo/pcp-ballistics-service.yaml @@ -76,6 +76,7 @@ app: settings: calculation-interval: 7 stations-service: ${pcp.services.stations} + satellite-catalog-service: ${pcp.services.satellite-catalog} server: port: 8080 @@ -126,6 +127,7 @@ settings: calculation-interval: 28 ic-service: http://${pcp.network.host}:9080 stations-service: ${pcp.services.stations} + satellite-catalog-service: ${pcp.services.satellite-catalog} server: port: 7003 diff --git a/services/pcp-ballistics-service/build.gradle.kts b/services/pcp-ballistics-service/build.gradle.kts index d6bdbac..fd467b0 100644 --- a/services/pcp-ballistics-service/build.gradle.kts +++ b/services/pcp-ballistics-service/build.gradle.kts @@ -31,6 +31,7 @@ configurations { dependencies { implementation(project(":libs:pcp-types-lib")) implementation(project(":libs:ballistics-lib")) + implementation(project(":libs:angular-motion-lib")) // implementation("org.nstart.dep265:ballistics:2.4.0") implementation("${property("dep.spring.actuator")}") 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 new file mode 100644 index 0000000..73350c1 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionController.kt @@ -0,0 +1,36 @@ +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 +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 java.time.LocalDateTime + +@RestController +@RequestMapping("/api/angular-motion") +class AngularMotionController( + private val angularMotionService: AngularMotionService, +) { + @GetMapping("/satellites") + fun availableSatellites( + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + time: LocalDateTime?, + ): List = angularMotionService.availableSatellites(time) + + @GetMapping("/satellites/{satelliteId}/flight-line") + fun flightLine( + @PathVariable satelliteId: Long, + @RequestParam + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + time: LocalDateTime, + ): List = angularMotionService.flightLine(satelliteId, time) + + @PostMapping("/calculate") + fun calculate(@RequestBody request: AngularMotionCalculationRequestDTO): AngularMotionCalculationResultDTO = + angularMotionService.calculate(request) +} diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionDto.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionDto.kt new file mode 100644 index 0000000..7b914a6 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionDto.kt @@ -0,0 +1,67 @@ +package space.nstart.pcp.pcp_request_service.angular + +import space.nstart.pcp.angularmotion.AngularMotionMode +import java.time.LocalDateTime + +/** Запрос на ручной расчет программы управления угловым движением. */ +data class AngularMotionCalculationRequestDTO( + val satelliteId: Long, + val approximateTime: LocalDateTime, + val mode: AngularMotionMode = AngularMotionMode.CONST_ORIENT, + val latitudeDeg: Double, + val longitudeDeg: Double, + val heightM: Double = 0.0, + val durationSec: Double, + val leadAngleDeg: Double = 0.0, + val azimuthDeg: Double = 0.0, + val sdi: Double? = null, + val pointInCenter: Boolean = false, + val focusMm: Double? = null, + val stepPuudSec: Double? = null, + val stepSdiSec: Double? = null, +) + +data class AngularMotionSatelliteDTO( + val satelliteId: Long, + val code: String, + val name: String, + val typeCode: String, + val availabilityStart: LocalDateTime, + val availabilityStop: LocalDateTime, +) + +data class AngularMotionFlightLinePointDTO( + val time: LocalDateTime, + val revolution: Long, + val latitudeDeg: Double, + val longitudeDeg: Double, + val leftLatitudeDeg: Double, + val leftLongitudeDeg: Double, + val rightLatitudeDeg: Double, + val rightLongitudeDeg: Double, +) + +data class AngularMotionCalculationResultDTO( + val mode: AngularMotionMode, + val satelliteId: Long, + val approximateTime: LocalDateTime, + val startTime: LocalDateTime, + val points: List, +) + +data class AngularMotionPointDTO( + val time: LocalDateTime, + val revolution: Long, + val tangDeg: Double, + val krenDeg: Double, + val riskDeg: Double, + val groundLatitudeDeg: Double?, + val groundLongitudeDeg: Double?, + val omegaX: Double, + val omegaY: Double, + val omegaZ: Double, + val wdX: Double, + val wdY: Double, + val wdZ: Double, + val sdi: Double, +) 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 new file mode 100644 index 0000000..750b009 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/angular/AngularMotionService.kt @@ -0,0 +1,182 @@ +package space.nstart.pcp.pcp_request_service.angular + +import ballistics.orbitalPoints.timeStepper.RungeStepper +import ballistics.types.EarthType +import ballistics.types.OrbitalPoint +import ballistics.utils.fromDateTime +import ballistics.utils.toDateTime +import ballistics.utils.math.Vector3D +import org.springframework.stereotype.Service +import space.nstart.pcp.angularmotion.AngularMotionCalculatorFactory +import space.nstart.pcp.angularmotion.AngularMotionConfig +import space.nstart.pcp.angularmotion.AngularMotionMode +import space.nstart.pcp.angularmotion.SurveyId +import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException +import space.nstart.pcp.pcp_request_service.service.SatelliteCatalogClient +import space.nstart.pcp.pcp_request_service.service.SatelliteService +import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.roundToLong + +@Service +class AngularMotionService( + private val satelliteService: SatelliteService, + private val satelliteCatalogClient: SatelliteCatalogClient, +) { + fun availableSatellites(time: java.time.LocalDateTime?): List { + val satellitesByBallisticsId = satelliteCatalogClient.getSatellites() + .flatMap { satellite -> + listOfNotNull( + satellite.id to satellite, + satellite.noradId?.let { it to satellite }, + ) + } + .toMap() + return satelliteService.getOrbitAvailability(time) + .map { availability -> + val satellite = satellitesByBallisticsId[availability.satelliteId] + AngularMotionSatelliteDTO( + satelliteId = availability.satelliteId, + code = satellite?.code ?: availability.satelliteId.toString(), + name = satellite?.name ?: "КА ${availability.satelliteId}", + typeCode = satellite?.typeCode.orEmpty(), + availabilityStart = availability.timeStart, + availabilityStop = availability.timeStop, + ) + } + .sortedWith(compareBy { it.code }.thenBy { it.satelliteId }) + } + + fun flightLine(satelliteId: Long, time: java.time.LocalDateTime): List { + val start = time.minusMinutes(MAP_INTERVAL_MINUTES) + val stop = time.plusMinutes(MAP_INTERVAL_MINUTES) + return satelliteService.getFL(satelliteId, start, stop, 60.0).map { point -> + AngularMotionFlightLinePointDTO( + time = point.time, + revolution = point.revolution, + latitudeDeg = point.lat, + longitudeDeg = point.long, + leftLatitudeDeg = point.latLeft, + leftLongitudeDeg = point.longLeft, + rightLatitudeDeg = point.latRight, + rightLongitudeDeg = point.longRight, + ) + } + } + + fun calculate(request: AngularMotionCalculationRequestDTO): AngularMotionCalculationResultDTO { + validate(request) + val defaultConfig = AngularMotionConfig() + val config = AngularMotionConfig( + focus = request.focusMm ?: defaultConfig.focus, + stepPuud = request.stepPuudSec ?: defaultConfig.stepPuud, + stepSdi = request.stepSdiSec ?: defaultConfig.stepSdi, + ) + val sourcePoints = loadOrbitalPoints(request, config) + val stepper = RungeStepper(sourcePoints.toMutableList(), EarthType.PZ90d02) + val calculator = AngularMotionCalculatorFactory.create(request.mode, stepper, EarthType.PZ90d02, config) + val surveyId = request.toSurveyId() + val result = try { + calculator.calculate(surveyId) + } catch (error: RuntimeException) { + throw CustomValidationException(error.message ?: "Ошибка расчета ПУУД") + } + + return AngularMotionCalculationResultDTO( + mode = result.mode, + satelliteId = request.satelliteId, + approximateTime = request.approximateTime, + startTime = toDateTime(result.startTime), + points = result.points.map { point -> + AngularMotionPointDTO( + time = toDateTime(point.t), + revolution = point.orbitalPoint.vit.toLong(), + tangDeg = point.orientation.tang.toDegrees(), + krenDeg = point.orientation.kren.toDegrees(), + riskDeg = point.orientation.risk.toDegrees(), + groundLatitudeDeg = point.groundPoint?.lat?.toDegrees(), + groundLongitudeDeg = point.groundPoint?.long?.toDegrees(), + omegaX = point.omega.x, + omegaY = point.omega.y, + omegaZ = point.omega.z, + wdX = point.wd.x, + wdY = point.wd.y, + wdZ = point.wd.z, + sdi = point.sdi, + ) + }, + ) + } + + private fun validate(request: AngularMotionCalculationRequestDTO) { + if (request.durationSec <= 0.0) { + throw CustomValidationException("durationSec must be positive") + } + if (request.latitudeDeg !in -90.0..90.0) { + throw CustomValidationException("latitudeDeg must be in [-90; 90]") + } + if (request.longitudeDeg !in -180.0..180.0) { + throw CustomValidationException("longitudeDeg must be in [-180; 180]") + } + if ((request.mode == AngularMotionMode.AZIMUTH || request.mode == AngularMotionMode.SMOOTH_SDI) && + (request.sdi == null || abs(request.sdi) < 1.0e-4) + ) { + throw CustomValidationException("Для азимутального режима и Smooth SDI требуется ненулевая СДИ") + } + } + + private fun loadOrbitalPoints( + request: AngularMotionCalculationRequestDTO, + config: AngularMotionConfig, + ): List { + val reserveBeforeSec = 240L + val reserveAfterSec = max(240L, request.durationSec.roundToLong() + 240L) + val stepMs = ((config.stepPuud * 1000.0).roundToLong()).coerceAtLeast(1L) + val response = satelliteService.exactTime( + request.satelliteId, + ExactTimePositionRequestDTO( + time = request.approximateTime.minusSeconds(reserveBeforeSec), + timeStop = request.approximateTime.plusSeconds(reserveAfterSec), + stepMs = stepMs, + ), + ).collectList().block().orEmpty() + + if (response.isEmpty()) { + throw CustomValidationException( + "Баллистика не вернула точки орбиты для КА ${request.satelliteId} на интервале расчета", + ) + } + return response.map { it.toOrbitalPoint() } + } + + private fun AngularMotionCalculationRequestDTO.toSurveyId(): SurveyId = SurveyId( + oep = listOf(true, false, false, false), + t = fromDateTime(approximateTime), + b = latitudeDeg.toRadians(), + l = longitudeDeg.toRadians(), + h = heightM, + duration = durationSec, + sdi = sdi?.let { listOf(it) } ?: emptyList(), + azimuth = azimuthDeg.toRadians(), + uprAngle = leadAngleDeg.toRadians(), + pointInCenter = pointInCenter, + ) + + private fun OrbPointDTO.toOrbitalPoint(): OrbitalPoint = OrbitalPoint( + t = fromDateTime(time), + vit = revolution.toInt(), + r = Vector3D(x, y, z), + v = Vector3D(vx, vy, vz), + ) + + private fun Double.toRadians(): Double = this * PI / 180.0 + + private fun Double.toDegrees(): Double = this * 180.0 / PI + + private companion object { + const val MAP_INTERVAL_MINUTES = 5L + } +} diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteCatalogClient.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteCatalogClient.kt new file mode 100644 index 0000000..4ed9059 --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteCatalogClient.kt @@ -0,0 +1,34 @@ +package space.nstart.pcp.pcp_request_service.service + +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO + +/** Клиент к pcp-satellite-catalog-service для отображения человекочитаемых данных КА. */ +@Service +class SatelliteCatalogClient(webClientBuilderProvider: ObjectProvider) { + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + private val logger: Logger = LoggerFactory.getLogger(this::class.java) + + @Value("\${settings.satellite-catalog-service:pcp-satellite-catalog-service}") + val url = "" + + fun getSatellites(): List = + runCatching { + webClientBuilder.build() + .get() + .uri("$url/api/satellites") + .retrieve() + .bodyToFlux(SatelliteSummaryDTO::class.java) + .collectList() + .block() + ?: emptyList() + }.getOrElse { e -> + logger.error("Не удалось получить список КА из $url/api/satellites: ${e.message}") + emptyList() + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/BallTaskServce.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/BallTaskServce.kt index 3821b91..7fd96b8 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/BallTaskServce.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/BallTaskServce.kt @@ -32,6 +32,20 @@ class BallTaskServce { .body(SatelliteOrbitAvailabilityDTO::class.java) ?: throw IllegalStateException("Пустой ответ баллистики для доступности ПДЦМ КА $noradId") + fun getOrbitAvailability(time: LocalDateTime? = null): List = + RestClient.create() + .get() + .uri( + UriComponentsBuilder + .fromUriString(ballisticsServiceUrl) + .path("/api/satellites/orbit/availability") + .apply { time?.let { queryParam("time", it) } } + .toUriString() + ) + .retrieve() + .body(ORBIT_AVAILABILITY_LIST_TYPE) + .orEmpty() + fun getFlightLine(noradId: Long, timeStart : LocalDateTime, timeStop : LocalDateTime): List = RestClient.create() .get() @@ -93,6 +107,7 @@ class BallTaskServce { private companion object { val FLIGHT_LINE_LIST_TYPE = object : ParameterizedTypeReference>() {} val ORB_POINT_LIST_TYPE = object : ParameterizedTypeReference>() {} + val ORBIT_AVAILABILITY_LIST_TYPE = object : ParameterizedTypeReference>() {} val SQUARE_VIEW_PARAM_LIST_TYPE = object : ParameterizedTypeReference>() {} } } diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/DynamicPlanController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/DynamicPlanController.kt index e441de7..afdb7c3 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/DynamicPlanController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/DynamicPlanController.kt @@ -20,6 +20,14 @@ class DynamicPlanPageController { fun dynamicPlanPage(): String = "dynamic-plan" } + +@Controller +class AngularMotionPageController { + + @GetMapping("/angular-motion") + fun angularMotionPage(): String = "angular-motion" +} + @RestController @RequestMapping("/api/dynamic-plan") class DynamicPlanRestController( @@ -65,3 +73,28 @@ class DynamicPlanRestController( @RequestParam(defaultValue = "0") offset: Int ) = dynamicPlanService.getIntervals(runId, limit, offset) } + + +@RestController +@RequestMapping("/api/angular-motion") +class AngularMotionRestController( + private val dynamicPlanService: DynamicPlanService +) { + @GetMapping("/satellites") + @ResponseBody + fun satellites(@RequestParam(required = false) time: String?) = + dynamicPlanService.getAngularMotionSatellites(time) + + @GetMapping("/satellites/{satelliteId}/flight-line") + @ResponseBody + fun flightLine( + @PathVariable satelliteId: Long, + @RequestParam time: String + ) = dynamicPlanService.getAngularMotionFlightLine(satelliteId, time) + + @PostMapping("/calculate") + @ResponseBody + fun calculate(@RequestBody request: Map) = + dynamicPlanService.calculateAngularMotion(request) +} + 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 d16a264..5bd5ba4 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 @@ -20,6 +20,9 @@ class DynamicPlanService(webClientBuilderProvider: ObjectProvider value.isNotBlank() } + ?.let { value -> "?time=${URLEncoder.encode(value, StandardCharsets.UTF_8)}" } + ?: "" + + return webClientBuilder.build() + .get() + .uri("$ballisticsServiceUrl/api/angular-motion/satellites$timeParam") + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(JsonNode::class.java) + .block() ?: ObjectMapper().createArrayNode() + } + + fun getAngularMotionFlightLine(satelliteId: Long, time: String): JsonNode = + webClientBuilder.build() + .get() + .uri("$ballisticsServiceUrl/api/angular-motion/satellites/$satelliteId/flight-line?time=${URLEncoder.encode(time, StandardCharsets.UTF_8)}") + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(JsonNode::class.java) + .block() ?: ObjectMapper().createArrayNode() + + fun calculateAngularMotion(request: Any): JsonNode = + webClientBuilder.build() + .post() + .uri("$ballisticsServiceUrl/api/angular-motion/calculate") + .header("X-Requested-By", "pcp-ui-service") + .bodyValue(request) + .retrieve() + .onStatus({ status -> status.isError }) { response -> + response.bodyToMono(String::class.java) + .defaultIfEmpty("error") + .flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) } + } + .bodyToMono(JsonNode::class.java) + .block() ?: ObjectMapper().createObjectNode() + private fun extractErrorMessage(body: String): String { val text = body.trim() if (text.isEmpty()) return "error" 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 new file mode 100644 index 0000000..d49f67d --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/angular_motion_scripts.js @@ -0,0 +1,351 @@ +(function () { + const state = { + satellites: [], + selectedSatellite: null, + flightLine: [], + picking: false, + result: null, + }; + + function el(id) { + return document.getElementById(id); + } + + function text(value, fallback = '') { + if (value === null || value === undefined || value === '') return fallback; + return String(value); + } + + function escapeHtml(value) { + return text(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function showAlert(message, type = 'danger') { + el('angular-motion-alert').innerHTML = message + ? `` + : ''; + } + + async function requestJson(url, options = {}) { + const response = await fetch(url, options); + const isJson = response.headers.get('content-type')?.includes('application/json'); + const body = isJson ? await response.json() : await response.text(); + if (!response.ok) { + if (body && typeof body === 'object') { + throw new Error(body.error || body.message || Object.values(body).join('; ')); + } + throw new Error(body || `HTTP ${response.status}`); + } + return body; + } + + function nowLocalInput() { + const d = new Date(); + d.setSeconds(0, 0); + const offsetMs = d.getTimezoneOffset() * 60000; + return new Date(d.getTime() - offsetMs).toISOString().slice(0, 16); + } + + function localDateTimeValue() { + const value = el('angular-motion-time').value; + return value && value.length === 16 ? `${value}:00` : value; + } + + function formatDateTime(value) { + return text(value, '-').replace('T', ' '); + } + + function formatNumber(value, digits = 6) { + const number = Number(value); + return Number.isFinite(number) ? number.toFixed(digits) : '-'; + } + + function setLoading(isLoading) { + el('angular-motion-submit').disabled = isLoading; + el('angular-motion-submit').textContent = isLoading ? 'Расчет...' : 'Рассчитать'; + } + + async function loadSatellites() { + const time = localDateTimeValue(); + const query = time ? `?time=${encodeURIComponent(time)}` : ''; + state.satellites = await requestJson(`/api/angular-motion/satellites${query}`); + renderSatellites(); + await loadFlightLine(); + } + + function renderSatellites() { + const select = el('angular-motion-satellite'); + const previous = select.value; + const satellites = Array.isArray(state.satellites) ? state.satellites : []; + if (satellites.length === 0) { + select.innerHTML = ''; + state.selectedSatellite = null; + el('angular-motion-satellite-meta').textContent = 'Для заданного времени нет КА с ПДЦМ.'; + return; + } + select.innerHTML = satellites.map(satellite => { + const label = `${satellite.code || satellite.satelliteId} — ${satellite.name || ''}`.trim(); + return ``; + }).join(''); + if (satellites.some(item => String(item.satelliteId) === previous)) { + select.value = previous; + } + state.selectedSatellite = satellites.find(item => String(item.satelliteId) === select.value) || satellites[0]; + renderSatelliteMeta(); + } + + function renderSatelliteMeta() { + const sat = state.selectedSatellite; + el('angular-motion-satellite-meta').textContent = sat + ? `Доступность ПДЦМ: ${formatDateTime(sat.availabilityStart)} — ${formatDateTime(sat.availabilityStop)}` + : ''; + } + + async function loadFlightLine() { + const satelliteId = el('angular-motion-satellite').value; + const time = localDateTimeValue(); + if (!satelliteId || !time) { + state.flightLine = []; + drawMap(); + return; + } + state.flightLine = await requestJson(`/api/angular-motion/satellites/${satelliteId}/flight-line?time=${encodeURIComponent(time)}`); + el('angular-motion-map-meta').textContent = state.flightLine.length > 0 + ? `Трасса и полосы обзора на интервале ${formatDateTime(time)} ± 5 минут.` + : 'Нет данных трассы полета для выбранного КА и времени.'; + drawMap(); + } + + function lonToX(lon, width) { + return (Number(lon) + 180) / 360 * width; + } + + function latToY(lat, height) { + return (90 - Number(lat)) / 180 * height; + } + + 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; + return { + lon: x / canvas.width * 360 - 180, + lat: 90 - y / canvas.height * 180, + }; + } + + 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(); + } + 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); + } + + 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); + }); + ctx.stroke(); + } + + function drawTarget(ctx) { + 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(); + } + + function drawMap() { + const canvas = el('angular-motion-map'); + const ctx = canvas.getContext('2d'); + drawGrid(ctx, canvas.width, canvas.height); + 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); + } + + function setPicking(value) { + state.picking = value; + el('angular-motion-map').classList.toggle('angular-motion-pick-active', value); + el('angular-motion-pick-state').textContent = value + ? 'Кликните по карте, чтобы перенести координаты.' + : 'Режим выбора координат выключен.'; + } + + function modeChanged() { + const mode = el('angular-motion-mode').value; + const sdiVisible = mode === 'AZIMUTH' || mode === 'SMOOTH_SDI'; + document.querySelectorAll('.angular-motion-sdi-fields').forEach(item => item.classList.toggle('d-none', !sdiVisible)); + document.querySelectorAll('.angular-motion-azimuth-field').forEach(item => item.classList.toggle('d-none', mode === 'CONST_ORIENT')); + } + + function payloadFromForm() { + const mode = el('angular-motion-mode').value; + const payload = { + satelliteId: Number(el('angular-motion-satellite').value), + approximateTime: localDateTimeValue(), + mode, + latitudeDeg: Number(el('angular-motion-lat').value), + longitudeDeg: Number(el('angular-motion-lon').value), + heightM: Number(el('angular-motion-height').value || 0), + durationSec: Number(el('angular-motion-duration').value), + leadAngleDeg: Number(el('angular-motion-lead-angle').value || 0), + azimuthDeg: Number(el('angular-motion-azimuth').value || 0), + focusMm: Number(el('angular-motion-focus').value || 5500), + stepPuudSec: Number(el('angular-motion-step').value || 0.125), + stepSdiSec: Number(el('angular-motion-step-sdi').value || 20), + }; + if (mode === 'AZIMUTH' || mode === 'SMOOTH_SDI') { + payload.sdi = Number(el('angular-motion-sdi').value); + } + return payload; + } + + function renderResult(result) { + state.result = result; + const rows = Array.isArray(result?.points) ? result.points : []; + el('angular-motion-result-meta').textContent = rows.length > 0 + ? `Старт режима: ${formatDateTime(result.startTime)}. Точек: ${rows.length}.` + : 'Расчет не вернул точек.'; + el('angular-motion-export-csv').disabled = rows.length === 0; + const body = el('angular-motion-result-body'); + if (rows.length === 0) { + body.innerHTML = 'Нет данных'; + return; + } + body.innerHTML = rows.map(point => ` + + ${escapeHtml(formatDateTime(point.time))} + ${escapeHtml(point.revolution)} + ${escapeHtml(formatNumber(point.tangDeg, 3))} + ${escapeHtml(formatNumber(point.krenDeg, 3))} + ${escapeHtml(formatNumber(point.riskDeg, 3))} + ${escapeHtml(formatNumber(point.groundLatitudeDeg, 5))} + ${escapeHtml(formatNumber(point.groundLongitudeDeg, 5))} + ${escapeHtml(formatNumber(point.omegaX, 7))} + ${escapeHtml(formatNumber(point.omegaY, 7))} + ${escapeHtml(formatNumber(point.omegaZ, 7))} + ${escapeHtml(formatNumber(point.wdX, 7))} + ${escapeHtml(formatNumber(point.wdY, 7))} + ${escapeHtml(formatNumber(point.wdZ, 7))} + ${escapeHtml(formatNumber(point.sdi, 4))} + + `).join(''); + } + + async function calculate(event) { + event.preventDefault(); + showAlert(''); + setLoading(true); + try { + const result = await requestJson('/api/angular-motion/calculate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify(payloadFromForm()), + }); + renderResult(result); + showAlert('Расчет ПУУД выполнен.', 'success'); + } catch (error) { + showAlert(error.message || String(error)); + } finally { + setLoading(false); + } + } + + function exportCsv() { + const rows = Array.isArray(state.result?.points) ? state.result.points : []; + if (rows.length === 0) return; + const header = ['time', 'revolution', 'tangDeg', 'krenDeg', 'riskDeg', 'groundLatitudeDeg', 'groundLongitudeDeg', 'omegaX', 'omegaY', 'omegaZ', 'wdX', 'wdY', 'wdZ', 'sdi']; + const lines = [header.join(';')]; + rows.forEach(row => { + lines.push(header.map(key => row[key] ?? '').join(';')); + }); + const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'angular-motion.csv'; + a.click(); + URL.revokeObjectURL(url); + } + + function init() { + el('angular-motion-time').value = nowLocalInput(); + 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; + 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)); + el('angular-motion-export-csv').addEventListener('click', exportCsv); + modeChanged(); + drawMap(); + loadSatellites().catch(error => showAlert(error.message || String(error))); + } + + document.addEventListener('DOMContentLoaded', init); +})(); 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 new file mode 100644 index 0000000..18f83d3 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/css/angular-motion.css @@ -0,0 +1,70 @@ +.angular-motion-page { + height: 100%; +} + +.angular-motion-card { + border: 1px solid #dbe4f0; + box-shadow: 0 0.25rem 0.75rem rgba(15, 23, 42, 0.05); +} + +.angular-motion-section-title { + font-weight: 600; + color: #1f2a44; +} + +.angular-motion-helper { + color: #64748b; + font-size: 0.875rem; +} + +.angular-motion-form-body { + max-height: calc(100vh - 11rem); + overflow: auto; +} + +.angular-motion-map { + width: 100%; + min-height: 320px; + border: 1px solid #dbe4f0; + border-radius: 0.5rem; + background: #f8fbff; + cursor: crosshair; +} + +.angular-motion-map.angular-motion-pick-active { + outline: 3px solid rgba(13, 110, 253, 0.25); +} + +.angular-motion-result-card { + min-height: 360px; +} + +.angular-motion-table-wrap { + max-height: calc(100vh - 35rem); + min-height: 320px; + overflow: auto; +} + +.angular-motion-table thead th { + position: sticky; + top: 0; + background: #f8fafc; + z-index: 1; + white-space: nowrap; +} + +.angular-motion-table td, +.angular-motion-table th { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +@media (max-width: 1199.98px) { + .angular-motion-form-body { + max-height: none; + } + + .angular-motion-table-wrap { + max-height: 460px; + } +} 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 new file mode 100644 index 0000000..30ee73c --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/angular-motion.html @@ -0,0 +1,172 @@ + + + + ПУУД + + + + +
+
+
+

Расчет ПУУД

+
Программа управления угловым движением для выбранного КА и варианта сканирования.
+
+ +
+ +
+ +
+
+
+
+
Исходные данные
+
+
+
+ + +
При изменении времени список КА фильтруется по доступным ПДЦМ.
+
+ +
+ + +
+
+ +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + Режим выбора координат выключен. +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
Мини-карта
+
Выберите время и спутник.
+
+
+
+ +
+
+ +
+
+
+
Результат расчета
+
Расчет еще не запускался.
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
ВремяВитокТангажКренРысканьеШиротаДолготаωxωyωzwd.xwd.ywd.zСДИ
Нет данных
+
+
+
+
+
+
+ +
+ + diff --git a/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html b/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html index 496e029..4ce3368 100644 --- a/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html +++ b/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html @@ -70,6 +70,9 @@ +