Прототип страницы расчета ПУУД
This commit is contained in:
@@ -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")}")
|
||||
|
||||
+36
@@ -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)
|
||||
}
|
||||
+67
@@ -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,
|
||||
)
|
||||
+182
@@ -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
|
||||
}
|
||||
}
|
||||
+34
@@ -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()
|
||||
}
|
||||
}
|
||||
+15
@@ -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>>() {}
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+51
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user