Прототип страницы расчета ПУУД

This commit is contained in:
emelianov
2026-06-15 10:13:27 +03:00
parent bf2e88431e
commit ce32a03072
13 changed files with 1017 additions and 0 deletions
+2
View File
@@ -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
@@ -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")}")
@@ -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<AngularMotionSatelliteDTO> = angularMotionService.availableSatellites(time)
@GetMapping("/satellites/{satelliteId}/flight-line")
fun flightLine(
@PathVariable satelliteId: Long,
@RequestParam
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
time: LocalDateTime,
): List<AngularMotionFlightLinePointDTO> = angularMotionService.flightLine(satelliteId, time)
@PostMapping("/calculate")
fun calculate(@RequestBody request: AngularMotionCalculationRequestDTO): AngularMotionCalculationResultDTO =
angularMotionService.calculate(request)
}
@@ -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<AngularMotionPointDTO>,
)
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,
)
@@ -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<AngularMotionSatelliteDTO> {
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<AngularMotionSatelliteDTO> { it.code }.thenBy { it.satelliteId })
}
fun flightLine(satelliteId: Long, time: java.time.LocalDateTime): List<AngularMotionFlightLinePointDTO> {
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<OrbitalPoint> {
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
}
}
@@ -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<WebClient.Builder>) {
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<SatelliteSummaryDTO> =
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()
}
}
@@ -32,6 +32,20 @@ class BallTaskServce {
.body(SatelliteOrbitAvailabilityDTO::class.java)
?: throw IllegalStateException("Пустой ответ баллистики для доступности ПДЦМ КА $noradId")
fun getOrbitAvailability(time: LocalDateTime? = null): List<SatelliteOrbitAvailabilityDTO> =
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<FlightLineDTO> =
RestClient.create()
.get()
@@ -93,6 +107,7 @@ class BallTaskServce {
private companion object {
val FLIGHT_LINE_LIST_TYPE = object : ParameterizedTypeReference<List<FlightLineDTO>>() {}
val ORB_POINT_LIST_TYPE = object : ParameterizedTypeReference<List<OrbPointDTO>>() {}
val ORBIT_AVAILABILITY_LIST_TYPE = object : ParameterizedTypeReference<List<SatelliteOrbitAvailabilityDTO>>() {}
val SQUARE_VIEW_PARAM_LIST_TYPE = object : ParameterizedTypeReference<List<SquareViewParamDTO>>() {}
}
}
@@ -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<String, Any?>) =
dynamicPlanService.calculateAngularMotion(request)
}
@@ -20,6 +20,9 @@ class DynamicPlanService(webClientBuilderProvider: ObjectProvider<WebClient.Buil
@Value("\${settings.dynamic-plan-service:pcp-dynamic-plan-service}")
private lateinit var dynamicPlanUrl: String
@Value("\${settings.ballistics-service:pcp-ballistics-service}")
private lateinit var ballisticsServiceUrl: String
fun calculate(request: DynamicPlanCalculationRequestDTO): JsonNode =
webClientBuilder.build()
.post()
@@ -106,6 +109,54 @@ class DynamicPlanService(webClientBuilderProvider: ObjectProvider<WebClient.Buil
.bodyToMono(JsonNode::class.java)
.block() ?: ObjectMapper().createObjectNode()
fun getAngularMotionSatellites(time: String?): JsonNode {
val timeParam = time
?.takeIf { value -> 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"
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function showAlert(message, type = 'danger') {
el('angular-motion-alert').innerHTML = message
? `<div class="alert alert-${type} mb-3" role="alert">${escapeHtml(message)}</div>`
: '';
}
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 = '<option value="">Нет доступных КА</option>';
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 `<option value="${escapeHtml(satellite.satelliteId)}">${escapeHtml(label)}</option>`;
}).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 = '<tr><td colspan="14" class="text-center text-muted py-4">Нет данных</td></tr>';
return;
}
body.innerHTML = rows.map(point => `
<tr>
<td>${escapeHtml(formatDateTime(point.time))}</td>
<td>${escapeHtml(point.revolution)}</td>
<td>${escapeHtml(formatNumber(point.tangDeg, 3))}</td>
<td>${escapeHtml(formatNumber(point.krenDeg, 3))}</td>
<td>${escapeHtml(formatNumber(point.riskDeg, 3))}</td>
<td>${escapeHtml(formatNumber(point.groundLatitudeDeg, 5))}</td>
<td>${escapeHtml(formatNumber(point.groundLongitudeDeg, 5))}</td>
<td>${escapeHtml(formatNumber(point.omegaX, 7))}</td>
<td>${escapeHtml(formatNumber(point.omegaY, 7))}</td>
<td>${escapeHtml(formatNumber(point.omegaZ, 7))}</td>
<td>${escapeHtml(formatNumber(point.wdX, 7))}</td>
<td>${escapeHtml(formatNumber(point.wdY, 7))}</td>
<td>${escapeHtml(formatNumber(point.wdZ, 7))}</td>
<td>${escapeHtml(formatNumber(point.sdi, 4))}</td>
</tr>
`).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);
})();
@@ -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;
}
}
@@ -0,0 +1,172 @@
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.w3.org/1999/xhtml"
layout:decorate="~{layouts/main-layout}">
<head>
<title>ПУУД</title>
<link rel="stylesheet" href="/css/angular-motion.css"/>
</head>
<body>
<th:block layout:fragment="content">
<div class="angular-motion-page py-3">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h1 class="h3 mb-1">Расчет ПУУД</h1>
<div class="angular-motion-helper">Программа управления угловым движением для выбранного КА и варианта сканирования.</div>
</div>
<button id="angular-motion-refresh-satellites" type="button" class="btn btn-outline-secondary btn-sm">Обновить КА</button>
</div>
<div id="angular-motion-alert"></div>
<div class="row g-3 angular-motion-layout">
<div class="col-12 col-xl-4">
<form id="angular-motion-form" class="card angular-motion-card h-100">
<div class="card-header bg-white">
<div class="angular-motion-section-title">Исходные данные</div>
</div>
<div class="card-body angular-motion-form-body">
<div class="mb-3">
<label for="angular-motion-time" class="form-label">Примерное время</label>
<input id="angular-motion-time" type="datetime-local" class="form-control" required>
<div class="form-text">При изменении времени список КА фильтруется по доступным ПДЦМ.</div>
</div>
<div class="mb-3">
<label for="angular-motion-satellite" class="form-label">Спутник</label>
<select id="angular-motion-satellite" class="form-select" required></select>
<div id="angular-motion-satellite-meta" class="form-text"></div>
</div>
<hr>
<div class="mb-3">
<label for="angular-motion-mode" class="form-label">Вариант сканирования</label>
<select id="angular-motion-mode" class="form-select">
<option value="CONST_ORIENT">Постоянная ориентация ЦЛВ в ОСК</option>
<option value="AZIMUTH">Заданный азимут</option>
<option value="SMOOTH_SDI">Плавная СДИ</option>
</select>
</div>
<div class="row g-2 mb-3">
<div class="col-12 col-md-6">
<label for="angular-motion-lat" class="form-label">Широта, град</label>
<input id="angular-motion-lat" type="number" step="0.000001" min="-90" max="90" class="form-control" required>
</div>
<div class="col-12 col-md-6">
<label for="angular-motion-lon" class="form-label">Долгота, град</label>
<input id="angular-motion-lon" type="number" step="0.000001" min="-180" max="180" class="form-control" required>
</div>
</div>
<div class="d-flex gap-2 align-items-center mb-3">
<button id="angular-motion-pick-from-map" type="button" class="btn btn-outline-primary btn-sm">Выбрать с карты</button>
<span id="angular-motion-pick-state" class="angular-motion-helper">Режим выбора координат выключен.</span>
</div>
<div class="row g-2 mb-3">
<div class="col-12 col-md-6">
<label for="angular-motion-height" class="form-label">Высота, м</label>
<input id="angular-motion-height" type="number" step="1" class="form-control" value="0">
</div>
<div class="col-12 col-md-6">
<label for="angular-motion-duration" class="form-label">Длительность, с</label>
<input id="angular-motion-duration" type="number" min="0.125" step="0.125" class="form-control" value="60" required>
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-12 col-md-6">
<label for="angular-motion-lead-angle" class="form-label">Упреждающий угол, град</label>
<input id="angular-motion-lead-angle" type="number" step="0.001" class="form-control" value="0">
</div>
<div class="col-12 col-md-6 angular-motion-azimuth-field">
<label for="angular-motion-azimuth" class="form-label">Азимут, град</label>
<input id="angular-motion-azimuth" type="number" step="0.001" class="form-control" value="0">
</div>
</div>
<div class="row g-2 mb-3 angular-motion-sdi-fields">
<div class="col-12 col-md-6">
<label for="angular-motion-sdi" class="form-label">СДИ, мм/с</label>
<input id="angular-motion-sdi" type="number" step="0.001" class="form-control" value="100">
</div>
<div class="col-12 col-md-6">
<label for="angular-motion-focus" class="form-label">Фокус, мм</label>
<input id="angular-motion-focus" type="number" min="1" step="1" class="form-control" value="5500">
</div>
</div>
<div class="row g-2 mb-4">
<div class="col-12 col-md-6">
<label for="angular-motion-step" class="form-label">Шаг ПУУД, с</label>
<input id="angular-motion-step" type="number" min="0.001" step="0.001" class="form-control" value="0.125">
</div>
<div class="col-12 col-md-6">
<label for="angular-motion-step-sdi" class="form-label">Шаг СДИ, с</label>
<input id="angular-motion-step-sdi" type="number" min="0.001" step="0.001" class="form-control" value="20">
</div>
</div>
<button id="angular-motion-submit" type="submit" class="btn btn-primary w-100">Рассчитать</button>
</div>
</form>
</div>
<div class="col-12 col-xl-8">
<div class="card angular-motion-card mb-3">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<div>
<div class="angular-motion-section-title">Мини-карта</div>
<div id="angular-motion-map-meta" class="angular-motion-helper mt-1">Выберите время и спутник.</div>
</div>
</div>
<div class="card-body">
<canvas id="angular-motion-map" width="980" height="360" class="angular-motion-map" aria-label="Мини-карта трассы и полос обзора"></canvas>
</div>
</div>
<div class="card angular-motion-card angular-motion-result-card">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<div>
<div class="angular-motion-section-title">Результат расчета</div>
<div id="angular-motion-result-meta" class="angular-motion-helper mt-1">Расчет еще не запускался.</div>
</div>
<button id="angular-motion-export-csv" type="button" class="btn btn-outline-secondary btn-sm" disabled>CSV</button>
</div>
<div class="card-body p-0">
<div class="table-responsive angular-motion-table-wrap">
<table class="table table-sm table-hover mb-0 angular-motion-table">
<thead>
<tr>
<th>Время</th>
<th>Виток</th>
<th>Тангаж</th>
<th>Крен</th>
<th>Рысканье</th>
<th>Широта</th>
<th>Долгота</th>
<th>ωx</th>
<th>ωy</th>
<th>ωz</th>
<th>wd.x</th>
<th>wd.y</th>
<th>wd.z</th>
<th>СДИ</th>
</tr>
</thead>
<tbody id="angular-motion-result-body">
<tr><td colspan="14" class="text-center text-muted py-4">Нет данных</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/angular_motion_scripts.js"></script>
</th:block>
</body>
</html>
@@ -70,6 +70,9 @@
<li class="nav-item">
<a class="nav-link" href="/dynamic-plan">Dynamic Plan</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/angular-motion">ПУУД</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/current-plans">Текущие планы</a>
</li>