diff --git a/config-repo/pcp-mission-planing-service.yaml b/config-repo/pcp-mission-planing-service.yaml index 0bfa911..ce70309 100644 --- a/config-repo/pcp-mission-planing-service.yaml +++ b/config-repo/pcp-mission-planing-service.yaml @@ -75,7 +75,7 @@ spring: camunda: client: mode: self-managed - grpc-address: http://camunda.k8s.265.nstart.local:30901 + grpc-address: http://camunda.k8s.265.nstart.cloud:30901 auth: method: none prefer-rest-over-grpc: false diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt index e2b3be2..57dcfff 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogPageController.kt @@ -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" diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TleComparisonController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TleComparisonController.kt new file mode 100644 index 0000000..8b8cae2 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TleComparisonController.kt @@ -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 = 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}с" + } +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt new file mode 100644 index 0000000..6b89fbd --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt @@ -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 +) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BallisticsService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BallisticsService.kt index ac49885..b268c6c 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BallisticsService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/BallisticsService.kt @@ -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 { + 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) => ` + + ${escapeHtml(row.name || row.code || '')} + ${escapeHtml(emptyToDash(row.first))} + ${escapeHtml(emptyToDash(row.second))} + ${escapeHtml(emptyToDash(row.delta))} + + `).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 + ? `` + : ''; + } + + 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('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + function el(id) { + return document.getElementById(id); + } +})(); diff --git a/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html b/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html index f3534ac..b8160a6 100644 --- a/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html +++ b/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html @@ -46,6 +46,7 @@