сравнение TLE

This commit is contained in:
emelianov
2026-05-27 11:40:31 +03:00
parent 1a6e51bf25
commit 29c06a4c9c
9 changed files with 619 additions and 1 deletions
@@ -18,6 +18,9 @@ class CatalogPageController {
@GetMapping("/satellites/pdcm")
fun satellitesPdcmPage(): String = "satellites-pdcm"
@GetMapping("/tle-comparison")
fun tleComparisonPage(): String = "tle-comparison"
@GetMapping("/current-plans")
fun currentPlansPage(): String = "current-plans"
@@ -0,0 +1,135 @@
package space.nstart.pcp.slots_service.controller
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.RestController
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
import space.nstart.pcp.slots_service.configuration.CustomValidationException
import space.nstart.pcp.slots_service.dto.tlecomparison.TleCompareRequestDTO
import space.nstart.pcp.slots_service.dto.tlecomparison.TleCompareResponseDTO
import space.nstart.pcp.slots_service.dto.tlecomparison.TleCompareRowDTO
import space.nstart.pcp.slots_service.dto.tlecomparison.TleParsedDTO
import space.nstart.pcp.slots_service.dto.tlecomparison.TleTextRequestDTO
import space.nstart.pcp.slots_service.service.BallisticsService
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlin.math.abs
@RestController
@RequestMapping("/api/tle-comparison")
class TleComparisonController(
private val ballisticsService: BallisticsService
) {
@PostMapping("/parse")
fun parse(@RequestBody request: TleTextRequestDTO): TleParsedDTO =
ballisticsService.parseTle(parseTleText(request.tle))
@PostMapping("/compare")
fun compare(@RequestBody request: TleCompareRequestDTO): TleCompareResponseDTO {
val first = ballisticsService.parseTle(parseTleText(request.first))
val second = ballisticsService.parseTle(parseTleText(request.second))
return TleCompareResponseDTO(
first = first,
second = second,
rows = buildRows(first, second)
)
}
private fun parseTleText(raw: String): TLEDTO {
val lines = raw.lines()
.map { it.trim() }
.filter { it.isNotBlank() }
if (lines.isEmpty()) {
throw CustomValidationException("Введите TLE")
}
val firstIndex = lines.indexOfFirst { it.startsWith("1 ") }
val secondIndex = lines.indexOfFirst { it.startsWith("2 ") }
if (firstIndex < 0 || secondIndex < 0) {
throw CustomValidationException("TLE должен содержать строки 1 и 2")
}
val first = lines[firstIndex]
val second = lines[secondIndex]
val header = lines.take(firstIndex)
.firstOrNull { !it.startsWith("1 ") && !it.startsWith("2 ") }
if (first.length < 69 || second.length < 69) {
throw CustomValidationException("Строки TLE должны содержать не менее 69 символов")
}
return TLEDTO(
header = header,
first = first.take(69),
second = second.take(69)
)
}
private fun buildRows(first: TleParsedDTO, second: TleParsedDTO): List<TleCompareRowDTO> = listOf(
textRow("satName", "Название", first.satName, second.satName),
textRow("noradId", "NORAD ID", first.noradId?.toString(), second.noradId?.toString()),
textRow("revolution", "Номер витка", first.revolution?.toString(), second.revolution?.toString()),
timeRow("time", "Эпоха", first.time, second.time),
numberRow("inclination", "Наклонение", first.inclination, second.inclination),
numberRow("ascendingNode", "ВУЗ", first.ascendingNode, second.ascendingNode),
numberRow("argPerigee", "Аргумент перигея", first.argPerigee, second.argPerigee),
numberRow("meanAnomaly", "Средняя аномалия", first.meanAnomaly, second.meanAnomaly),
numberRow("eccentricity", "Эксцентриситет", first.eccentricity, second.eccentricity),
numberRow("perigee", "Перигей", first.perigee, second.perigee),
numberRow("apogee", "Апогей", first.apogee, second.apogee),
numberRow("period", "Период", first.period, second.period),
numberRow("semiMajor", "Большая полуось", first.semiMajor, second.semiMajor),
numberRow("semiMinor", "Малая полуось", first.semiMinor, second.semiMinor),
numberRow("meanMotion", "Среднее движение", first.meanMotion, second.meanMotion),
numberRow("meanMotionTLE", "Среднее движение TLE", first.meanMotionTLE, second.meanMotionTLE)
)
private fun textRow(code: String, name: String, first: String?, second: String?): TleCompareRowDTO =
TleCompareRowDTO(
code = code,
name = name,
first = first,
second = second,
delta = if (first != null && second != null && first != second) "изменено" else null
)
private fun timeRow(code: String, name: String, first: LocalDateTime?, second: LocalDateTime?): TleCompareRowDTO {
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
val delta = if (first != null && second != null) {
val seconds = java.time.Duration.between(first, second).seconds
formatSecondsDelta(seconds)
} else {
null
}
return TleCompareRowDTO(code, name, first?.format(formatter), second?.format(formatter), delta)
}
private fun numberRow(code: String, name: String, first: Double?, second: Double?): TleCompareRowDTO =
TleCompareRowDTO(
code = code,
name = name,
first = first?.formatNumber(),
second = second?.formatNumber(),
delta = if (first != null && second != null) (second - first).formatSignedNumber() else null
)
private fun Double.formatNumber(): String = String.format(Locale.US, "%.8f", this)
private fun Double.formatSignedNumber(): String {
val sign = if (this > 0) "+" else ""
return sign + String.format(Locale.US, "%.8f", this)
}
private fun formatSecondsDelta(seconds: Long): String {
val sign = if (seconds > 0) "+" else if (seconds < 0) "-" else ""
val absolute = abs(seconds)
val hours = absolute / 3600
val minutes = (absolute % 3600) / 60
val secs = absolute % 60
return "$sign${hours}ч ${minutes}м ${secs}с"
}
}
@@ -0,0 +1,47 @@
package space.nstart.pcp.slots_service.dto.tlecomparison
import java.time.LocalDateTime
class TleTextRequestDTO(
var tle: String = ""
)
class TleCompareRequestDTO(
var first: String = "",
var second: String = ""
)
data class TleParsedDTO(
val satName: String? = null,
val noradId: Long? = null,
val revolution: Long? = null,
val time: LocalDateTime? = null,
val inclination: Double? = null,
val perigee: Double? = null,
val apogee: Double? = null,
val argPerigee: Double? = null,
val eccentricity: Double? = null,
val major: Double? = null,
val minor: Double? = null,
val meanAnomaly: Double? = null,
val period: Double? = null,
val semiMajor: Double? = null,
val semiMinor: Double? = null,
val ascendingNode: Double? = null,
val meanMotion: Double? = null,
val meanMotionTLE: Double? = null
)
data class TleCompareRowDTO(
val code: String,
val name: String,
val first: String?,
val second: String?,
val delta: String? = null
)
data class TleCompareResponseDTO(
val first: TleParsedDTO,
val second: TleParsedDTO,
val rows: List<TleCompareRowDTO>
)
@@ -10,6 +10,8 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
import space.nstart.pcp.slots_service.dto.tlecomparison.TleParsedDTO
import space.nstart.pcp.slots_service.configuration.CustomErrorException
import java.net.URI
import java.time.LocalDateTime
@@ -68,6 +70,16 @@ class BallisticsService (webClientBuilderProvider: ObjectProvider<WebClient.Buil
?.firstOrNull()
?: throw CustomErrorException("Не удалось получить положение спутника $id на время $time")
fun parseTle(tle: TLEDTO): TleParsedDTO =
webClientBuilder.build()
.post()
.uri("$url/api/tle/parse")
.bodyValue(tle)
.retrieve()
.bodyToMono(TleParsedDTO::class.java)
.block()
?: throw CustomErrorException("Не удалось разобрать TLE")
private fun withTimeInterval(
path: String,
timeStart: LocalDateTime?,
@@ -0,0 +1,92 @@
.tle-comparison-page {
min-height: calc(100vh - 5.5rem);
}
.tle-input-card,
.tle-result-card {
border: 0;
box-shadow: 0 0.5rem 1.25rem rgba(15, 23, 42, 0.08);
}
.tle-input-widget {
display: flex;
flex-direction: column;
}
.tle-textarea {
min-height: 11rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.9rem;
resize: vertical;
white-space: pre;
}
.tle-reference {
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 0.75rem;
background: rgba(248, 250, 252, 0.82);
overflow: hidden;
}
.tle-reference-row {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.55rem 0.75rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
font-size: 0.92rem;
}
.tle-reference-row:last-child {
border-bottom: 0;
}
.tle-reference-row span {
color: #64748b;
}
.tle-reference-row strong {
text-align: right;
font-weight: 600;
color: #0f172a;
}
.tle-parse-status {
font-size: 0.82rem;
}
.tle-parse-status.tle-parse-ok {
color: #198754 !important;
}
.tle-parse-status.tle-parse-error {
color: #dc3545 !important;
}
.tle-result-table-wrap {
max-height: calc(100vh - 30rem);
min-height: 18rem;
overflow: auto;
}
.tle-result-table-wrap thead th {
position: sticky;
top: 0;
z-index: 2;
}
.tle-delta-positive {
color: #0d6efd;
font-weight: 600;
}
.tle-delta-negative {
color: #dc3545;
font-weight: 600;
}
@media (max-width: 1199.98px) {
.tle-result-table-wrap {
max-height: none;
}
}
@@ -0,0 +1,210 @@
(() => {
const state = {
first: null,
second: null,
parseTimers: {}
};
document.addEventListener('DOMContentLoaded', () => {
bindEvents();
updateReference('first', null);
updateReference('second', null);
});
function bindEvents() {
const first = el('tle-first-text');
const second = el('tle-second-text');
first?.addEventListener('input', () => scheduleParse('first'));
second?.addEventListener('input', () => scheduleParse('second'));
el('tle-compare-submit')?.addEventListener('click', compareTle);
el('tle-compare-clear')?.addEventListener('click', clearPage);
}
function scheduleParse(side) {
window.clearTimeout(state.parseTimers[side]);
const text = getTleText(side);
if (!text.trim()) {
state[side] = null;
updateReference(side, null);
return;
}
setStatus(side, 'Разбор...', 'text-muted');
state.parseTimers[side] = window.setTimeout(() => parseTle(side), 350);
}
async function parseTle(side) {
try {
const parsed = await postJson('/api/tle-comparison/parse', { tle: getTleText(side) });
state[side] = parsed;
updateReference(side, parsed);
setStatus(side, 'Разобрано', 'tle-parse-ok');
} catch (error) {
state[side] = null;
updateReference(side, null);
setStatus(side, error.message || 'Ошибка разбора', 'tle-parse-error');
}
}
async function compareTle() {
showAlert('');
const first = getTleText('first');
const second = getTleText('second');
if (!first.trim() || !second.trim()) {
showAlert('Введите оба TLE для сравнения.', 'warning');
return;
}
setCompareLoading(true);
try {
const response = await postJson('/api/tle-comparison/compare', { first, second });
state.first = response.first;
state.second = response.second;
updateReference('first', response.first);
updateReference('second', response.second);
setStatus('first', 'Разобрано', 'tle-parse-ok');
setStatus('second', 'Разобрано', 'tle-parse-ok');
renderComparison(response.rows || []);
} catch (error) {
showAlert(error.message || 'Не удалось выполнить сравнение TLE.', 'danger');
} finally {
setCompareLoading(false);
}
}
function renderComparison(rows) {
const body = el('tle-comparison-result-body');
const empty = el('tle-comparison-empty');
const wrap = el('tle-comparison-table-wrap');
const meta = el('tle-comparison-result-meta');
if (!body || !empty || !wrap) return;
if (!rows.length) {
body.innerHTML = '';
empty.classList.remove('d-none');
wrap.classList.add('d-none');
if (meta) meta.textContent = 'Нет данных для сравнения.';
return;
}
body.innerHTML = rows.map((row) => `
<tr>
<td>${escapeHtml(row.name || row.code || '')}</td>
<td>${escapeHtml(emptyToDash(row.first))}</td>
<td>${escapeHtml(emptyToDash(row.second))}</td>
<td class="${deltaClass(row.delta)}">${escapeHtml(emptyToDash(row.delta))}</td>
</tr>
`).join('');
empty.classList.add('d-none');
wrap.classList.remove('d-none');
if (meta) {
const firstEpoch = formatDateTime(state.first?.time);
const secondEpoch = formatDateTime(state.second?.time);
meta.textContent = `Эпохи: TLE 1 — ${firstEpoch || '—'}, TLE 2 — ${secondEpoch || '—'}`;
}
}
function updateReference(side, parsed) {
setText(`tle-${side}-epoch`, formatDateTime(parsed?.time) || '—');
setText(`tle-${side}-norad`, parsed?.noradId ?? '—');
setText(`tle-${side}-revolution`, parsed?.revolution ?? '—');
if (!parsed) {
const text = getTleText(side);
setStatus(side, text.trim() ? 'Ошибка разбора' : 'Нет данных', text.trim() ? 'tle-parse-error' : 'text-muted');
}
}
function clearPage() {
el('tle-first-text').value = '';
el('tle-second-text').value = '';
state.first = null;
state.second = null;
updateReference('first', null);
updateReference('second', null);
showAlert('');
renderComparison([]);
setText('tle-comparison-result-meta', 'Нажмите “Сравнить” после ввода двух TLE.');
}
async function postJson(url, payload) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
let message = `HTTP ${response.status}`;
try {
const error = await response.json();
message = error.error || Object.values(error)[0] || message;
} catch (_) {
message = await response.text() || message;
}
throw new Error(message);
}
return response.json();
}
function getTleText(side) {
return el(`tle-${side}-text`)?.value || '';
}
function setStatus(side, text, cssClass) {
const node = el(`tle-${side}-status`);
if (!node) return;
node.className = `tle-parse-status ${cssClass || 'text-muted'}`;
node.textContent = text;
}
function setCompareLoading(loading) {
const button = el('tle-compare-submit');
if (!button) return;
button.disabled = loading;
button.textContent = loading ? 'Сравнение...' : 'Сравнить';
}
function showAlert(message, type = 'info') {
const container = el('tle-comparison-alert');
if (!container) return;
container.innerHTML = message
? `<div class="alert alert-${type} alert-dismissible fade show" role="alert">
${escapeHtml(message)}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
</div>`
: '';
}
function formatDateTime(value) {
if (!value) return '';
return String(value).replace('T', ' ');
}
function emptyToDash(value) {
return value === null || value === undefined || value === '' ? '—' : value;
}
function deltaClass(value) {
const text = String(value || '').trim();
if (text.startsWith('+')) return 'tle-delta-positive';
if (text.startsWith('-')) return 'tle-delta-negative';
return '';
}
function setText(id, value) {
const node = el(id);
if (node) node.textContent = value;
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function el(id) {
return document.getElementById(id);
}
})();
@@ -46,6 +46,7 @@
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/satellites">Управление</a></li>
<li><a class="dropdown-item" href="/satellites/pdcm">ПДЦМ</a></li>
<li><a class="dropdown-item" href="/tle-comparison">Сравнение TLE</a></li>
</ul>
</li>
<li class="nav-item dropdown">
@@ -0,0 +1,118 @@
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.w3.org/1999/xhtml"
layout:decorate="~{layouts/main-layout}">
<head>
<title>Сравнение TLE</title>
<link rel="stylesheet" href="/css/catalog.css"/>
<link rel="stylesheet" href="/css/tle-comparison.css"/>
</head>
<body>
<th:block layout:fragment="content">
<div id="tle-comparison-page" class="catalog-page tle-comparison-page py-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="mb-1">Сравнение TLE</h2>
<div class="catalog-helper">Вставьте два набора TLE, проверьте эпохи и сравните параметры, полученные через ballistics-service.</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button id="tle-compare-submit" type="button" class="btn btn-primary">Сравнить</button>
<button id="tle-compare-clear" type="button" class="btn btn-outline-secondary">Очистить</button>
</div>
</div>
<div id="tle-comparison-alert"></div>
<div class="card catalog-card tle-input-card mb-4">
<div class="card-header">
<div class="catalog-section-title">Ввод данных</div>
<div class="catalog-helper mt-1">Можно вставить две или три строки TLE. Заголовок определяется автоматически, строки 1 и 2 обязательны.</div>
</div>
<div class="card-body">
<div class="row g-4">
<div class="col-12 col-xl-6">
<div class="tle-input-widget h-100">
<div class="d-flex justify-content-between align-items-center gap-3 mb-2">
<label for="tle-first-text" class="form-label mb-0">TLE 1</label>
<span id="tle-first-status" class="tle-parse-status text-muted">Нет данных</span>
</div>
<textarea id="tle-first-text"
class="form-control tle-textarea"
spellcheck="false"
placeholder="ISS (ZARYA)&#10;1 25544U 98067A ...&#10;2 25544 ..."></textarea>
<div class="tle-reference mt-3">
<div class="tle-reference-row">
<span>Эпоха TLE</span>
<strong id="tle-first-epoch"></strong>
</div>
<div class="tle-reference-row">
<span>NORAD</span>
<strong id="tle-first-norad"></strong>
</div>
<div class="tle-reference-row">
<span>Виток</span>
<strong id="tle-first-revolution"></strong>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="tle-input-widget h-100">
<div class="d-flex justify-content-between align-items-center gap-3 mb-2">
<label for="tle-second-text" class="form-label mb-0">TLE 2</label>
<span id="tle-second-status" class="tle-parse-status text-muted">Нет данных</span>
</div>
<textarea id="tle-second-text"
class="form-control tle-textarea"
spellcheck="false"
placeholder="ISS (ZARYA)&#10;1 25544U 98067A ...&#10;2 25544 ..."></textarea>
<div class="tle-reference mt-3">
<div class="tle-reference-row">
<span>Эпоха TLE</span>
<strong id="tle-second-epoch"></strong>
</div>
<div class="tle-reference-row">
<span>NORAD</span>
<strong id="tle-second-norad"></strong>
</div>
<div class="tle-reference-row">
<span>Виток</span>
<strong id="tle-second-revolution"></strong>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card catalog-card tle-result-card">
<div class="card-header d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<div class="catalog-section-title">Результаты</div>
<div id="tle-comparison-result-meta" class="catalog-helper mt-1">Нажмите “Сравнить” после ввода двух TLE.</div>
</div>
</div>
<div class="card-body p-0">
<div id="tle-comparison-empty" class="catalog-empty">Результаты сравнения появятся здесь.</div>
<div id="tle-comparison-table-wrap" class="table-responsive tle-result-table-wrap d-none">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Параметр</th>
<th>TLE 1</th>
<th>TLE 2</th>
<th>Разница</th>
</tr>
</thead>
<tbody id="tle-comparison-result-body"></tbody>
</table>
</div>
</div>
</div>
</div>
<script src="/tle_comparison_scripts.js"></script>
</th:block>
</body>
</html>