This commit is contained in:
Дмитрий Соловьев
2026-05-25 14:23:52 +03:00
parent b3a6012ebb
commit d48ddd2657
1066 changed files with 104601 additions and 3 deletions
+8
View File
@@ -0,0 +1,8 @@
FROM bellsoft/liberica-openjre-alpine:21.0.5
ENV JAVA_OPTS=""
ADD ./build/libs/*.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"]
+79
View File
@@ -0,0 +1,79 @@
group = "space.nstart.pcp"
plugins {
kotlin("jvm")
kotlin("plugin.spring")
kotlin("plugin.lombok")
id("org.springframework.boot")
id("io.spring.dependency-management")
id("org.sonarqube")
jacoco
}
version = "1.0.0"
kotlin {
jvmToolchain((property("versions.java") as String).toInt())
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(property("versions.java") as String))
}
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
dependencies {
implementation(project(":libs:ballistics-lib"))
implementation(project(":libs:pcp-types-lib"))
implementation("org.springframework.boot:spring-boot-starter-logging")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("jakarta.validation:jakarta.validation-api")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.cloud:spring-cloud-starter-config")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("versions.open-api")}")
implementation("org.projectlombok:lombok")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")
implementation("nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.2.1")
implementation("org.webjars.npm:cesium:1.107.2")
// implementation("org.webjars.npm:cesium:1.116.0")
implementation("org.webjars:bootstrap:5.3.0")
implementation("org.webjars:jquery:3.6.2")
implementation("org.webjars:webjars-locator:0.45")
implementation("org.locationtech.jts:jts-core:1.19.0")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property")
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(property("versions.java") as String))
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}")
}
}
@@ -0,0 +1,11 @@
package space.nstart.pcp.slots_service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class PcpUiServiceApplication
fun main(args: Array<String>) {
runApplication<PcpUiServiceApplication>(*args)
}
@@ -0,0 +1,96 @@
package space.nstart.pcp.slots_service.configuration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
import org.springframework.web.filter.CorsFilter
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.server.WebFilter
import org.springframework.web.server.WebFilterChain
import reactor.core.publisher.Mono
@Component
class CorsFilterComponent : WebFilter {
override fun filter(ctx: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
if (ctx != null) {
ctx.response.headers.add("Access-Control-Allow-Origin", "*")
ctx.response.headers.add("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS")
ctx.response.headers.add(
"Access-Control-Allow-Headers",
"DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range"
)
ctx.response.headers.add(
"Access-Control-Expose-Headers",
"DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range"
)
return chain?.filter(ctx) ?: Mono.empty()
} else {
return chain?.filter(ctx) ?: Mono.empty()
}
}
}
@Configuration
class WebConfig : WebMvcConfigurer {
override fun addCorsMappings(registry: CorsRegistry) {
registry.addMapping("/**")
.allowedOrigins(
"http://localhost:3000",
"http://localhost:8080",
"http://192.168.60.68:7008",
"http://192.168.60.68:8080"
)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600)
}
// Альтернативный вариант через CorsFilter
@Bean
fun corsFilter(): CorsFilter {
val source = UrlBasedCorsConfigurationSource()
val config = CorsConfiguration().apply {
allowCredentials = true
// Разрешенные origin'ы
allowedOriginPatterns = listOf(
"http://localhost:*",
"http://192.168.60.68:*"
)
// Разрешенные заголовки
allowedHeaders = listOf(
"Origin",
"Content-Type",
"Accept",
"Authorization",
"X-Requested-With",
"Access-Control-Request-Method",
"Access-Control-Request-Headers"
)
// Разрешенные методы
allowedMethods = listOf(
"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"
)
// Заголовки, доступные клиенту
exposedHeaders = listOf(
"Access-Control-Allow-Origin",
"Access-Control-Allow-Credentials"
)
maxAge = 3600L
}
source.registerCorsConfiguration("/**", config)
return CorsFilter(source)
}
}
@@ -0,0 +1,46 @@
package space.nstart.pcp.slots_service.configuration
import org.springframework.http.ResponseEntity
import org.springframework.validation.FieldError
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.support.WebExchangeBindException
import reactor.core.publisher.Mono
class CustomValidationException(message: String) : RuntimeException(message)
class CustomErrorException(message: String) : RuntimeException(message)
@ControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(CustomValidationException::class)
fun handleValidation(ex: CustomValidationException): ResponseEntity<Map<String, String>> {
return ResponseEntity.badRequest()
.body(mapOf("error" to ex.message!!))
}
@ExceptionHandler(CustomErrorException::class)
fun handleError(ex: CustomErrorException): ResponseEntity<Map<String, String>> {
return ResponseEntity.internalServerError()
.body(mapOf("error" to ex.message!!))
}
@ExceptionHandler(WebExchangeBindException::class)
fun handleValidationExceptions(ex: WebExchangeBindException): Mono<ResponseEntity<Map<String, String>>> {
val errors = mutableMapOf<String, String>()
ex.bindingResult.fieldErrors.forEach { error ->
errors[error.field] = error.defaultMessage ?: "Validation error"
}
return Mono.just(ResponseEntity.badRequest().body(errors))
}
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidationExceptions(ex: MethodArgumentNotValidException): ResponseEntity<Map<String, String>> {
val errors = mutableMapOf<String, String>()
ex.bindingResult.allErrors.forEach { error ->
val fieldName = (error as FieldError).field
val errorMessage = error.defaultMessage
errors[fieldName] = errorMessage ?: "Validation error"
}
return ResponseEntity.badRequest().body(errors)
}
}
@@ -0,0 +1,14 @@
package space.nstart.pcp.slots_service.configuration
import jakarta.validation.Validation
import jakarta.validation.Validator
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class ValidationConfig {
@Bean
fun validator(): Validator {
return Validation.buildDefaultValidatorFactory().validator
}
}
@@ -0,0 +1,28 @@
package space.nstart.pcp.slots_service.configuration
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.client.reactive.ReactorClientHttpConnector
import org.springframework.web.reactive.function.client.WebClient
import reactor.netty.http.client.HttpClient
import java.time.Duration
@Configuration
class WebClientConfig(
@param:Value("\${settings.web-client.max-in-memory-size-bytes:134217728}")
private val maxInMemorySizeBytes: Int
) {
@Bean
fun webClientBuilder(): WebClient.Builder {
val httpClient = HttpClient.create()
.responseTimeout(Duration.ofSeconds(230))
return WebClient.builder()
.clientConnector(ReactorClientHttpConnector(httpClient))
.codecs { configurer ->
configurer.defaultCodecs().maxInMemorySize(maxInMemorySizeBytes)
}
}
}
@@ -0,0 +1,23 @@
package space.nstart.pcp.slots_service.controller
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
@Controller
class CatalogPageController {
@GetMapping("/groups")
fun groupsPage(): String = "groups"
@GetMapping("/group-state")
fun groupStatePage(): String = "group-state"
@GetMapping("/satellites")
fun satellitesPage(): String = "satellites"
@GetMapping("/satellites/pdcm")
fun satellitesPdcmPage(): String = "satellites-pdcm"
@GetMapping("/current-plans")
fun currentPlansPage(): String = "current-plans"
}
@@ -0,0 +1,157 @@
package space.nstart.pcp.slots_service.controller
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
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.PutMapping
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 space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCalculationSummaryDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteCreateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupCreateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupUpdateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteUpdateDTO
import space.nstart.pcp.slots_service.service.GroupStateService
import space.nstart.pcp.slots_service.service.SatelliteCatalogService
import space.nstart.pcp.slots_service.service.SatellitePdcmService
import space.nstart.pcp.slots_service.service.SlotService
import java.time.LocalDateTime
@RestController
@RequestMapping("/api/catalog")
class CatalogRestController(
private val satelliteCatalogService: SatelliteCatalogService,
private val slotService: SlotService,
private val groupStateService: GroupStateService,
private val satellitePdcmService: SatellitePdcmService
) {
@GetMapping("/satellites")
fun allSatellites() = satelliteCatalogService.allSatellites()
@GetMapping("/satellites/slot-calculation-summaries")
fun satelliteSlotCalculationSummaries(@RequestParam satelliteIds: List<Long>) =
runCatching { slotService.slotCalculationSummaries(satelliteIds) }
.getOrElse { satelliteIds.distinct().map { satelliteId -> SlotCalculationSummaryDTO(satelliteId = satelliteId) } }
@GetMapping("/satellites/{satelliteId}")
fun satellite(@PathVariable satelliteId: Long) = satelliteCatalogService.satellite(satelliteId)
@GetMapping("/satellites/{satelliteId}/slot-calculation-summary")
fun satelliteSlotCalculationSummary(@PathVariable satelliteId: Long) =
runCatching { slotService.slotCalculationSummary(satelliteId) }
.getOrElse { SlotCalculationSummaryDTO(satelliteId = satelliteId) }
@GetMapping("/satellites/pdcm")
fun satellitesPdcm() = satellitePdcmService.satelliteSummaries()
@GetMapping("/satellites/pdcm/{satelliteId}")
fun satellitePdcm(@PathVariable satelliteId: Long) = satellitePdcmService.satelliteDetails(satelliteId)
@PostMapping("/satellites")
fun createSatellite(@Valid @RequestBody request: SatelliteCreateDTO) =
satelliteCatalogService.createSatellite(request)
@PutMapping("/satellites/{satelliteId}")
fun updateSatellite(
@PathVariable satelliteId: Long,
@Valid @RequestBody request: SatelliteUpdateDTO
) = satelliteCatalogService.updateSatellite(satelliteId, request)
@DeleteMapping("/satellites/{satelliteId}")
fun deleteSatellite(@PathVariable satelliteId: Long): ResponseEntity<Void> {
satelliteCatalogService.deleteSatellite(satelliteId)
return ResponseEntity.noContent().build()
}
@PostMapping("/satellites/{satelliteId}/observation-profile")
fun saveObservationProfile(
@PathVariable satelliteId: Long,
@RequestParam(defaultValue = "false") create: Boolean,
@Valid @RequestBody request: SatelliteObservationProfileDTO
) = satelliteCatalogService.saveObservationProfile(satelliteId, request, create)
@DeleteMapping("/satellites/{satelliteId}/observation-profile")
fun deleteObservationProfile(@PathVariable satelliteId: Long): ResponseEntity<Void> {
satelliteCatalogService.deleteObservationProfile(satelliteId)
return ResponseEntity.noContent().build()
}
@PostMapping("/satellites/{satelliteId}/slot-profile")
fun saveSlotProfile(
@PathVariable satelliteId: Long,
@RequestParam(defaultValue = "false") create: Boolean,
@Valid @RequestBody request: SatelliteSlotProfileDTO
) = satelliteCatalogService.saveSlotProfile(satelliteId, request, create)
@PostMapping("/satellites/{satelliteId}/slot-calculation")
fun startSlotCalculation(
@PathVariable satelliteId: Long,
@Valid @RequestBody request: SatelliteSlotProfileDTO
): ResponseEntity<Void> {
slotService.startSlotCalculation(satelliteId, request)
return ResponseEntity.noContent().build()
}
@DeleteMapping("/satellites/{satelliteId}/slot-profile")
fun deleteSlotProfile(@PathVariable satelliteId: Long): ResponseEntity<Void> {
satelliteCatalogService.deleteSlotProfile(satelliteId)
return ResponseEntity.noContent().build()
}
@GetMapping("/satellites/{satelliteId}/initial-conditions")
fun satelliteInitialConditions(@PathVariable satelliteId: Long): ResponseEntity<Any> {
val response = slotService.satelliteInitialConditions(satelliteId)
return if (response == null) {
ResponseEntity.noContent().build()
} else {
ResponseEntity.ok(response)
}
}
@PostMapping("/satellites/{satelliteId}/initial-conditions")
fun saveSatelliteInitialConditions(
@PathVariable satelliteId: Long,
@RequestParam(defaultValue = "false") create: Boolean,
@Valid @RequestBody request: InitialConditionsDTO
) = slotService.saveSatelliteInitialConditions(satelliteId, request, create)
@GetMapping("/groups")
fun allGroups() = satelliteCatalogService.allGroups()
@GetMapping("/groups/{groupId}")
fun group(@PathVariable groupId: Long) = satelliteCatalogService.group(groupId)
@GetMapping("/group-states")
fun allGroupStates() = groupStateService.groupSummaries()
@GetMapping("/group-states/{groupId}")
fun groupState(
@PathVariable groupId: Long,
@RequestParam(required = false) time: LocalDateTime?
) = groupStateService.groupState(groupId, time)
@PostMapping("/groups")
fun createGroup(@Valid @RequestBody request: SatelliteGroupCreateDTO) =
satelliteCatalogService.createGroup(request)
@PutMapping("/groups/{groupId}")
fun updateGroup(
@PathVariable groupId: Long,
@Valid @RequestBody request: SatelliteGroupUpdateDTO
) = satelliteCatalogService.updateGroup(groupId, request)
@DeleteMapping("/groups/{groupId}")
fun deleteGroup(@PathVariable groupId: Long): ResponseEntity<Void> {
satelliteCatalogService.deleteGroup(groupId)
return ResponseEntity.noContent().build()
}
}
@@ -0,0 +1,44 @@
package space.nstart.pcp.slots_service.controller
import org.springframework.stereotype.Controller
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp.slots_service.service.ComplexMissionService
@Controller
class ComplexPlanPageController {
@GetMapping("/com-plan")
fun complexPlanPage(): String = "complex-plan"
}
@RestController
@RequestMapping("/api/com-plan/runs")
class ComplexPlanRunsRestController(
private val complexMissionService: ComplexMissionService
) {
@GetMapping
@ResponseBody
fun runs() = complexMissionService.complexPlanRuns()
@GetMapping("/{id}")
@ResponseBody
fun run(@PathVariable id: Long) = complexMissionService.complexPlanRun(id)
@GetMapping("/{id}/modes")
@ResponseBody
fun runModes(@PathVariable id: Long) = complexMissionService.complexPlanRunModes(id)
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteRun(@PathVariable id: Long) {
complexMissionService.deleteComplexPlanRun(id)
}
}
@@ -0,0 +1,47 @@
package space.nstart.pcp.slots_service.controller
import jakarta.validation.Valid
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 space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO
import space.nstart.pcp.slots_service.service.MissionService
import space.nstart.pcp.slots_service.service.SatelliteCatalogService
import java.util.UUID
@RestController
@RequestMapping("/api/current-plans")
class CurrentPlansController(
private val satelliteCatalogService: SatelliteCatalogService,
private val missionService: MissionService
) {
@GetMapping("/satellites")
fun satellites() = satelliteCatalogService.allSatellites()
@GetMapping("/satellites/{satelliteId}/missions")
fun missions(
@PathVariable satelliteId: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "4") size: Int
) = missionService.missionsBySatellite(satelliteId, page, size)
@PostMapping("/missions")
fun createMission(@Valid @RequestBody body: MissionRequestDTO) = missionService.createMission(body)
@GetMapping("/missions/{missionId}/modes")
fun modes(@PathVariable missionId: UUID) = missionService.modesByMission(missionId)
@PostMapping("/missions/{missionId}/surveys/calculate")
fun calculateSurveys(
@PathVariable missionId: UUID,
@RequestParam(name = "comPlanSnapshotId", required = false) comPlanSnapshotId: Long?
) = missionService.calculateSurveys(missionId, comPlanSnapshotId)
@PostMapping("/missions/{missionId}/drops/calculate")
fun calculateDrops(@PathVariable missionId: UUID) = missionService.calculateDrops(missionId)
}
@@ -0,0 +1,67 @@
package space.nstart.pcp.slots_service.controller
import org.springframework.stereotype.Controller
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.ResponseBody
import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp.slots_service.dto.dynamicplan.DynamicPlanCalculationRequestDTO
import space.nstart.pcp.slots_service.service.DynamicPlanService
import java.util.UUID
@Controller
class DynamicPlanPageController {
@GetMapping("/dynamic-plan")
fun dynamicPlanPage(): String = "dynamic-plan"
}
@RestController
@RequestMapping("/api/dynamic-plan")
class DynamicPlanRestController(
private val dynamicPlanService: DynamicPlanService
) {
@PostMapping("/calc")
@ResponseBody
fun calculate(@RequestBody request: DynamicPlanCalculationRequestDTO) =
dynamicPlanService.calculate(request)
@GetMapping("/runs")
@ResponseBody
fun getRuns(
@RequestParam(required = false) status: String?,
@RequestParam(defaultValue = "50") limit: Int,
@RequestParam(defaultValue = "0") offset: Int
) = dynamicPlanService.getRuns(status, limit, offset)
@GetMapping("/runs/{runId}")
@ResponseBody
fun getRun(@PathVariable runId: UUID) =
dynamicPlanService.getRun(runId)
@GetMapping("/runs/{runId}/result")
@ResponseBody
fun getResult(@PathVariable runId: UUID) =
dynamicPlanService.getResult(runId)
@GetMapping("/runs/{runId}/routes")
@ResponseBody
fun getRoutes(
@PathVariable runId: UUID,
@RequestParam(defaultValue = "1000") limit: Int,
@RequestParam(defaultValue = "0") offset: Int
) = dynamicPlanService.getRoutes(runId, limit, offset)
@GetMapping("/runs/{runId}/intervals")
@ResponseBody
fun getIntervals(
@PathVariable runId: UUID,
@RequestParam(defaultValue = "1000") limit: Int,
@RequestParam(defaultValue = "0") offset: Int
) = dynamicPlanService.getIntervals(runId, limit, offset)
}
@@ -0,0 +1,103 @@
package space.nstart.pcp.slots_service.controller
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.DeleteMapping
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.RequestParam
import org.springframework.web.bind.annotation.ResponseBody
import reactor.core.publisher.Mono
import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookingRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
import space.nstart.pcp.slots_service.service.EarthService
import space.nstart.pcp.slots_service.service.ComplexMissionService
import space.nstart.pcp.slots_service.service.SlotService
import space.nstart.pcp.slots_service.service.StationService
import java.time.LocalDateTime
@Controller
class MapController {
@Autowired
private lateinit var earthService: EarthService
@Autowired
private lateinit var complexMissionService: ComplexMissionService
@Autowired
private lateinit var stationService: StationService
@Autowired
private lateinit var slotService: SlotService
@GetMapping("/map")
fun showMap(model: Model): Mono<String> {
model.addAttribute("requests", earthService.reqs().collectList().block())
model.addAttribute("satellites", complexMissionService.satellites().collectList().block())
model.addAttribute("stations", stationService.stations().collectList().block())
return Mono.just("map")
}
@PostMapping("/api/requests")
@ResponseBody
fun addRequest(@RequestBody request: RequestDTO): RequestDTO? {
return earthService.addRequest(request)
}
@PostMapping("/api/slots/booking/request")
@ResponseBody
fun bookRequestSlots(@RequestBody request: BookingRequestDTO): List<BookedSlotDTO> {
return slotService.bookReq(request)
}
@DeleteMapping("/api/slots/booking/request")
@ResponseBody
fun cancelRequestBooking(@RequestParam requestId: String): Int {
return slotService.cancelReq(requestId)
}
@GetMapping("/api/satellites/{satelliteId}/booked-slots")
@ResponseBody
fun satelliteBookedSlots(
@PathVariable satelliteId: Long,
@RequestParam timeStart: LocalDateTime,
@RequestParam timeStop: LocalDateTime
): List<SurveySlotDTO> {
return slotService.plan(satelliteId, timeStart, timeStop)
.collectList()
.block()
?: emptyList()
}
@GetMapping("/api/satellites/{satelliteId}/slots")
@ResponseBody
fun satelliteSlots(
@PathVariable satelliteId: Long,
@RequestParam timeStart: LocalDateTime,
@RequestParam timeStop: LocalDateTime
): List<SlotDTO> = slotService.allSlotsByInterval(satelliteId, timeStart, timeStop)
@PostMapping("/api/com-plan/process")
@ResponseBody
fun processComplexPlan(@RequestBody request: ComplexPlanProcessRequestDTO) =
complexMissionService.processComplexPlan(request)
// @GetMapping("/map")
// fun showMap() : Mono<Rendering>{
// val requests = earthService.reqs().toList()
//
// return Mono.just(
// Rendering
// .view("map")
// .modelAttribute("requests", requests)
// .build()
// )
// }
}
@@ -0,0 +1,60 @@
package space.nstart.pcp.slots_service.controller
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
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.ResponseBody
import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO
import space.nstart.pcp.slots_service.service.ComplexMissionService
import space.nstart.pcp.slots_service.service.SlotService
import space.nstart.pcp.slots_service.service.StationService
@Controller
class RvaPageController(
private val complexMissionService: ComplexMissionService,
private val stationService: StationService
) {
@GetMapping("/rva")
fun rvaPage(model: Model): String {
model.addAttribute("satellites", complexMissionService.satellites().collectList().block() ?: emptyList<Any>())
model.addAttribute("stations", stationService.stations().collectList().block() ?: emptyList<Any>())
return "rva"
}
}
@RestController
@RequestMapping("/api/rva")
class RvaRestController(
private val slotService: SlotService
) {
@PostMapping("/common/{satelliteId}/{duration}")
@ResponseBody
fun rvaCommon(
@PathVariable satelliteId: Long,
@PathVariable duration: Long,
@RequestBody stations: List<StationDTO>
) = slotService.rvaCommon(satelliteId, duration, stations)
@PostMapping("/merge/{satelliteId}/{duration}")
@ResponseBody
fun rvaMerge(
@PathVariable satelliteId: Long,
@PathVariable duration: Long,
@RequestBody stations: List<StationDTO>
) = slotService.rvaMerge(satelliteId, duration, stations)
@PostMapping("/merge-on-rev/{satelliteId}/{duration}")
@ResponseBody
fun rvaMergeOnRev(
@PathVariable satelliteId: Long,
@PathVariable duration: Long,
@RequestBody stations: List<StationDTO>
) = slotService.rvaMergeOnRev(satelliteId, duration, stations)
}
@@ -0,0 +1,46 @@
package space.nstart.pcp.slots_service.controller
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.DeleteMapping
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.RestController
import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO
import space.nstart.pcp.slots_service.configuration.CustomErrorException
import space.nstart.pcp.slots_service.service.StationService
@Controller
class StationCatalogPageController {
@GetMapping("/stations")
fun stationsPage(): String = "stations"
}
@RestController
@RequestMapping("/api/stations")
class StationCatalogRestController(
private val stationService: StationService
) {
@GetMapping
fun allStations() = stationService.allStations()
@GetMapping("/{stationId}")
fun station(@PathVariable stationId: String) =
stationService.station(stationId).block()
?: throw CustomErrorException("Не удалось получить станцию $stationId: пустой ответ")
@PostMapping
fun saveStation(@Valid @RequestBody request: StationDTO) = stationService.saveStation(request)
@DeleteMapping("/{stationId}")
fun deleteStation(@PathVariable stationId: String): ResponseEntity<Void> {
stationService.deleteStation(stationId)
return ResponseEntity.noContent().build()
}
}
@@ -0,0 +1,25 @@
package space.nstart.pcp.slots_service.czml
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import io.swagger.v3.oas.annotations.media.Schema
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "Картинка")
class CZMLBillboard (
@Schema(title = "")
val image : String?,
@Schema(title = "")
val scale : Double?,
val show : Boolean = true){
constructor() : this (null, null)
}
@@ -0,0 +1,35 @@
package space.nstart.pcp.slots_service.czml
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import io.swagger.v3.oas.annotations.media.Schema
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
import java.time.LocalDateTime
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "Часы")
class CZMLClock(
val interval : String = "",
val currentTime : String = "",
val multiplayer : Int = 1,
var range : String = "LOOP_STOP",
var step : String = "SYSTEM_CLOCK_MULTIPLIER"
) {
constructor(tn : LocalDateTime, tk : LocalDateTime, cur : LocalDateTime) : this(
interval ="${
tn.toString()
}/${
tk
.toString()
}",
currentTime = cur.toString()
)
}
@@ -0,0 +1,21 @@
package space.nstart.pcp.slots_service.czml
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import io.swagger.v3.oas.annotations.media.Schema
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "Узел CZML")
class CZMLColor(
var rgba : Iterable<Int>? = null
) {
constructor(r : Int, g : Int, b : Int, a : Int) : this (rgba = listOf(r,g,b,a))
}
@@ -0,0 +1,29 @@
package space.nstart.pcp.slots_service.czml
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import io.swagger.v3.oas.annotations.media.Schema
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "Узел CZML")
@JsonInclude(JsonInclude.Include.NON_NULL)
class CZMLEllipse(
val show : Boolean = true,
val semiMajorAxis : Double = 1.0,
val semiMinorAxis : Double = 1.0,
val extrudedHeight : Double = 0.0,
val height : Double = 0.0,
val fill : Boolean = false,
val outline : Boolean = true,
val outlineWidth : Double = 3.0,
val outlineColor : CZMLColor = CZMLColor(0,0,225,225)
) {
}
@@ -0,0 +1,33 @@
package space.nstart.pcp.slots_service.czml
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import io.swagger.v3.oas.annotations.media.Schema
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "Узел CZML")
@JsonInclude(JsonInclude.Include.NON_NULL)
class CZMLEntity (
val id : String,
val name : String,
val version : String? = null,
val description : String? = null,
val billboard : CZMLBillboard? = null,
val position : CZMLPosition? = null,
val ellipse : CZMLEllipse? = null,
val polyline : CZMLPolyLine? = null,
val point : CZMLPoint? = null,
val clock : CZMLClock? = null,
val polygon: CZMLPolygon? = null,
val path : CZMLPath? = null,
val model: CZMLModel? = null
){
}
@@ -0,0 +1,22 @@
package space.nstart.pcp.slots_service.czml
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import io.swagger.v3.oas.annotations.media.Schema
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "Узел CZML")
@JsonInclude(JsonInclude.Include.NON_NULL)
class CZMLInterval(
var interval : String = "",
var epoch : String = "",
var number : Iterable<Double> = mutableListOf()
) {
}
@@ -0,0 +1,28 @@
package space.nstart.pcp.slots_service.czml
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import io.swagger.v3.oas.annotations.media.Schema
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "Узел CZML")
@JsonInclude(JsonInclude.Include.NON_NULL)
class CZMLLabel(
val text : String,
val show: Boolean = true,
val style : String = "FILL",
val horizontalOrigin : String = "LEFT",
val verticalOrigin : String = "CENTER",
val font : String = "11pt Lucida Console",
val scale : Int = 1,
val fillColor : CZMLColor = CZMLColor(0,0,0,0),
val outLineColor: CZMLSolidColor? = null
) {
}
@@ -0,0 +1,22 @@
package space.nstart.pcp.slots_service.czml
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import io.swagger.v3.oas.annotations.media.Schema
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "Узел CZML")
@JsonInclude(JsonInclude.Include.NON_NULL)
class CZMLMaterial(
val solidColor: CZMLSolidColor? = null,
) {
constructor(r : Int, g : Int, b : Int, a : Int) : this(CZMLSolidColor(r, g, b, a))
}
@@ -0,0 +1,26 @@
package space.nstart.pcp.slots_service.czml
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import io.swagger.v3.oas.annotations.media.Schema
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "Узел CZML")
@JsonInclude(JsonInclude.Include.NON_NULL)
class CZMLModel(
val gltf : String? = null,
val uri : String? = null,
val minimumPixelSize: Int = 64,
val maximumScale: Int = 10000,
val scale: Int = 1000,
val show: Boolean = true,
val shadows : String = "DISABLED"
) {
}
@@ -0,0 +1,107 @@
package space.nstart.pcp.slots_service.czml
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import io.swagger.v3.oas.annotations.media.Schema
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO
import java.time.LocalDateTime
import java.time.ZoneOffset
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "Узел CZML")
@JsonInclude(JsonInclude.Include.NON_NULL)
class CZMLPath(
var show: Boolean = true,
val width: Int = 1,
val resolution: Int = 120,
val material: CZMLMaterial = CZMLMaterial(255, 0, 0, 0),
var leadTime: Iterable<CZMLInterval> = listOf(),
var trailTime: Iterable<CZMLInterval> = listOf()
) {
private fun fromDateTime(t: LocalDateTime) = t.toEpochSecond(ZoneOffset.UTC) + t.nano / 1e9
constructor(
vuz: Iterable<AscNodeDTO>, epoch: LocalDateTime, epochStop: LocalDateTime, width: Int, resolution: Int,
material: CZMLMaterial
) : this(width = width, resolution = resolution, material = material) {
var t = epoch.toEpochSecond(ZoneOffset.UTC).toDouble()
var lt = mutableListOf<CZMLInterval>()
var tt = mutableListOf<CZMLInterval>()
for (v in vuz) {
var dt = fromDateTime(v.time) - t
if (dt > 1) {
lt.add(
CZMLInterval(
"${
LocalDateTime.ofEpochSecond((t).toLong(), (t % 1 * 1e9).toInt(), ZoneOffset.UTC)
}/${
LocalDateTime.ofEpochSecond(fromDateTime(v.time).toLong(), (fromDateTime(v.time) % 1 * 1e9).toInt(), ZoneOffset.UTC)
.toString()
}",
LocalDateTime.ofEpochSecond((t).toLong(), (t % 1 * 1e9).toInt(), ZoneOffset.UTC).toString(),
listOf(0.0, dt, dt, 0.0)
)
)
tt.add(
CZMLInterval(
"${
LocalDateTime.ofEpochSecond(t.toLong(), ((t) % 1 * 1e9).toInt(), ZoneOffset.UTC)
}/${
LocalDateTime.ofEpochSecond((fromDateTime(v.time)).toLong(), (fromDateTime(v.time) % 1 * 1e9).toInt(), ZoneOffset.UTC)
.toString()
}",
LocalDateTime.ofEpochSecond((t).toLong(), (t % 1 * 1e9).toInt(), ZoneOffset.UTC).toString(),
listOf(0.0, 0.0, dt, dt)
)
)
}
t = fromDateTime(v.time)
}
var tk = epochStop.toEpochSecond(ZoneOffset.UTC).toDouble()
var dt = tk - t
if (dt > 1) {
lt.add(
CZMLInterval(
"${
LocalDateTime.ofEpochSecond((t).toLong(), (t % 1 * 1e9).toInt(), ZoneOffset.UTC)
}/${
LocalDateTime.ofEpochSecond((tk).toLong(), (tk % 1 * 1e9).toInt(), ZoneOffset.UTC)
.toString()
}",
LocalDateTime.ofEpochSecond((t).toLong(), (t % 1 * 1e9).toInt(), ZoneOffset.UTC).toString(),
listOf(0.0, dt, dt, 0.0)
)
)
tt.add(
CZMLInterval(
"${
LocalDateTime.ofEpochSecond((t).toLong(), (t % 1 * 1e9).toInt(), ZoneOffset.UTC)
}/${
LocalDateTime.ofEpochSecond((tk).toLong(), (tk % 1 * 1e9).toInt(), ZoneOffset.UTC)
.toString()
}",
LocalDateTime.ofEpochSecond((t).toLong(), (t % 1 * 1e9).toInt(), ZoneOffset.UTC).toString(),
listOf(0.0, 0.0, dt, dt)
)
)
}
leadTime = lt
trailTime = tt
}
}
@@ -0,0 +1,23 @@
package space.nstart.pcp.slots_service.czml
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import io.swagger.v3.oas.annotations.media.Schema
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "Узел CZML")
@JsonInclude(JsonInclude.Include.NON_NULL)
class CZMLPoint(
val show : Boolean = true,
val pixelSize : Double = 2.0,
val color : CZMLColor = CZMLColor(255,0,0,255)
){
}
@@ -0,0 +1,23 @@
package space.nstart.pcp.slots_service.czml
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import io.swagger.v3.oas.annotations.media.Schema
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "Узел CZML")
@JsonInclude(JsonInclude.Include.NON_NULL)
class CZMLPolyLine(
val show : Boolean = true,
val width : Double = 1.0,
val followSurface : Boolean = false,
val material : CZMLMaterial? = null,
val positions : CZMLPosition
) {
}
@@ -0,0 +1,30 @@
package space.nstart.pcp.slots_service.czml
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import io.swagger.v3.oas.annotations.media.Schema
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "Узел CZML")
@JsonInclude(JsonInclude.Include.NON_NULL)
class CZMLPolygon(
val positions : CZMLPosition,
val show : Boolean = true,
val perPositionHeight : Boolean = false,
val stRotation : Double = 0.0,
val extrudedHeight : Double = 0.0,
val height : Double = 0.0,
val fill : Boolean = false,
val outline : Boolean = true,
val outlineWidth : Double = 3.0,
val outlineColor : CZMLColor = CZMLColor(0,0,225,225),
val material : CZMLMaterial? = null
) {
}
@@ -0,0 +1,26 @@
package space.nstart.pcp.slots_service.czml
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import io.swagger.v3.oas.annotations.media.Schema
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "Положение")
class CZMLPosition(
val referenceFrame : String = "FIXED",
val cartesian : Iterable<Double>? = null,
val cartographicRadians : Iterable<Double>? = null,
val cartographicDegrees : Iterable<Double>? = null,
val interpolationAlgorithm :String? = null,
val interpolationDegree : Double? = null,
val epoch : String? = null
){
}
@@ -0,0 +1,21 @@
package space.nstart.pcp.slots_service.czml
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import io.swagger.v3.oas.annotations.media.Schema
import lombok.AllArgsConstructor
import lombok.Data
import lombok.NoArgsConstructor
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "Узел CZML")
@JsonInclude(JsonInclude.Include.NON_NULL)
class CZMLSolidColor(
val color : CZMLColor
) {
constructor(r : Int, g : Int, b : Int, a : Int) :this (color = CZMLColor(r,g,b,a))
}
@@ -0,0 +1,16 @@
package space.nstart.pcp.slots_service.dto.currentplans
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionResponseDTO
/**
* Страница миссий выбранного спутника для UI "Текущие планы".
* Пагинация выполняется в ui-service поверх существующего API mission-service.
*/
data class CurrentPlansMissionPageDTO(
val satelliteId: Long,
val page: Int,
val size: Int,
val totalElements: Int,
val totalPages: Int,
val items: List<MissionResponseDTO>
)
@@ -0,0 +1,6 @@
package space.nstart.pcp.slots_service.dto.dynamicplan
enum class DynamicPlanCalculationModeDTO {
FULL,
GREEDY
}
@@ -0,0 +1,14 @@
package space.nstart.pcp.slots_service.dto.dynamicplan
import java.time.LocalDateTime
import java.util.UUID
data class DynamicPlanCalculationRequestDTO(
val requestId: UUID,
val satelliteIds: List<Long>,
val calculationStart: LocalDateTime,
val calculationEnd: LocalDateTime,
val calculationMode: DynamicPlanCalculationModeDTO = DynamicPlanCalculationModeDTO.FULL,
val revolutionMode: DynamicPlanRevolutionModeDTO = DynamicPlanRevolutionModeDTO.BOTH,
val filterCoveredRoutes: Boolean = false
)
@@ -0,0 +1,7 @@
package space.nstart.pcp.slots_service.dto.dynamicplan
enum class DynamicPlanRevolutionModeDTO {
BOTH,
ASC,
DESC
}
@@ -0,0 +1,45 @@
package space.nstart.pcp.slots_service.dto.groupstate
import java.time.LocalDateTime
data class GroupStateAvailabilityDTO(
val timeStart: LocalDateTime,
val timeStop: LocalDateTime
)
data class GroupStateSummaryDTO(
val groupId: Long,
val groupName: String,
val satelliteCount: Int,
val availableInterval: GroupStateAvailabilityDTO? = null
)
data class GroupStateSatelliteDTO(
val satelliteId: Long,
val noradId: Long?,
val code: String,
val name: String,
val omegabDeg: Double,
val uDeg: Double
)
data class GroupStateMatrixCellDTO(
val rowSatelliteId: Long,
val columnSatelliteId: Long,
val deltaOmegabDeg: Double,
val deltaUDeg: Double
)
data class GroupStateMatrixRowDTO(
val satelliteId: Long,
val cells: List<GroupStateMatrixCellDTO>
)
data class GroupStateDetailsDTO(
val groupId: Long,
val groupName: String,
val availableInterval: GroupStateAvailabilityDTO? = null,
val calculationTime: LocalDateTime? = null,
val satellites: List<GroupStateSatelliteDTO> = emptyList(),
val matrix: List<GroupStateMatrixRowDTO> = emptyList()
)
@@ -0,0 +1,26 @@
package space.nstart.pcp.slots_service.dto.pdcm
import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO
import java.time.LocalDateTime
data class SatellitePdcmAvailabilityDTO(
val timeStart: LocalDateTime,
val timeStop: LocalDateTime
)
data class SatellitePdcmSummaryDTO(
val satelliteId: Long,
val code: String,
val name: String,
val activeInterval: Boolean,
val availableInterval: SatellitePdcmAvailabilityDTO?
)
data class SatellitePdcmDetailsDTO(
val satelliteId: Long,
val code: String,
val name: String,
val activeInterval: Boolean,
val availableInterval: SatellitePdcmAvailabilityDTO?,
val ascNodes: List<AscNodeDTO> = emptyList()
)
@@ -0,0 +1,119 @@
package space.nstart.pcp.slots_service.rest
import org.springframework.beans.factory.annotation.Autowired
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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.slots_service.service.CZMLService
import space.nstart.pcp.slots_service.service.CoverageSchemeService
import java.time.LocalDateTime
@RestController
@RequestMapping("/requests")
class RequestRestController {
@Autowired
private lateinit var czmlService: CZMLService
@Autowired
private lateinit var coverageSchemeService: CoverageSchemeService
@PostMapping()
fun test (
@RequestParam importance: Double,
@RequestParam(required = false) countLat: Int?,
@RequestParam(required = false) countLong: Int?,
) =
czmlService.drawHeatMap(importance, countLat, countLong)
//czmlService.showWithCells()
@PostMapping("/req-with-cells/{id}")
fun reqWithCells(@PathVariable("id") id : String) =
czmlService.showWithCells(id)
@PostMapping("/plan/{satellite_id}")
fun plan(@PathVariable("satellite_id") id : Long,
@RequestParam tn : LocalDateTime,
@RequestParam tk : LocalDateTime,
@RequestParam(required = false) runId: Long?) =
czmlService.showSatPlan(id, tn, tk, runId)
@GetMapping("/plan/{satellite_id}")
fun planGet(
@PathVariable("satellite_id") id: Long,
@RequestParam tn: LocalDateTime,
@RequestParam tk: LocalDateTime,
@RequestParam(required = false) runId: Long?
) = czmlService.showSatPlan(id, tn, tk, runId)
@PostMapping("/sat/{satellite_id}")
fun sat(
@PathVariable("satellite_id") id : Long,
@RequestParam view : Boolean,
@RequestParam orbit : Boolean,
@RequestParam sw : Boolean,
@RequestParam fl : Boolean,
@RequestParam(required = false) tn : LocalDateTime?,
@RequestParam(required = false) tk : LocalDateTime?) =
czmlService.satFLD(id, view, orbit, sw, fl, tn, tk)
@PostMapping("/station/{station_id}")
fun sat(@PathVariable("station_id") id : String, @RequestParam view : Boolean) =
czmlService.station(id, view)
@PostMapping("req-with-covs/{request_id}")
fun covs(
@PathVariable("request_id") id : String,
@RequestParam tn : LocalDateTime,
@RequestParam tk : LocalDateTime,
@RequestParam sign : RevolutionSign?,
@RequestParam cov : Boolean?,
@RequestParam(required = false) sun : Double?,
@RequestParam(name = "satellites", required = false) satellites : List<Long>?,
@RequestParam(name = "satellites[]", required = false) satellitesArray : List<Long>?
) = czmlService.covs(
id,
tn,
tk,
sign,
cov,
sun,
satellites?.takeIf { it.isNotEmpty() } ?: satellitesArray?.takeIf { it.isNotEmpty() }
)
@PostMapping("req-with-coverage-scheme/{request_id}")
fun coverageScheme(
@PathVariable("request_id") id: String,
@RequestParam tn: LocalDateTime,
@RequestParam tk: LocalDateTime,
@RequestParam(name = "satellites", required = false) satellites: List<Long>?,
@RequestParam(name = "satellites[]", required = false) satellitesArray: List<Long>?,
@RequestParam(required = false) rollStepDegrees: Double?,
@RequestParam(required = false) minimumTechnologyOverlap: Double?,
@RequestParam(required = false) strictContinuousCoverage: Boolean?,
@RequestParam(required = false) allowPartialCoverage: Boolean?,
@RequestParam(required = false) orientationToleranceDegrees: Double?,
@RequestParam(required = false) includeDebugInfo: Boolean?
) = coverageSchemeService.requestCoverageScheme(
id,
tn,
tk,
satellites?.takeIf { it.isNotEmpty() } ?: satellitesArray?.takeIf { it.isNotEmpty() } ?: emptyList(),
rollStepDegrees,
minimumTechnologyOverlap,
strictContinuousCoverage,
allowPartialCoverage,
orientationToleranceDegrees,
includeDebugInfo
)
}
@@ -0,0 +1,81 @@
package space.nstart.pcp.slots_service.service
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 org.springframework.web.util.UriComponentsBuilder
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
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.slots_service.configuration.CustomErrorException
import java.net.URI
import java.time.LocalDateTime
@Service
class BallisticsService (webClientBuilderProvider: ObjectProvider<WebClient.Builder>){
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
@Value("\${settings.ballistics-service:ballistics-service}")
val url = ""
fun ascNodes(id : Long, timeStart: LocalDateTime? = null, timeStop: LocalDateTime? = null) =
webClientBuilder.build()
.get()
.uri(withTimeInterval("/api/satellites/$id/asc-node", timeStart, timeStop))
.retrieve()
.bodyToFlux(AscNodeDTO::class.java)
fun flightLine(id : Long, timeStart: LocalDateTime? = null, timeStop: LocalDateTime? = null) =
webClientBuilder.build()
.get()
.uri(withTimeInterval("/api/satellites/$id/flight-line", timeStart, timeStop))
.retrieve()
.bodyToFlux(FlightLineDTO::class.java)
fun points(id : Long, timeStart: LocalDateTime? = null, timeStop: LocalDateTime? = null) =
webClientBuilder.build()
.get()
.uri(withTimeInterval("/api/satellites/$id/orbit", timeStart, timeStop))
.retrieve()
.bodyToFlux(OrbPointDTO::class.java)
fun orbitAvailability(): List<SatelliteOrbitAvailabilityDTO> =
webClientBuilder.build()
.get()
.uri("$url/api/satellites/orbit/availability")
.retrieve()
.bodyToFlux(SatelliteOrbitAvailabilityDTO::class.java)
.collectList()
.block()
?: emptyList()
fun exactTimePoint(id: Long, time: LocalDateTime): OrbPointDTO =
webClientBuilder.build()
.post()
.uri("$url/api/satellites/$id/extract-time")
.bodyValue(ExactTimePositionRequestDTO(time = time))
.retrieve()
.bodyToFlux(OrbPointDTO::class.java)
.collectList()
.block()
?.firstOrNull()
?: throw CustomErrorException("Не удалось получить положение спутника $id на время $time")
private fun withTimeInterval(
path: String,
timeStart: LocalDateTime?,
timeStop: LocalDateTime?
): URI {
val builder = UriComponentsBuilder.fromUriString("$url$path")
timeStart?.let { builder.queryParam("time_start", it) }
timeStop?.let { builder.queryParam("time_stop", it) }
return builder.build().toUri()
}
}
@@ -0,0 +1,565 @@
package space.nstart.pcp.slots_service.service
import org.locationtech.jts.geom.Polygon
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
import org.springframework.web.bind.annotation.RequestParam
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyModeResponseDTO
import space.nstart.pcp.pcp_types_lib.utils.wkt.WKTParser
import space.nstart.pcp.slots_service.configuration.CustomErrorException
import space.nstart.pcp.slots_service.czml.*
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneOffset
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.asin
import kotlin.math.sin
@Service
class CZMLService {
private data class SatelliteCzmlStyle(
val outlineColor: CZMLColor,
val material: CZMLMaterial
)
val logger : Logger = LoggerFactory.getLogger(this.javaClass)
@Autowired
private lateinit var earthService: EarthService
@Autowired
private lateinit var complexMissionService: ComplexMissionService
@Autowired
private lateinit var ballisticsService: BallisticsService
@Autowired
private lateinit var slotService: SlotService
private val docNameHeatMap = "heatMap"
private val docNameReq = "req_"
private val entNameReq = "request_"
private val entityNip = "cell_"
private val docPlan = "plan_"
private val entityMar = "survey_"
private val docStationName = "station_"
private val entityStationName = "station_"
private val docReqCovName ="req_cov_"
@Autowired
private lateinit var stationService: StationService
private fun ellipseAx(hOrb: Double, gamma: Double): Double {
val rz = 6378100.0
val gam = asin(rz / (rz + hOrb) * sin(PI / 2.0 + gamma))
val a = PI - gam - PI / 2 - gamma
return rz * a
}
fun showSatPlan(id : Long , tn : LocalDateTime,
tk : LocalDateTime,
runId: Long? = null) : Iterable<CZMLEntity>{
val docId = docPlan + id
val wktParser = WKTParser()
val r: MutableList<CZMLEntity> = mutableListOf(CZMLEntity("document", docId , "1.1"))
var ind = 0
val sat = complexMissionService.satellite(id).block()
val mars = complexMissionService.plan(id, tn, tk, runId).collectList().block()
if (sat != null) {
println("${sat.red} ${sat.green} ${sat.blue}")
val ents = mars?.mapNotNull { mar ->
val poly = wktParser.parseWKT(mar.contourWkt?:"")
if (poly is Polygon) {
val pos = mutableListOf<Double>()
poly.coordinates.forEach { coordinate ->
pos.add(coordinate.x)
pos.add(coordinate.y)
pos.add(0.0)
}
CZMLEntity(
entityMar + "${id}_${++ind}", "Маршрут КА $id$ind", null,
description = "Время начала : ${mar.startTime}<br>" +
"Время конца : ${mar.startTime.plusSeconds(mar.duration.toLong())}<br>" +
"Виток : ${mar.revolution}<br>" +
"Крен : ${mar.roll}<br>" +
"Широта : ${mar.lat}"+
"Долгота : ${mar.longitude}"+
"Слотов : ${mar.bookedSlotIds.size}"
,
polygon = CZMLPolygon(
positions = CZMLPosition(
cartographicDegrees = pos
),
outlineWidth = 2.0,
fill = true,
outlineColor = CZMLColor(sat.red.toInt(), sat.green.toInt(), sat.blue.toInt(), 255),
material = CZMLMaterial(sat.red.toInt(), sat.green.toInt(), sat.blue.toInt(), 150)
)
)
} else {
logger.warn("Контур маршрута не ПОЛИГОН! ${mar.contourWkt}")
null
}
}
if (ents!=null)
r.addAll(ents)
// val slotEnts = mutableListOf<CZMLEntity>()
//
// slotSurvs?.forEach { mar ->
//
// if (mar is SurveyModeResponseDTO) {
//
// val poly = wktParser.parseWKT(mar.contourWkt ?: "") as Polygon
// val pos = mutableListOf<Double>()
// poly.coordinates.forEach { coordinate ->
// pos.add(coordinate.x)
// pos.add(coordinate.y)
// pos.add(0.0)
// }
//
//
// slotEnts.add(
// CZMLEntity(
// entityMar + "${id}_${++ind}", "Маршрут бронирования КА $id №$ind", null,
// description = "Время начала : ${mar.timeStart}<br>" +
// "Время конца : ${mar.timeStart?.plusSeconds(mar.duration.toLong())}<br>" +
// "Виток : ${mar.revolution}<br>" +
// "Крен : ${mar.roll}<br>" +
// "Слотов : ${mar.bookedSlotIds.size}",
// polygon = CZMLPolygon(
// positions = CZMLPosition(
// cartographicDegrees = pos
// ),
// outlineWidth = 2.0,
// fill = true,
// outlineColor = CZMLColor(sat.red.toInt(), sat.green.toInt(), sat.blue.toInt(), 255),
// material = CZMLMaterial(sat.red.toInt(), sat.green.toInt(), sat.blue.toInt(), 150)
//
// )
//
// )
// )
// }
// }
// r.addAll(slotEnts)
}
return r
}
fun showWithCells(idReq : String) : Iterable<CZMLEntity>{
val id = idReq//"b9bad368-be24-410f-afa6-c140bacdbd8f"
val req = earthService.reqcells(id)?:return listOf()
val wktParser = WKTParser()
val r: MutableList<CZMLEntity> = mutableListOf(CZMLEntity("document", docNameReq+id , "1.1"))
// r.addAll(req.cells.map { cell ->
//
// val poly = wktParser.parseWKT(cell.contour) as Polygon
//
//
// val pos = mutableListOf<Double>()
// poly.coordinates.forEach { coordinate ->
// pos.add(coordinate.x)
// pos.add(coordinate.y)
// pos.add(0.0)
// }
//
// val alfa = ((cell.importance / 1.2) * 200).toInt()
// println( alfa)
// CZMLEntity(
// entityNip + "${cell.id}", cell.id.toString(), null,
//
// polygon = CZMLPolygon(
// positions = CZMLPosition(
// cartographicDegrees = pos
// ),
// outlineWidth = 2.0,
// fill = true,
// outlineColor = CZMLColor(255,0,0,255),
// material = CZMLMaterial(255,0,0,alfa)
//
// )
//
// )
// }
// )
val poly = wktParser.parseWKT(req.request.contour) as Polygon
val pos = mutableListOf<Double>()
poly.coordinates.forEach { coordinate ->
pos.add(coordinate.x)
pos.add(coordinate.y)
pos.add(0.0)
}
r.add(
CZMLEntity(
entNameReq + "${req.request.requestId}", req.request.name, null,
polygon = CZMLPolygon(
positions = CZMLPosition(
cartographicDegrees = pos
),
outlineWidth = 2.0,
fill = true,
outlineColor = CZMLColor(191, 0, 255,255),
material = CZMLMaterial(191, 0, 255,150)
),
description = "важность : ${req.request.importance} <br> id : ${req.request.requestId}"
))
return r
}
fun drawHeatMap(importance: Double, countLat: Int? = null, countLong: Int? = null): Iterable<CZMLEntity> {
val cells = earthService.cells(0.0, countLat, countLong).toList()
val wktParser = WKTParser()
val r: MutableList<CZMLEntity> = mutableListOf(CZMLEntity("document", docNameHeatMap, "1.1"))
if (!cells.any()) {
return r
}
val importanceMax = cells.maxOf { it.importance }
if (importanceMax <= 0.0) {
return r
}
r.addAll(cells.filter { it.importance / importanceMax >= importance }.map { cell ->
val poly = wktParser.parseWKT(cell.contour) as Polygon
val pos = mutableListOf<Double>()
poly.coordinates.forEach { coordinate ->
pos.add(coordinate.x)
pos.add(coordinate.y)
pos.add(0.0)
}
val importance = cell.importance / importanceMax
val alfa = 180
CZMLEntity(
entityNip + "${cell.id}", cell.id.toString(), null,
description = "важность : $importance",
polygon = CZMLPolygon(
positions = CZMLPosition(
cartographicDegrees = pos
),
outlineWidth = 2.0,
fill = true,
outlineColor = CZMLColor(255, 0, 0, 255),
material = CZMLMaterial(255, (255 * (1 - importance)).toInt(), 0, alfa)
)
)
}
)
return r
}
class CovsDTO(
val cov : List<CZMLEntity> = listOf(),
val slots : List<SlotDTO> = listOf()
)
private fun SatelliteInfoDTO.toSlotStyle() = SatelliteCzmlStyle(
outlineColor = CZMLColor(red.toInt(), green.toInt(), blue.toInt(), 255),
material = CZMLMaterial(red.toInt(), green.toInt(), blue.toInt(), 150)
)
private fun defaultSlotMaterial(slotStatus: SlotStatus) =
when (slotStatus) {
SlotStatus.BOOKED -> CZMLMaterial(0, (255 * (1 - 0.5)).toInt(), 0, 200)
SlotStatus.BUSY -> CZMLMaterial(125, 125, 125, 200)
else -> CZMLMaterial(255, (255 * (1 - 0.5)).toInt(), 0, 150)
}
fun covs(
id : String,
tn : LocalDateTime,
tk : LocalDateTime,
sign : RevolutionSign?,
cover : Boolean?,
sun : Double?,
satellites : List<Long>?
): CovsDTO {
println("$id $tn $tk $sign $cover $sun $satellites")
// val req = earthService.reqcells(id)?:return listOf()
val r: MutableList<CZMLEntity> = mutableListOf(CZMLEntity("document", docReqCovName + id, "1.1"))
val wktParser = WKTParser()
try {
val slots = slotService.slots(id, tn, tk, sign, cover, satellites, sun).collectList().block()?.toList() ?: return CovsDTO()
val satelliteStyles = slots
.map { it.satelliteId }
.distinct()
.associateWith { satelliteId ->
complexMissionService.satellite(satelliteId).block()?.toSlotStyle()
}
r.addAll(slots.map { slot ->
val poly = wktParser.parseWKT(slot.contour) as Polygon
val satelliteStyle = satelliteStyles[slot.satelliteId]
val pos = mutableListOf<Double>()
poly.coordinates.forEach { coordinate ->
pos.add(coordinate.x)
pos.add(coordinate.y)
pos.add(0.0)
}
CZMLEntity(
"slot_${slot.satelliteId}_${slot.cycle}_${slot.slotNumber}",
"Слот",
"1.1",
description = "КА №${slot.satelliteId}<br>" +
"Виток ${slot.revolution} ${slot.revolutionSign}<br>" +
"ДМВ ${slot.tn}<br>" +
"Длительность ${abs(Duration.between(slot.tn, slot.tk).seconds)}<br>" +
"Крен ${slot.roll}<br>" +
"№слота ${slot.slotNumber}<br>" +
"Цикл ${slot.cycle}",
polygon = CZMLPolygon(
positions = CZMLPosition(
cartographicDegrees = pos
),
outlineWidth = 2.0,
fill = true,
outlineColor = satelliteStyle?.outlineColor ?: CZMLColor(255, 0, 0, 255),
material = satelliteStyle?.material ?: defaultSlotMaterial(slot.state)
),
polyline = CZMLPolyLine(
width = 2.0,
material = satelliteStyle?.material ?: CZMLMaterial(255, 0, 0, 255),
positions = CZMLPosition(
cartographicDegrees = if (pos.size >= 3) pos + pos.take(3) else pos
)
)
)
})
return CovsDTO(r, slots)
}
catch (ex : CustomErrorException){
logger.warn(ex.message)
throw ex
}
}
fun station(id : String, view : Boolean) : Iterable<CZMLEntity>{
val req = stationService.station(id).block()?:return listOf()
val radius = ellipseAx( (500.0 * 1000.0), req.elevationMin * PI / 180)
val r: MutableList<CZMLEntity> = mutableListOf(CZMLEntity("document", docStationName+id , "1.1"))
r.add(
CZMLEntity(
entityStationName + "${req.id}", req.name, null,
billboard = CZMLBillboard(
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAScwAAEnMBjCK5BwAAABl0RVh0U29mdHdhcmUAcGFpbnQubmV0IDQuMC4xOdTWsmQAAAFuSURBVEhLlZRRlsUgCEO79C6tO3O4SBSp7bzmnDSCkDw/Zo7W2mEw8UM7z3Mhvbj32a8Eph2cMb2u66YfQgSvlwAZvYXEvMzEgeTnd6CXAWoMn0LQ2HED7YgB3VlrNsevfDPPIaxqvdfDy80h8E4GPUzEXQjUblWDm0PgHcASoPeFfed0NQxzCGjeQD+/or5AM5MesJjD/nGZL8gGb+ZSelG7X2b/xDCQAaCvAFF9IGP0KaR/0kJV7na/nJp+vlfNmDgPsSjUECDFSKb0ZJ5m3RPOQwyCne7MUfq5TrNrgBdhklFDFJDNsnkN2QYw8KQ1RDV3Us0ZakB8DNm0KjMyRuk9hSwBGOD/SwjIQfSq1oCxwF8lCrKpmGvtYCrSHyE2O8ylnfuQJ51780Xet/sRoH9YGtQZZLP/qH2n7aSAlb3fXwJZBm9aWQLuLzBoxloz6FcuATs1aGaQO3FnCsdMLHmxU4NmtnSTDft9O/4AurwQU2nT95gAAAAASUVORK5CYII=",
1.0,
show = view
),
position = CZMLPosition(
cartographicDegrees = listOf(req.position.long, req.position.lat, req.position.height)
),
ellipse = CZMLEllipse(
semiMajorAxis = radius,
semiMinorAxis = radius,
outlineColor = CZMLColor(250, 0, 0, 250),
extrudedHeight = 1000.0,
height = 1000.0
),
description = "Станция ${req.name}",
model = CZMLModel(
gltf = "/model/station.glb",
show = !view
)
))
return r
}
fun fromDateTime(t: LocalDateTime) = t.toEpochSecond(ZoneOffset.UTC) + t.nano / 1e9
fun satFLD(
id : Long,
view : Boolean,
showOrbit : Boolean,
showSw : Boolean,
showFl : Boolean,
timeStart: LocalDateTime? = null,
timeStop: LocalDateTime? = null
): Iterable<CZMLEntity> {
val tstart = timeStart ?: LocalDateTime.now()
val tstop = timeStop ?: tstart.plusDays(1)
var vuz = ballisticsService.ascNodes(id, tstart, tstop).collectList().block()
vuz = vuz?.filter { it.time >= tstart && it.time <= tstop }?.sortedBy { it.time }
val fl = ballisticsService.flightLine(id, tstart, tstop).collectList().block()
?.filter { it.time >= tstart && it.time <= tstop }
?.sortedBy { it.time }
val orbP = ballisticsService.points(id, tstart, tstop).collectList().block()
?.filter { it.time >= tstart && it.time <= tstop }
?.sortedBy { it.time }
val tn = fromDateTime(tstart)
val sat = complexMissionService.satellite(id).block()
val doc = mutableListOf<CZMLEntity>()
if (sat != null && fl != null && vuz != null && orbP != null) {
val flr = mutableListOf<Double>()
val fll = mutableListOf<Double>()
val sw = mutableListOf<Double>()
val orb = mutableListOf<Double>()
for (op in fl) {
fll.addAll(listOf(fromDateTime(op.time) - tn, op.longLeft, op.latLeft, 50.0))
flr.addAll(listOf(fromDateTime(op.time) - tn, op.longRight, op.latRight, 50.0))
sw.addAll(listOf(fromDateTime(op.time) - tn, op.long, op.lat, 50.0))
}
for (op in orbP)
orb.addAll(listOf(fromDateTime(op.time) - tn, op.x, op.y, op.z))
val path = CZMLPath(
vuz,
tstart,
tstop,
1,
120,
CZMLMaterial(
sat.red.toInt(),
sat.green.toInt(),
sat.blue.toInt(),
250
)
)
val sats = listOf(
// "sat",
"radarSat",
"CloudSat")
doc.addAll(
mutableListOf(
CZMLEntity(
id = "document", name = "sat_$id", version = "1.1",
clock = CZMLClock(tstart, tstop, tstart)
),
CZMLEntity(
id = "swath_r_ka_$id", name = sat.name,
position = CZMLPosition(
cartographicDegrees = flr,
interpolationAlgorithm = "LAGRANGE",
interpolationDegree = 1.0,
epoch = tstart.toString()
),
path = path.apply { show = showSw }
),
CZMLEntity(
id = "swath_l_ka_$id", name = sat.name,
position = CZMLPosition(
cartographicDegrees = fll,
interpolationAlgorithm = "LAGRANGE",
interpolationDegree = 1.0,
epoch = tstart.toString()
),
path = path.apply { show = showSw }
),
CZMLEntity(
id = "flight_line_ka_$id", name = sat.name,
point = CZMLPoint(
pixelSize = 5.0, color = CZMLColor(
sat.red.toInt(),
sat.green.toInt(),
sat.blue.toInt(),
250
),
show = showFl
),
position = CZMLPosition(
cartographicDegrees = sw,
interpolationAlgorithm = "LAGRANGE",
interpolationDegree = 1.0,
epoch = tstart.toString()
),
path = path.apply { show = showFl }
),
CZMLEntity(
id = "orbit_$id", name = sat.name,
point = CZMLPoint(
pixelSize = 8.0,
color = CZMLColor(
sat.red.toInt(),
sat.green.toInt(),
sat.blue.toInt(),
250
),
show = view
),
model = CZMLModel(
gltf = "/model/${sats.shuffled().first()}.glb",
show = !view
),
// billboard = CZMLBillboard(
// "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAScwAAEnMBjCK5BwAAABl0RVh0U29mdHdhcmUAcGFpbnQubmV0IDQuMC4xOdTWsmQAAAFuSURBVEhLlZRRlsUgCEO79C6tO3O4SBSp7bzmnDSCkDw/Zo7W2mEw8UM7z3Mhvbj32a8Eph2cMb2u66YfQgSvlwAZvYXEvMzEgeTnd6CXAWoMn0LQ2HED7YgB3VlrNsevfDPPIaxqvdfDy80h8E4GPUzEXQjUblWDm0PgHcASoPeFfed0NQxzCGjeQD+/or5AM5MesJjD/nGZL8gGb+ZSelG7X2b/xDCQAaCvAFF9IGP0KaR/0kJV7na/nJp+vlfNmDgPsSjUECDFSKb0ZJ5m3RPOQwyCne7MUfq5TrNrgBdhklFDFJDNsnkN2QYw8KQ1RDV3Us0ZakB8DNm0KjMyRuk9hSwBGOD/SwjIQfSq1oCxwF8lCrKpmGvtYCrSHyE2O8ylnfuQJ51780Xet/sRoH9YGtQZZLP/qH2n7aSAlb3fXwJZBm9aWQLuLzBoxloz6FcuATs1aGaQO3FnCsdMLHmxU4NmtnSTDft9O/4AurwQU2nT95gAAAAASUVORK5CYII=",
// 1.0
// ),
position = CZMLPosition(
cartesian = orb,
interpolationAlgorithm = "LAGRANGE",
interpolationDegree = 1.0,
epoch = tstart.toString()
),
path = path.apply { show = showOrbit }
)
)
)
}
return doc
}
}
@@ -0,0 +1,167 @@
package space.nstart.pcp.slots_service.service
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 reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
import space.nstart.pcp.slots_service.configuration.CustomErrorException
import tools.jackson.databind.JsonNode
import tools.jackson.databind.ObjectMapper
import java.time.LocalDateTime
@Service
class ComplexMissionService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
@Value("\${settings.complex-mission-service:pcp-complex-mission-service}")
val complexMissionUrl = ""
@Value("\${settings.satellite-catalog-service:\${settings.complex-mission-service:pcp-satellite-catalog-service}}")
val satelliteCatalogUrl = ""
fun satellites() =
webClientBuilder.build()
.get()
.uri("$satelliteCatalogUrl/api/satellites")
.retrieve()
.bodyToFlux(SatelliteSummaryDTO::class.java)
.map { it.toLegacyInfo() }
fun satellite(sat : Long) =
webClientBuilder.build()
.get()
.uri("$satelliteCatalogUrl/api/satellites/$sat")
.retrieve()
.bodyToMono(SatelliteDTO::class.java)
.map { it.toLegacyInfo() }
fun plan(sat : Long, tn : LocalDateTime, tk : LocalDateTime, runId: Long? = null) : Flux<SatelliteModeResponseDTO> {
val runIdParam = runId?.let { "&runId=$it" }.orEmpty()
val s = "$complexMissionUrl/api/satellites/$sat/modes?intervalStart=$tn&intervalEnd=$tk$runIdParam"
return webClientBuilder.build()
.get()
.uri(s)
.retrieve()
.bodyToFlux(SatelliteModeResponseDTO::class.java)
}
fun processComplexPlan(request: ComplexPlanProcessRequestDTO): JsonNode =
webClientBuilder.build()
.post()
.uri("$complexMissionUrl/api/com-plan/process")
.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()
fun complexPlanRuns(): JsonNode =
webClientBuilder.build()
.get()
.uri("$complexMissionUrl/api/com-plan/runs")
.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 complexPlanRun(id: Long): JsonNode =
webClientBuilder.build()
.get()
.uri("$complexMissionUrl/api/com-plan/runs/$id")
.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()
fun complexPlanRunModes(id: Long): JsonNode =
webClientBuilder.build()
.get()
.uri("$complexMissionUrl/api/com-plan/runs/$id/modes")
.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 deleteComplexPlanRun(id: Long) {
webClientBuilder.build()
.delete()
.uri("$complexMissionUrl/api/com-plan/runs/$id")
.retrieve()
.onStatus({ status -> status.isError }) { response ->
response.bodyToMono(String::class.java)
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
.toBodilessEntity()
.block()
}
private fun extractErrorMessage(body: String): String {
val text = body.trim()
if (text.isEmpty()) return "error"
return runCatching {
val node = ObjectMapper().readTree(text)
when {
node.hasNonNull("error") -> node["error"].asText()
node.hasNonNull("message") -> node["message"].asText()
else -> text
}
}.getOrDefault(text)
}
private fun SatelliteSummaryDTO.toLegacyInfo() = SatelliteInfoDTO(
noradId = id,
name = name,
red = visualization.red,
green = visualization.green,
blue = visualization.blue,
scanTLE = scanTle,
code = code,
typeCode = typeCode,
active = active
)
private fun SatelliteDTO.toLegacyInfo(): SatelliteInfoDTO {
return SatelliteInfoDTO(
noradId = id,
name = name,
red = visualization.red,
green = visualization.green,
blue = visualization.blue,
captureAngle = observationProfile?.captureAngle ?: 1.5,
scanTLE = scanTle,
maxSurveyDurationSeconds = observationProfile?.durationMaxSeconds,
mmiSeconds = observationProfile?.mmiSeconds,
code = code,
typeCode = typeCode,
active = active
)
}
}
@@ -0,0 +1,194 @@
package space.nstart.pcp.slots_service.service
import org.locationtech.jts.geom.Polygon
import org.springframework.beans.factory.ObjectProvider
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeCalculateRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
import space.nstart.pcp.pcp_types_lib.utils.wkt.WKTParser
import space.nstart.pcp.slots_service.configuration.CustomErrorException
import space.nstart.pcp.slots_service.czml.CZMLColor
import space.nstart.pcp.slots_service.czml.CZMLEntity
import space.nstart.pcp.slots_service.czml.CZMLMaterial
import space.nstart.pcp.slots_service.czml.CZMLPolygon
import space.nstart.pcp.slots_service.czml.CZMLPosition
import tools.jackson.databind.ObjectMapper
import java.time.LocalDateTime
@Service
class CoverageSchemeService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
private val wktParser = WKTParser()
@Value("\${settings.coverage-scheme-service:pcp-coverage-scheme-service}")
val url = ""
@Autowired
private lateinit var earthService: EarthService
@Autowired
private lateinit var complexMissionService: ComplexMissionService
fun requestCoverageScheme(
requestId: String,
timeStart: LocalDateTime,
timeStop: LocalDateTime,
satelliteIds: List<Long>,
rollStepDegrees: Double?,
minimumTechnologyOverlap: Double?,
strictContinuousCoverage: Boolean?,
allowPartialCoverage: Boolean?,
orientationToleranceDegrees: Double?,
includeDebugInfo: Boolean?
): CoverageSchemeUiResponseDTO {
if (satelliteIds.isEmpty()) {
throw CustomErrorException("Для расчета схемы покрытия выберите хотя бы один спутник")
}
val request = earthService.reqcells(requestId)?.request
?: throw CustomErrorException("Заявка $requestId не найдена")
val response = webClientBuilder.build()
.post()
.uri("$url/api/coverage-schemes/calculate")
.bodyValue(
CoverageSchemeCalculateRequestDTO(
polygonWkt = request.contour,
satelliteIds = satelliteIds,
timeStart = timeStart,
timeEnd = timeStop,
rollStepDegrees = rollStepDegrees,
minimumTechnologyOverlap = minimumTechnologyOverlap,
strictContinuousCoverage = strictContinuousCoverage ?: true,
allowPartialCoverage = allowPartialCoverage,
orientationToleranceDegrees = orientationToleranceDegrees,
includeDebugInfo = includeDebugInfo ?: true
)
)
.retrieve()
.onStatus({ status -> status.isError }) { httpResponse ->
httpResponse.bodyToMono(String::class.java)
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
.bodyToMono(CoverageSchemeResponseDTO::class.java)
.block()
?: CoverageSchemeResponseDTO()
val rows = response.coverageScheme
.mapIndexed { index, slot ->
val sequence = index + 1
CoverageSchemeModeRowDTO(
entityId = buildCoverageModeEntityId(requestId, sequence, slot.satelliteId),
sequence = sequence,
satelliteId = slot.satelliteId,
tn = slot.tn,
tk = slot.tk,
roll = slot.roll,
revolutionSign = slot.revolutionSign,
contour = slot.contour
)
}
val satelliteById = rows
.map { it.satelliteId }
.distinct()
.associateWith { satelliteId -> complexMissionService.satellite(satelliteId).block() }
val entities = buildList {
add(CZMLEntity("document", "req_cov_scheme_$requestId", "1.1"))
rows.mapNotNullTo(this) { row ->
row.toCzmlEntity(satelliteById[row.satelliteId])
}
}
return CoverageSchemeUiResponseDTO(
cov = entities,
slots = rows
)
}
private fun CoverageSchemeModeRowDTO.toCzmlEntity(satellite: SatelliteInfoDTO?): CZMLEntity? {
val geometry = runCatching { wktParser.parseWKT(contour) }.getOrNull()
if (geometry !is Polygon) {
return null
}
val positions = mutableListOf<Double>()
geometry.coordinates.forEach { coordinate ->
positions.add(coordinate.x)
positions.add(coordinate.y)
positions.add(0.0)
}
return CZMLEntity(
id = entityId,
name = "Режим схемы покрытия",
version = "1.1",
description = buildString {
append("Последовательность ${sequence}<br>")
append("КА №${satelliteId}<br>")
append("ДМВ ${tn}<br>")
append("Длительность ${java.time.Duration.between(tn, tk).seconds}<br>")
append("Крен ${roll ?: "-"}<br>")
append("Ветвь ${revolutionSign ?: "-"}")
if (!selectionReason.isNullOrBlank()) {
append("<br>Причина выбора ${selectionReason}")
}
},
polygon = CZMLPolygon(
positions = CZMLPosition(cartographicDegrees = positions),
outlineWidth = 2.0,
fill = true,
outlineColor = satellite?.toOutlineColor() ?: CZMLColor(30, 144, 255, 255),
material = satellite?.toMaterial() ?: CZMLMaterial(30, 144, 255, 150)
)
)
}
private fun SatelliteInfoDTO.toOutlineColor() =
CZMLColor(red.toInt(), green.toInt(), blue.toInt(), 255)
private fun SatelliteInfoDTO.toMaterial() =
CZMLMaterial(red.toInt(), green.toInt(), blue.toInt(), 150)
private fun buildCoverageModeEntityId(requestId: String, sequence: Int, satelliteId: Long) =
"coverage_mode_${requestId}_${satelliteId}_$sequence"
private fun extractErrorMessage(body: String): String {
val text = body.trim()
if (text.isEmpty()) return "error"
return runCatching {
val node = ObjectMapper().readTree(text)
when {
node.hasNonNull("error") -> node["error"].asText("")
node.hasNonNull("message") -> node["message"].asText("")
else -> text
}
}.getOrDefault(text)
}
}
class CoverageSchemeUiResponseDTO(
val cov: List<CZMLEntity> = listOf(),
val slots: List<CoverageSchemeModeRowDTO> = listOf()
)
class CoverageSchemeModeRowDTO(
val entityId: String = "",
val sequence: Int = 0,
val satelliteId: Long = 0,
val tn: LocalDateTime = LocalDateTime.now(),
val tk: LocalDateTime = LocalDateTime.now(),
val roll: Double? = null,
val revolutionSign: RevolutionSign? = null,
val contour: String = "",
val selectionReason: String? = null
)
@@ -0,0 +1,122 @@
package space.nstart.pcp.slots_service.service
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 reactor.core.publisher.Mono
import space.nstart.pcp.slots_service.configuration.CustomErrorException
import space.nstart.pcp.slots_service.dto.dynamicplan.DynamicPlanCalculationRequestDTO
import tools.jackson.databind.JsonNode
import tools.jackson.databind.ObjectMapper
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.UUID
@Service
class DynamicPlanService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
@Value("\${settings.dynamic-plan-service:pcp-dynamic-plan-service}")
private lateinit var dynamicPlanUrl: String
fun calculate(request: DynamicPlanCalculationRequestDTO): JsonNode =
webClientBuilder.build()
.post()
.uri("$dynamicPlanUrl/v1/main/calcPlan")
.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()
fun getRun(runId: UUID): JsonNode =
webClientBuilder.build()
.get()
.uri("$dynamicPlanUrl/v1/main/calcPlan/$runId")
.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()
fun getRuns(status: String?, limit: Int, offset: Int): JsonNode {
val statusParam = status
?.takeIf { value -> value.isNotBlank() }
?.let { value -> "&status=${URLEncoder.encode(value, StandardCharsets.UTF_8)}" }
?: ""
return webClientBuilder.build()
.get()
.uri("$dynamicPlanUrl/v1/main/calcPlan/runs?limit=$limit&offset=$offset$statusParam")
.retrieve()
.onStatus({ statusCode -> statusCode.isError }) { response ->
response.bodyToMono(String::class.java)
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
.bodyToMono(JsonNode::class.java)
.block() ?: ObjectMapper().createArrayNode()
}
fun getResult(runId: UUID): JsonNode =
webClientBuilder.build()
.get()
.uri("$dynamicPlanUrl/v1/main/calcPlan/$runId/result")
.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()
fun getRoutes(runId: UUID, limit: Int, offset: Int): JsonNode =
webClientBuilder.build()
.get()
.uri("$dynamicPlanUrl/v1/main/calcPlan/$runId/routes?limit=$limit&offset=$offset")
.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()
fun getIntervals(runId: UUID, limit: Int, offset: Int): JsonNode =
webClientBuilder.build()
.get()
.uri("$dynamicPlanUrl/v1/main/calcPlan/$runId/intervals?limit=$limit&offset=$offset")
.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"
return runCatching {
val node = ObjectMapper().readTree(text)
when {
node.hasNonNull("error") -> node["error"].asText()
node.hasNonNull("message") -> node["message"].asText()
else -> text
}
}.getOrDefault(text)
}
}
@@ -0,0 +1,298 @@
package space.nstart.pcp.slots_service.service
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
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 reactor.core.publisher.Flux
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.RequestWithCellsDTO
import java.util.UUID
@Service
/**
* Request/grid reader backed by pcp-request-service.
*/
class EarthService(
webClientBuilderProvider: ObjectProvider<WebClient.Builder>,
@param:Value("\${settings.request-service:\${settings.earth-grid-service:pcp-request-service}}")
private val url: String = "pcp-request-service",
) {
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
fun cells(importance: Double, countLat: Int? = null, countLong: Int? = null): Iterable<EarthCellDTO> {
/**
* countLat/countLong remain in the UI contract for compatibility.
* TODO: forward them when pcp-request-service restores aggregation for GET /v1/cells.
*/
val firstPage = cellsPage(importance, page = 0)
val items = firstPage.items.toMutableList()
for (page in 1 until firstPage.totalPages) {
items += cellsPage(importance, page).items
}
return items.map { cell -> cell.toEarthCellDto() }
}
private fun cellsPage(importance: Double, page: Int): CellsListResponseDto =
webClientBuilder.build()
.get()
.uri(cellsUri(importance, page))
.retrieve()
.bodyToMono(CellsListResponseDto::class.java)
.block() ?: CellsListResponseDto()
private fun cellsUri(importance: Double, page: Int): String =
"$url/v1/cells?minImportance=$importance&page=$page&size=$CELLS_PAGE_SIZE"
fun reqcells(id : String): RequestWithCellsDTO? =
webClientBuilder.build()
.get()
.uri("$url/v1/requests/$id/with-cells")
.retrieve()
.bodyToMono(RequestWithCellsResponseDto::class.java)
.block()
?.toRequestWithCellsDto()
fun reqs(): Flux<RequestDTO> {
val firstPage = requestsPage(page = 0)
val summaries = firstPage.items.toMutableList()
for (page in 1 until firstPage.totalPages) {
summaries += requestsPage(page).items
}
val requests = summaries.map { summary -> requestDetails(summary.id).toRequestDto() }
return Flux.fromIterable(requests)
}
private fun requestsPage(page: Int): RequestsListResponseDto =
webClientBuilder.build()
.get()
.uri("$url/v1/requests?page=$page&size=$REQUESTS_PAGE_SIZE")
.retrieve()
.bodyToMono(RequestsListResponseDto::class.java)
.block() ?: RequestsListResponseDto()
private fun requestDetails(id: UUID): RequestDetailsResponseDto =
webClientBuilder.build()
.get()
.uri("$url/v1/requests/$id")
.retrieve()
.bodyToMono(RequestDetailsResponseDto::class.java)
.block() ?: RequestDetailsResponseDto(id = id)
fun addRequest(request: RequestDTO): RequestDTO? =
webClientBuilder.build()
.post()
.uri("$url/v1/requests")
.bodyValue(request.toCreateRequestRequestDto())
.retrieve()
.bodyToMono(CreateRequestResponseDto::class.java)
.block()
?.toRequestDto(request)
private fun CellSummaryResponseDto.toEarthCellDto(): EarthCellDTO =
EarthCellDTO(
id = cellNum,
num = cellNum,
latitude = latitude,
longitude = longitude,
importance = importance,
contour = contour,
)
private fun RequestDetailsResponseDto.toRequestDto(): RequestDTO =
RequestDTO(
requestId = id,
name = name,
importance = importance,
contour = geometry,
)
private fun RequestWithCellsResponseDto.toRequestWithCellsDto(): RequestWithCellsDTO =
RequestWithCellsDTO(
request = request.toRequestDto(),
cells = cells.map { cell -> cell.toEarthCellDto() },
)
private fun RequestResponseDto.toRequestDto(): RequestDTO =
RequestDTO(
requestId = id,
name = name,
importance = importance,
contour = geometry,
)
private fun RequestDTO.toCreateRequestRequestDto(): CreateRequestRequestDto =
CreateRequestRequestDto(
id = UUID.randomUUID(),
name = name,
geometry = contour,
importance = importance,
beginDateTime = DEFAULT_BEGIN_DATE_TIME,
endDateTime = DEFAULT_END_DATE_TIME,
kpp = emptyList(),
highPriorityTransmit = false,
optics = DEFAULT_OPTICS,
rsa = null,
)
private fun CreateRequestResponseDto.toRequestDto(source: RequestDTO): RequestDTO =
RequestDTO(
requestId = id,
importance = importance,
contour = source.contour,
name = name,
)
private fun RequestCellResponseDto.toEarthCellDto(): EarthCellDTO =
EarthCellDTO(
id = cellNum,
num = cellNum,
importance = importance,
contour = contour,
)
private companion object {
const val CELLS_PAGE_SIZE = 500
const val REQUESTS_PAGE_SIZE = 500
const val DEFAULT_BEGIN_DATE_TIME = "2026-01-01T00:00:00Z"
const val DEFAULT_END_DATE_TIME = "2026-12-31T23:59:59Z"
val DEFAULT_OPTICS = OpticsParamsDto(
resultType = "PANCHROMATIC",
resolution = 5.0,
sunAngleMin = 10.0,
sunAngleMax = 90.0,
clouds = 100.0,
)
}
}
data class CreateRequestRequestDto(
val id: UUID,
val name: String,
val geometry: String,
val importance: Double,
val beginDateTime: String,
val endDateTime: String,
val kpp: List<Int>,
val highPriorityTransmit: Boolean,
val optics: OpticsParamsDto?,
val rsa: RsaParamsDto?,
)
data class OpticsParamsDto(
val resultType: String,
val resolution: Double,
val sunAngleMin: Double? = null,
val sunAngleMax: Double? = null,
val clouds: Double? = null,
)
data class RsaParamsDto(
val resultType: String,
val interferometry: Boolean? = null,
val polarisation: String,
val resolution: Double,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CreateRequestResponseDto(
val id: UUID = UUID(0L, 0L),
val name: String = "",
val status: String = "",
val surveyType: String = "",
val importance: Double = 0.0,
val message: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class RequestWithCellsResponseDto(
val request: RequestResponseDto = RequestResponseDto(),
val cells: List<RequestCellResponseDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class RequestResponseDto(
val id: UUID = UUID(0L, 0L),
val name: String = "",
val status: String = "",
val surveyType: String = "",
val geometry: String = "",
val importance: Double = 0.0,
val beginDateTime: String = "",
val endDateTime: String = "",
val kpp: List<Int> = emptyList(),
val highPriorityTransmit: Boolean = false,
val optics: Any? = null,
val rsa: Any? = null,
val coverage: CoverageStateDto = CoverageStateDto(),
val createdAt: String = "",
val updatedAt: String = "",
val deletedAt: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class RequestCellResponseDto(
val cellNum: Long = 0,
val coveragePercent: Double = 0.0,
val importance: Double = 0.0,
val contour: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CoverageStateDto(
val requiredPercent: Double = 0.0,
val currentPercent: Double = 0.0,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class RequestsListResponseDto(
val items: List<RequestSummaryResponseDto> = emptyList(),
val page: Int = 0,
val size: Int = 0,
val totalItems: Long = 0,
val totalPages: Int = 0,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class RequestSummaryResponseDto(
val id: UUID,
val name: String = "",
val status: String = "",
val surveyType: String = "",
val importance: Double = 0.0,
val beginDateTime: String = "",
val endDateTime: String = "",
val kpp: List<Int> = emptyList(),
val highPriorityTransmit: Boolean = false,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class RequestDetailsResponseDto(
val id: UUID,
val name: String = "",
val geometry: String = "",
val importance: Double = 0.0,
)
data class CellsListResponseDto(
val items: List<CellSummaryResponseDto> = emptyList(),
val page: Int = 0,
val size: Int = 0,
val totalItems: Long = 0,
val totalPages: Int = 0,
)
data class CellSummaryResponseDto(
val cellNum: Long = 0,
val latitude: Double = 0.0,
val longitude: Double = 0.0,
val importance: Double = 0.0,
val contour: String = "",
)
@@ -0,0 +1,160 @@
package space.nstart.pcp.slots_service.service
import ballistics.Ballistics
import ballistics.types.OrbitalPoint
import ballistics.utils.fromDateTime
import ballistics.utils.math.Vector3D
import org.springframework.stereotype.Service
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.satellite.SatelliteGroupDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO
import space.nstart.pcp.slots_service.configuration.CustomErrorException
import space.nstart.pcp.slots_service.dto.groupstate.GroupStateAvailabilityDTO
import space.nstart.pcp.slots_service.dto.groupstate.GroupStateDetailsDTO
import space.nstart.pcp.slots_service.dto.groupstate.GroupStateMatrixCellDTO
import space.nstart.pcp.slots_service.dto.groupstate.GroupStateMatrixRowDTO
import space.nstart.pcp.slots_service.dto.groupstate.GroupStateSatelliteDTO
import space.nstart.pcp.slots_service.dto.groupstate.GroupStateSummaryDTO
import java.time.LocalDateTime
import kotlin.math.abs
@Service
class GroupStateService(
private val satelliteCatalogService: SatelliteCatalogService,
private val ballisticsService: BallisticsService
) {
fun groupSummaries(): List<GroupStateSummaryDTO> {
val groups = satelliteCatalogService.allGroups()
val satellitesById = satelliteCatalogService.allSatellites().associateBy { it.id }
val availabilityByNoradId = ballisticsService.orbitAvailability().associateBy { it.satelliteId }
return groups.map { group ->
GroupStateSummaryDTO(
groupId = group.id,
groupName = group.name,
satelliteCount = group.satelliteIds.size,
availableInterval = resolveAvailability(group, satellitesById, availabilityByNoradId)
)
}
}
fun groupState(groupId: Long, time: LocalDateTime?): GroupStateDetailsDTO {
val group = satelliteCatalogService.group(groupId)
val satellitesById = satelliteCatalogService.allSatellites().associateBy { it.id }
val availabilityByNoradId = ballisticsService.orbitAvailability().associateBy { it.satelliteId }
val groupSatellites = resolveGroupSatellites(group, satellitesById)
val availableInterval = resolveAvailability(group, satellitesById, availabilityByNoradId)
if (availableInterval == null) {
return GroupStateDetailsDTO(
groupId = group.id,
groupName = group.name,
availableInterval = null
)
}
val calculationTime = time ?: availableInterval.timeStart
if (calculationTime < availableInterval.timeStart || calculationTime > availableInterval.timeStop) {
throw CustomErrorException(
"Время $calculationTime не входит в доступный интервал ${availableInterval.timeStart} - ${availableInterval.timeStop}"
)
}
val satellites = groupSatellites.map { satellite ->
val noradId = satellite.id
?: throw CustomErrorException("Для спутника ${satellite.id} не задан NORAD ID")
val point = ballisticsService.exactTimePoint(noradId, calculationTime)
val keplerParams = Ballistics().calculateKeplerParams(point.toOrbitalPoint())
GroupStateSatelliteDTO(
satelliteId = satellite.id,
noradId = noradId,
code = satellite.code,
name = satellite.name,
omegabDeg = normalizeDegrees360(Math.toDegrees(keplerParams.omegab)),
uDeg = normalizeDegrees360(Math.toDegrees(keplerParams.u))
)
}
val matrix = satellites.map { row ->
GroupStateMatrixRowDTO(
satelliteId = row.satelliteId,
cells = satellites.map { column ->
GroupStateMatrixCellDTO(
rowSatelliteId = row.satelliteId,
columnSatelliteId = column.satelliteId,
deltaOmegabDeg = normalizeDegrees180(column.omegabDeg - row.omegabDeg),
deltaUDeg = normalizeDegrees180(column.uDeg - row.uDeg)
)
}
)
}
return GroupStateDetailsDTO(
groupId = group.id,
groupName = group.name,
availableInterval = availableInterval,
calculationTime = calculationTime,
satellites = satellites,
matrix = matrix
)
}
private fun resolveGroupSatellites(
group: SatelliteGroupDTO,
satellitesById: Map<Long, SatelliteSummaryDTO>
): List<SatelliteSummaryDTO> = group.satelliteIds.mapNotNull { satellitesById[it] }
private fun resolveAvailability(
group: SatelliteGroupDTO,
satellitesById: Map<Long, SatelliteSummaryDTO>,
availabilityByNoradId: Map<Long, SatelliteOrbitAvailabilityDTO>
): GroupStateAvailabilityDTO? {
val groupSatellites = resolveGroupSatellites(group, satellitesById)
if (group.satelliteIds.isEmpty() || groupSatellites.size != group.satelliteIds.size) {
return null
}
val intervals = groupSatellites.map { satellite ->
val noradId = satellite.id
availabilityByNoradId[noradId] ?: return null
}
if (intervals.isEmpty()) {
return null
}
// Общее время расчета ОГ существует только там, где пересекаются интервалы всех спутников.
val timeStart = intervals.maxOf { it.timeStart }
val timeStop = intervals.minOf { it.timeStop }
return if (timeStart <= timeStop) {
GroupStateAvailabilityDTO(timeStart = timeStart, timeStop = timeStop)
} else {
null
}
}
private fun OrbPointDTO.toOrbitalPoint(): OrbitalPoint =
OrbitalPoint(
t = fromDateTime(time),
vit = revolution.toInt(),
r = Vector3D(x, y, z),
v = Vector3D(vx, vy, vz)
)
private fun normalizeDegrees360(value: Double): Double {
var normalized = value % 360.0
if (normalized < 0) {
normalized += 360.0
}
return normalized
}
private fun normalizeDegrees180(value: Double): Double {
var normalized = normalizeDegrees360(value)
if (normalized > 180.0) {
normalized -= 360.0
}
return if (abs(normalized) < 1e-9) 0.0 else normalized
}
}
@@ -0,0 +1,116 @@
package space.nstart.pcp.slots_service.service
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 org.springframework.web.reactive.function.client.bodyToMono
import org.springframework.web.reactive.function.client.bodyToFlux
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionSurveyCalculationResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeResponseDTO
import space.nstart.pcp.slots_service.dto.currentplans.CurrentPlansMissionPageDTO
import java.util.UUID
import java.time.LocalDateTime
@Service
class MissionService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
@Value("\${settings.mission-service:pcp-complex-mission-service}")
val url = ""
fun plan(sat : Long, tn : LocalDateTime, tk : LocalDateTime) =
webClientBuilder.build()
.get()
.uri("$url/api/missions/modes/satellite?satellite_id=$sat&timeStart=$tn&timeStop=$tk")
.retrieve()
.bodyToFlux<ModeResponseDTO>()
fun allMissions(): List<MissionResponseDTO> =
webClientBuilder.build()
.get()
.uri("$url/api/missions")
.retrieve()
.bodyToFlux(MissionResponseDTO::class.java)
.collectList()
.block()
?: emptyList()
fun missionsBySatellite(satelliteId: Long, page: Int, size: Int): CurrentPlansMissionPageDTO {
val normalizedPage = page.coerceAtLeast(0)
val normalizedSize = size.coerceIn(1, 100)
val filtered = allMissions()
.asSequence()
.filter { it.satelliteId == satelliteId }
.sortedWith(
compareByDescending<MissionResponseDTO> { it.missionStart ?: it.missionStop ?: LocalDateTime.MIN }
.thenByDescending { it.missionStop ?: LocalDateTime.MIN }
.thenByDescending { it.missionId.toString() }
)
.toList()
val from = (normalizedPage * normalizedSize).coerceAtMost(filtered.size)
val to = (from + normalizedSize).coerceAtMost(filtered.size)
val totalPages = if (filtered.isEmpty()) 0 else ((filtered.size - 1) / normalizedSize) + 1
return CurrentPlansMissionPageDTO(
satelliteId = satelliteId,
page = normalizedPage,
size = normalizedSize,
totalElements = filtered.size,
totalPages = totalPages,
items = filtered.subList(from, to)
)
}
fun createMission(body: MissionRequestDTO): MissionResponseDTO =
webClientBuilder.build()
.post()
.uri("$url/api/missions")
.bodyValue(body)
.retrieve()
.bodyToMono<MissionResponseDTO>()
.block()
?: error("Mission service returned empty response while creating mission")
fun calculateSurveys(missionId: UUID, comPlanSnapshotId: Long? = null): MissionSurveyCalculationResponseDTO {
val targetUrl = buildString {
append("$url/api/missions/$missionId/surveys/calculate")
if (comPlanSnapshotId != null) {
append("?comPlanSnapshotId=")
append(comPlanSnapshotId)
}
}
return webClientBuilder.build()
.post()
.uri(targetUrl)
.retrieve()
.bodyToMono<MissionSurveyCalculationResponseDTO>()
.block()
?: error("Mission service returned empty survey calculation response")
}
fun calculateDrops(missionId: UUID) {
webClientBuilder.build()
.post()
.uri("$url/api/missions/$missionId/drops/calculate")
.retrieve()
.toBodilessEntity()
.block()
}
fun modesByMission(missionId: UUID): List<ModeResponseDTO> =
webClientBuilder.build()
.get()
.uri("$url/api/missions/$missionId/modes")
.retrieve()
.bodyToFlux<ModeResponseDTO>()
.collectList()
.block()
?: emptyList()
}
@@ -0,0 +1,187 @@
package space.nstart.pcp.slots_service.service
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 reactor.core.publisher.Mono
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteCreateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupCreateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupUpdateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteUpdateDTO
import space.nstart.pcp.slots_service.configuration.CustomErrorException
import tools.jackson.databind.ObjectMapper
@Service
class SatelliteCatalogService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
@Value("\${settings.satellite-catalog-service:\${settings.complex-mission-service:pcp-satellite-catalog-service}}")
val satelliteCatalogUrl = ""
fun allSatellites(): List<SatelliteSummaryDTO> =
webClientBuilder.build()
.get()
.uri("$satelliteCatalogUrl/api/satellites")
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.bodyToFlux(SatelliteSummaryDTO::class.java)
.collectList()
.block()
?: emptyList()
fun satellite(id: Long): SatelliteDTO =
webClientBuilder.build()
.get()
.uri("$satelliteCatalogUrl/api/satellites/$id")
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.bodyToMono(SatelliteDTO::class.java)
.block() ?: throw CustomErrorException("Не удалось получить спутник $id: пустой ответ")
fun createSatellite(request: SatelliteCreateDTO): SatelliteDTO =
webClientBuilder.build()
.post()
.uri("$satelliteCatalogUrl/api/satellites")
.bodyValue(request)
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.bodyToMono(SatelliteDTO::class.java)
.block() ?: throw CustomErrorException("Не удалось создать спутник: пустой ответ")
fun updateSatellite(id: Long, request: SatelliteUpdateDTO): SatelliteDTO =
webClientBuilder.build()
.put()
.uri("$satelliteCatalogUrl/api/satellites/$id")
.bodyValue(request)
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.bodyToMono(SatelliteDTO::class.java)
.block() ?: throw CustomErrorException("Не удалось обновить спутник $id: пустой ответ")
fun deleteSatellite(id: Long) {
webClientBuilder.build()
.delete()
.uri("$satelliteCatalogUrl/api/satellites/$id")
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.toBodilessEntity()
.block()
}
fun saveObservationProfile(id: Long, request: SatelliteObservationProfileDTO, create: Boolean): SatelliteObservationProfileDTO {
val methodSpec = if (create) webClientBuilder.build().post() else webClientBuilder.build().put()
return methodSpec
.uri("$satelliteCatalogUrl/api/satellites/$id/observation-profile")
.bodyValue(request)
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.bodyToMono(SatelliteObservationProfileDTO::class.java)
.block() ?: throw CustomErrorException("Не удалось сохранить observation-profile для КА $id")
}
fun deleteObservationProfile(id: Long) {
webClientBuilder.build()
.delete()
.uri("$satelliteCatalogUrl/api/satellites/$id/observation-profile")
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.toBodilessEntity()
.block()
}
fun saveSlotProfile(id: Long, request: SatelliteSlotProfileDTO, create: Boolean): SatelliteSlotProfileDTO {
val methodSpec = if (create) webClientBuilder.build().post() else webClientBuilder.build().put()
return methodSpec
.uri("$satelliteCatalogUrl/api/satellites/$id/slot-profile")
.bodyValue(request)
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.bodyToMono(SatelliteSlotProfileDTO::class.java)
.block() ?: throw CustomErrorException("Не удалось сохранить slot-profile для КА $id")
}
fun deleteSlotProfile(id: Long) {
webClientBuilder.build()
.delete()
.uri("$satelliteCatalogUrl/api/satellites/$id/slot-profile")
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.toBodilessEntity()
.block()
}
fun allGroups(): List<SatelliteGroupDTO> =
webClientBuilder.build()
.get()
.uri("$satelliteCatalogUrl/api/satellite-groups")
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.bodyToFlux(SatelliteGroupDTO::class.java)
.collectList()
.block()
?: emptyList()
fun group(id: Long): SatelliteGroupDTO =
webClientBuilder.build()
.get()
.uri("$satelliteCatalogUrl/api/satellite-groups/$id")
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.bodyToMono(SatelliteGroupDTO::class.java)
.block() ?: throw CustomErrorException("Не удалось получить группировку $id: пустой ответ")
fun createGroup(request: SatelliteGroupCreateDTO): SatelliteGroupDTO =
webClientBuilder.build()
.post()
.uri("$satelliteCatalogUrl/api/satellite-groups")
.bodyValue(request)
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.bodyToMono(SatelliteGroupDTO::class.java)
.block() ?: throw CustomErrorException("Не удалось создать группировку: пустой ответ")
fun updateGroup(id: Long, request: SatelliteGroupUpdateDTO): SatelliteGroupDTO =
webClientBuilder.build()
.put()
.uri("$satelliteCatalogUrl/api/satellite-groups/$id")
.bodyValue(request)
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.bodyToMono(SatelliteGroupDTO::class.java)
.block() ?: throw CustomErrorException("Не удалось обновить группировку $id: пустой ответ")
fun deleteGroup(id: Long) {
webClientBuilder.build()
.delete()
.uri("$satelliteCatalogUrl/api/satellite-groups/$id")
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.toBodilessEntity()
.block()
}
private fun mapError(response: org.springframework.web.reactive.function.client.ClientResponse): Mono<Throwable> =
response.bodyToMono(String::class.java)
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
private fun extractErrorMessage(body: String): String {
val text = body.trim()
if (text.isEmpty()) return "error"
return runCatching {
val node = ObjectMapper().readTree(text)
when {
node.hasNonNull("error") -> node["error"].asText()
node.hasNonNull("message") -> node["message"].asText()
else -> text
}
}.getOrDefault(text)
}
}
@@ -0,0 +1,75 @@
package space.nstart.pcp.slots_service.service
import org.springframework.stereotype.Service
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO
import space.nstart.pcp.slots_service.configuration.CustomErrorException
import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmAvailabilityDTO
import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmDetailsDTO
import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmSummaryDTO
import java.time.LocalDateTime
@Service
class SatellitePdcmService(
private val satelliteCatalogService: SatelliteCatalogService,
private val ballisticsService: BallisticsService
) {
fun satelliteSummaries(): List<SatellitePdcmSummaryDTO> = satelliteSummaries(LocalDateTime.now())
fun satelliteSummaries(referenceTime: LocalDateTime): List<SatellitePdcmSummaryDTO> {
val satellites = satelliteCatalogService.allSatellites()
val availabilityBySatelliteId = ballisticsService.orbitAvailability().associateBy { it.satelliteId }
return satellites
.map { satellite ->
val availability = availabilityBySatelliteId[satellite.id]
SatellitePdcmSummaryDTO(
satelliteId = satellite.id,
code = satellite.code,
name = satellite.name,
activeInterval = availability.isActive(referenceTime),
availableInterval = availability?.toDto()
)
}
.sortedWith(
compareByDescending<SatellitePdcmSummaryDTO> { it.activeInterval }
.thenBy { it.name.lowercase() }
.thenBy { it.satelliteId }
)
}
fun satelliteDetails(satelliteId: Long): SatellitePdcmDetailsDTO =
satelliteDetails(satelliteId, LocalDateTime.now())
fun satelliteDetails(satelliteId: Long, referenceTime: LocalDateTime): SatellitePdcmDetailsDTO {
val satellites = satelliteCatalogService.allSatellites()
val satellite = satellites.firstOrNull { it.id == satelliteId }
?: throw CustomErrorException("Спутник $satelliteId не найден")
val availability = ballisticsService.orbitAvailability().firstOrNull { it.satelliteId == satelliteId }
val ascNodes = availability?.let {
// Для страницы ПДЦМ используется ID спутника из каталога, без перехода к NORAD ID.
ballisticsService.ascNodes(satellite.id, it.timeStart, it.timeStop).collectList().block()
} ?: emptyList()
return SatellitePdcmDetailsDTO(
satelliteId = satellite.id,
code = satellite.code,
name = satellite.name,
activeInterval = availability.isActive(referenceTime),
availableInterval = availability?.toDto(),
ascNodes = ascNodes ?: emptyList()
)
}
private fun SatelliteOrbitAvailabilityDTO?.isActive(referenceTime: LocalDateTime): Boolean {
if (this == null) {
return false
}
return !referenceTime.isBefore(timeStart) && !referenceTime.isAfter(timeStop)
}
private fun SatelliteOrbitAvailabilityDTO.toDto() = SatellitePdcmAvailabilityDTO(
timeStart = timeStart,
timeStop = timeStop
)
}
@@ -0,0 +1,285 @@
package space.nstart.pcp.slots_service.service
import org.springframework.beans.factory.ObjectProvider
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.web.util.UriComponentsBuilder
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.bodyToFlux
import space.nstart.pcp.pcp_types_lib.dto.ballistics.DurationOnRevDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
import reactor.core.publisher.Mono
import space.nstart.pcp.pcp_types_lib.dto.ballistics.IntervalDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RadioVisibilityAreaDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookingRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCalculationSummaryDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
import tools.jackson.databind.ObjectMapper
import space.nstart.pcp.slots_service.configuration.CustomErrorException
import java.time.LocalDateTime
@Service
class SlotService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
@Value("\${settings.slots-service:slots-service}")
val url = ""
fun slots(
id : String,
tn : LocalDateTime,
tk : LocalDateTime,
sign : RevolutionSign?,
cov : Boolean?,
satellites : List<Long>? = null,
sun : Double? = null
) =
webClientBuilder.build()
.get()
.uri(
UriComponentsBuilder
.fromUriString("$url/api/slots/request-cover")
.queryParam("requestId", id)
.queryParam("timeStart", tn)
.queryParam("timeStop", tk)
.queryParamIfPresent("revSign", java.util.Optional.ofNullable(sign))
.queryParam("cov", cov)
.queryParamIfPresent("sun", java.util.Optional.ofNullable(sun))
.apply {
satellites?.forEach { queryParam("satellites", it) }
}
.build()
.toUriString()
)
.retrieve()
.onStatus({ status -> status.isError }) { response ->
response.bodyToMono(String::class.java)
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
.bodyToFlux<SlotDTO>()
fun plan(id : Long, tn : LocalDateTime, tk : LocalDateTime) =
webClientBuilder.build()
.get()
.uri("$url/api/slots/mission/satellite?satellite_id=$id&timeStart=$tn&timeStop=$tk")
.retrieve()
.onStatus({ status -> status.isError }) { response ->
response.bodyToMono(String::class.java)
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
.bodyToFlux< SurveySlotDTO>()
fun allSlotsByInterval(id: Long, tn: LocalDateTime, tk: LocalDateTime): List<SlotDTO> =
webClientBuilder.build()
.get()
.uri("$url/api/slots/interval?satelliteId=$id&timeStart=$tn&timeStop=$tk")
.retrieve()
.onStatus({ status -> status.isError }) { response ->
response.bodyToMono(String::class.java)
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
.bodyToFlux(SlotDTO::class.java)
.collectList()
.block() ?: emptyList()
fun slotCalculationSummaries(satelliteIds: List<Long>): List<SlotCalculationSummaryDTO> {
if (satelliteIds.isEmpty()) {
return emptyList()
}
return webClientBuilder.build()
.get()
.uri(
UriComponentsBuilder
.fromUriString("$url/api/slots/calculation-summary")
.apply {
satelliteIds.distinct().forEach { queryParam("satelliteIds", it) }
}
.build()
.toUriString()
)
.retrieve()
.onStatus({ status -> status.isError }) { response ->
response.bodyToMono(String::class.java)
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
.bodyToFlux(SlotCalculationSummaryDTO::class.java)
.collectList()
.block() ?: emptyList()
}
fun slotCalculationSummary(satelliteId: Long): SlotCalculationSummaryDTO =
slotCalculationSummaries(listOf(satelliteId)).firstOrNull()
?: SlotCalculationSummaryDTO(satelliteId = satelliteId)
fun startSlotCalculation(satelliteId: Long, slotProfile: SatelliteSlotProfileDTO) {
webClientBuilder.build()
.post()
.uri(
UriComponentsBuilder
.fromUriString("$url/api/slots/test-init")
.queryParam("sats", satelliteId)
.queryParam("slotDuration", slotProfile.slotDuration)
.queryParam("dailyDuration", slotProfile.durationCalcDays)
.queryParam("revolutionDuration", slotProfile.cycleRevs)
.queryParam("recover", false)
.build()
.toUriString()
)
.retrieve()
.onStatus({ status -> status.isError }) { response ->
response.bodyToMono(String::class.java)
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
.toBodilessEntity()
.block()
}
fun bookReq(req: BookingRequestDTO): List<BookedSlotDTO> =
webClientBuilder.build()
.post()
.uri("$url/api/slots/booking/request")
.bodyValue(req)
.retrieve()
.bodyToFlux<BookedSlotDTO>()
.collectList()
.block() ?: emptyList()
fun cancelReq(requestId: String): Int =
webClientBuilder.build()
.delete()
.uri("$url/api/slots/booking/request?requestId=$requestId")
.retrieve()
.onStatus({ status -> status.isError }) { response ->
response.bodyToMono(String::class.java)
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
.bodyToMono(Int::class.java)
.block() ?: 0
fun rvaCommon(satelliteId: Long, duration: Long, stations: List<StationDTO>): List<RadioVisibilityAreaDTO> =
webClientBuilder.build()
.post()
.uri("$url/api/satellite/rva-common/$satelliteId/$duration")
.bodyValue(stations)
.retrieve()
.onStatus({ status -> status.isError }) { response ->
response.bodyToMono(String::class.java)
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
.bodyToFlux(RadioVisibilityAreaDTO::class.java)
.collectList()
.block()
?: emptyList()
fun rvaMerge(satelliteId: Long, duration: Long, stations: List<StationDTO>): List<IntervalDTO> =
webClientBuilder.build()
.post()
.uri("$url/api/satellite/rva-merge/$satelliteId/$duration")
.bodyValue(stations)
.retrieve()
.onStatus({ status -> status.isError }) { response ->
response.bodyToMono(String::class.java)
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
.bodyToFlux(IntervalDTO::class.java)
.collectList()
.block()
?: emptyList()
fun rvaMergeOnRev(satelliteId: Long, duration: Long, stations: List<StationDTO>): List<DurationOnRevDTO> =
webClientBuilder.build()
.post()
.uri("$url/api/satellite/rva-merge-on-rev/$satelliteId/$duration")
.bodyValue(stations)
.retrieve()
.onStatus({ status -> status.isError }) { response ->
response.bodyToMono(String::class.java)
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
.bodyToFlux(DurationOnRevDTO::class.java)
.collectList()
.block()
?: emptyList()
fun satelliteInitialConditions(satelliteId: Long): SatelliteICDTO? =
webClientBuilder.build()
.get()
.uri("$url/api/satellite-ic/$satelliteId")
.exchangeToMono { response ->
when {
response.statusCode().is2xxSuccessful -> response.bodyToMono(SatelliteICDTO::class.java)
response.statusCode().is4xxClientError -> response.bodyToMono(String::class.java)
.defaultIfEmpty("error")
.flatMap { body ->
val message = extractErrorMessage(body)
if (message.contains("не зарегистрированы")) {
Mono.empty()
} else {
Mono.error(CustomErrorException(message))
}
}
else -> response.bodyToMono(String::class.java)
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
}
.block()
fun saveSatelliteInitialConditions(
satelliteId: Long,
request: InitialConditionsDTO,
create: Boolean
): SatelliteICDTO {
val methodSpec = if (create) webClientBuilder.build().post() else webClientBuilder.build().put()
return methodSpec
.uri(
if (create) "$url/api/satellite-ic"
else "$url/api/satellite-ic/$satelliteId"
)
.bodyValue(
if (create) SatelliteICDTO(satelliteId = satelliteId, ic = request)
else request
)
.retrieve()
.onStatus({ status -> status.isError }) { response ->
response.bodyToMono(String::class.java)
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
.bodyToMono(SatelliteICDTO::class.java)
.block() ?: throw CustomErrorException("Не удалось сохранить начальные условия для КА $satelliteId")
}
private fun extractErrorMessage(body: String): String {
val text = body.trim()
if (text.isEmpty()) return "error"
return runCatching {
val node = ObjectMapper().readTree(text)
when {
node.hasNonNull("error") -> node["error"].asText()
node.hasNonNull("message") -> node["message"].asText()
else -> text
}
}.getOrDefault(text)
}
}
@@ -0,0 +1,77 @@
package space.nstart.pcp.slots_service.service
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 reactor.core.publisher.Mono
import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO
import space.nstart.pcp.slots_service.configuration.CustomErrorException
import tools.jackson.databind.ObjectMapper
@Service
class StationService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
@Value("\${settings.stations-service:stations-service}")
val url = ""
fun stations() =
webClientBuilder.build()
.get()
.uri("$url/api/stations")
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.bodyToFlux(StationDTO::class.java)
fun station(id : String) =
webClientBuilder.build()
.get()
.uri("$url/api/stations/$id")
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.bodyToMono(StationDTO::class.java)
fun allStations(): List<StationDTO> =
stations().collectList().block() ?: emptyList()
fun saveStation(request: StationDTO): StationDTO =
webClientBuilder.build()
.post()
.uri("$url/api/stations")
.bodyValue(request)
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.bodyToMono(StationDTO::class.java)
.block() ?: throw CustomErrorException("Не удалось сохранить станцию: пустой ответ")
fun deleteStation(id: String) {
webClientBuilder.build()
.delete()
.uri("$url/api/stations/$id")
.retrieve()
.onStatus({ status -> status.isError }, ::mapError)
.toBodilessEntity()
.block()
}
private fun mapError(response: org.springframework.web.reactive.function.client.ClientResponse): Mono<Throwable> =
response.bodyToMono(String::class.java)
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
private fun extractErrorMessage(body: String): String {
val text = body.trim()
if (text.isEmpty()) return "error"
return runCatching {
val node = ObjectMapper().readTree(text)
when {
node.hasNonNull("error") -> node["error"].asText()
node.hasNonNull("message") -> node["message"].asText()
else -> text
}
}.getOrDefault(text)
}
}
@@ -0,0 +1,13 @@
spring:
application:
name: pcp-ui-service
profiles:
default: local
config:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:dev}
@@ -0,0 +1,827 @@
(function () {
const pageRoot = document.getElementById('catalog-page');
if (!pageRoot) {
return;
}
const page = pageRoot.dataset.page;
const apiBase = '/api/catalog';
function showAlert(containerId, message, type = 'danger') {
const container = document.getElementById(containerId);
if (!container) return;
if (!message) {
container.innerHTML = '';
return;
}
container.innerHTML = `<div class="alert alert-${type} mb-3" role="alert">${message}</div>`;
}
async function requestJson(url, options = {}) {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json'
},
...options
});
if (response.status === 204) {
return null;
}
const isJson = response.headers.get('content-type')?.includes('application/json');
const payload = isJson ? await response.json() : await response.text();
if (!response.ok) {
if (payload && typeof payload === 'object') {
if (payload.error) {
throw new Error(payload.error);
}
throw new Error(Object.values(payload).join('; '));
}
throw new Error(payload || `HTTP ${response.status}`);
}
return payload;
}
function toNullableNumber(value) {
return value === '' || value === null || value === undefined ? null : Number(value);
}
function formatDateTimeInput(value) {
if (!value) return '';
const match = String(value).match(
/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d{1,9}))?)?/
);
if (!match) return value;
const [, year, month, day, hour, minute, second = '00', millis = '000'] = match;
return `${day}.${month}.${year} ${hour}:${minute}:${second}.${millis.slice(0, 3).padEnd(3, '0')}`;
}
function parseDateTimeInput(value, fieldName, required = false) {
const text = value?.trim() ?? '';
if (!text) {
if (required) {
throw new Error(`${fieldName}: укажите время в формате dd.mm.yyyy hh:mm:ss.zzz`);
}
return null;
}
const displayMatch = text.match(/^(\d{2})\.(\d{2})\.(\d{4})\s+(\d{2}):(\d{2}):(\d{2})[.,](\d{1,3})$/);
if (displayMatch) {
const [, day, month, year, hour, minute, second, millis] = displayMatch;
validateDateTimeParts(day, month, year, hour, minute, second, fieldName);
return `${year}-${month}-${day}T${hour}:${minute}:${second}.${millis.padEnd(3, '0')}`;
}
const isoMatch = text.match(/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d{1,3}))?)?$/);
if (isoMatch) {
const [, year, month, day, hour, minute, second = '00', millis = '000'] = isoMatch;
validateDateTimeParts(day, month, year, hour, minute, second, fieldName);
return `${year}-${month}-${day}T${hour}:${minute}:${second}.${millis.padEnd(3, '0')}`;
}
throw new Error(`${fieldName}: укажите время в формате dd.mm.yyyy hh:mm:ss.zzz`);
}
function validateDateTimeParts(day, month, year, hour, minute, second, fieldName) {
const date = new Date(
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute),
Number(second)
);
const isValid = date.getFullYear() === Number(year) &&
date.getMonth() === Number(month) - 1 &&
date.getDate() === Number(day) &&
date.getHours() === Number(hour) &&
date.getMinutes() === Number(minute) &&
date.getSeconds() === Number(second);
if (!isValid) {
throw new Error(`${fieldName}: некорректная дата или время`);
}
}
function formatDisplayDateTime(value) {
if (!value) return '—';
return formatDateTimeInput(value);
}
function buildRepeatedQueryParam(name, values) {
const params = new URLSearchParams();
values.forEach(value => params.append(name, String(value)));
return params.toString();
}
function parseAngles(text) {
const lines = text
.split('\n')
.map(line => line.trim())
.filter(Boolean);
return lines.map((line, index) => {
const parts = line.split(',').map(part => part.trim());
if (parts.length !== 2) {
throw new Error(`Строка ${index + 1} должна содержать два числа через запятую`);
}
const angleBegin = Number(parts[0]);
const angleEnd = Number(parts[1]);
if (Number.isNaN(angleBegin) || Number.isNaN(angleEnd)) {
throw new Error(`Строка ${index + 1} содержит некорректный диапазон углов`);
}
return { angleBegin, angleEnd };
});
}
function renderChips(items) {
if (!items.length) {
return '<span class="text-muted">Пусто</span>';
}
return items.map(item => `<span class="catalog-chip">${item}</span>`).join('');
}
function initGroupsPage() {
const state = {
groups: [],
satellites: [],
selectedGroupId: null
};
const groupTableBody = document.getElementById('groups-table-body');
const groupForm = document.getElementById('group-form');
const satelliteChecklist = document.getElementById('group-satellite-checklist');
const title = document.getElementById('group-form-title');
function renderSatelliteChecklist() {
satelliteChecklist.innerHTML = '';
state.satellites.forEach(satellite => {
const wrapper = document.createElement('div');
wrapper.className = 'form-check';
wrapper.innerHTML = `
<input class="form-check-input" type="checkbox" value="${satellite.id}" id="group-satellite-${satellite.id}">
<label class="form-check-label" for="group-satellite-${satellite.id}">
${satellite.name} (${satellite.code}, ${satellite.id})
</label>
`;
satelliteChecklist.appendChild(wrapper);
});
}
function selectedSatelliteIds() {
return Array.from(satelliteChecklist.querySelectorAll('input:checked'))
.map(input => Number(input.value));
}
function setSelectedSatelliteIds(ids) {
const selected = new Set(ids);
satelliteChecklist.querySelectorAll('input').forEach(input => {
input.checked = selected.has(Number(input.value));
});
}
function renderGroups() {
groupTableBody.innerHTML = '';
if (!state.groups.length) {
groupTableBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-4">Группировки не найдены</td></tr>';
return;
}
state.groups.forEach(group => {
const tr = document.createElement('tr');
if (group.id === state.selectedGroupId) {
tr.classList.add('table-active');
}
tr.innerHTML = `
<td>${group.id}</td>
<td>${group.name}</td>
<td>${group.satelliteIds.length}</td>
<td>${renderChips(group.satelliteIds.map(String))}</td>
`;
tr.addEventListener('click', () => selectGroup(group.id));
groupTableBody.appendChild(tr);
});
}
function resetForm() {
groupForm.reset();
document.getElementById('group-id').value = '';
state.selectedGroupId = null;
setSelectedSatelliteIds([]);
title.textContent = 'Новая группировка';
renderGroups();
showAlert('groups-alert', '');
}
function fillForm(group) {
document.getElementById('group-id').value = String(group.id);
document.getElementById('group-name').value = group.name;
setSelectedSatelliteIds(group.satelliteIds);
title.textContent = `Группировка #${group.id}`;
}
function selectGroup(groupId) {
const group = state.groups.find(item => item.id === groupId);
if (!group) return;
state.selectedGroupId = groupId;
fillForm(group);
renderGroups();
showAlert('groups-alert', '');
}
async function loadData() {
showAlert('groups-alert', '');
const [satellites, groups] = await Promise.all([
requestJson(`${apiBase}/satellites`),
requestJson(`${apiBase}/groups`)
]);
state.satellites = satellites;
state.groups = groups;
renderSatelliteChecklist();
renderGroups();
if (state.selectedGroupId) {
const selected = state.groups.find(item => item.id === state.selectedGroupId);
if (selected) {
fillForm(selected);
} else {
resetForm();
}
}
}
document.getElementById('groups-refresh').addEventListener('click', async () => {
try {
await loadData();
} catch (error) {
showAlert('groups-alert', error.message);
}
});
document.getElementById('groups-new').addEventListener('click', resetForm);
groupForm.addEventListener('submit', async event => {
event.preventDefault();
try {
const groupId = document.getElementById('group-id').value;
const payload = {
name: document.getElementById('group-name').value.trim(),
satelliteIds: selectedSatelliteIds()
};
const result = groupId
? await requestJson(`${apiBase}/groups/${groupId}`, { method: 'PUT', body: JSON.stringify(payload) })
: await requestJson(`${apiBase}/groups`, { method: 'POST', body: JSON.stringify(payload) });
state.selectedGroupId = result.id;
await loadData();
showAlert('groups-alert', 'Группировка сохранена', 'success');
} catch (error) {
showAlert('groups-alert', error.message);
}
});
document.getElementById('groups-delete').addEventListener('click', async () => {
const groupId = document.getElementById('group-id').value;
if (!groupId) {
showAlert('groups-alert', 'Сначала выберите группировку для удаления');
return;
}
if (!window.confirm(`Удалить группировку #${groupId}?`)) {
return;
}
try {
await requestJson(`${apiBase}/groups/${groupId}`, { method: 'DELETE' });
resetForm();
await loadData();
showAlert('groups-alert', 'Группировка удалена', 'success');
} catch (error) {
showAlert('groups-alert', error.message);
}
});
loadData().catch(error => showAlert('groups-alert', error.message));
}
function initSatellitesPage() {
const state = {
summaries: [],
selectedSatelliteId: null,
selectedSatellite: null,
selectedSatelliteInitialConditions: null,
selectedSlotCalculationSummary: null,
createDraftMode: false,
slotSummariesBySatelliteId: new Map()
};
const tableBody = document.getElementById('satellites-table-body');
const searchInput = document.getElementById('satellite-search');
function updateColorPreview() {
const red = Number(document.getElementById('satellite-red').value || 0);
const green = Number(document.getElementById('satellite-green').value || 0);
const blue = Number(document.getElementById('satellite-blue').value || 0);
document.getElementById('satellite-color-preview').style.background = `rgb(${red}, ${green}, ${blue})`;
}
function renderList() {
const query = searchInput.value.trim().toLowerCase();
const items = state.summaries.filter(item =>
!query ||
String(item.id).includes(query) ||
item.name.toLowerCase().includes(query) ||
item.code.toLowerCase().includes(query)
);
tableBody.innerHTML = '';
if (!items.length) {
tableBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Спутники не найдены</td></tr>';
return;
}
items.forEach(satellite => {
const slotSummary = state.slotSummariesBySatelliteId.get(Number(satellite.id));
const tr = document.createElement('tr');
if (satellite.id === state.selectedSatelliteId) {
tr.classList.add('table-active');
}
tr.innerHTML = `
<td>${satellite.id}</td>
<td>${satellite.code}</td>
<td>${satellite.name}</td>
<td>${satellite.typeCode}</td>
<td>${renderSlotIndicator(slotSummary)}</td>
`;
tr.addEventListener('click', () => selectSatellite(satellite.id));
tableBody.appendChild(tr);
});
}
function renderSlotIndicator(summary) {
const calculated = Boolean(summary?.calculated);
const title = calculated
? 'Рассчитанные слоты есть'
: 'Рассчитанные слоты отсутствуют';
return `<span class="catalog-slot-indicator ${calculated ? 'catalog-slot-indicator-active' : ''}" title="${title}"></span>`;
}
function renderSlotCalculationSummary(summary) {
const container = document.getElementById('slot-calculation-summary');
if (!container) return;
if (!summary?.calculated) {
container.innerHTML = `
<div class="catalog-empty mb-0">
Рассчитанные слоты отсутствуют.
</div>
`;
return;
}
container.innerHTML = `
<div class="catalog-section-title mb-3">Рассчитанные слоты</div>
<div class="catalog-slot-summary-grid">
<div>
<div class="catalog-summary-label">Минимальное время</div>
<div class="catalog-summary-value">${formatDisplayDateTime(summary.minTime)}</div>
</div>
<div>
<div class="catalog-summary-label">Максимальное время</div>
<div class="catalog-summary-value">${formatDisplayDateTime(summary.maxTime)}</div>
</div>
<div>
<div class="catalog-summary-label">Интервал витков</div>
<div class="catalog-summary-value">${summary.revolutionInterval ?? '—'}</div>
</div>
</div>
`;
}
function resetBaseForm() {
document.getElementById('satellite-form').reset();
document.getElementById('satellite-id').disabled = false;
document.getElementById('satellite-active').checked = true;
document.getElementById('satellite-scan-tle').checked = false;
document.getElementById('satellite-red').value = 255;
document.getElementById('satellite-green').value = 0;
document.getElementById('satellite-blue').value = 0;
document.getElementById('satellite-form-title').textContent = 'Новый спутник';
state.selectedSatelliteId = null;
state.selectedSatellite = null;
state.selectedSatelliteInitialConditions = null;
state.selectedSlotCalculationSummary = null;
state.createDraftMode = false;
clearProfiles();
updateColorPreview();
renderList();
}
function clearProfiles() {
document.getElementById('observation-form').reset();
document.getElementById('slot-form').reset();
document.getElementById('slot-ic-form').reset();
document.getElementById('slot-default-angles').value = '';
document.getElementById('slot-tn-calc').value = '';
document.getElementById('slot-ic-time').value = '';
document.getElementById('slot-ic-status').textContent = 'Начальные условия не заданы.';
renderSlotCalculationSummary(null);
}
function fillProfiles(satellite, initialConditions, slotCalculationSummary) {
const observation = satellite.observationProfile;
document.getElementById('observation-capture-angle').value = observation?.captureAngle ?? '';
document.getElementById('observation-sun-angle-min').value = observation?.sunAngleMin ?? '';
document.getElementById('observation-duration-min').value = observation?.durationMinSeconds ?? '';
document.getElementById('observation-duration-max').value = observation?.durationMaxSeconds ?? '';
document.getElementById('observation-mmi').value = observation?.mmiSeconds ?? '';
document.getElementById('observation-daily-max').value = observation?.dailyMaxDurationSeconds ?? '';
document.getElementById('observation-revolution-max').value = observation?.revolutionMaxDurationSeconds ?? '';
const slotProfile = satellite.slotProfile;
document.getElementById('slot-cycle-revs').value = slotProfile?.cycleRevs ?? '';
document.getElementById('slot-tn-calc').value = formatDateTimeInput(slotProfile?.tnCalc);
document.getElementById('slot-duration').value = slotProfile?.slotDuration ?? '';
document.getElementById('slot-duration-calc-days').value = slotProfile?.durationCalcDays ?? '';
document.getElementById('slot-max-chain-length').value = slotProfile?.maxChainLength ?? '';
document.getElementById('slot-default-angles').value = slotProfile?.defaultAngles?.map(angle => `${angle.angleBegin}, ${angle.angleEnd}`).join('\n') ?? '';
document.getElementById('slot-ic-time').value = formatDateTimeInput(initialConditions?.ic?.orbPoint?.time);
document.getElementById('slot-ic-revolution').value = initialConditions?.ic?.orbPoint?.revolution ?? '';
document.getElementById('slot-ic-s-ball').value = initialConditions?.ic?.sBall ?? '';
document.getElementById('slot-ic-f81').value = initialConditions?.ic?.f81 ?? '';
document.getElementById('slot-ic-x').value = initialConditions?.ic?.orbPoint?.x ?? '';
document.getElementById('slot-ic-y').value = initialConditions?.ic?.orbPoint?.y ?? '';
document.getElementById('slot-ic-z').value = initialConditions?.ic?.orbPoint?.z ?? '';
document.getElementById('slot-ic-vx').value = initialConditions?.ic?.orbPoint?.vx ?? '';
document.getElementById('slot-ic-vy').value = initialConditions?.ic?.orbPoint?.vy ?? '';
document.getElementById('slot-ic-vz').value = initialConditions?.ic?.orbPoint?.vz ?? '';
document.getElementById('slot-ic-status').textContent = initialConditions
? 'Начальные условия загружены из slot-service.'
: 'Начальные условия не заданы.';
renderSlotCalculationSummary(slotCalculationSummary);
}
function fillSatelliteForm(satellite, createMode = false, initialConditions = state.selectedSatelliteInitialConditions, slotCalculationSummary = state.selectedSlotCalculationSummary) {
document.getElementById('satellite-id').value = createMode ? '' : satellite.id;
document.getElementById('satellite-id').disabled = !createMode;
document.getElementById('satellite-norad-id').value = satellite.noradId ?? '';
document.getElementById('satellite-code').value = satellite.code;
document.getElementById('satellite-name').value = satellite.name;
document.getElementById('satellite-type-code').value = satellite.typeCode;
document.getElementById('satellite-active').checked = satellite.active;
document.getElementById('satellite-scan-tle').checked = satellite.scanTle;
document.getElementById('satellite-red').value = satellite.visualization.red;
document.getElementById('satellite-green').value = satellite.visualization.green;
document.getElementById('satellite-blue').value = satellite.visualization.blue;
document.getElementById('satellite-form-title').textContent = createMode ? 'Новый спутник' : `Спутник #${satellite.id}`;
fillProfiles(
satellite,
initialConditions,
slotCalculationSummary
);
updateColorPreview();
}
function newSatelliteFromSelected() {
if (!state.selectedSatellite) {
resetBaseForm();
return;
}
state.selectedSatelliteId = null;
state.selectedSatellite = null;
state.selectedSatelliteInitialConditions = null;
state.selectedSlotCalculationSummary = null;
state.createDraftMode = true;
document.getElementById('satellite-id').value = '';
document.getElementById('satellite-id').disabled = false;
document.getElementById('satellite-form-title').textContent = 'Новый спутник';
document.getElementById('slot-ic-status').textContent = 'Начальные условия скопированы из выбранного спутника.';
renderSlotCalculationSummary(null);
renderList();
}
async function loadSummaries() {
state.summaries = await requestJson(`${apiBase}/satellites`);
const satelliteIds = state.summaries.map(satellite => satellite.id);
renderList();
try {
const slotSummaries = satelliteIds.length
? await requestJson(`${apiBase}/satellites/slot-calculation-summaries?${buildRepeatedQueryParam('satelliteIds', satelliteIds)}`)
: [];
state.slotSummariesBySatelliteId = new Map(
slotSummaries.map(summary => [Number(summary.satelliteId), summary])
);
} catch (error) {
console.warn('Slot calculation summaries are unavailable', error);
state.slotSummariesBySatelliteId = new Map();
}
renderList();
}
async function selectSatellite(id) {
try {
const [satellite, initialConditions] = await Promise.all([
requestJson(`${apiBase}/satellites/${id}`),
requestJson(`${apiBase}/satellites/${id}/initial-conditions`)
]);
let slotCalculationSummary = null;
try {
slotCalculationSummary = await requestJson(`${apiBase}/satellites/${id}/slot-calculation-summary`);
} catch (error) {
console.warn(`Slot calculation summary is unavailable for satellite ${id}`, error);
}
state.selectedSatelliteId = id;
state.selectedSatellite = satellite;
state.selectedSatelliteInitialConditions = initialConditions;
state.selectedSlotCalculationSummary = slotCalculationSummary;
state.createDraftMode = false;
state.slotSummariesBySatelliteId.set(Number(id), slotCalculationSummary);
fillSatelliteForm(satellite);
renderList();
showAlert('satellites-alert', '');
} catch (error) {
showAlert('satellites-alert', error.message);
}
}
function basePayload() {
return {
noradId: toNullableNumber(document.getElementById('satellite-norad-id').value),
code: document.getElementById('satellite-code').value.trim(),
name: document.getElementById('satellite-name').value.trim(),
typeCode: document.getElementById('satellite-type-code').value.trim(),
active: document.getElementById('satellite-active').checked,
scanTle: document.getElementById('satellite-scan-tle').checked,
visualization: {
red: Number(document.getElementById('satellite-red').value),
green: Number(document.getElementById('satellite-green').value),
blue: Number(document.getElementById('satellite-blue').value)
}
};
}
function observationPayload() {
return {
captureAngle: Number(document.getElementById('observation-capture-angle').value),
sunAngleMin: Number(document.getElementById('observation-sun-angle-min').value),
durationMinSeconds: Number(document.getElementById('observation-duration-min').value),
durationMaxSeconds: Number(document.getElementById('observation-duration-max').value),
mmiSeconds: Number(document.getElementById('observation-mmi').value),
dailyMaxDurationSeconds: Number(document.getElementById('observation-daily-max').value),
revolutionMaxDurationSeconds: Number(document.getElementById('observation-revolution-max').value)
};
}
function slotPayload() {
return {
cycleRevs: Number(document.getElementById('slot-cycle-revs').value),
tnCalc: parseDateTimeInput(document.getElementById('slot-tn-calc').value, 'TN calc'),
slotDuration: Number(document.getElementById('slot-duration').value),
durationCalcDays: Number(document.getElementById('slot-duration-calc-days').value),
maxChainLength: Number(document.getElementById('slot-max-chain-length').value),
defaultAngles: parseAngles(document.getElementById('slot-default-angles').value)
};
}
function initialConditionsPayload() {
return {
orbPoint: {
time: parseDateTimeInput(document.getElementById('slot-ic-time').value, 'Time', true),
revolution: Number(document.getElementById('slot-ic-revolution').value),
vx: Number(document.getElementById('slot-ic-vx').value),
vy: Number(document.getElementById('slot-ic-vy').value),
vz: Number(document.getElementById('slot-ic-vz').value),
x: Number(document.getElementById('slot-ic-x').value),
y: Number(document.getElementById('slot-ic-y').value),
z: Number(document.getElementById('slot-ic-z').value)
},
sBall: Number(document.getElementById('slot-ic-s-ball').value),
f81: Number(document.getElementById('slot-ic-f81').value)
};
}
document.getElementById('satellites-refresh').addEventListener('click', async () => {
try {
await loadSummaries();
if (state.selectedSatelliteId) {
await selectSatellite(state.selectedSatelliteId);
}
} catch (error) {
showAlert('satellites-alert', error.message);
}
});
document.getElementById('satellites-new').addEventListener('click', () => {
newSatelliteFromSelected();
showAlert('satellites-alert', '');
});
searchInput.addEventListener('input', renderList);
['satellite-red', 'satellite-green', 'satellite-blue'].forEach(id => {
document.getElementById(id).addEventListener('input', updateColorPreview);
});
document.getElementById('satellite-form').addEventListener('submit', async event => {
event.preventDefault();
try {
const idField = document.getElementById('satellite-id');
const isCreate = !state.selectedSatelliteId;
const satelliteId = Number(idField.value);
const result = isCreate
? await requestJson(`${apiBase}/satellites`, {
method: 'POST',
body: JSON.stringify({ id: satelliteId, ...basePayload() })
})
: await requestJson(`${apiBase}/satellites/${satelliteId}`, {
method: 'PUT',
body: JSON.stringify(basePayload())
});
await loadSummaries();
if (isCreate && state.createDraftMode) {
state.selectedSatelliteId = result.id;
state.selectedSatellite = result;
state.selectedSatelliteInitialConditions = null;
state.selectedSlotCalculationSummary = null;
document.getElementById('satellite-id').value = result.id;
document.getElementById('satellite-id').disabled = true;
document.getElementById('satellite-form-title').textContent = `Спутник #${result.id}`;
renderList();
} else {
await selectSatellite(result.id);
}
showAlert('satellites-alert', 'Спутник сохранён', 'success');
} catch (error) {
showAlert('satellites-alert', error.message);
}
});
document.getElementById('satellite-delete').addEventListener('click', async () => {
if (!state.selectedSatelliteId) {
showAlert('satellites-alert', 'Сначала выберите спутник для удаления');
return;
}
if (!window.confirm(`Удалить спутник #${state.selectedSatelliteId}?`)) {
return;
}
try {
await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}`, { method: 'DELETE' });
await loadSummaries();
resetBaseForm();
showAlert('satellites-alert', 'Спутник удалён', 'success');
} catch (error) {
showAlert('satellites-alert', error.message);
}
});
document.getElementById('observation-form').addEventListener('submit', async event => {
event.preventDefault();
if (!state.selectedSatelliteId) {
showAlert('satellites-alert', 'Сначала сохраните спутник');
return;
}
try {
const create = !state.selectedSatellite?.observationProfile;
await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/observation-profile?create=${create}`, {
method: 'POST',
body: JSON.stringify(observationPayload())
});
if (state.createDraftMode) {
state.selectedSatellite = {
...state.selectedSatellite,
observationProfile: observationPayload()
};
} else {
await selectSatellite(state.selectedSatelliteId);
}
showAlert('satellites-alert', 'Observation profile сохранён', 'success');
} catch (error) {
showAlert('satellites-alert', error.message);
}
});
document.getElementById('observation-delete').addEventListener('click', async () => {
if (!state.selectedSatelliteId || !state.selectedSatellite?.observationProfile) {
showAlert('satellites-alert', 'Observation profile отсутствует');
return;
}
try {
await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/observation-profile`, { method: 'DELETE' });
await selectSatellite(state.selectedSatelliteId);
showAlert('satellites-alert', 'Observation profile удалён', 'success');
} catch (error) {
showAlert('satellites-alert', error.message);
}
});
document.getElementById('slot-form').addEventListener('submit', async event => {
event.preventDefault();
if (!state.selectedSatelliteId) {
showAlert('satellites-alert', 'Сначала сохраните спутник');
return;
}
try {
const create = !state.selectedSatellite?.slotProfile;
await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/slot-profile?create=${create}`, {
method: 'POST',
body: JSON.stringify(slotPayload())
});
if (state.createDraftMode) {
state.selectedSatellite = {
...state.selectedSatellite,
slotProfile: slotPayload()
};
} else {
await selectSatellite(state.selectedSatelliteId);
}
showAlert('satellites-alert', 'Slot profile сохранён', 'success');
} catch (error) {
showAlert('satellites-alert', error.message);
}
});
document.getElementById('slot-delete').addEventListener('click', async () => {
if (!state.selectedSatelliteId || !state.selectedSatellite?.slotProfile) {
showAlert('satellites-alert', 'Slot profile отсутствует');
return;
}
try {
await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/slot-profile`, { method: 'DELETE' });
await selectSatellite(state.selectedSatelliteId);
showAlert('satellites-alert', 'Slot profile удалён', 'success');
} catch (error) {
showAlert('satellites-alert', error.message);
}
});
document.getElementById('slot-calculate').addEventListener('click', async () => {
if (!state.selectedSatelliteId) {
showAlert('satellites-alert', 'Сначала сохраните спутник');
return;
}
try {
await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/slot-calculation`, {
method: 'POST',
body: JSON.stringify(slotPayload())
});
await loadSummaries();
await selectSatellite(state.selectedSatelliteId);
showAlert('satellites-alert', 'Расчет слотов запущен', 'success');
} catch (error) {
showAlert('satellites-alert', error.message);
}
});
document.getElementById('slot-ic-form').addEventListener('submit', async event => {
event.preventDefault();
if (!state.selectedSatelliteId) {
showAlert('satellites-alert', 'Сначала сохраните спутник');
return;
}
try {
const create = !state.selectedSatelliteInitialConditions;
await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/initial-conditions?create=${create}`, {
method: 'POST',
body: JSON.stringify(initialConditionsPayload())
});
if (state.createDraftMode) {
state.selectedSatelliteInitialConditions = {
satelliteId: state.selectedSatelliteId,
ic: initialConditionsPayload()
};
document.getElementById('slot-ic-status').textContent = 'Начальные условия сохранены в slot-service.';
} else {
await selectSatellite(state.selectedSatelliteId);
}
showAlert('satellites-alert', 'Начальные условия сохранены', 'success');
} catch (error) {
showAlert('satellites-alert', error.message);
}
});
loadSummaries()
.then(() => {
updateColorPreview();
if (state.summaries.length) {
return selectSatellite(state.summaries[0].id);
}
resetBaseForm();
return null;
})
.catch(error => showAlert('satellites-alert', error.message));
}
if (page === 'groups') {
initGroupsPage();
}
if (page === 'satellites') {
initSatellitesPage();
}
})();
@@ -0,0 +1,654 @@
var MNTH = ["Янв","Фев","Мар","Апр","Май","Июн","Июл","Авг","Сен","Окт","Ноя","Дек"];
var pcpCesiumToolsState = null;
/** преобразование целого числа к строке заданного формата
s - число (25)
count - размер строки (4)
результат - строка (0025)
*/
function STRTOFORMAT(s, count)
{
var s2 = String(s);
var len = s2.length;
if (len < count)
{
for (iSTRTOFORMAT=0; iSTRTOFORMAT<count-len; iSTRTOFORMAT++)
s2 = "0"+s2;
}
return s2;
}
/** получение строки декретного московского времени
*/
var TDMT = function GetDMTime(e,t)
{
var d = new Cesium.JulianDate();
Cesium.JulianDate.addHours(e,3,d)
var gregorian = new Cesium.JulianDate.toGregorianDate(d);
//var s = new String();
if (Math.abs(t._clockViewModel.multiplier)<1)
s = STRTOFORMAT(gregorian.hour,2) + ':' + STRTOFORMAT(gregorian.minute,2) + ':' + STRTOFORMAT(gregorian.second,2)+'.'+STRTOFORMAT(Math.round(gregorian.millisecond))
else
s = STRTOFORMAT(gregorian.hour,2) + ':' + STRTOFORMAT(gregorian.minute,2) + ':' + STRTOFORMAT(gregorian.second,2)+' ДМВ';
return s;
}
/** получение строки декретной московской даты
*/
var TDMD = function GetDMDate(e)
{
var d = new Cesium.JulianDate();
Cesium.JulianDate.addHours(e,3,d)
var gregorian = new Cesium.JulianDate.toGregorianDate(d);
s = STRTOFORMAT(gregorian.day,2)+ ' ' + MNTH[gregorian.month-1] + ' ' + STRTOFORMAT(gregorian.year,4);
return s;
}
/** получение строки декретного московского даты и времени
*/
var TDMDT = function GetDMDdateTime(e)
{
s = "";
var d = new Cesium.JulianDate();
Cesium.JulianDate.addHours(e,3,d);
var gregorian = new Cesium.JulianDate.toGregorianDate(d);
s = gregorian.day+" "+ MNTH[gregorian.month-1]+" "+ gregorian.year+" "+STRTOFORMAT(gregorian.hour,2)+":"+STRTOFORMAT(gregorian.minute,2)+":"+STRTOFORMAT(gregorian.second,2)+" ДМВ";
return s;
}
function formatDistanceMeters(distanceMeters) {
if (!Cesium.defined(distanceMeters) || !isFinite(distanceMeters)) {
return "-";
}
if (distanceMeters < 1000) {
return distanceMeters.toFixed(1) + " м";
}
return (distanceMeters / 1000).toFixed(3) + " км";
}
function getMapCartesianPosition(viewer, screenPosition) {
if (!viewer || !screenPosition) {
return null;
}
var scene = viewer.scene;
var ray = viewer.camera.getPickRay(screenPosition);
if (ray) {
var globePoint = scene.globe.pick(ray, scene);
if (Cesium.defined(globePoint)) {
return globePoint;
}
}
var ellipsoidPoint = viewer.camera.pickEllipsoid(screenPosition, scene.globe.ellipsoid);
return Cesium.defined(ellipsoidPoint) ? ellipsoidPoint : null;
}
function computeSurfaceDistanceMeters(viewer, pointA, pointB) {
if (!viewer || !Cesium.defined(pointA) || !Cesium.defined(pointB)) {
return 0;
}
var ellipsoid = viewer.scene.globe.ellipsoid;
var cartographicA = ellipsoid.cartesianToCartographic(pointA);
var cartographicB = ellipsoid.cartesianToCartographic(pointB);
if (!Cesium.defined(cartographicA) || !Cesium.defined(cartographicB)) {
return 0;
}
var geodesic = new Cesium.EllipsoidGeodesic(cartographicA, cartographicB);
return geodesic.surfaceDistance || 0;
}
function getMidpoint(pointA, pointB) {
if (!Cesium.defined(pointA) || !Cesium.defined(pointB)) {
return Cesium.Cartesian3.ZERO;
}
return Cesium.Cartesian3.midpoint(pointA, pointB, new Cesium.Cartesian3());
}
function formatDegree(value, positiveSuffix, negativeSuffix) {
if (!isFinite(value)) {
return '-';
}
var suffix = value >= 0 ? positiveSuffix : negativeSuffix;
return Math.abs(value).toFixed(6) + '° ' + suffix;
}
function formatCursorCoordinates(viewer, cartesian) {
if (!viewer || !Cesium.defined(cartesian)) {
return 'Координаты: -';
}
var ellipsoid = viewer.scene.globe.ellipsoid;
var cartographic = ellipsoid.cartesianToCartographic(cartesian);
if (!Cesium.defined(cartographic)) {
return 'Координаты: -';
}
var lon = Cesium.Math.toDegrees(cartographic.longitude);
var lat = Cesium.Math.toDegrees(cartographic.latitude);
return 'Lon: ' + formatDegree(lon, 'E', 'W') + ' | Lat: ' + formatDegree(lat, 'N', 'S');
}
function setRulerInfoText(text) {
return;
}
function getRulerDisplayPositions(ruler) {
var positions = [];
if (!ruler || !ruler.points) {
return positions;
}
for (var i = 0; i < ruler.points.length; i++) {
positions.push(ruler.points[i]);
}
if (ruler.points.length > 0 && ruler.tempPoint) {
var lastFixed = ruler.points[ruler.points.length - 1];
if (!Cesium.Cartesian3.equalsEpsilon(lastFixed, ruler.tempPoint, Cesium.Math.EPSILON7, Cesium.Math.EPSILON7)) {
positions.push(ruler.tempPoint);
}
}
return positions;
}
function computePolylineDistanceMeters(viewer, positions) {
if (!viewer || !positions || positions.length < 2) {
return 0;
}
var total = 0;
for (var i = 1; i < positions.length; i++) {
total += computeSurfaceDistanceMeters(viewer, positions[i - 1], positions[i]);
}
return total;
}
function addRulerVertexMarker(viewer, position, isFirstPoint) {
return viewer.entities.add({
name: 'pcp-ruler-vertex',
position: position,
point: {
pixelSize: 8,
color: isFirstPoint ? Cesium.Color.LIME : Cesium.Color.RED,
outlineColor: Cesium.Color.WHITE,
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY
}
});
}
function updateCursorCoordinatesText(viewer, screenPosition) {
if (!pcpCesiumToolsState || !pcpCesiumToolsState.cursorCoordsPanel) {
return;
}
if (!pcpCesiumToolsState.cursorCoordsEnabled) {
pcpCesiumToolsState.cursorCoordsPanel.style.display = 'none';
return;
}
var point = getMapCartesianPosition(viewer, screenPosition);
pcpCesiumToolsState.cursorCoordsPanel.textContent = formatCursorCoordinates(viewer, point);
}
function updateButtonActiveState(button, active) {
if (!button) {
return;
}
if (active) {
button.classList.add('active');
button.style.background = '#0d6efd';
button.style.borderColor = '#0d6efd';
button.style.color = '#ffffff';
} else {
button.classList.remove('active');
button.style.background = '';
button.style.borderColor = '';
button.style.color = '';
}
}
function clearRulerMeasurement(viewer) {
if (!pcpCesiumToolsState || !pcpCesiumToolsState.ruler) {
return;
}
var ruler = pcpCesiumToolsState.ruler;
if (ruler.lineEntity) {
viewer.entities.remove(ruler.lineEntity);
ruler.lineEntity = null;
}
if (ruler.labelEntity) {
viewer.entities.remove(ruler.labelEntity);
ruler.labelEntity = null;
}
if (ruler.pointEntities && ruler.pointEntities.length) {
for (var i = 0; i < ruler.pointEntities.length; i++) {
viewer.entities.remove(ruler.pointEntities[i]);
}
}
ruler.pointEntities = [];
ruler.points = [];
ruler.tempPoint = null;
ruler.completed = false;
}
function ensureRulerEntities(viewer) {
var ruler = pcpCesiumToolsState.ruler;
if (ruler.lineEntity) {
return;
}
ruler.lineEntity = viewer.entities.add({
name: 'pcp-ruler-line',
polyline: {
positions: new Cesium.CallbackProperty(function () {
return getRulerDisplayPositions(ruler);
}, false),
width: 3,
material: Cesium.Color.CYAN
}
});
ruler.labelEntity = viewer.entities.add({
name: 'pcp-ruler-label',
position: new Cesium.CallbackProperty(function () {
var positions = getRulerDisplayPositions(ruler);
if (!positions.length) {
return Cesium.Cartesian3.ZERO;
}
return positions[positions.length - 1];
}, false),
label: {
text: new Cesium.CallbackProperty(function () {
var positions = getRulerDisplayPositions(ruler);
if (positions.length < 2) {
return '';
}
var distance = computePolylineDistanceMeters(viewer, positions);
return 'Σ ' + formatDistanceMeters(distance);
}, false),
showBackground: true,
backgroundColor: Cesium.Color.BLACK.withAlpha(0.75),
fillColor: Cesium.Color.WHITE,
font: '14px sans-serif',
pixelOffset: new Cesium.Cartesian2(10, -10),
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
disableDepthTestDistance: Number.POSITIVE_INFINITY
}
});
}
function updateRulerDistanceInfo(viewer) {
return;
}
function startRulerMeasurement(viewer, point) {
var ruler = pcpCesiumToolsState.ruler;
clearRulerMeasurement(viewer);
ensureRulerEntities(viewer);
ruler.points = [Cesium.Cartesian3.clone(point)];
ruler.tempPoint = Cesium.Cartesian3.clone(point);
ruler.completed = false;
ruler.pointEntities = [addRulerVertexMarker(viewer, ruler.points[0], true)];
updateRulerDistanceInfo(viewer);
}
function completeRulerMeasurement(viewer, point) {
var ruler = pcpCesiumToolsState.ruler;
var fixedPoint = Cesium.Cartesian3.clone(point);
ruler.points.push(fixedPoint);
ruler.tempPoint = Cesium.Cartesian3.clone(point);
ruler.completed = false;
if (!ruler.pointEntities) {
ruler.pointEntities = [];
}
ruler.pointEntities.push(addRulerVertexMarker(viewer, fixedPoint, false));
updateRulerDistanceInfo(viewer);
}
function disableRuler(viewer) {
if (!pcpCesiumToolsState || !pcpCesiumToolsState.ruler) {
return;
}
var ruler = pcpCesiumToolsState.ruler;
ruler.enabled = false;
if (ruler.handler) {
ruler.handler.destroy();
ruler.handler = null;
}
clearRulerMeasurement(viewer);
updateButtonActiveState(pcpCesiumToolsState.rulerButton, false);
viewer.container.style.cursor = '';
viewer.scene.requestRender();
}
function enableRuler(viewer) {
if (!pcpCesiumToolsState || !pcpCesiumToolsState.ruler) {
return;
}
var ruler = pcpCesiumToolsState.ruler;
if (ruler.enabled) {
return;
}
ruler.enabled = true;
clearRulerMeasurement(viewer);
ruler.handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
ruler.handler.setInputAction(function (movement) {
var point = getMapCartesianPosition(viewer, movement.position);
if (!point) {
return;
}
if (!ruler.points || ruler.points.length === 0) {
startRulerMeasurement(viewer, point);
return;
}
completeRulerMeasurement(viewer, point);
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
ruler.handler.setInputAction(function (movement) {
if (!ruler.points || ruler.points.length === 0) {
return;
}
var point = getMapCartesianPosition(viewer, movement.endPosition);
if (!point) {
return;
}
ruler.tempPoint = Cesium.Cartesian3.clone(point);
updateRulerDistanceInfo(viewer);
viewer.scene.requestRender();
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
ruler.handler.setInputAction(function () {
clearRulerMeasurement(viewer);
updateRulerDistanceInfo(viewer);
viewer.scene.requestRender();
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK);
updateButtonActiveState(pcpCesiumToolsState.rulerButton, true);
viewer.container.style.cursor = 'crosshair';
viewer.scene.requestRender();
}
function toggleRuler(viewer) {
if (!pcpCesiumToolsState || !pcpCesiumToolsState.ruler) {
return;
}
if (pcpCesiumToolsState.ruler.enabled) {
disableRuler(viewer);
} else {
enableRuler(viewer);
}
}
function setSunLightingEnabled(viewer, enabled) {
if (!pcpCesiumToolsState) {
return;
}
pcpCesiumToolsState.sunLightingEnabled = enabled;
viewer.scene.globe.enableLighting = enabled;
viewer.scene.globe.dynamicAtmosphereLighting = enabled;
viewer.scene.globe.dynamicAtmosphereLightingFromSun = enabled;
updateButtonActiveState(pcpCesiumToolsState.sunButton, enabled);
if (pcpCesiumToolsState.sunButton) {
pcpCesiumToolsState.sunButton.title = enabled
? 'Освещенность Солнцем: включена'
: 'Освещенность Солнцем: выключена';
}
viewer.scene.requestRender();
}
function toggleSunLighting(viewer) {
if (!pcpCesiumToolsState) {
return;
}
setSunLightingEnabled(viewer, !pcpCesiumToolsState.sunLightingEnabled);
}
function setCursorCoordinatesEnabled(viewer, enabled) {
if (!pcpCesiumToolsState) {
return;
}
pcpCesiumToolsState.cursorCoordsEnabled = enabled;
updateButtonActiveState(pcpCesiumToolsState.cursorCoordsButton, enabled);
if (pcpCesiumToolsState.cursorCoordsPanel) {
pcpCesiumToolsState.cursorCoordsPanel.style.display = enabled ? 'block' : 'none';
pcpCesiumToolsState.cursorCoordsPanel.textContent = enabled
? 'Координаты: наведите курсор на карту'
: '';
}
viewer.scene.requestRender();
}
function toggleCursorCoordinates(viewer) {
if (!pcpCesiumToolsState) {
return;
}
setCursorCoordinatesEnabled(viewer, !pcpCesiumToolsState.cursorCoordsEnabled);
}
function createCesiumToolsWidget(viewer) {
if (pcpCesiumToolsState && pcpCesiumToolsState.toolbarEl) {
return;
}
var viewerToolbar = viewer.container.querySelector('.cesium-viewer-toolbar');
if (!viewerToolbar) {
return;
}
var rulerButton = document.createElement('button');
rulerButton.type = 'button';
rulerButton.className = 'cesium-button cesium-toolbar-button';
rulerButton.title = 'Включить/выключить линейку';
rulerButton.style.marginLeft = '4px';
rulerButton.style.width = '32px';
rulerButton.style.height = '32px';
rulerButton.style.display = 'inline-flex';
rulerButton.style.alignItems = 'center';
rulerButton.style.justifyContent = 'center';
rulerButton.innerHTML = '<i class="bi bi-rulers"></i>';
var coordsButton = document.createElement('button');
coordsButton.type = 'button';
coordsButton.className = 'cesium-button cesium-toolbar-button';
coordsButton.title = 'Включить/выключить отображение координат под курсором';
coordsButton.style.marginLeft = '4px';
coordsButton.style.width = '32px';
coordsButton.style.height = '32px';
coordsButton.style.display = 'inline-flex';
coordsButton.style.alignItems = 'center';
coordsButton.style.justifyContent = 'center';
coordsButton.innerHTML = '<img alt="Координаты" ' +
'src="data:image/svg+xml;utf8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 24 24%27 fill=%27none%27 stroke=%27%23ffffff%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27%3E%3Ccircle cx=%2712%27 cy=%2712%27 r=%274%27/%3E%3Cpath d=%27M12 2v4M12 18v4M2 12h4M18 12h4%27/%3E%3C/svg%3E" ' +
'style="width:16px;height:16px;display:block;">';
var sunButton = document.createElement('button');
sunButton.type = 'button';
sunButton.className = 'cesium-button cesium-toolbar-button';
sunButton.title = 'Включить/выключить освещенность Солнцем';
sunButton.style.marginLeft = '4px';
sunButton.style.width = '32px';
sunButton.style.height = '32px';
sunButton.style.display = 'inline-flex';
sunButton.style.alignItems = 'center';
sunButton.style.justifyContent = 'center';
sunButton.innerHTML = '<i class="bi bi-sun"></i>';
var cursorCoordsPanel = document.createElement('div');
cursorCoordsPanel.className = 'pcp-cursor-coords-panel';
cursorCoordsPanel.style.position = 'absolute';
cursorCoordsPanel.style.top = '50px';
cursorCoordsPanel.style.right = '10px';
cursorCoordsPanel.style.zIndex = '1000';
cursorCoordsPanel.style.maxWidth = '520px';
cursorCoordsPanel.style.padding = '6px 10px';
cursorCoordsPanel.style.background = 'rgba(0, 0, 0, 0.75)';
cursorCoordsPanel.style.color = '#ffffff';
cursorCoordsPanel.style.borderRadius = '4px';
cursorCoordsPanel.style.fontSize = '12px';
cursorCoordsPanel.style.lineHeight = '1.3';
cursorCoordsPanel.style.display = 'none';
cursorCoordsPanel.style.pointerEvents = 'none';
cursorCoordsPanel.textContent = '';
viewerToolbar.appendChild(rulerButton);
viewerToolbar.appendChild(coordsButton);
viewerToolbar.appendChild(sunButton);
viewer.cesiumWidget.container.appendChild(cursorCoordsPanel);
pcpCesiumToolsState = {
toolbarEl: viewerToolbar,
rulerButton: rulerButton,
cursorCoordsButton: coordsButton,
cursorCoordsPanel: cursorCoordsPanel,
sunButton: sunButton,
cursorCoordsEnabled: false,
sunLightingEnabled: false,
ruler: {
enabled: false,
handler: null,
points: [],
tempPoint: null,
completed: false,
lineEntity: null,
labelEntity: null,
pointEntities: []
}
};
rulerButton.addEventListener('click', function () {
toggleRuler(viewer);
});
coordsButton.addEventListener('click', function () {
toggleCursorCoordinates(viewer);
});
sunButton.addEventListener('click', function () {
toggleSunLighting(viewer);
});
viewer.scene.canvas.addEventListener('mousemove', function (event) {
if (!pcpCesiumToolsState || !pcpCesiumToolsState.cursorCoordsEnabled) {
return;
}
var rect = viewer.scene.canvas.getBoundingClientRect();
var screenPosition = new Cesium.Cartesian2(
event.clientX - rect.left,
event.clientY - rect.top
);
updateCursorCoordinatesText(viewer, screenPosition);
});
viewer.scene.canvas.addEventListener('mouseleave', function () {
if (!pcpCesiumToolsState || !pcpCesiumToolsState.cursorCoordsEnabled || !pcpCesiumToolsState.cursorCoordsPanel) {
return;
}
pcpCesiumToolsState.cursorCoordsPanel.textContent = 'Координаты: курсор вне карты';
});
}
function initializeCesium() {
// Задаем область по умолчанию
var ext = Cesium.Rectangle.fromDegrees(351, 72.6, 76.3, 31.4);
Cesium.Camera.DEFAULT_VIEW_RECTANGLE = ext;
var imageryProviderViewModels = Cesium.createDefaultImageryProviderViewModels();
var arcGisWorldImagery = imageryProviderViewModels.find(function (viewModel) {
return viewModel && viewModel.name && viewModel.name.indexOf('ArcGIS World Imagery') !== -1;
}) || imageryProviderViewModels[0];
// Создаем viewer
var viewer = new Cesium.Viewer('cesiumContainer', {
baseLayerPicker: true,
imageryProviderViewModels: imageryProviderViewModels,
selectedImageryProviderViewModel: arcGisWorldImagery,
geocoder: false,
infoBox: true,
navigationHelpButton: false,
sceneMode: Cesium.SceneMode.SCENE2D,
// Оптимизация для полного заполнения контейнера
useDefaultRenderLoop: true,
orderIndependentTranslucency: false,
contextOptions: {
webgl: {
alpha: false,
antialias: true,
preserveDrawingBuffer: true,
failIfMajorPerformanceCaveat: false
}
}
});
var scene = viewer.scene;
// Настройка форматирования времени
if (viewer.animation && viewer.animation.viewModel) {
viewer.animation.viewModel.timeFormatter = TDMT;
viewer.animation.viewModel.dateFormatter = TDMD;
}
if (viewer.timeline) {
viewer.timeline.makeLabel = TDMDT;
}
// Обработчик изменения размера окна
window.addEventListener('resize', function() {
viewer.resize();
});
// Принудительно вызываем resize после создания viewer
setTimeout(function() {
viewer.resize();
}, 100);
createCesiumToolsWidget(viewer);
setSunLightingEnabled(viewer, false);
// Сохраняем viewer в глобальной области видимости для доступа из других функций
window.viewer = viewer;
window.heatmapLayer = null; // Здесь будет храниться слой тепловой карты
}
@@ -0,0 +1,752 @@
(function () {
const apiBase = '/api/com-plan/runs';
const state = {
runs: [],
selectedRunId: null,
selectedRun: null,
selectedRunModes: null,
selectedRunModesError: null,
page: 1,
pageSize: 10
};
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') {
const container = document.getElementById('complex-plan-alert');
container.innerHTML = message
? `<div class="alert alert-${type} mb-3" role="alert">${escapeHtml(message)}</div>`
: '';
}
function formatDateTime(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('ru-RU');
}
function formatDuration(value) {
if (value === null || value === undefined) return '-';
const ms = Number(value);
if (!Number.isFinite(ms)) return text(value, '-');
const seconds = Math.round(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return minutes > 0 ? `${minutes} мин ${remainder} с` : `${remainder} с`;
}
function formatList(values) {
return Array.isArray(values) && values.length ? values.join(', ') : '-';
}
function formatNumber(value) {
const number = Number(value);
if (!Number.isFinite(number)) return '-';
return number.toLocaleString('ru-RU', { maximumFractionDigits: 4 });
}
function formatPercent(value) {
const number = Number(value);
if (!Number.isFinite(number)) return '-';
return `${number.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}%`;
}
function isoDateTime(value) {
if (!value) return '';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : date.toISOString();
}
function formatDateTimeLocal(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return String(value).slice(0, 16);
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
async function requestJson(url, options) {
const response = await fetch(url, options);
if (response.status === 204) {
return null;
}
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 renderRuns() {
const body = document.getElementById('complex-plan-runs-body');
const meta = document.getElementById('complex-plan-list-meta');
const pagination = document.getElementById('complex-plan-pagination');
if (!state.runs.length) {
body.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-5">Расчеты не найдены</td></tr>';
meta.textContent = 'Нет расчетов';
pagination.innerHTML = '';
return;
}
const totalPages = Math.max(1, Math.ceil(state.runs.length / state.pageSize));
state.page = Math.min(Math.max(1, state.page), totalPages);
const pageStart = (state.page - 1) * state.pageSize;
const pageRuns = state.runs.slice(pageStart, pageStart + state.pageSize);
meta.textContent = `Всего расчетов: ${state.runs.length}`;
body.innerHTML = pageRuns.map(run => {
const runId = run.runId ?? run.id;
const activeClass = String(runId) === String(state.selectedRunId) ? ' active' : '';
const interval = `${formatDateTime(run.intervalStart)} - ${formatDateTime(run.intervalEnd)}`;
return `
<tr class="complex-plan-run-row${activeClass}" data-run-id="${escapeHtml(runId)}">
<td>${escapeHtml(runId)}</td>
<td>${escapeHtml(run.status)}</td>
<td>${escapeHtml(interval)}</td>
<td>${escapeHtml(formatList(run.satelliteIds))}</td>
<td>
<button type="button"
class="btn btn-outline-danger btn-sm complex-plan-delete-btn"
data-delete-run-id="${escapeHtml(runId)}">
Удалить
</button>
</td>
</tr>
`;
}).join('');
body.querySelectorAll('.complex-plan-run-row').forEach(row => {
row.addEventListener('click', () => selectRun(row.dataset.runId));
});
body.querySelectorAll('[data-delete-run-id]').forEach(button => {
button.addEventListener('click', event => {
event.stopPropagation();
deleteRun(button.dataset.deleteRunId);
});
});
pagination.innerHTML = `
<button type="button" class="btn btn-outline-secondary btn-sm" id="complex-plan-page-prev" ${state.page <= 1 ? 'disabled' : ''}>
Назад
</button>
<span class="complex-plan-helper">Страница ${state.page} из ${totalPages}</span>
<button type="button" class="btn btn-outline-secondary btn-sm" id="complex-plan-page-next" ${state.page >= totalPages ? 'disabled' : ''}>
Вперед
</button>
`;
document.getElementById('complex-plan-page-prev')?.addEventListener('click', () => {
state.page -= 1;
renderRuns();
});
document.getElementById('complex-plan-page-next')?.addEventListener('click', () => {
state.page += 1;
renderRuns();
});
}
function field(label, value) {
return `
<div class="complex-plan-field">
<div class="complex-plan-field-label">${escapeHtml(label)}</div>
<div class="complex-plan-field-value">${escapeHtml(value)}</div>
</div>
`;
}
function isSurveyMode(mode) {
return !mode.type || String(mode.type).toUpperCase() === 'SURVEY';
}
function isAddedSurveyMode(mode) {
const source = String(mode.source || '').toUpperCase();
return isSurveyMode(mode) && (source === 'COMPLAN' || source === 'MIXED');
}
function addedSurveyRowsBySatellite(run, modes) {
const countsBySatelliteId = new Map();
modes
.filter(isAddedSurveyMode)
.forEach(mode => {
if (mode.satelliteId === null || mode.satelliteId === undefined || mode.satelliteId === '') {
return;
}
const key = String(mode.satelliteId);
countsBySatelliteId.set(key, (countsBySatelliteId.get(key) || 0) + 1);
});
const satelliteIds = [...new Set([
...(Array.isArray(run.satelliteIds) ? run.satelliteIds : []),
...modes.map(mode => mode.satelliteId)
].filter(id => id !== null && id !== undefined && id !== ''))]
.sort((left, right) => Number(left) - Number(right));
return satelliteIds.map(satelliteId => [
satelliteId,
countsBySatelliteId.get(String(satelliteId)) || 0
]);
}
function renderAddedSurveyStats(run) {
if (state.selectedRunModesError) {
return '<div class="text-muted">Не удалось загрузить съемки расчета</div>';
}
if (state.selectedRunModes === null) {
return '<div class="text-muted">Загрузка съемок...</div>';
}
const rows = addedSurveyRowsBySatellite(run, state.selectedRunModes);
if (!rows.length) {
return '<div class="text-muted">Нет данных по спутникам</div>';
}
return `
<div class="complex-plan-added-surveys">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Спутник</th>
<th class="text-end">Добавлено съемок</th>
</tr>
</thead>
<tbody>
${rows.map(([satelliteId, count]) => `
<tr>
<td>${escapeHtml(satelliteId)}</td>
<td class="text-end">${escapeHtml(count)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
function renderDetail(run) {
const container = document.getElementById('complex-plan-detail');
const meta = document.getElementById('complex-plan-detail-meta');
if (!run) {
meta.textContent = 'Выберите расчет слева.';
container.innerHTML = '<div class="text-muted py-5 text-center">Нет выбранного расчета</div>';
document.getElementById('complex-plan-report').disabled = true;
return;
}
const runId = run.runId ?? run.id;
meta.textContent = `Расчет #${runId}`;
document.getElementById('complex-plan-report').disabled = false;
const requestPayload = formatJsonText(run.requestPayload);
container.innerHTML = `
<div class="complex-plan-detail-grid mb-4">
${field('ID', runId)}
${field('Статус', run.status)}
${field('Начало интервала', formatDateTime(run.intervalStart))}
${field('Конец интервала', formatDateTime(run.intervalEnd))}
${field('Спутники', formatList(run.satelliteIds))}
${field('Инициатор', text(run.requestedBy, '-'))}
${field('Создан', formatDateTime(run.createdAt))}
${field('Запущен', formatDateTime(run.startedAt))}
${field('Завершен', formatDateTime(run.finishedAt))}
${field('Длительность', formatDuration(run.durationMs))}
${field('Ошибка', text(run.errorMessage, '-'))}
${field('Snapshot IDs', formatList(run.snapshotIds))}
${field('Calculated modes', run.calculatedModesCount ?? 0)}
${field('Booked modes', run.bookedModesCount ?? 0)}
${field('Complan modes', run.complanModesCount ?? 0)}
${field('Mixed modes', run.mixedModesCount ?? 0)}
${field('Affected satellites', run.affectedSatellitesCount ?? 0)}
${field('Processed cells', run.processedCellsCount ?? 0)}
${field('Booked slot links', run.bookedSlotLinksCount ?? 0)}
${field('Процент покрытия', formatPercent(run.coveredAreaPercent))}
</div>
<div class="complex-plan-section-title mb-2">Добавлено съемок по спутникам</div>
<div class="mb-4">${renderAddedSurveyStats(run)}</div>
<div class="complex-plan-section-title mb-2">Request payload</div>
<pre class="complex-plan-json mb-0">${escapeHtml(requestPayload || '-')}</pre>
`;
}
function formatJsonText(value) {
if (!value) return '';
if (typeof value === 'object') {
return JSON.stringify(value, null, 2);
}
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch (error) {
return String(value);
}
}
function parseRequestPayload(value) {
if (!value) return {};
if (typeof value === 'object') return value;
try {
return JSON.parse(value);
} catch (error) {
return {};
}
}
function fillProcessForm(run) {
const payload = parseRequestPayload(run.requestPayload);
const intervalStart = payload.intervalStart || run.intervalStart;
const intervalEnd = payload.intervalEnd || run.intervalEnd;
const satelliteIds = Array.isArray(payload.satelliteIds) && payload.satelliteIds.length
? payload.satelliteIds
: (Array.isArray(run.satelliteIds) ? run.satelliteIds : []);
document.getElementById('complex-plan-interval-start').value = formatDateTimeLocal(intervalStart);
document.getElementById('complex-plan-interval-end').value = formatDateTimeLocal(intervalEnd);
document.getElementById('complex-plan-satellite-ids').value = satelliteIds.join(', ');
document.getElementById('complex-plan-sun').value = payload.sun === null || payload.sun === undefined
? ''
: payload.sun;
document.getElementById('complex-plan-count-lat').value = payload.countLat === null || payload.countLat === undefined
? ''
: payload.countLat;
document.getElementById('complex-plan-count-long').value = payload.countLong === null || payload.countLong === undefined
? ''
: payload.countLong;
const coverageSourceInput = document.getElementById('complex-plan-coverage-source');
if (payload.coverageSource && Array.from(coverageSourceInput.options).some(option => option.value === payload.coverageSource)) {
coverageSourceInput.value = payload.coverageSource;
} else {
coverageSourceInput.value = 'SLOTS';
}
const coverageStrategyInput = document.getElementById('complex-plan-coverage-strategy');
if (payload.coverageStrategy && Array.from(coverageStrategyInput.options).some(option => option.value === payload.coverageStrategy)) {
coverageStrategyInput.value = payload.coverageStrategy;
} else {
coverageStrategyInput.value = 'GREEDY';
}
}
async function selectRun(runId) {
if (!runId) return;
state.selectedRunId = runId;
state.selectedRunModes = null;
state.selectedRunModesError = null;
renderRuns();
renderDetail(null);
document.getElementById('complex-plan-detail-meta').textContent = `Загрузка расчета #${runId}...`;
try {
const run = await requestJson(`${apiBase}/${encodeURIComponent(runId)}`);
state.selectedRun = run;
renderDetail(run);
fillProcessForm(run);
showAlert('');
try {
const modes = await requestJson(`${apiBase}/${encodeURIComponent(runId)}/modes`);
if (String(state.selectedRunId) !== String(runId)) {
return;
}
state.selectedRunModes = Array.isArray(modes) ? modes : [];
state.selectedRunModesError = null;
} catch (error) {
if (String(state.selectedRunId) !== String(runId)) {
return;
}
state.selectedRunModes = [];
state.selectedRunModesError = error;
}
renderDetail(run);
} catch (error) {
state.selectedRun = null;
state.selectedRunModes = null;
state.selectedRunModesError = null;
showAlert(error.message || 'Не удалось загрузить расчет');
}
}
async function deleteRun(runId) {
if (!runId) return;
const confirmed = window.confirm(`Удалить версию расчета #${runId}?`);
if (!confirmed) return;
try {
await requestJson(`${apiBase}/${encodeURIComponent(runId)}`, { method: 'DELETE' });
if (String(state.selectedRunId) === String(runId)) {
state.selectedRunId = null;
state.selectedRun = null;
state.selectedRunModes = null;
state.selectedRunModesError = null;
renderDetail(null);
}
await loadRuns();
showAlert(`Версия расчета #${runId} удалена`, 'success');
} catch (error) {
showAlert(error.message || 'Не удалось удалить расчет');
}
}
function parseSatelliteIds(value) {
return value
.split(/[,\s;]+/)
.map(item => item.trim())
.filter(Boolean)
.map(item => Number(item))
.filter(item => Number.isFinite(item));
}
function parseOptionalPositiveInt(value) {
const parsedValue = Number.parseInt(String(value || '').trim(), 10);
return Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : null;
}
async function submitProcessForm(event) {
event.preventDefault();
const submitButton = document.getElementById('complex-plan-process-submit');
const intervalStart = document.getElementById('complex-plan-interval-start').value;
const intervalEnd = document.getElementById('complex-plan-interval-end').value;
const satelliteIds = parseSatelliteIds(document.getElementById('complex-plan-satellite-ids').value);
const sunValue = document.getElementById('complex-plan-sun').value.trim();
const coverageSource = document.getElementById('complex-plan-coverage-source').value;
const coverageStrategy = document.getElementById('complex-plan-coverage-strategy').value;
const countLat = parseOptionalPositiveInt(document.getElementById('complex-plan-count-lat').value);
const countLong = parseOptionalPositiveInt(document.getElementById('complex-plan-count-long').value);
if (!intervalStart || !intervalEnd || satelliteIds.length === 0) {
showAlert('Заполните интервал и список Satellite IDs');
return;
}
submitButton.disabled = true;
showAlert('');
try {
const response = await requestJson('/api/com-plan/process', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
intervalStart: intervalStart,
intervalEnd: intervalEnd,
satelliteId: null,
satelliteIds: satelliteIds,
sun: sunValue ? Number(sunValue) : null,
coverageSource: coverageSource || null,
countLat: countLat,
countLong: countLong,
coverageStrategy: coverageStrategy || null
})
});
const runId = response?.runId ?? response?.id;
await loadRuns(runId ? String(runId) : null);
showAlert(`Расчет${runId ? ` #${runId}` : ''} запущен`, 'success');
} catch (error) {
showAlert(error.message || 'Не удалось запустить расчет');
} finally {
submitButton.disabled = false;
}
}
async function loadRuns(preferredRunId) {
const refreshButton = document.getElementById('complex-plan-refresh');
refreshButton.disabled = true;
if (!preferredRunId) {
showAlert('');
}
document.getElementById('complex-plan-list-meta').textContent = 'Загрузка...';
try {
const runs = await requestJson(apiBase);
state.runs = Array.isArray(runs) ? runs : [];
const preferredId = preferredRunId || state.selectedRunId;
const preferredRun = state.runs.find(run => String(run.runId ?? run.id) === String(preferredId));
state.selectedRunId = preferredRun
? String(preferredRun.runId ?? preferredRun.id)
: (state.runs[0] ? String(state.runs[0].runId ?? state.runs[0].id) : null);
if (state.selectedRunId) {
const selectedIndex = state.runs.findIndex(run => String(run.runId ?? run.id) === String(state.selectedRunId));
state.page = Math.floor(Math.max(0, selectedIndex) / state.pageSize) + 1;
}
renderRuns();
if (state.selectedRunId) {
await selectRun(state.selectedRunId);
} else {
state.selectedRun = null;
renderDetail(null);
}
} catch (error) {
state.runs = [];
renderRuns();
state.selectedRunModes = null;
state.selectedRunModesError = null;
renderDetail(null);
showAlert(error.message || 'Не удалось загрузить расчеты');
} finally {
refreshButton.disabled = false;
}
}
function csvValue(value) {
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value).replace('.', ',');
}
const normalized = text(value, '')
.replace(/\r?\n/g, ' ')
.replace(/"/g, '""');
return `"${normalized}"`;
}
function csvRow(values) {
return values.map(csvValue).join(';');
}
function runDetailRows(run) {
return [
['ID', run.runId ?? run.id],
['Статус', run.status],
['Начало интервала', isoDateTime(run.intervalStart)],
['Конец интервала', isoDateTime(run.intervalEnd)],
['Спутники', formatList(run.satelliteIds)],
['Инициатор', text(run.requestedBy, '-')],
['Создан', isoDateTime(run.createdAt)],
['Запущен', isoDateTime(run.startedAt)],
['Завершен', isoDateTime(run.finishedAt)],
['Длительность, мс', run.durationMs ?? ''],
['Ошибка', text(run.errorMessage, '-')],
['Snapshot IDs', formatList(run.snapshotIds)],
['Calculated modes', run.calculatedModesCount ?? 0],
['Booked modes', run.bookedModesCount ?? 0],
['Complan modes', run.complanModesCount ?? 0],
['Mixed modes', run.mixedModesCount ?? 0],
['Affected satellites', run.affectedSatellitesCount ?? 0],
['Processed cells', run.processedCellsCount ?? 0],
['Booked slot links', run.bookedSlotLinksCount ?? 0],
['Процент покрытия', Number(run.coveredAreaPercent) || 0]
];
}
function parseDate(value) {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function intervalDurationDays(run) {
const intervalStart = parseDate(run.intervalStart);
const intervalEnd = parseDate(run.intervalEnd);
if (!intervalStart || !intervalEnd || intervalEnd <= intervalStart) {
return 0;
}
return (intervalEnd.getTime() - intervalStart.getTime()) / 86400000;
}
function modeDurationSeconds(mode) {
const duration = Number(mode.duration);
if (Number.isFinite(duration) && duration > 0) {
return duration;
}
const startTime = parseDate(mode.startTime);
const endTime = parseDate(mode.endTime);
if (!startTime || !endTime || endTime <= startTime) {
return 0;
}
return (endTime.getTime() - startTime.getTime()) / 1000;
}
async function loadSatelliteDetails(satelliteIds) {
const uniqueIds = [...new Set(satelliteIds.filter(id => id !== null && id !== undefined && id !== ''))];
const entries = await Promise.all(uniqueIds.map(async satelliteId => {
try {
const satellite = await requestJson(`/api/catalog/satellites/${encodeURIComponent(satelliteId)}`);
return [String(satelliteId), satellite];
} catch (error) {
return [String(satelliteId), null];
}
}));
return new Map(entries);
}
function satelliteLoadRows(run, modes, satellitesById) {
const intervalDays = intervalDurationDays(run);
const satelliteIds = [...new Set([
...(Array.isArray(run.satelliteIds) ? run.satelliteIds : []),
...modes.map(mode => mode.satelliteId)
].filter(id => id !== null && id !== undefined && id !== ''))]
.sort((left, right) => Number(left) - Number(right));
return satelliteIds.map(satelliteId => {
const satellite = satellitesById.get(String(satelliteId));
const dailyMaxDurationSeconds = Number(satellite?.observationProfile?.dailyMaxDurationSeconds);
const totalSurveySeconds = modes
.filter(mode => String(mode.satelliteId) === String(satelliteId))
.filter(isSurveyMode)
.reduce((sum, mode) => sum + modeDurationSeconds(mode), 0);
const capacitySeconds = Number.isFinite(dailyMaxDurationSeconds) && dailyMaxDurationSeconds > 0
? intervalDays * dailyMaxDurationSeconds
: 0;
const loadFactor = capacitySeconds > 0
? (totalSurveySeconds / capacitySeconds) * 100
: null;
return [
Number(satelliteId),
satellite?.name ?? '',
intervalDays,
Number.isFinite(dailyMaxDurationSeconds) ? dailyMaxDurationSeconds / 86400 : '',
Number.isFinite(dailyMaxDurationSeconds) ? dailyMaxDurationSeconds : '',
capacitySeconds,
totalSurveySeconds,
loadFactor === null ? '' : loadFactor
];
});
}
function modeRows(modes) {
return modes
.slice()
.sort((left, right) => String(left.startTime || '').localeCompare(String(right.startTime || '')))
.map(mode => [
mode.id ?? '',
mode.satelliteId ?? '',
mode.source ?? '',
mode.type ?? '',
isoDateTime(mode.startTime),
isoDateTime(mode.endTime),
mode.revolution ?? '',
mode.roll ?? '',
mode.lat ?? '',
mode.longitude ?? '',
mode.duration ?? '',
Array.isArray(mode.cellNums) ? mode.cellNums.join(', ') : text(mode.cellNum, ''),
Array.isArray(mode.bookedSlotIds) ? mode.bookedSlotIds.join(', ') : '',
mode.snapshotId ?? '',
mode.contourWkt ?? ''
]);
}
function downloadCsv(filename, rows) {
const csv = '\uFEFF' + rows.map(csvRow).join('\n');
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' }));
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
async function downloadReport() {
const run = state.selectedRun;
const runId = run?.runId ?? run?.id;
if (!runId) {
showAlert('Выберите расчет для формирования отчета');
return;
}
const reportButton = document.getElementById('complex-plan-report');
reportButton.disabled = true;
try {
const modes = await requestJson(`${apiBase}/${encodeURIComponent(runId)}/modes`);
const normalizedModes = Array.isArray(modes) ? modes : [];
const satelliteIds = [
...(Array.isArray(run.satelliteIds) ? run.satelliteIds : []),
...normalizedModes.map(mode => mode.satelliteId)
];
const satellitesById = await loadSatelliteDetails(satelliteIds);
const rows = [
['Детальная информация о плане'],
['Параметр', 'Значение'],
...runDetailRows(run),
[],
['Добавлено съемок по спутникам'],
['satelliteId', 'addedSurveyModesCount'],
...addedSurveyRowsBySatellite(run, normalizedModes),
[],
['Сводная таблица по загруженности спутников'],
[
'satelliteId',
'satelliteName',
'calculationDurationDays',
'dailyMaxDurationDays',
'dailyMaxDurationSeconds',
'availableDurationSeconds',
'surveyModesDurationSeconds',
'loadFactor'
],
...satelliteLoadRows(run, normalizedModes, satellitesById),
[],
['Режимы спутников'],
[
'modeId',
'satelliteId',
'source',
'type',
'startTime',
'endTime',
'revolution',
'roll',
'lat',
'longitude',
'duration',
'cellNums',
'bookedSlotIds',
'snapshotId',
'contourWkt'
],
...modeRows(normalizedModes)
];
downloadCsv(`complex-plan-${runId}.csv`, rows);
showAlert(`Отчет по расчету #${runId} сформирован`, 'success');
} catch (error) {
showAlert(error.message || 'Не удалось сформировать отчет');
} finally {
reportButton.disabled = false;
}
}
document.getElementById('complex-plan-refresh').addEventListener('click', () => loadRuns());
document.getElementById('complex-plan-process-form').addEventListener('submit', submitProcessForm);
document.getElementById('complex-plan-report').addEventListener('click', downloadReport);
loadRuns();
})();
@@ -0,0 +1,145 @@
.catalog-page {
min-height: calc(100vh - 6rem);
}
.catalog-card {
border: 1px solid #dbe5ef;
border-radius: 1rem;
box-shadow: 0 0.75rem 2rem rgba(15, 32, 55, 0.08);
}
.catalog-card .card-header {
background: linear-gradient(135deg, #edf5ff, #f7fbff);
border-bottom: 1px solid #dbe5ef;
border-radius: 1rem 1rem 0 0;
}
.catalog-list {
max-height: 68vh;
overflow: auto;
}
.catalog-list-card {
display: flex;
flex-direction: column;
}
.catalog-list-card .catalog-list-search {
flex: 0 0 auto;
}
.catalog-list-card .catalog-list {
flex: 1 1 auto;
min-height: 0;
}
.catalog-list-card .catalog-list-page-scroll {
max-height: none;
overflow: visible;
}
.catalog-list .table tbody tr {
cursor: pointer;
}
.catalog-list .table tbody tr.table-active {
--bs-table-bg: #e9f3ff;
}
.catalog-section-title {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #52708f;
}
.catalog-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.catalog-helper {
color: #5e7288;
font-size: 0.9rem;
}
.catalog-checkbox-list {
max-height: 20rem;
overflow: auto;
border: 1px solid #dbe5ef;
border-radius: 0.75rem;
padding: 0.75rem;
background: #fbfdff;
}
.catalog-checkbox-list .form-check {
padding: 0.35rem 0 0.35rem 1.8rem;
}
.catalog-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.25rem 0.65rem;
background: #eef6ff;
color: #36516e;
font-size: 0.85rem;
margin: 0.15rem;
}
.catalog-color-preview {
width: 100%;
height: 2.75rem;
border-radius: 0.75rem;
border: 1px solid #dbe5ef;
background: rgb(255, 0, 0);
}
.catalog-empty {
padding: 1rem;
border: 1px dashed #bfd0e2;
border-radius: 0.75rem;
color: #5e7288;
background: #fbfdff;
}
.catalog-slot-indicator {
display: inline-block;
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
border: 1px solid #9caec2;
background: #e5ebf1;
}
.catalog-slot-indicator-active {
border-color: #248a55;
background: #2fb66d;
box-shadow: 0 0 0 0.2rem rgba(47, 182, 109, 0.16);
}
.catalog-slot-summary {
border-top: 1px solid #dbe5ef;
padding-top: 1rem;
}
.catalog-slot-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
}
.catalog-summary-label {
color: #5e7288;
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
}
.catalog-summary-value {
color: #24364a;
font-size: 0.98rem;
font-weight: 700;
}
@@ -0,0 +1,127 @@
.complex-plan-page {
min-height: calc(100vh - 6rem);
}
.complex-plan-panel {
border: 1px solid #dbe5ef;
border-radius: 0.75rem;
background: #ffffff;
box-shadow: 0 0.75rem 2rem rgba(15, 32, 55, 0.08);
overflow: hidden;
}
.complex-plan-panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1rem 1.25rem;
background: #f7fbff;
border-bottom: 1px solid #dbe5ef;
}
.complex-plan-section-title {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #52708f;
}
.complex-plan-helper {
color: #5e7288;
font-size: 0.9rem;
}
.complex-plan-table-wrap {
max-height: calc(100vh - 24rem);
overflow: auto;
}
.complex-plan-table-wrap th,
.complex-plan-table-wrap td {
white-space: nowrap;
}
.complex-plan-run-row {
cursor: pointer;
}
.complex-plan-run-row.active td {
background: #eef6ff;
}
.complex-plan-pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-top: 1px solid #dbe5ef;
background: #ffffff;
}
.complex-plan-form {
padding: 1rem 1.25rem 1.25rem;
}
.complex-plan-delete-btn {
padding: 0.15rem 0.45rem;
}
.complex-plan-detail {
padding: 1.25rem;
min-height: 24rem;
}
.complex-plan-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.complex-plan-field {
border-bottom: 1px solid #eef4fb;
padding-bottom: 0.75rem;
}
.complex-plan-field-label {
color: #6b7d90;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 0.2rem;
}
.complex-plan-field-value {
font-weight: 600;
word-break: break-word;
}
.complex-plan-json {
background: #f7fbff;
border: 1px solid #dbe5ef;
border-radius: 0.5rem;
padding: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
max-height: 18rem;
overflow: auto;
}
.complex-plan-added-surveys {
border: 1px solid #dbe5ef;
border-radius: 0.5rem;
overflow: hidden;
}
.complex-plan-added-surveys th,
.complex-plan-added-surveys td {
padding: 0.5rem 0.75rem;
}
@media (max-width: 768px) {
.complex-plan-detail-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,256 @@
.current-plans-page {
height: calc(100vh - 4.25rem);
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
padding-bottom: 0 !important;
}
.current-plans-page > .d-flex:first-child {
flex: 0 0 auto;
margin-bottom: 0.75rem !important;
}
#current-plans-alert {
flex: 0 0 auto;
}
.current-plans-layout {
flex: 1 1 auto;
min-height: 0;
height: auto;
overflow: hidden;
}
.current-plans-layout > [class*="col-"] {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.current-plans-layout > [class*="col-"] > .catalog-card,
.current-plans-layout > [class*="col-"] > .current-plans-main-column {
width: 100%;
}
.current-plans-main-column {
min-height: 0;
height: 100%;
width: 100%;
}
.current-plans-sidebar,
.current-plans-missions-card,
.current-plans-modes-card {
overflow: hidden;
}
.current-plans-sidebar {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.current-plans-sidebar > .card-header,
.current-plans-sidebar > .card-body:not(.catalog-list) {
flex: 0 0 auto;
}
.current-plans-sidebar .catalog-list {
flex: 1 1 auto;
min-height: 0;
max-height: none;
overflow-y: auto;
overflow-x: hidden;
}
.current-plans-missions-card {
display: flex;
flex: 0 0 auto;
flex-direction: column;
max-height: none;
min-height: 0;
}
.current-plans-missions-card .card-body {
min-height: 0;
overflow: hidden;
}
.current-plans-missions-card .current-plans-table-wrap {
max-height: 15rem;
overflow-x: auto;
overflow-y: hidden;
}
.current-plans-modes-card {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.current-plans-modes-card .card-header {
flex: 0 0 auto;
}
.current-plans-modes-card .card-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.current-plans-modes-card .current-plans-table-wrap {
flex: 1 1 auto;
min-height: 0;
max-height: none;
overflow: auto;
}
.current-plans-modes-card .catalog-empty {
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: center;
min-height: 12rem;
}
.current-plans-satellites-table tbody tr,
#current-plans-missions-body tr {
cursor: pointer;
}
.current-plans-selected-row > td {
background: rgba(13, 110, 253, 0.12) !important;
}
.current-plans-table-wrap {
overflow: auto;
}
.current-plans-pagination {
white-space: nowrap;
}
.current-plans-id {
font-family: var(--bs-font-monospace);
font-size: 0.82rem;
}
.current-plans-main-text {
font-weight: 600;
}
.current-plans-sub-text {
color: #6c757d;
font-size: 0.82rem;
}
.current-plans-badge {
display: inline-flex;
align-items: center;
max-width: 100%;
border: 1px solid rgba(108, 117, 125, 0.35);
border-radius: 999px;
padding: 0.1rem 0.45rem;
font-size: 0.78rem;
color: #495057;
background: rgba(248, 249, 250, 0.9);
}
.current-plans-mode-params {
min-width: 11rem;
}
#current-plans-modes-body tr.current-plans-mode-row-drop > td {
background: rgba(25, 135, 84, 0.14) !important;
}
#current-plans-modes-body tr.current-plans-mode-row-slots > td {
background: rgba(255, 193, 7, 0.18) !important;
}
#current-plans-modes-body tr.current-plans-mode-row-drop:hover > td {
background: rgba(25, 135, 84, 0.22) !important;
}
#current-plans-modes-body tr.current-plans-mode-row-slots:hover > td {
background: rgba(255, 193, 7, 0.28) !important;
}
.current-plans-badge-drop {
border-color: rgba(25, 135, 84, 0.45);
color: #0f5132;
background: rgba(25, 135, 84, 0.14);
}
.current-plans-badge-slots {
border-color: rgba(255, 193, 7, 0.55);
color: #664d03;
background: rgba(255, 193, 7, 0.22);
}
@media (max-width: 1199.98px) {
.current-plans-page {
height: auto;
min-height: 0;
overflow: visible;
padding-bottom: 1rem !important;
}
.current-plans-layout {
height: auto;
min-height: 0;
overflow: visible;
}
.current-plans-layout > [class*="col-"] {
display: block;
min-height: auto;
height: auto;
}
.current-plans-main-column,
.current-plans-sidebar,
.current-plans-missions-card,
.current-plans-modes-card,
.current-plans-missions-card .card-body,
.current-plans-modes-card .card-body {
height: auto;
min-height: auto;
}
.current-plans-missions-card {
max-height: none;
}
.current-plans-sidebar .catalog-list,
.current-plans-table-wrap,
.current-plans-missions-card .current-plans-table-wrap,
.current-plans-modes-card .current-plans-table-wrap {
max-height: none;
overflow: visible;
}
}
.current-plans-actions-col {
min-width: 13rem;
}
.current-plans-actions-cell {
min-width: 13rem;
white-space: nowrap;
}
.current-plans-action-btn + .current-plans-action-btn {
margin-left: 0.35rem;
}
#current-plans-create-modal .form-label {
font-weight: 600;
}
@@ -0,0 +1,172 @@
.dynamic-plan-page {
min-height: calc(100vh - 6rem);
}
.dynamic-plan-panel {
border: 1px solid #dbe5ef;
border-radius: 0.5rem;
background: #ffffff;
box-shadow: 0 0.75rem 2rem rgba(15, 32, 55, 0.08);
overflow: hidden;
}
.dynamic-plan-panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1rem 1.25rem;
background: #f7fbff;
border-bottom: 1px solid #dbe5ef;
}
.dynamic-plan-section-title {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #52708f;
}
.dynamic-plan-helper {
color: #5e7288;
font-size: 0.9rem;
}
.dynamic-plan-form,
.dynamic-plan-result {
padding: 1rem 1.25rem 1.25rem;
}
.dynamic-plan-runs {
padding: 0.75rem;
}
.dynamic-plan-mode-group {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.dynamic-plan-mode-group .btn {
width: 100%;
}
.dynamic-plan-revolution-group {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.dynamic-plan-revolution-group .btn {
width: 100%;
}
.dynamic-plan-run-item {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
border: 1px solid #e5edf6;
border-radius: 0.5rem;
background: #ffffff;
padding: 0.75rem;
text-align: left;
}
.dynamic-plan-run-item + .dynamic-plan-run-item {
margin-top: 0.5rem;
}
.dynamic-plan-run-main {
min-width: 0;
}
.dynamic-plan-run-title {
font-weight: 700;
color: #2c4057;
word-break: break-word;
}
.dynamic-plan-run-details {
margin-top: 0.25rem;
color: #63788f;
font-size: 0.82rem;
}
.dynamic-plan-run-status {
white-space: nowrap;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
color: #52708f;
}
.dynamic-plan-result-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.dynamic-plan-map-controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
border: 1px solid #dbe5ef;
border-radius: 0.5rem;
background: #f7fbff;
padding: 0.75rem;
}
.dynamic-plan-map-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.dynamic-plan-field {
border-bottom: 1px solid #eef4fb;
padding-bottom: 0.75rem;
}
.dynamic-plan-field-label {
color: #6b7d90;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 0.2rem;
}
.dynamic-plan-field-value {
font-weight: 600;
word-break: break-word;
}
.dynamic-plan-json {
background: #f7fbff;
border: 1px solid #dbe5ef;
border-radius: 0.5rem;
padding: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
max-height: 18rem;
overflow: auto;
}
@media (max-width: 768px) {
.dynamic-plan-result-grid {
grid-template-columns: 1fr;
}
.dynamic-plan-map-controls {
align-items: stretch;
flex-direction: column;
}
.dynamic-plan-map-actions {
justify-content: flex-start;
}
}
@@ -0,0 +1,134 @@
.group-state-sidebar .catalog-list {
max-height: 76vh;
}
.group-state-interval-cell {
min-width: 16rem;
font-size: 0.9rem;
color: #42576d;
}
.group-state-time-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 14rem;
gap: 1rem;
}
.group-state-table-wrap {
overflow: auto;
}
.group-state-matrix-table {
min-width: 48rem;
}
.group-state-matrix-table thead th {
min-width: 12rem;
background: #f6faff;
vertical-align: bottom;
}
.group-state-matrix-table tbody th {
min-width: 12rem;
background: #f9fbfe;
}
.group-state-matrix-cell {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.9rem;
color: #344b63;
}
.group-state-matrix-diagonal {
text-align: center;
color: #7c8ea1;
font-weight: 600;
}
.group-state-chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
.group-state-chart-card {
border: 1px solid #dbe5ef;
border-radius: 1rem;
padding: 1rem;
background: linear-gradient(180deg, #fbfdff, #f4f9ff);
}
.group-state-chart-title {
font-size: 0.95rem;
font-weight: 700;
color: #3e5c7a;
margin-bottom: 0.75rem;
}
.group-state-chart-svg {
width: 100%;
height: auto;
display: block;
}
.group-state-chart-ring {
fill: none;
stroke: #c8d7e8;
stroke-width: 2;
}
.group-state-chart-inner {
fill: #ffffff;
stroke: #e6eef7;
stroke-width: 1.5;
}
.group-state-chart-center {
font-size: 0.9rem;
fill: #53708d;
font-weight: 700;
}
.group-state-chart-center-value {
font-size: 1.5rem;
fill: #1f3650;
font-weight: 700;
}
.group-state-chart-legend {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
.group-state-legend-item {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.9rem;
color: #3d536b;
}
.group-state-legend-color {
width: 0.9rem;
height: 0.9rem;
border-radius: 999px;
flex: 0 0 auto;
}
@media (max-width: 991.98px) {
.group-state-time-grid {
grid-template-columns: 1fr;
}
.group-state-interval-cell {
min-width: 0;
}
.group-state-matrix-table {
min-width: 36rem;
}
}
@@ -0,0 +1,732 @@
.main-container {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
}
.sidebar {
width: 700px;
min-width: 700px;
max-width: 700px;
height: 100%;
overflow-y: auto;
background-color: #f8f9fa;
border-right: 1px solid #dee2e6;
display: flex;
flex-direction: column;
}
.sidebar-content {
padding: 1rem;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.sidebar-header {
padding-bottom: 1rem;
border-bottom: 1px solid #dee2e6;
margin-bottom: 1rem;
}
.sidebar-controls {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #dee2e6;
}
.cesium-container {
flex: 1;
height: 100%;
position: relative;
min-width: 0;
}
#cesiumContainer {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
/* Стили для тепловой карты */
.heatmap-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: 1rem;
}
.heatmap-toggle .btn {
flex: 1;
}
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
margin-left: 10px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
}
input:checked + .slider {
background-color: #2196F3;
}
input:checked + .slider:before {
transform: translateX(26px);
}
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
/* Стили таблицы */
.table-container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.table-responsive {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: white;
overflow: hidden;
/* Прокрутка теперь здесь */
overflow-y: auto;
}
.table {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
margin-bottom: 0;
width: 100%;
}
.table thead {
flex: 0 0 auto;
display: table;
width: 100%;
table-layout: fixed;
position: sticky;
top: 0;
z-index: 2;
background-color: #f8f9fa;
}
.table tbody {
flex: 1 1 auto;
display: block;
/* Убираем overflow-y: auto отсюда */
}
.table tbody tr {
display: table;
width: 100%;
table-layout: fixed;
cursor: pointer;
transition: background-color 0.2s;
}
.table tbody tr:hover {
background-color: #f8f9fa;
}
.table th, .table td {
padding: 0.5rem 0.75rem;
vertical-align: middle;
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.table thead th {
font-weight: 600;
background-color: #f8f9fa;
border-bottom: 2px solid #dee2e6;
/* Убираем position: sticky отсюда */
}
/* Фиксированные ширины столбцов */
#table-stations th:nth-child(1),
#table-stations td:nth-child(1) {
width: 40px;
min-width: 40px;
max-width: 40px;
text-align: center;
}
#table-stations th:nth-child(2),
#table-stations td:nth-child(2) {
width: 60%;
min-width: 60%;
max-width: 60%;
}
#table-stations th:nth-child(3),
#table-stations td:nth-child(3) {
width: 40%;
min-width: 40%;
max-width: 40%;
}
/* Фиксированные ширины столбцов */
#table-request th:nth-child(1),
#table-request td:nth-child(1) {
width: 40px;
min-width: 40px;
max-width: 40px;
text-align: center;
}
#table-request th:nth-child(2),
#table-request td:nth-child(2) {
width: 45%;
min-width: 45%;
max-width: 45%;
}
#table-request th:nth-child(3),
#table-request td:nth-child(3) {
width: 10%;
min-width: 10%;
max-width: 10%;
}
#table-request th:nth-child(4),
#table-request td:nth-child(4) {
width: 25%;
min-width: 25%;
max-width: 25%;
}
#table-request th:nth-child(5),
#table-request td:nth-child(5) {
width: 10%;
min-width: 10%;
max-width: 10%;
}
#table-request th:nth-child(6),
#table-request td:nth-child(6) {
width: 10%;
min-width: 10%;
max-width: 10%;
}
#table-request th:nth-child(7),
#table-request td:nth-child(7) {
width: 10%;
min-width: 10%;
max-width: 10%;
}
#slotsTable th:nth-child(1),
#slotsTable td:nth-child(1) {
width: 14%;
min-width: 14%;
max-width: 14%;
}
#slotsTable th:nth-child(2),
#slotsTable td:nth-child(2) {
width: 14%;
min-width:14%;
max-width: 14%;
}
#slotsTable th:nth-child(3),
#slotsTable td:nth-child(3) {
width: 28%;
min-width: 28%;
max-width: 28%;
}
#slotsTable th:nth-child(4),
#slotsTable td:nth-child(4) {
width: 14%;
min-width: 14%;
max-width: 14%;
}
#slotsTable th:nth-child(5),
#slotsTable td:nth-child(5) {
width: 14%;
min-width: 14%;
max-width: 14%;
}
#slotsTable th:nth-child(6),
#slotsTable td:nth-child(6) {
width: 8%;
min-width: 8%;
max-width: 8%;
}
#slotsTable th:nth-child(7),
#slotsTable td:nth-child(7) {
width: 8%;
min-width: 8%;
max-width: 8%;
}
#table-satellites th:nth-child(1),
#table-satellites td:nth-child(1) {
width: 20%;
min-width: 20%;
max-width: 20%;
}
#table-satellites th:nth-child(2),
#table-satellites td:nth-child(2) {
width: 60%;
min-width: 60%;
max-width: 60%;
}
#table-satellites th:nth-child(3),
#table-satellites td:nth-child(3) {
width: 10%;
min-width: 10%;
max-width: 20%;
}
#table-satellites th:nth-child(4),
#table-satellites td:nth-child(4) {
width: 10%;
min-width: 10%;
max-width: 20%;
}
/* Стили для сортировки */
.table th.sortable {
cursor: pointer;
position: relative;
user-select: none;
transition: background-color 0.2s;
padding-right: 25px;
}
.table th.sortable:hover {
background-color: #e9ecef;
}
.sort-icon {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
font-size: 0.8em;
opacity: 0.5;
transition: opacity 0.2s, color 0.2s;
}
.table th.sortable:hover .sort-icon {
opacity: 0.8;
}
.table th.sortable.asc .sort-icon,
.table th.sortable.desc .sort-icon {
opacity: 1;
color: #0d6efd;
}
.table th.sortable.desc .sort-icon i {
transform: rotate(180deg);
}
/* Чекбоксы */
.request-checkbox {
cursor: pointer;
margin: 0;
width: 16px;
height: 16px;
}
#selectAllCheckbox {
cursor: pointer;
width: 16px;
height: 16px;
margin: 0;
}
/* Бейджи */
.badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
min-width: 40px;
text-align: center;
display: inline-block;
}
/* Счетчик */
.selected-counter {
font-size: 0.8rem;
color: #6c757d;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid #dee2e6;
}
/* Сообщение "Нет заявок" */
.no-requests {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #6c757d;
font-style: italic;
}
/* Полоса прокрутки теперь у .table-responsive */
.table-responsive::-webkit-scrollbar {
width: 8px;
}
.table-responsive::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.table-responsive::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.table-responsive::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Стили для таблицы слотов */
.slots-container {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.slots-header {
padding: 10px 0;
border-bottom: 2px solid #0d6efd;
}
.slots-actions .btn {
padding: 5px 10px;
font-size: 0.85rem;
}
.slots-actions .btn i {
font-size: 0.9rem;
}
#slotsTable {
font-size: 0.85rem;
}
#slotsTable th {
padding: 8px 10px;
font-weight: 600;
position: sticky;
top: 0;
background-color: #f8f9fa;
z-index: 10;
}
#slotsTable td {
padding: 6px 10px;
vertical-align: middle;
}
#slotsTable .sortable-slot {
cursor: pointer;
user-select: none;
}
#slotsTable .sortable-slot:hover {
background-color: #e9ecef;
}
#slotsTable .sortable-slot .sort-icon {
margin-left: 5px;
font-size: 0.8rem;
color: #6c757d;
}
#slotsTable .sortable-slot:hover .sort-icon {
color: #0d6efd;
}
#slotsTable .badge {
font-size: 0.75rem;
padding: 4px 8px;
}
#slotsTable .text-nowrap {
white-space: nowrap;
}
.coverage-satellites-list {
max-height: 150px;
overflow-y: auto;
background-color: #fff;
}
.slots-summary {
font-size: 0.85rem;
}
.slots-summary .row {
row-gap: 5px;
}
.slots-summary small {
display: block;
}
/* Адаптивность */
@media (max-width: 768px) {
.slots-summary .row > div {
flex: 0 0 50%;
max-width: 50%;
}
}
@media (max-width: 576px) {
.slots-summary .row > div {
flex: 0 0 100%;
max-width: 100%;
}
.slots-actions .btn {
margin-bottom: 5px;
}
.slots-actions .btn:not(:last-child) {
margin-right: 0;
}
}
/* Панель инструментов карты */
.cesium-toolbar {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
}
.toolbar-group {
background: rgba(255, 255, 255, 0.9);
border-radius: 6px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
padding: 5px;
backdrop-filter: blur(10px);
border: 1px solid rgba(0, 0, 0, 0.1);
}
/* Кнопки панели инструментов */
.cesium-toolbar-btn {
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 0.9rem;
color: #495057;
transition: all 0.2s ease;
min-width: 120px;
text-align: left;
}
.cesium-toolbar-btn:hover {
background: #e9ecef;
border-color: #adb5bd;
color: #0d6efd;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.cesium-toolbar-btn:active {
transform: translateY(0);
}
.cesium-toolbar-btn.active {
background: #0d6efd;
border-color: #0d6efd;
color: white;
box-shadow: 0 2px 8px rgba(13, 110, 253, 0.4);
}
.cesium-toolbar-btn.active:hover {
background: #0b5ed7;
border-color: #0b5ed7;
}
.cesium-toolbar-btn i {
font-size: 1.1rem;
}
.btn-label {
font-weight: 500;
}
/* Информация о линейке */
.ruler-info {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 6px;
padding: 10px 15px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ruler-info-content {
display: flex;
align-items: center;
gap: 8px;
}
.ruler-info i {
font-size: 1.2rem;
}
.ruler-info .btn-close {
opacity: 0.7;
transition: opacity 0.2s;
}
.ruler-info .btn-close:hover {
opacity: 1;
}
/* Стили для линий линейки */
.ruler-line {
position: absolute;
pointer-events: none;
z-index: 1000;
background: linear-gradient(90deg, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0.8) 50%, rgba(255,255,255,0.2) 100%);
height: 2px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
/* Стили для маркеров точек */
.ruler-marker {
position: absolute;
width: 12px;
height: 12px;
background: #ff4444;
border: 2px solid white;
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 0 10px rgba(255, 68, 68, 0.8);
pointer-events: none;
z-index: 1001;
}
/* Стили для меток расстояния */
.ruler-label {
position: absolute;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
z-index: 1002;
white-space: nowrap;
transform: translate(-50%, -100%);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
/* Стили для временной линии при наведении */
.ruler-temp-line {
position: absolute;
pointer-events: none;
z-index: 999;
height: 2px;
background: rgba(255, 255, 255, 0.5);
width: 0;
}
@@ -0,0 +1,74 @@
.rva-page {
min-height: calc(100vh - 6rem);
}
.rva-card {
border: 1px solid #dbe5ef;
border-radius: 1rem;
box-shadow: 0 0.75rem 2rem rgba(15, 32, 55, 0.08);
}
.rva-card .card-header {
background: linear-gradient(135deg, #edf5ff, #f7fbff);
border-bottom: 1px solid #dbe5ef;
border-radius: 1rem 1rem 0 0;
}
.rva-section-title {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #52708f;
}
.rva-helper {
color: #5e7288;
font-size: 0.9rem;
}
.rva-stations {
max-height: 26rem;
overflow: auto;
border: 1px solid #dbe5ef;
border-radius: 0.75rem;
padding: 0.75rem;
background: #fbfdff;
}
.rva-station-item {
display: flex;
align-items: flex-start;
width: 100%;
gap: 0.65rem;
padding: 0.55rem 0.25rem;
border-bottom: 1px solid #eef4fb;
}
.rva-station-item.form-check {
margin-bottom: 0;
padding-left: 0;
}
.rva-station-item .form-check-input {
margin-left: 0;
margin-top: 0.2rem;
flex-shrink: 0;
}
.rva-station-label {
display: block;
min-width: 0;
}
.rva-station-item:last-child {
border-bottom: 0;
}
#rva-results-head th {
white-space: nowrap;
}
#rva-results-body td {
white-space: nowrap;
}
@@ -0,0 +1,107 @@
.satellite-pdcm-sidebar .catalog-list {
max-height: 76vh;
}
.satellite-pdcm-sidebar th:first-child,
.satellite-pdcm-sidebar td:first-child {
width: 45%;
}
.satellite-pdcm-summary-root {
min-height: 1.5rem;
}
.satellite-pdcm-status {
display: inline-flex;
width: 0.75rem;
height: 0.75rem;
border-radius: 999px;
margin-right: 0.5rem;
vertical-align: middle;
}
.satellite-pdcm-status-active {
background: #2f9e44;
box-shadow: 0 0 0 0.2rem rgba(47, 158, 68, 0.16);
}
.satellite-pdcm-status-inactive {
background: #9aa9b8;
box-shadow: 0 0 0 0.2rem rgba(154, 169, 184, 0.16);
}
.satellite-pdcm-interval-cell {
min-width: 10.5rem;
font-size: 0.85rem;
color: #42576d;
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.3;
}
.satellite-pdcm-satellite-name {
color: #1f3650;
font-size: 0.95rem;
}
.satellite-pdcm-interval-line {
display: block;
}
.satellite-pdcm-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.satellite-pdcm-summary-card {
border: 1px solid #dbe5ef;
border-radius: 0.9rem;
padding: 0.9rem 1rem;
background: linear-gradient(180deg, #fbfdff, #f5f9ff);
}
.satellite-pdcm-summary-label {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #6d8398;
margin-bottom: 0.25rem;
}
.satellite-pdcm-summary-value {
color: #1f3650;
font-weight: 600;
}
.satellite-pdcm-table-wrap {
overflow: auto;
}
.satellite-pdcm-table {
min-width: 92rem;
}
.satellite-pdcm-table thead th {
background: #f6faff;
white-space: nowrap;
}
.satellite-pdcm-table td {
white-space: nowrap;
}
@media (max-width: 991.98px) {
.satellite-pdcm-sidebar th:first-child,
.satellite-pdcm-sidebar td:first-child {
width: auto;
}
.satellite-pdcm-interval-cell {
min-width: 0;
}
.satellite-pdcm-table {
min-width: 72rem;
}
}
@@ -0,0 +1,655 @@
(() => {
const state = {
satellites: [],
filteredSatellites: [],
missions: [],
currentModes: [],
selectedSatelliteId: null,
selectedMissionId: null,
selectedMission: null,
missionsPage: 0,
missionsSize: 4,
missionsTotalPages: 0,
missionsTotalElements: 0
};
const el = (id) => document.getElementById(id);
document.addEventListener('DOMContentLoaded', () => {
bindEvents();
loadSatellites();
});
function bindEvents() {
el('current-plans-refresh')?.addEventListener('click', () => reloadAll());
el('current-plans-satellite-filter')?.addEventListener('input', () => applySatelliteFilter());
el('current-plans-missions-prev')?.addEventListener('click', () => changeMissionPage(-1));
el('current-plans-missions-next')?.addEventListener('click', () => changeMissionPage(1));
el('current-plans-create-mission')?.addEventListener('click', () => openCreateMissionDialog());
el('current-plans-create-form')?.addEventListener('submit', (event) => createMission(event));
el('current-plans-export-csv')?.addEventListener('click', () => exportSelectedMissionCsv());
}
function reloadAll() {
if (state.selectedSatelliteId) {
loadMissions(state.selectedSatelliteId, state.missionsPage, state.selectedMissionId);
} else {
loadSatellites();
}
}
async function loadSatellites() {
showAlert('');
setCreateMissionEnabled(false);
clearMissions('Выберите спутник для загрузки миссий.');
clearModes('Режимы появятся после выбора миссии.');
setTableLoading('current-plans-satellites-body', 3, 'Загрузка спутников...');
try {
const satellites = await fetchJson('/api/current-plans/satellites');
state.satellites = Array.isArray(satellites) ? satellites : [];
applySatelliteFilter();
} catch (error) {
state.satellites = [];
state.filteredSatellites = [];
renderSatellites();
showAlert(error.message || 'Не удалось загрузить список спутников.', 'danger');
}
}
function applySatelliteFilter() {
const query = (el('current-plans-satellite-filter')?.value || '').trim().toLowerCase();
state.filteredSatellites = state.satellites.filter((satellite) => {
const text = [
satellite.id,
satellite.noradId,
satellite.code,
satellite.name,
satellite.typeCode
].filter((value) => value !== null && value !== undefined)
.join(' ')
.toLowerCase();
return !query || text.includes(query);
});
renderSatellites();
}
function renderSatellites() {
const tbody = el('current-plans-satellites-body');
if (!tbody) return;
if (!state.filteredSatellites.length) {
tbody.innerHTML = emptyRow(3, 'Спутники не найдены.');
return;
}
tbody.innerHTML = state.filteredSatellites.map((satellite) => {
const selected = Number(satellite.id) === Number(state.selectedSatelliteId) ? ' current-plans-selected-row' : '';
const title = escapeHtml(satellite.name || satellite.code || `КА ${satellite.id}`);
const code = escapeHtml(satellite.code || '—');
const norad = satellite.noradId ? `NORAD ${escapeHtml(satellite.noradId)}` : 'NORAD —';
return `
<tr class="${selected}" data-satellite-id="${escapeHtml(satellite.id)}">
<td class="current-plans-id">${escapeHtml(satellite.id)}</td>
<td>
<div class="current-plans-main-text">${title}</div>
<div class="current-plans-sub-text">${code} · ${norad}</div>
</td>
<td>${escapeHtml(satellite.typeCode || '—')}</td>
</tr>`;
}).join('');
tbody.querySelectorAll('tr[data-satellite-id]').forEach((row) => {
row.addEventListener('click', () => selectSatellite(Number(row.dataset.satelliteId)));
});
}
function selectSatellite(satelliteId) {
state.selectedSatelliteId = satelliteId;
state.selectedMissionId = null;
state.selectedMission = null;
state.missionsPage = 0;
renderSatellites();
const satellite = selectedSatellite();
el('current-plans-selected-satellite').textContent = satelliteLabel(satellite);
setCreateMissionEnabled(true);
clearModes('Выберите миссию в верхней таблице.');
loadMissions(satelliteId, 0);
}
async function loadMissions(satelliteId, page, missionIdToSelect = null) {
setTableLoading('current-plans-missions-body', 6, 'Загрузка миссий...');
el('current-plans-missions-empty')?.classList.add('d-none');
el('current-plans-missions-wrap')?.classList.remove('d-none');
setMissionPaging(false);
try {
const data = await fetchJson(`/api/current-plans/satellites/${encodeURIComponent(satelliteId)}/missions?page=${page}&size=${state.missionsSize}`);
state.missionsPage = data.page || 0;
state.missionsTotalPages = data.totalPages || 0;
state.missionsTotalElements = data.totalElements || 0;
state.missions = data.items || [];
renderMissions(state.missions);
updateMissionPaging();
if (missionIdToSelect && state.missions.some((mission) => mission.missionId === missionIdToSelect)) {
selectMission(missionIdToSelect);
}
} catch (error) {
clearMissions('Не удалось загрузить миссии выбранного спутника.');
showAlert(error.message || 'Не удалось загрузить миссии выбранного спутника.', 'danger');
}
}
function renderMissions(missions) {
const tbody = el('current-plans-missions-body');
if (!tbody) return;
if (!missions.length) {
tbody.innerHTML = emptyRow(6, 'Для выбранного спутника миссии не найдены.');
clearModes('Выберите миссию в верхней таблице.');
return;
}
tbody.innerHTML = missions.map((mission) => {
const selected = mission.missionId === state.selectedMissionId ? ' current-plans-selected-row' : '';
return `
<tr class="${selected}" data-mission-id="${escapeHtml(mission.missionId)}">
<td class="current-plans-id">${shortUuid(mission.missionId)}</td>
<td>${formatDateTime(mission.missionStart)}</td>
<td>${formatDateTime(mission.missionStop)}</td>
<td>${escapeHtml(mission.station || '—')}</td>
<td><span class="current-plans-badge">${escapeHtml(mission.status || '—')}</span></td>
<td class="current-plans-actions-cell">
<button type="button"
class="btn btn-outline-primary btn-sm current-plans-action-btn"
data-action="calculate-surveys"
data-mission-id="${escapeHtml(mission.missionId)}">Расчёт съёмки</button>
<button type="button"
class="btn btn-outline-success btn-sm current-plans-action-btn"
data-action="calculate-drops"
data-mission-id="${escapeHtml(mission.missionId)}">Расчёт сбросов</button>
</td>
</tr>`;
}).join('');
tbody.querySelectorAll('tr[data-mission-id]').forEach((row) => {
row.addEventListener('click', () => selectMission(row.dataset.missionId));
});
tbody.querySelectorAll('button[data-action]').forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const missionId = button.dataset.missionId;
if (button.dataset.action === 'calculate-surveys') {
calculateSurveys(missionId, button);
} else if (button.dataset.action === 'calculate-drops') {
calculateDrops(missionId, button);
}
});
});
}
function selectMission(missionId) {
state.selectedMissionId = missionId;
state.selectedMission = state.missions.find((mission) => mission.missionId === missionId) || null;
el('current-plans-selected-mission').textContent = missionCaption(state.selectedMission, missionId);
el('current-plans-missions-body')?.querySelectorAll('tr').forEach((row) => {
row.classList.toggle('current-plans-selected-row', row.dataset.missionId === missionId);
});
loadModes(missionId);
}
async function loadModes(missionId) {
setTableLoading('current-plans-modes-body', 6, 'Загрузка режимов...');
el('current-plans-modes-empty')?.classList.add('d-none');
el('current-plans-modes-wrap')?.classList.remove('d-none');
setExportEnabled(false);
try {
const modes = await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/modes`);
state.currentModes = Array.isArray(modes) ? modes : [];
renderModes(state.currentModes);
setExportEnabled(true);
return state.currentModes;
} catch (error) {
clearModes('Не удалось загрузить режимы выбранной миссии.');
showAlert(error.message || 'Не удалось загрузить режимы выбранной миссии.', 'danger');
return [];
}
}
function renderModes(modes) {
const tbody = el('current-plans-modes-body');
if (!tbody) return;
if (!modes.length) {
tbody.innerHTML = emptyRow(6, 'В выбранной миссии нет режимов работы.');
return;
}
tbody.innerHTML = modes.map((mode) => {
const type = mode.type || '—';
const duration = formatNumber(mode.duration);
const rowClass = modeRowClass(mode);
const badgeClass = modeBadgeClass(mode);
return `
<tr class="${rowClass}">
<td><span class="current-plans-badge ${badgeClass}">${escapeHtml(type)}</span></td>
<td>${formatDateTime(mode.timeStart)}</td>
<td>${escapeHtml(mode.revolution ?? '—')}</td>
<td>${duration}</td>
<td class="current-plans-mode-params">${modeParams(mode)}</td>
<td>${modeStatus(mode)}</td>
</tr>`;
}).join('');
}
function modeRowClass(mode) {
if (mode.type === 'DROP') return 'current-plans-mode-row-drop';
if (mode.source === 'SLOTS') return 'current-plans-mode-row-slots';
return '';
}
function modeBadgeClass(mode) {
if (mode.type === 'DROP') return 'current-plans-badge-drop';
if (mode.source === 'SLOTS') return 'current-plans-badge-slots';
return '';
}
function modeParams(mode) {
if (mode.type === 'DROP') {
const surveys = Array.isArray(mode.surveys) ? mode.surveys.length : 0;
return `
<div>Станция: ${escapeHtml(mode.station || '—')}</div>
<div class="current-plans-sub-text">Съёмок: ${surveys}</div>`;
}
const booked = Array.isArray(mode.bookedSlotIds) && mode.bookedSlotIds.length
? ` · slots: ${mode.bookedSlotIds.length}`
: '';
return `
<div>Крен: ${formatNumber(mode.roll)}°</div>
<div class="current-plans-sub-text">lat ${formatNumber(mode.lat)}, lon ${formatNumber(mode.longitude)}${booked}</div>`;
}
function modeStatus(mode) {
if (mode.type === 'DROP') {
return '<span class="current-plans-badge current-plans-badge-drop">DROP</span>';
}
const sourceBadgeClass = mode.source === 'SLOTS' ? ' current-plans-badge-slots' : '';
return `
<div><span class="current-plans-badge">${escapeHtml(mode.status || '—')}</span></div>
<div><span class="current-plans-badge${sourceBadgeClass}">${escapeHtml(mode.source || '—')}</span></div>`;
}
function openCreateMissionDialog() {
if (!state.selectedSatelliteId) {
showAlert('Сначала выберите спутник.', 'warning');
return;
}
const satellite = selectedSatellite();
el('current-plans-create-satellite').value = satelliteLabel(satellite);
const startInput = el('current-plans-create-start');
const stopInput = el('current-plans-create-stop');
if (startInput && !startInput.value) startInput.value = toDateTimeLocalValue(new Date());
if (stopInput && !stopInput.value) stopInput.value = toDateTimeLocalValue(new Date(Date.now() + 24 * 60 * 60 * 1000));
const modalElement = el('current-plans-create-modal');
if (window.bootstrap && modalElement) {
window.bootstrap.Modal.getOrCreateInstance(modalElement).show();
}
}
async function createMission(event) {
event.preventDefault();
if (!state.selectedSatelliteId) {
showAlert('Сначала выберите спутник.', 'warning');
return;
}
const missionStart = el('current-plans-create-start')?.value;
const missionStop = el('current-plans-create-stop')?.value;
if (!missionStart || !missionStop) {
showAlert('Укажите начало и конец миссии.', 'warning');
return;
}
if (new Date(missionStart).getTime() > new Date(missionStop).getTime()) {
showAlert('Время начала миссии должно быть меньше или равно времени окончания.', 'warning');
return;
}
const button = el('current-plans-create-submit');
setButtonLoading(button, true, 'Создание...');
const body = {
satelliteId: Number(state.selectedSatelliteId),
station: trimToNull(el('current-plans-create-station')?.value),
missionStart,
missionStop,
status: trimToNull(el('current-plans-create-status')?.value) || 'NEW'
};
try {
const mission = await fetchJson('/api/current-plans/missions', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
body: JSON.stringify(body)
});
closeCreateMissionDialog();
showAlert(`План создан: ${mission?.missionId || ''}`, 'success');
await loadMissions(state.selectedSatelliteId, 0, mission?.missionId);
} catch (error) {
showAlert(error.message || 'Не удалось создать план.', 'danger');
} finally {
setButtonLoading(button, false);
}
}
async function calculateSurveys(missionId, button) {
setButtonLoading(button, true, 'Расчёт...');
try {
const result = await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/surveys/calculate`, {
method: 'POST',
headers: {'Accept': 'application/json'}
});
showAlert(surveyCalculationMessage(result), 'success');
if (state.selectedMissionId === missionId) {
await loadModes(missionId);
}
} catch (error) {
showAlert(error.message || 'Не удалось рассчитать съёмку.', 'danger');
} finally {
setButtonLoading(button, false);
}
}
async function calculateDrops(missionId, button) {
setButtonLoading(button, true, 'Расчёт...');
try {
await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/drops/calculate`, {
method: 'POST',
headers: {'Accept': 'application/json'}
});
const modes = state.selectedMissionId === missionId
? await loadModes(missionId)
: await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/modes`);
showAlert(`Расчёт сбросов выполнен. ${modeCountsMessage(Array.isArray(modes) ? modes : [])}`, 'success');
} catch (error) {
showAlert(error.message || 'Не удалось рассчитать сбросы.', 'danger');
} finally {
setButtonLoading(button, false);
}
}
function exportSelectedMissionCsv() {
if (!state.selectedMissionId) {
showAlert('Выберите миссию для сохранения CSV.', 'warning');
return;
}
const mission = state.selectedMission || state.missions.find((item) => item.missionId === state.selectedMissionId);
const satellite = selectedSatellite();
const modes = state.currentModes || [];
const csv = buildMissionCsv(mission, satellite, modes);
const filename = `mission-${state.selectedMissionId}-modes.csv`;
downloadTextFile(filename, csv, 'text/csv;charset=utf-8;');
}
function buildMissionCsv(mission, satellite, modes) {
const lines = [];
lines.push(['Миссия', mission?.missionId || state.selectedMissionId].map(csvCell).join(';'));
lines.push(['Спутник ID', mission?.satelliteId || state.selectedSatelliteId].map(csvCell).join(';'));
lines.push(['Спутник', satelliteLabel(satellite)].map(csvCell).join(';'));
lines.push(['Начало миссии', mission?.missionStart || ''].map(csvCell).join(';'));
lines.push(['Конец миссии', mission?.missionStop || ''].map(csvCell).join(';'));
lines.push(['Станция', mission?.station || ''].map(csvCell).join(';'));
lines.push(['Статус', mission?.status || ''].map(csvCell).join(';'));
lines.push(['Количество режимов', modes.length].map(csvCell).join(';'));
lines.push(['Состав режимов', modeCountsPlain(modes)].map(csvCell).join(';'));
lines.push('');
lines.push(['Тип', 'ID', 'Plan ID', 'Начало', 'Виток', 'Длительность, с', 'Статус', 'Источник', 'Крен', 'Широта', 'Долгота', 'Станция', 'Связанные съёмки', 'Booked slots'].map(csvCell).join(';'));
modes.forEach((mode) => {
lines.push([
mode.type || '',
mode.id ?? '',
mode.planId ?? '',
mode.timeStart || '',
mode.revolution ?? '',
mode.duration ?? '',
mode.status || '',
mode.source || '',
mode.roll ?? '',
mode.lat ?? '',
mode.longitude ?? '',
mode.station || '',
Array.isArray(mode.surveys) ? mode.surveys.join('|') : '',
Array.isArray(mode.bookedSlotIds) ? mode.bookedSlotIds.join('|') : ''
].map(csvCell).join(';'));
});
return '\ufeff' + lines.join('\r\n');
}
function changeMissionPage(delta) {
if (!state.selectedSatelliteId) return;
const next = state.missionsPage + delta;
if (next < 0 || (state.missionsTotalPages > 0 && next >= state.missionsTotalPages)) return;
state.selectedMissionId = null;
state.selectedMission = null;
clearModes('Выберите миссию в верхней таблице.');
loadMissions(state.selectedSatelliteId, next);
}
function updateMissionPaging() {
const pageText = state.missionsTotalPages > 0
? `${state.missionsPage + 1} / ${state.missionsTotalPages}`
: '0 / 0';
el('current-plans-missions-page').textContent = `${pageText} · ${state.missionsTotalElements}`;
setMissionPaging(true);
}
function setMissionPaging(enabled) {
const hasPrev = enabled && state.missionsPage > 0;
const hasNext = enabled && state.missionsTotalPages > 0 && state.missionsPage < state.missionsTotalPages - 1;
el('current-plans-missions-prev').disabled = !hasPrev;
el('current-plans-missions-next').disabled = !hasNext;
}
function clearMissions(message) {
el('current-plans-missions-wrap')?.classList.add('d-none');
el('current-plans-missions-empty')?.classList.remove('d-none');
el('current-plans-missions-empty').textContent = message;
el('current-plans-missions-body').innerHTML = '';
state.missions = [];
state.missionsTotalPages = 0;
state.missionsTotalElements = 0;
updateMissionPaging();
}
function clearModes(message) {
el('current-plans-modes-wrap')?.classList.add('d-none');
el('current-plans-modes-empty')?.classList.remove('d-none');
el('current-plans-modes-empty').textContent = message;
el('current-plans-modes-body').innerHTML = '';
el('current-plans-selected-mission').textContent = 'Выберите миссию в верхней таблице.';
state.currentModes = [];
setExportEnabled(false);
}
function setTableLoading(bodyId, colspan, message) {
const tbody = el(bodyId);
if (tbody) tbody.innerHTML = emptyRow(colspan, message);
}
function selectedSatellite() {
return state.satellites.find((item) => Number(item.id) === Number(state.selectedSatelliteId)) || null;
}
function satelliteLabel(satellite) {
if (!satellite) return `Спутник ${state.selectedSatelliteId}`;
const name = satellite.name || satellite.code || `КА ${satellite.id}`;
const type = satellite.typeCode ? `, тип ${satellite.typeCode}` : '';
const norad = satellite.noradId ? `, NORAD ${satellite.noradId}` : '';
return `${name} (ID ${satellite.id}${norad}${type})`;
}
function missionCaption(mission, missionId) {
if (!mission) return `Миссия ${missionId}`;
return `Миссия ${mission.missionId}: ${formatDateTime(mission.missionStart)}${formatDateTime(mission.missionStop)}`;
}
async function fetchJson(url, options = {}) {
const response = await fetch(url, options.headers ? options : {...options, headers: {'Accept': 'application/json'}});
if (!response.ok) {
const text = await response.text();
throw new Error(extractError(text) || `HTTP ${response.status}`);
}
if (response.status === 204) return null;
const text = await response.text();
return text ? JSON.parse(text) : null;
}
function extractError(text) {
if (!text) return '';
try {
const json = JSON.parse(text);
return json.message || json.error || text;
} catch (_) {
return text;
}
}
function showAlert(message, type = 'danger') {
const target = el('current-plans-alert');
if (!target) return;
if (!message) {
target.innerHTML = '';
return;
}
target.innerHTML = `<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="Close"></button>
</div>`;
}
function surveyCalculationMessage(result) {
if (!result) return 'Расчёт съёмки выполнен.';
return [
`Расчёт съёмки выполнен. Всего режимов съёмки: ${result.surveyModesCount ?? 0}`,
`COMPLAN: ${result.complanModesCount ?? 0}`,
`SLOTS: ${result.slotsModesCount ?? 0}`,
`MIXED: ${result.mixedModesCount ?? 0}`,
`MANUAL: ${result.manualModesCount ?? 0}`,
`booked slots: ${result.bookedSlotIdsCount ?? 0}`
].join(' · ');
}
function modeCountsMessage(modes) {
const counts = countModes(modes);
const parts = Object.keys(counts).sort().map((type) => `${type}: ${counts[type]}`);
return parts.length ? `Режимы: ${parts.join(' · ')}` : 'Режимы отсутствуют.';
}
function modeCountsPlain(modes) {
const counts = countModes(modes);
return Object.keys(counts).sort().map((type) => `${type}: ${counts[type]}`).join(', ');
}
function countModes(modes) {
return (modes || []).reduce((acc, mode) => {
const type = mode.type || 'UNKNOWN';
acc[type] = (acc[type] || 0) + 1;
return acc;
}, {});
}
function emptyRow(colspan, message) {
return `<tr><td colspan="${colspan}" class="text-muted text-center py-4">${escapeHtml(message)}</td></tr>`;
}
function shortUuid(value) {
if (!value) return '—';
return `${String(value).slice(0, 8)}`;
}
function formatDateTime(value) {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return escapeHtml(value);
return date.toLocaleString('ru-RU', {
year: '2-digit',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
function formatNumber(value) {
const number = Number(value);
if (!Number.isFinite(number)) return '—';
return number.toLocaleString('ru-RU', {maximumFractionDigits: 2});
}
function toDateTimeLocalValue(date) {
const pad = (number) => String(number).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function trimToNull(value) {
const trimmed = String(value || '').trim();
return trimmed || null;
}
function setCreateMissionEnabled(enabled) {
const button = el('current-plans-create-mission');
if (button) button.disabled = !enabled;
}
function setExportEnabled(enabled) {
const button = el('current-plans-export-csv');
if (button) button.disabled = !enabled || !state.selectedMissionId;
}
function setButtonLoading(button, loading, text = 'Загрузка...') {
if (!button) return;
if (loading) {
button.dataset.originalText = button.textContent;
button.textContent = text;
button.disabled = true;
} else {
button.textContent = button.dataset.originalText || button.textContent;
button.disabled = false;
}
}
function closeCreateMissionDialog() {
const modalElement = el('current-plans-create-modal');
if (window.bootstrap && modalElement) {
window.bootstrap.Modal.getOrCreateInstance(modalElement).hide();
}
}
function csvCell(value) {
const text = String(value ?? '');
return `"${text.replaceAll('"', '""')}"`;
}
function downloadTextFile(filename, content, type) {
const blob = new Blob([content], {type});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
})();
@@ -0,0 +1,326 @@
(function () {
const storageKey = 'pcp.dynamicPlan.visibleRuns';
const routePageLimit = 1000;
const intervalPageLimit = 1000;
const maxRoutesToDraw = 10000;
const maxIntervalsToDraw = 10000;
const dataSourcePrefix = 'dynamic_plan_run_';
let mapViewer = null;
function readRuns() {
try {
const value = window.localStorage.getItem(storageKey);
const parsed = value ? JSON.parse(value) : [];
return Array.isArray(parsed) ? parsed.slice(0, 1) : [];
} catch (error) {
console.warn('Failed to read dynamic plan map runs', error);
return [];
}
}
function writeRuns(runs) {
window.localStorage.setItem(storageKey, JSON.stringify(runs));
window.dispatchEvent(new CustomEvent('dynamic-plan-map-runs-changed', { detail: runs }));
}
function normalizeRun(run) {
return {
runId: String(run.runId),
requestId: run.requestId ? String(run.requestId) : null,
routesCount: Number.isFinite(Number(run.routesCount)) ? Number(run.routesCount) : null,
addedAt: new Date().toISOString()
};
}
function showRun(run) {
if (!run || !run.runId) {
return;
}
const normalizedRun = normalizeRun(run);
writeRuns([normalizedRun]);
}
function hideRun(runId) {
const id = String(runId);
writeRuns(readRuns().filter(item => String(item.runId) !== id));
removeRunDataSource(id);
}
function isRunVisible(runId) {
const id = String(runId);
return readRuns().some(item => String(item.runId) === id);
}
function dataSourceName(runId) {
return `${dataSourcePrefix}${runId}`;
}
function removeRunDataSource(runId) {
if (!mapViewer) {
return;
}
let dataSource = mapViewer.dataSources.getByName(dataSourceName(runId))[0];
while (dataSource && mapViewer.dataSources.remove(dataSource)) {
dataSource = mapViewer.dataSources.getByName(dataSourceName(runId))[0];
}
}
function closeDegreesArrayHeights(positions) {
if (!Array.isArray(positions) || positions.length < 9) {
return positions;
}
const first = positions.slice(0, 3);
const last = positions.slice(-3);
const isClosed = first.every((value, index) => value === last[index]);
return isClosed ? positions : positions.concat(first);
}
function parsePolygonWktToDegreesArray(contourWkt) {
if (typeof contourWkt !== 'string') {
return null;
}
const trimmed = contourWkt.trim();
if (!trimmed.toUpperCase().startsWith('POLYGON')) {
return null;
}
const body = trimmed
.replace(/^POLYGON\s*\(\s*\(/i, '')
.replace(/\)\s*\)\s*$/i, '');
const outerRingText = body.split(/\)\s*,\s*\(/)[0];
const coordinates = outerRingText
.split(',')
.map(pair => pair.trim().split(/\s+/))
.map(parts => ({
longitude: Number(parts[0]),
latitude: Number(parts[1])
}))
.filter(point => Number.isFinite(point.longitude) && Number.isFinite(point.latitude));
if (coordinates.length < 3) {
return null;
}
const degrees = [];
coordinates.forEach(point => {
degrees.push(point.longitude, point.latitude, 0.0);
});
return degrees;
}
function intervalColor(revSign) {
return revSign === 'DESC'
? Cesium.Color.CYAN
: Cesium.Color.YELLOW;
}
function colorForSatellite(satelliteId) {
const id = Number(satelliteId);
const seed = Number.isFinite(id) ? Math.abs(id) : 0;
const hue = ((seed * 37) % 360) / 360;
return Cesium.Color.fromHsl(hue, 0.72, 0.52, 1.0);
}
function formatValue(value) {
return value === null || value === undefined || value === '' ? '-' : String(value).replace('T', ' ');
}
function createRoutesDataSource(run, routes, intervals) {
const dataSource = new Cesium.CustomDataSource(dataSourceName(run.runId));
intervals.forEach((interval, index) => {
const positions = parsePolygonWktToDegreesArray(interval.contourWkt);
if (!positions) {
console.warn('Failed to parse dynamic plan interval contour', interval);
return;
}
const color = intervalColor(interval.revSign);
dataSource.entities.add({
id: `dynamic_plan_interval_${run.runId}_${interval.id || index}`,
name: `Dynamic Plan interval ${interval.revSign || '-'}`,
description: [
`Run ID: ${formatValue(run.runId)}`,
`Request ID: ${formatValue(interval.requestId || run.requestId)}`,
`Interval ID: ${formatValue(interval.intervalId)}`,
`Region ID: ${formatValue(interval.regionId)}`,
`Revolution sign: ${formatValue(interval.revSign)}`,
`Observation parameters: ${formatValue(interval.observationParametersCount)}`
].join('<br>'),
polygon: {
hierarchy: Cesium.Cartesian3.fromDegreesArrayHeights(positions),
material: color.withAlpha(0.08),
outline: true,
outlineColor: color.withAlpha(0.95),
outlineWidth: 1.0
},
polyline: {
positions: Cesium.Cartesian3.fromDegreesArrayHeights(closeDegreesArrayHeights(positions)),
width: 1.0,
material: color.withAlpha(0.95)
}
});
});
routes.forEach((route, index) => {
const positions = parsePolygonWktToDegreesArray(route.contourWkt);
if (!positions) {
console.warn('Failed to parse dynamic plan route contour', route);
return;
}
const color = colorForSatellite(route.satelliteId);
dataSource.entities.add({
id: `dynamic_plan_route_${run.runId}_${route.id || index}`,
name: `Dynamic Plan route ${route.satelliteId || '-'}`,
description: [
`Run ID: ${formatValue(run.runId)}`,
`Request ID: ${formatValue(route.requestId || run.requestId)}`,
`Satellite: ${formatValue(route.satelliteId)}`,
`Start: ${formatValue(route.startTime)}`,
`End: ${formatValue(route.endTime)}`,
`Duration: ${formatValue(route.duration)}`,
`Revolution: ${formatValue(route.revolution)}`,
`Roll: ${formatValue(route.roll)}`
].join('<br>'),
polygon: {
hierarchy: Cesium.Cartesian3.fromDegreesArrayHeights(positions),
material: color.withAlpha(0.22),
outline: true,
outlineColor: color,
outlineWidth: 2.0
},
polyline: {
positions: Cesium.Cartesian3.fromDegreesArrayHeights(closeDegreesArrayHeights(positions)),
width: 2.0,
material: color
}
});
});
return dataSource;
}
async function requestJson(url) {
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
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;
}
async function loadRoutes(run) {
const routes = [];
let offset = 0;
while (routes.length < maxRoutesToDraw) {
const page = await requestJson(
`/api/dynamic-plan/runs/${encodeURIComponent(run.runId)}/routes?limit=${routePageLimit}&offset=${offset}`
);
if (!Array.isArray(page) || page.length === 0) {
break;
}
routes.push(...page);
if (page.length < routePageLimit) {
break;
}
offset += routePageLimit;
}
if (run.routesCount !== null && run.routesCount > maxRoutesToDraw) {
console.warn(`Dynamic plan run ${run.runId} has ${run.routesCount} routes; only ${maxRoutesToDraw} are drawn`);
}
return routes.slice(0, maxRoutesToDraw);
}
async function loadIntervals(run) {
const intervals = [];
let offset = 0;
while (intervals.length < maxIntervalsToDraw) {
const page = await requestJson(
`/api/dynamic-plan/runs/${encodeURIComponent(run.runId)}/intervals?limit=${intervalPageLimit}&offset=${offset}`
);
if (!Array.isArray(page) || page.length === 0) {
break;
}
intervals.push(...page);
if (page.length < intervalPageLimit) {
break;
}
offset += intervalPageLimit;
}
if (intervals.length >= maxIntervalsToDraw) {
console.warn(`Dynamic plan run ${run.runId} has at least ${maxIntervalsToDraw} intervals; only ${maxIntervalsToDraw} are drawn`);
}
return intervals.slice(0, maxIntervalsToDraw);
}
async function drawRun(run) {
if (!mapViewer || !run || !run.runId) {
return;
}
removeRunDataSource(run.runId);
const [intervals, routes] = await Promise.all([
loadIntervals(run),
loadRoutes(run)
]);
const dataSource = createRoutesDataSource(run, routes, intervals);
await mapViewer.dataSources.add(dataSource);
if (dataSource.entities.values.length > 0) {
mapViewer.flyTo(dataSource, { duration: 1.5 });
}
}
function syncMap() {
if (!mapViewer) {
return;
}
const runs = readRuns();
const visibleIds = new Set(runs.map(run => String(run.runId)));
mapViewer.dataSources._dataSources
.filter(dataSource => dataSource.name && dataSource.name.startsWith(dataSourcePrefix))
.forEach(dataSource => {
const runId = dataSource.name.substring(dataSourcePrefix.length);
if (!visibleIds.has(runId)) {
mapViewer.dataSources.remove(dataSource);
}
});
runs.forEach(run => {
const existing = mapViewer.dataSources.getByName(dataSourceName(run.runId))[0];
if (!existing) {
drawRun(run).catch(error => console.error('Failed to draw dynamic plan run', run.runId, error));
}
});
}
function initializeMap(viewer) {
mapViewer = viewer;
syncMap();
}
window.addEventListener('storage', event => {
if (event.key === storageKey) {
syncMap();
}
});
window.addEventListener('dynamic-plan-map-runs-changed', syncMap);
window.PcpDynamicPlanMapLayers = {
initializeMap,
showRun,
hideRun,
isRunVisible,
visibleRuns: readRuns
};
})();
@@ -0,0 +1,483 @@
(function () {
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const pollingDelayMs = 3000;
let pollingTimer = null;
let selectedRun = null;
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') {
const container = document.getElementById('dynamic-plan-alert');
container.innerHTML = message
? `<div class="alert alert-${type} mb-3" role="alert">${escapeHtml(message)}</div>`
: '';
}
function parseSatelliteIds(value) {
return String(value || '')
.split(/[,\s;]+/)
.map(item => item.trim())
.filter(Boolean)
.map(item => Number(item))
.filter(item => Number.isInteger(item));
}
function formatDuration(value) {
const ms = Number(value);
if (!Number.isFinite(ms) || ms < 0) {
return '-';
}
if (ms < 1000) {
return `${Math.round(ms)} мс`;
}
const seconds = Math.round(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return minutes > 0 ? `${minutes} мин ${remainder} с` : `${remainder} с`;
}
function formatDateTime(value) {
return text(value, '-').replace('T', ' ');
}
function startOfDay(value) {
return `${value}T00:00:00`;
}
function endOfDay(value) {
return `${value}T23:59:59`;
}
function shortId(value) {
const fullValue = text(value, '-');
return fullValue.length > 12 ? `${fullValue.slice(0, 8)}...${fullValue.slice(-4)}` : fullValue;
}
function statusText(status) {
switch (status) {
case 'PENDING':
return 'В очереди';
case 'RUNNING':
return 'Выполняется';
case 'COMPLETED':
return 'Готово';
case 'FAILED':
return 'Ошибка';
default:
return text(status, '-');
}
}
function calculationModeText(mode) {
switch (mode) {
case 'GREEDY':
return 'Жадный';
case 'FULL':
return 'Полный';
default:
return text(mode, '-');
}
}
function revolutionModeText(mode) {
switch (mode) {
case 'ASC':
return 'Восходящий';
case 'DESC':
return 'Нисходящий';
case 'BOTH':
return 'Оба';
default:
return text(mode, '-');
}
}
function booleanText(value) {
return value ? 'Да' : 'Нет';
}
function percentText(value) {
const percent = Number(value);
return Number.isFinite(percent) ? `${percent.toFixed(2)}%` : '-';
}
function routeIntervalText(start, end) {
if (!start && !end) {
return '-';
}
return `${formatDateTime(start)} - ${formatDateTime(end)}`;
}
function renderRuns(items) {
const body = document.getElementById('dynamic-plan-runs-body');
const meta = document.getElementById('dynamic-plan-runs-meta');
const runs = Array.isArray(items) ? items : [];
meta.textContent = runs.length > 0 ? `Показано запусков: ${runs.length}` : 'Запуски не найдены.';
if (runs.length === 0) {
body.innerHTML = '<div class="text-muted py-4 text-center">Нет данных</div>';
return;
}
body.innerHTML = runs.map(run => `
<button type="button" class="dynamic-plan-run-item" data-run-id="${escapeHtml(run.runId)}">
<span class="dynamic-plan-run-main">
<span class="dynamic-plan-run-title">${escapeHtml(shortId(run.runId))}</span>
<span class="dynamic-plan-run-details d-block">
${escapeHtml(formatDateTime(run.createdAt))} · ${escapeHtml(calculationModeText(run.calculationMode))} · ${escapeHtml(revolutionModeText(run.revolutionMode))} · фильтр ${escapeHtml(booleanText(run.filterCoveredRoutes))} · заявка ${escapeHtml(shortId(run.requestId))} · маршруты ${escapeHtml(run.routesCount ?? '-')}
</span>
</span>
<span class="dynamic-plan-run-status">${escapeHtml(statusText(run.status))}</span>
</button>
`).join('');
body.querySelectorAll('[data-run-id]').forEach(button => {
button.addEventListener('click', () => openRun(button.dataset.runId));
});
}
async function loadRuns() {
const payload = await requestJson('/api/dynamic-plan/runs?limit=20&offset=0', {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
renderRuns(payload);
}
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 field(label, value) {
return `
<div class="dynamic-plan-field">
<div class="dynamic-plan-field-label">${escapeHtml(label)}</div>
<div class="dynamic-plan-field-value">${escapeHtml(value)}</div>
</div>
`;
}
function mapControls(run) {
if (!run || run.status !== 'COMPLETED') {
return '';
}
return `
<div class="dynamic-plan-map-controls mb-4">
<div>
<div class="dynamic-plan-section-title">Карта</div>
<div id="dynamic-plan-map-state" class="dynamic-plan-helper mt-1"></div>
</div>
<div class="dynamic-plan-map-actions">
<button id="dynamic-plan-map-show" type="button" class="btn btn-outline-primary btn-sm">Отобразить расчет</button>
<button id="dynamic-plan-map-hide" type="button" class="btn btn-outline-secondary btn-sm">Скрыть расчет</button>
<button id="dynamic-plan-map-open" type="button" class="btn btn-primary btn-sm">Открыть карту</button>
</div>
</div>
`;
}
function updateMapControls(run) {
const showButton = document.getElementById('dynamic-plan-map-show');
const hideButton = document.getElementById('dynamic-plan-map-hide');
const state = document.getElementById('dynamic-plan-map-state');
if (!showButton || !hideButton || !state || !window.PcpDynamicPlanMapLayers) {
return;
}
const visible = window.PcpDynamicPlanMapLayers.isRunVisible(run.runId);
showButton.disabled = visible;
hideButton.disabled = !visible;
state.textContent = visible
? 'Расчет добавлен в слой карты.'
: 'Расчет не отображается на карте.';
}
function attachMapControls(run) {
const showButton = document.getElementById('dynamic-plan-map-show');
const hideButton = document.getElementById('dynamic-plan-map-hide');
const openButton = document.getElementById('dynamic-plan-map-open');
if (!showButton || !hideButton || !openButton || !window.PcpDynamicPlanMapLayers) {
return;
}
showButton.addEventListener('click', () => {
window.PcpDynamicPlanMapLayers.showRun(run);
updateMapControls(run);
showAlert('Расчет добавлен в слой карты. Откройте вкладку map для просмотра.', 'success');
});
hideButton.addEventListener('click', () => {
window.PcpDynamicPlanMapLayers.hideRun(run.runId);
updateMapControls(run);
showAlert('Расчет скрыт с карты.', 'success');
});
openButton.addEventListener('click', () => {
window.location.href = '/map';
});
updateMapControls(run);
}
function clearPolling() {
if (pollingTimer) {
clearTimeout(pollingTimer);
pollingTimer = null;
}
}
function renderRun(payload) {
selectedRun = payload;
const result = document.getElementById('dynamic-plan-result');
const meta = document.getElementById('dynamic-plan-result-meta');
meta.textContent = `Статус запуска: ${statusText(payload.status)}`;
result.innerHTML = `
<div class="dynamic-plan-result-grid mb-4">
${field('Run ID', payload.runId)}
${field('Статус запуска', statusText(payload.status))}
${field('Вариант расчета', calculationModeText(payload.calculationMode))}
${field('Виток', revolutionModeText(payload.revolutionMode))}
${field('Фильтр покрытия', booleanText(payload.filterCoveredRoutes))}
${field('Заявка', payload.requestId)}
${field('КА', Array.isArray(payload.satelliteIds) ? payload.satelliteIds.join(', ') : '-')}
${field('Начало интервала', formatDateTime(payload.calculationStart))}
${field('Конец интервала', formatDateTime(payload.calculationEnd))}
${field('Статус результата', payload.resultStatus ?? '-')}
${field('Маршруты', payload.routesCount ?? '-')}
${field('Длительность', formatDuration(payload.durationMs))}
</div>
${payload.errorMessage ? `<div class="alert alert-danger mb-4" role="alert">${escapeHtml(payload.errorMessage)}</div>` : ''}
${mapControls(payload)}
<div class="dynamic-plan-section-title mb-2">Run payload</div>
<pre class="dynamic-plan-json mb-0">${escapeHtml(JSON.stringify(payload, null, 2))}</pre>
`;
attachMapControls(payload);
}
function renderResult(payload, runId) {
const run = selectedRun && String(selectedRun.runId) === String(runId)
? {
...selectedRun,
status: 'COMPLETED',
requestId: payload.requestId ?? selectedRun.requestId,
routesCount: payload.routesCount ?? selectedRun.routesCount,
calculationMode: payload.calculationMode ?? selectedRun.calculationMode,
revolutionMode: payload.revolutionMode ?? selectedRun.revolutionMode,
filterCoveredRoutes: payload.filterCoveredRoutes ?? selectedRun.filterCoveredRoutes,
lastRouteStart: payload.lastRouteStart ?? selectedRun.lastRouteStart,
lastRouteEnd: payload.lastRouteEnd ?? selectedRun.lastRouteEnd,
coveredAreaPercent: payload.coveredAreaPercent ?? selectedRun.coveredAreaPercent
}
: {
runId,
status: 'COMPLETED',
requestId: payload.requestId,
routesCount: payload.routesCount,
calculationMode: payload.calculationMode,
revolutionMode: payload.revolutionMode,
filterCoveredRoutes: payload.filterCoveredRoutes,
lastRouteStart: payload.lastRouteStart,
lastRouteEnd: payload.lastRouteEnd,
coveredAreaPercent: payload.coveredAreaPercent
};
selectedRun = run;
const result = document.getElementById('dynamic-plan-result');
const meta = document.getElementById('dynamic-plan-result-meta');
meta.textContent = `Расчет ${runId}: готов`;
result.innerHTML = `
<div class="dynamic-plan-result-grid mb-4">
${field('Run ID', runId)}
${field('Статус результата', payload.status)}
${field('Вариант расчета', calculationModeText(payload.calculationMode))}
${field('Виток', revolutionModeText(payload.revolutionMode))}
${field('Фильтр покрытия', booleanText(payload.filterCoveredRoutes))}
${field('Заявка', payload.requestId)}
${field('КА', Array.isArray(payload.satelliteIds) ? payload.satelliteIds.join(', ') : '-')}
${field('Отсутствующие КА', Array.isArray(payload.missingSatelliteIds) && payload.missingSatelliteIds.length ? payload.missingSatelliteIds.join(', ') : '-')}
${field('Маршруты', payload.routesCount ?? '-')}
${field('Последний маршрут', routeIntervalText(payload.lastRouteStart, payload.lastRouteEnd))}
${field('Процент снятой территории', percentText(payload.coveredAreaPercent))}
${field('Длительность', formatDuration(payload.durationMs))}
</div>
${mapControls(run)}
<div class="dynamic-plan-section-title mb-2">Result payload</div>
<pre class="dynamic-plan-json mb-0">${escapeHtml(JSON.stringify(payload, null, 2))}</pre>
`;
attachMapControls(run);
}
function setSubmitLoading(isLoading) {
const button = document.getElementById('dynamic-plan-submit');
if (!button.dataset.defaultText) {
button.dataset.defaultText = button.innerHTML;
}
button.disabled = isLoading;
button.innerHTML = isLoading
? '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Запуск'
: button.dataset.defaultText;
}
async function loadResult(runId) {
const payload = await requestJson(`/api/dynamic-plan/runs/${encodeURIComponent(runId)}/result`, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
renderResult(payload, runId);
showAlert('Расчет завершен, результат получен', 'success');
}
async function openRun(runId) {
if (!runId) {
return;
}
clearPolling();
showAlert('');
try {
const payload = await requestJson(`/api/dynamic-plan/runs/${encodeURIComponent(runId)}`, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
renderRun(payload);
if (payload.status === 'COMPLETED') {
await loadResult(runId);
return;
}
if (payload.status === 'FAILED') {
showAlert(`Расчет завершился с ошибкой: ${payload.errorMessage || 'неизвестная ошибка'}`);
return;
}
pollingTimer = setTimeout(() => pollRun(runId), pollingDelayMs);
} catch (error) {
showAlert(`Ошибка получения статуса расчета: ${error.message}`);
}
}
async function pollRun(runId) {
try {
const payload = await requestJson(`/api/dynamic-plan/runs/${encodeURIComponent(runId)}`, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
renderRun(payload);
if (payload.status === 'COMPLETED') {
clearPolling();
await loadResult(runId);
loadRuns().catch(() => undefined);
return;
}
if (payload.status === 'FAILED') {
clearPolling();
loadRuns().catch(() => undefined);
showAlert(`Расчет завершился с ошибкой: ${payload.errorMessage || 'неизвестная ошибка'}`);
return;
}
pollingTimer = setTimeout(() => pollRun(runId), pollingDelayMs);
} catch (error) {
clearPolling();
showAlert(`Ошибка получения статуса расчета: ${error.message}`);
}
}
async function submitForm(event) {
event.preventDefault();
const requestId = document.getElementById('dynamic-plan-request-id').value.trim();
const satelliteIds = parseSatelliteIds(document.getElementById('dynamic-plan-satellite-ids').value);
const calculationMode = document.querySelector('input[name="dynamic-plan-mode"]:checked')?.value || 'FULL';
const revolutionMode = document.querySelector('input[name="dynamic-plan-revolution-mode"]:checked')?.value || 'BOTH';
const filterCoveredRoutes = document.getElementById('dynamic-plan-filter-covered-routes').checked;
const calculationStartDate = document.getElementById('dynamic-plan-interval-start').value;
const calculationEndDate = document.getElementById('dynamic-plan-interval-end').value;
if (!uuidPattern.test(requestId)) {
showAlert('Укажите корректный UUID заявки');
return;
}
if (satelliteIds.length === 0) {
showAlert('Укажите хотя бы один идентификатор КА');
return;
}
if (!calculationStartDate || !calculationEndDate) {
showAlert('Укажите дату начала и дату конца интервала расчета');
return;
}
if (calculationStartDate > calculationEndDate) {
showAlert('Дата начала интервала расчета должна быть не позже даты окончания');
return;
}
const calculationStart = startOfDay(calculationStartDate);
const calculationEnd = endOfDay(calculationEndDate);
setSubmitLoading(true);
clearPolling();
showAlert('');
try {
const payload = await requestJson('/api/dynamic-plan/calc', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
requestId,
satelliteIds,
calculationMode,
revolutionMode,
filterCoveredRoutes,
calculationStart,
calculationEnd
})
});
renderRun(payload);
showAlert('Расчет поставлен в очередь', 'success');
loadRuns().catch(() => undefined);
pollingTimer = setTimeout(() => pollRun(payload.runId), pollingDelayMs);
} catch (error) {
showAlert(`Ошибка запуска расчета: ${error.message}`);
} finally {
setSubmitLoading(false);
}
}
document.getElementById('dynamic-plan-form').addEventListener('submit', submitForm);
document.getElementById('dynamic-plan-runs-refresh').addEventListener('click', () => {
loadRuns().catch(error => showAlert(`Ошибка обновления истории запусков: ${error.message}`));
});
loadRuns().catch(error => showAlert(`Ошибка загрузки истории запусков: ${error.message}`));
})();
@@ -0,0 +1,327 @@
(function () {
const pageRoot = document.getElementById('group-state-page');
if (!pageRoot) {
return;
}
const apiBase = '/api/catalog/group-states';
const palette = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#17becf', '#bcbd22'];
const state = {
groups: [],
selectedGroupId: null,
details: null
};
function showAlert(message, type = 'danger') {
const container = document.getElementById('group-state-alert');
if (!message) {
container.innerHTML = '';
return;
}
container.innerHTML = `<div class="alert alert-${type} mb-3" role="alert">${message}</div>`;
}
async function requestJson(url, options = {}) {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json'
},
...options
});
if (response.status === 204) {
return null;
}
const isJson = response.headers.get('content-type')?.includes('application/json');
const payload = isJson ? await response.json() : await response.text();
if (!response.ok) {
if (payload && typeof payload === 'object') {
if (payload.error) {
throw new Error(payload.error);
}
throw new Error(Object.values(payload).join('; '));
}
throw new Error(payload || `HTTP ${response.status}`);
}
return payload;
}
function formatDateTimeLabel(value) {
return value ? value.replace('T', ' ') : '-';
}
function formatInputDateTime(value) {
if (!value) {
return '';
}
return value.length >= 19 ? value.slice(0, 19) : value;
}
function formatAngle(value) {
return `${value.toFixed(2)}deg`;
}
function renderGroupList() {
const tableBody = document.getElementById('group-state-list');
tableBody.innerHTML = '';
if (!state.groups.length) {
tableBody.innerHTML = '<tr><td colspan="3" class="text-center text-muted py-4">Группировки не найдены</td></tr>';
return;
}
state.groups.forEach(group => {
const tr = document.createElement('tr');
if (group.groupId === state.selectedGroupId) {
tr.classList.add('table-active');
}
const interval = group.availableInterval
? `${formatDateTimeLabel(group.availableInterval.timeStart)} - ${formatDateTimeLabel(group.availableInterval.timeStop)}`
: 'Нет пересечения';
tr.innerHTML = `
<td>
<div class="fw-semibold">${group.groupName}</div>
<div class="catalog-helper">#${group.groupId}</div>
</td>
<td>${group.satelliteCount}</td>
<td class="group-state-interval-cell">${interval}</td>
`;
tr.addEventListener('click', () => selectGroup(group.groupId));
tableBody.appendChild(tr);
});
}
function renderTable(details) {
const empty = document.getElementById('group-state-table-empty');
const wrap = document.getElementById('group-state-table-wrap');
if (!details || !details.availableInterval || !details.satellites.length) {
empty.classList.remove('d-none');
wrap.classList.add('d-none');
wrap.innerHTML = '';
return;
}
const headerCells = details.satellites
.map(satellite => `<th>${escapeHtml(satellite.name)}<div class="catalog-helper">${escapeHtml(satellite.code || String(satellite.satelliteId))}</div></th>`)
.join('');
const rows = details.matrix.map(row => {
const satellite = details.satellites.find(item => item.satelliteId === row.satelliteId);
const cells = row.cells.map(cell => {
const isDiagonal = cell.rowSatelliteId === cell.columnSatelliteId;
if (isDiagonal) {
return '<td class="group-state-matrix-diagonal">-</td>';
}
return `
<td>
<div class="group-state-matrix-cell">
<div>dOMEGAB: ${formatAngle(cell.deltaOmegabDeg)}</div>
<div>du: ${formatAngle(cell.deltaUDeg)}</div>
</div>
</td>
`;
}).join('');
return `
<tr>
<th scope="row">
${escapeHtml(satellite?.name || String(row.satelliteId))}
<div class="catalog-helper">${escapeHtml(satellite?.code || String(row.satelliteId))}</div>
</th>
${cells}
</tr>
`;
}).join('');
wrap.innerHTML = `
<table class="table table-bordered align-middle group-state-matrix-table mb-0">
<thead>
<tr>
<th>Спутник</th>
${headerCells}
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
empty.classList.add('d-none');
wrap.classList.remove('d-none');
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function polarPoint(angleDeg, radius, center) {
const angleRad = (angleDeg - 90) * Math.PI / 180;
return {
x: center + Math.cos(angleRad) * radius,
y: center + Math.sin(angleRad) * radius
};
}
function renderPolarChart(title, satellites, key, valueLabel) {
const size = 320;
const center = size / 2;
const radius = 112;
const innerRadius = 92;
const ticks = satellites.map((satellite, index) => {
const color = palette[index % palette.length];
const start = polarPoint(satellite[key], innerRadius, center);
const end = polarPoint(satellite[key], radius, center);
return `<line x1="${start.x}" y1="${start.y}" x2="${end.x}" y2="${end.y}" stroke="${color}" stroke-width="6" stroke-linecap="round"></line>`;
}).join('');
const legend = satellites.map((satellite, index) => {
const color = palette[index % palette.length];
return `
<div class="group-state-legend-item">
<span class="group-state-legend-color" style="background:${color}"></span>
<span>${escapeHtml(satellite.name)}: ${formatAngle(satellite[key])}</span>
</div>
`;
}).join('');
return `
<div class="group-state-chart-card">
<div class="group-state-chart-title">${title}</div>
<svg viewBox="0 0 ${size} ${size}" class="group-state-chart-svg" aria-label="${escapeHtml(title)}">
<circle cx="${center}" cy="${center}" r="${radius}" class="group-state-chart-ring"></circle>
<circle cx="${center}" cy="${center}" r="${innerRadius}" class="group-state-chart-inner"></circle>
${ticks}
<text x="${center}" y="${center - 4}" text-anchor="middle" class="group-state-chart-center">${escapeHtml(valueLabel)}</text>
<text x="${center}" y="${center + 18}" text-anchor="middle" class="group-state-chart-center-value">${satellites.length}</text>
</svg>
<div class="group-state-chart-legend">${legend}</div>
</div>
`;
}
function renderCharts(details) {
const empty = document.getElementById('group-state-charts-empty');
const container = document.getElementById('group-state-charts');
if (!details || !details.availableInterval || !details.satellites.length) {
empty.classList.remove('d-none');
container.classList.add('d-none');
container.innerHTML = '';
return;
}
container.innerHTML = [
renderPolarChart('ВУЗ орбиты', details.satellites, 'omegabDeg', 'OMEGAB'),
renderPolarChart('Аргумент широты', details.satellites, 'uDeg', 'U')
].join('');
empty.classList.add('d-none');
container.classList.remove('d-none');
}
function renderSummary(details) {
const summary = document.getElementById('group-state-summary');
const timeInput = document.getElementById('group-state-time');
const applyButton = document.getElementById('group-state-apply');
if (!details) {
summary.textContent = 'Выберите группировку слева.';
timeInput.value = '';
timeInput.min = '';
timeInput.max = '';
timeInput.disabled = true;
applyButton.disabled = true;
return;
}
if (!details.availableInterval) {
summary.textContent = `${details.groupName}: нет общего доступного интервала расчета по всем спутникам ОГ.`;
timeInput.value = '';
timeInput.min = '';
timeInput.max = '';
timeInput.disabled = true;
applyButton.disabled = true;
return;
}
summary.innerHTML = `
<div><strong>${escapeHtml(details.groupName)}</strong></div>
<div>Доступный интервал: ${formatDateTimeLabel(details.availableInterval.timeStart)} - ${formatDateTimeLabel(details.availableInterval.timeStop)}</div>
`;
timeInput.disabled = false;
applyButton.disabled = false;
timeInput.min = formatInputDateTime(details.availableInterval.timeStart);
timeInput.max = formatInputDateTime(details.availableInterval.timeStop);
timeInput.value = formatInputDateTime(details.calculationTime || details.availableInterval.timeStart);
}
function renderDetails(details) {
state.details = details;
renderSummary(details);
renderTable(details);
renderCharts(details);
}
async function selectGroup(groupId, requestedTime = null) {
state.selectedGroupId = groupId;
renderGroupList();
const query = requestedTime ? `?time=${encodeURIComponent(requestedTime)}` : '';
const details = await requestJson(`${apiBase}/${groupId}${query}`);
renderDetails(details);
}
async function loadGroups() {
state.groups = await requestJson(apiBase);
renderGroupList();
if (!state.groups.length) {
renderDetails(null);
return;
}
const selectedId = state.groups.some(group => group.groupId === state.selectedGroupId)
? state.selectedGroupId
: state.groups[0].groupId;
await selectGroup(selectedId);
}
document.getElementById('group-state-refresh').addEventListener('click', async () => {
try {
showAlert('');
await loadGroups();
} catch (error) {
showAlert(error.message);
}
});
document.getElementById('group-state-apply').addEventListener('click', async () => {
if (!state.selectedGroupId) {
showAlert('Сначала выберите группировку');
return;
}
const time = document.getElementById('group-state-time').value;
if (!time) {
showAlert('Выберите момент времени');
return;
}
try {
showAlert('');
await selectGroup(state.selectedGroupId, time);
} catch (error) {
showAlert(error.message);
}
});
renderDetails(null);
loadGroups().catch(error => showAlert(error.message));
})();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,247 @@
(function () {
const pageData = window.rvaPageData;
if (!pageData) {
return;
}
const apiBase = '/api/rva';
const stationsByNumber = new Map((pageData.stations || []).map(station => [station.number, station]));
const resultState = {
mode: 'common',
rows: []
};
function showAlert(message, type = 'danger') {
const container = document.getElementById('rva-alert');
if (!message) {
container.innerHTML = '';
return;
}
container.innerHTML = `<div class="alert alert-${type} mb-3" role="alert">${message}</div>`;
}
function formatDateTime(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('ru-RU');
}
function selectedMode() {
return document.querySelector('input[name="rva-mode"]:checked')?.value || 'common';
}
function selectedStations() {
return Array.from(document.querySelectorAll('.rva-station-checkbox:checked'))
.map(input => Number(input.dataset.number))
.map(number => stationsByNumber.get(number))
.filter(Boolean);
}
async function requestJson(url, payload) {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
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 || Object.values(body).join('; '));
}
throw new Error(body || `HTTP ${response.status}`);
}
return body;
}
function renderResults() {
const head = document.getElementById('rva-results-head');
const body = document.getElementById('rva-results-body');
const meta = document.getElementById('rva-result-meta');
const downloadButton = document.getElementById('rva-download');
if (!resultState.rows.length) {
head.innerHTML = '';
body.innerHTML = '<tr><td class="text-center text-muted py-5">Нет результатов</td></tr>';
meta.textContent = 'Выберите параметры и запустите расчет.';
downloadButton.disabled = true;
return;
}
if (resultState.mode === 'common') {
head.innerHTML = `
<tr>
<th>Станция</th>
<th>Виток</th>
<th>Начало</th>
<th>Максимум</th>
<th>Конец</th>
<th>Длительность, с</th>
</tr>
`;
body.innerHTML = resultState.rows.map(row => `
<tr>
<td>${row.stationId}</td>
<td>${row.revolution}</td>
<td>${formatDateTime(row.onStart.time)}</td>
<td>${formatDateTime(row.onMaximum.time)}</td>
<td>${formatDateTime(row.onStop.time)}</td>
<td>${row.duration}</td>
</tr>
`).join('');
} else if (resultState.mode === 'merge') {
head.innerHTML = `
<tr>
<th>Начало</th>
<th>Конец</th>
<th>Длительность, с</th>
</tr>
`;
body.innerHTML = resultState.rows.map(row => `
<tr>
<td>${formatDateTime(row.begin)}</td>
<td>${formatDateTime(row.end)}</td>
<td>${row.durationSec}</td>
</tr>
`).join('');
} else {
head.innerHTML = `
<tr>
<th>Виток</th>
<th>Суммарная длительность, с</th>
</tr>
`;
body.innerHTML = resultState.rows.map(row => `
<tr>
<td>${row.revolution}</td>
<td>${row.durationSec}</td>
</tr>
`).join('');
}
meta.textContent = `Получено строк: ${resultState.rows.length}`;
downloadButton.disabled = false;
}
function csvEscape(value) {
const text = String(value ?? '');
if (text.includes(';') || text.includes('"') || text.includes('\n')) {
return `"${text.replace(/"/g, '""')}"`;
}
return text;
}
function downloadCsv() {
if (!resultState.rows.length) {
return;
}
const lines = resultState.mode === 'common'
? [
['stationId', 'revolution', 'onStart', 'onMaximum', 'onStop', 'duration'],
...resultState.rows.map(row => [
row.stationId,
row.revolution,
row.onStart.time,
row.onMaximum.time,
row.onStop.time,
row.duration
])
]
: resultState.mode === 'merge'
? [
['begin', 'end', 'durationSec'],
...resultState.rows.map(row => [
row.begin,
row.end,
row.durationSec
])
]
: [
['revolution', 'durationSec'],
...resultState.rows.map(row => [
row.revolution,
row.durationSec
])
];
const csv = lines.map(line => line.map(csvEscape).join(';')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `rva-${resultState.mode}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
document.getElementById('rva-select-all').addEventListener('click', () => {
document.querySelectorAll('.rva-station-checkbox').forEach(input => {
input.checked = true;
});
});
document.getElementById('rva-reset').addEventListener('click', () => {
setTimeout(() => {
showAlert('');
resultState.rows = [];
resultState.mode = selectedMode();
renderResults();
}, 0);
});
document.getElementById('rva-download').addEventListener('click', downloadCsv);
document.getElementById('rva-form').addEventListener('submit', async event => {
event.preventDefault();
showAlert('');
const satelliteId = Number(document.getElementById('rva-satellite').value);
const duration = Number(document.getElementById('rva-duration').value);
const mode = selectedMode();
const stations = selectedStations();
if (!satelliteId) {
showAlert('Нужно выбрать спутник');
return;
}
if (!duration || duration < 1) {
showAlert('Горизонт расчета должен быть положительным');
return;
}
if (!stations.length) {
showAlert('Нужно выбрать хотя бы одну станцию');
return;
}
const submitButton = event.submitter;
submitButton.disabled = true;
submitButton.textContent = 'Расчет...';
try {
const rows = await requestJson(`${apiBase}/${mode}/${satelliteId}/${duration}`, stations);
resultState.mode = mode;
resultState.rows = rows;
renderResults();
showAlert('Расчет выполнен', 'success');
} catch (error) {
showAlert(error.message || 'Не удалось выполнить расчет');
} finally {
submitButton.disabled = false;
submitButton.textContent = 'Рассчитать';
}
});
renderResults();
}());
@@ -0,0 +1,322 @@
(function () {
const pageRoot = document.getElementById('satellite-pdcm-page');
if (!pageRoot) {
return;
}
const apiBase = '/api/catalog/satellites/pdcm';
const state = {
satellites: [],
selectedSatelliteId: null,
details: null
};
function showAlert(message, type = 'danger') {
const container = document.getElementById('satellite-pdcm-alert');
if (!message) {
container.innerHTML = '';
return;
}
container.innerHTML = `<div class="alert alert-${type} mb-3" role="alert">${escapeHtml(message)}</div>`;
}
async function requestJson(url, options = {}) {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json'
},
...options
});
if (response.status === 204) {
return null;
}
const isJson = response.headers.get('content-type')?.includes('application/json');
const payload = isJson ? await response.json() : await response.text();
if (!response.ok) {
if (payload && typeof payload === 'object') {
throw new Error(payload.error || Object.values(payload).join('; '));
}
throw new Error(payload || `HTTP ${response.status}`);
}
return payload;
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function formatDateTimeLabel(value) {
return value ? value.replace('T', ' ') : '-';
}
function formatNumber(value) {
return Number(value ?? 0).toFixed(3);
}
function formatNullableNumber(value) {
return value == null ? '-' : formatNumber(value);
}
function renderExportState(details) {
const exportButton = document.getElementById('satellite-pdcm-export');
exportButton.disabled = !details || !details.ascNodes || !details.ascNodes.length;
}
function renderList() {
const tableBody = document.getElementById('satellite-pdcm-list');
tableBody.innerHTML = '';
if (!state.satellites.length) {
tableBody.innerHTML = '<tr><td colspan="2" class="text-center text-muted py-4">Спутники не найдены</td></tr>';
return;
}
state.satellites.forEach(satellite => {
const tr = document.createElement('tr');
if (satellite.satelliteId === state.selectedSatelliteId) {
tr.classList.add('table-active');
}
const interval = satellite.availableInterval
? `
<span class="satellite-pdcm-interval-line">${escapeHtml(formatDateTimeLabel(satellite.availableInterval.timeStart))}</span>
<span class="satellite-pdcm-interval-line">${escapeHtml(formatDateTimeLabel(satellite.availableInterval.timeStop))}</span>
`
: 'Нет ПДЦМ';
tr.innerHTML = `
<td>
<div class="fw-semibold satellite-pdcm-satellite-name">
<span class="satellite-pdcm-status ${satellite.activeInterval ? 'satellite-pdcm-status-active' : 'satellite-pdcm-status-inactive'}"></span>
${escapeHtml(satellite.name)}
</div>
</td>
<td class="satellite-pdcm-interval-cell">${interval}</td>
`;
tr.addEventListener('click', () => selectSatellite(satellite.satelliteId));
tableBody.appendChild(tr);
});
}
function renderSummary(details) {
const summary = document.getElementById('satellite-pdcm-summary');
if (!details) {
summary.innerHTML = '<div class="catalog-helper">Выберите спутник слева.</div>';
renderExportState(null);
return;
}
const interval = details.availableInterval
? `${formatDateTimeLabel(details.availableInterval.timeStart)} - ${formatDateTimeLabel(details.availableInterval.timeStop)}`
: 'Нет доступного интервала';
const status = details.activeInterval ? 'Активен сейчас' : 'Неактивен сейчас';
summary.innerHTML = `
<div class="satellite-pdcm-summary-grid">
<div class="satellite-pdcm-summary-card">
<div class="satellite-pdcm-summary-label">Спутник</div>
<div class="satellite-pdcm-summary-value">${escapeHtml(details.name)}</div>
<div class="catalog-helper">${escapeHtml(details.code || String(details.satelliteId))} · ID ${details.satelliteId}</div>
</div>
<div class="satellite-pdcm-summary-card">
<div class="satellite-pdcm-summary-label">Статус</div>
<div class="satellite-pdcm-summary-value">${escapeHtml(status)}</div>
</div>
<div class="satellite-pdcm-summary-card">
<div class="satellite-pdcm-summary-label">Интервал ПДЦМ</div>
<div class="satellite-pdcm-summary-value">${escapeHtml(interval)}</div>
</div>
<div class="satellite-pdcm-summary-card">
<div class="satellite-pdcm-summary-label">Записей asc-node</div>
<div class="satellite-pdcm-summary-value">${details.ascNodes.length}</div>
</div>
</div>
`;
renderExportState(details);
}
function renderTable(details) {
const empty = document.getElementById('satellite-pdcm-table-empty');
const wrap = document.getElementById('satellite-pdcm-table-wrap');
if (!details || !details.availableInterval || !details.ascNodes.length) {
empty.classList.remove('d-none');
wrap.classList.add('d-none');
wrap.innerHTML = '';
renderExportState(details);
return;
}
const rows = details.ascNodes.map(item => `
<tr>
<td>${escapeHtml(formatDateTimeLabel(item.time))}</td>
<td>${item.revolution}</td>
<td>${formatNumber(item.long)}</td>
<td>${formatNumber(item.height)}</td>
<td>${escapeHtml(formatDateTimeLabel(item.localMeanTime))}</td>
<td>${formatNullableNumber(item.keps?.ael)}</td>
<td>${formatNullableNumber(item.keps?.e)}</td>
<td>${formatNullableNumber(item.keps?.inkl)}</td>
<td>${formatNullableNumber(item.keps?.omega)}</td>
<td>${formatNullableNumber(item.keps?.w)}</td>
<td>${formatNumber(item.x)}</td>
<td>${formatNumber(item.y)}</td>
<td>${formatNumber(item.z)}</td>
<td>${formatNumber(item.vx)}</td>
<td>${formatNumber(item.vy)}</td>
<td>${formatNumber(item.vz)}</td>
</tr>
`).join('');
wrap.innerHTML = `
<table class="table table-bordered align-middle satellite-pdcm-table mb-0">
<thead>
<tr>
<th>Время</th>
<th>Виток</th>
<th>Долгота</th>
<th>Высота</th>
<th>Местное ср. время</th>
<th>a</th>
<th>e</th>
<th>i</th>
<th>&Omega;</th>
<th>&omega;</th>
<th>X</th>
<th>Y</th>
<th>Z</th>
<th>Vx</th>
<th>Vy</th>
<th>Vz</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
empty.classList.add('d-none');
wrap.classList.remove('d-none');
renderExportState(details);
}
function buildCsvValue(value) {
const text = String(value ?? '');
return `"${text.replaceAll('"', '""')}"`;
}
function exportCurrentCsv() {
if (!state.details || !state.details.ascNodes.length) {
return;
}
const rows = [
[
'Время',
'Виток',
'Долгота',
'Высота',
'Местное ср. время',
'a',
'e',
'i',
'Omega',
'omega',
'X',
'Y',
'Z',
'Vx',
'Vy',
'Vz'
],
...state.details.ascNodes.map(item => [
formatDateTimeLabel(item.time),
item.revolution,
formatNumber(item.long),
formatNumber(item.height),
formatDateTimeLabel(item.localMeanTime),
item.keps?.ael ?? '',
item.keps?.e ?? '',
item.keps?.inkl ?? '',
item.keps?.omega ?? '',
item.keps?.w ?? '',
formatNumber(item.x),
formatNumber(item.y),
formatNumber(item.z),
formatNumber(item.vx),
formatNumber(item.vy),
formatNumber(item.vz)
])
];
const csv = rows
.map(row => row.map(buildCsvValue).join(';'))
.join('\n');
const fileName = `asc-node-${(state.details.code || state.details.satelliteId)}.csv`;
const blob = new Blob([`\uFEFF${csv}`], {type: 'text/csv;charset=utf-8;'});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
async function selectSatellite(satelliteId) {
state.selectedSatelliteId = satelliteId;
renderList();
showAlert('');
try {
state.details = await requestJson(`${apiBase}/${satelliteId}`);
renderSummary(state.details);
renderTable(state.details);
renderList();
} catch (error) {
state.details = null;
renderSummary(null);
renderTable(null);
showAlert(error.message || 'Не удалось загрузить ПДЦМ спутника');
}
}
async function loadSatellites() {
showAlert('');
try {
state.satellites = await requestJson(apiBase);
renderList();
const nextSatelliteId = state.selectedSatelliteId
?? state.satellites.find(item => item.activeInterval)?.satelliteId
?? state.satellites[0]?.satelliteId;
if (nextSatelliteId) {
await selectSatellite(nextSatelliteId);
} else {
state.details = null;
renderSummary(null);
renderTable(null);
}
} catch (error) {
state.satellites = [];
state.details = null;
renderList();
renderSummary(null);
renderTable(null);
showAlert(error.message || 'Не удалось загрузить список спутников');
}
}
document.getElementById('satellite-pdcm-refresh').addEventListener('click', () => {
loadSatellites();
});
document.getElementById('satellite-pdcm-export').addEventListener('click', exportCurrentCsv);
loadSatellites();
})();
@@ -0,0 +1,185 @@
(function () {
const pageRoot = document.getElementById('stations-page');
if (!pageRoot) {
return;
}
const state = {
stations: [],
selectedStationId: null
};
const tableBody = document.getElementById('stations-table-body');
const searchInput = document.getElementById('stations-search');
function showAlert(message, type = 'danger') {
const container = document.getElementById('stations-alert');
if (!message) {
container.innerHTML = '';
return;
}
container.innerHTML = `<div class="alert alert-${type} mb-3" role="alert">${message}</div>`;
}
async function requestJson(url, options = {}) {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json'
},
...options
});
const isJson = response.headers.get('content-type')?.includes('application/json');
const payload = isJson ? await response.json() : await response.text();
if (!response.ok) {
if (payload && typeof payload === 'object') {
if (payload.error) {
throw new Error(payload.error);
}
if (payload.message) {
throw new Error(payload.message);
}
}
throw new Error(payload || `HTTP ${response.status}`);
}
return payload;
}
function formPayload() {
const stationId = document.getElementById('station-id').value.trim();
return {
id: stationId || null,
number: Number(document.getElementById('station-number').value),
name: document.getElementById('station-name').value.trim(),
position: {
lat: Number(document.getElementById('station-lat').value),
long: Number(document.getElementById('station-long').value),
height: Number(document.getElementById('station-height').value)
},
elevationMin: Number(document.getElementById('station-elevation-min').value),
elevationMax: Number(document.getElementById('station-elevation-max').value)
};
}
function renderStations() {
const query = searchInput.value.trim().toLowerCase();
const items = state.stations.filter(station =>
!query ||
String(station.number).includes(query) ||
station.name.toLowerCase().includes(query)
);
tableBody.innerHTML = '';
if (!items.length) {
tableBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-4">Станции не найдены</td></tr>';
return;
}
items.forEach(station => {
const row = document.createElement('tr');
if (station.id === state.selectedStationId) {
row.classList.add('table-active');
}
row.innerHTML = `
<td>${station.number}</td>
<td>${station.name}</td>
<td>${station.elevationMin ?? ''}</td>
<td>${station.elevationMax ?? ''}</td>
`;
row.addEventListener('click', () => selectStation(station));
tableBody.appendChild(row);
});
}
function resetForm() {
document.getElementById('station-form').reset();
document.getElementById('station-id').value = '';
document.getElementById('station-form-title').textContent = 'Новая станция';
state.selectedStationId = null;
renderStations();
showAlert('');
}
function selectStation(station) {
state.selectedStationId = station.id ?? null;
document.getElementById('station-id').value = station.id ?? '';
document.getElementById('station-number').value = station.number;
document.getElementById('station-name').value = station.name;
document.getElementById('station-lat').value = station.position?.lat ?? '';
document.getElementById('station-long').value = station.position?.long ?? '';
document.getElementById('station-height').value = station.position?.height ?? '';
document.getElementById('station-elevation-min').value = station.elevationMin;
document.getElementById('station-elevation-max').value = station.elevationMax;
document.getElementById('station-form-title').textContent = `Станция ${station.name}`;
renderStations();
showAlert('');
}
async function loadStations() {
state.stations = await requestJson('/api/stations');
renderStations();
if (state.selectedStationId) {
const selected = state.stations.find(station => station.id === state.selectedStationId);
if (selected) {
selectStation(selected);
return;
}
}
if (!document.getElementById('station-id').value) {
resetForm();
}
}
document.getElementById('stations-refresh').addEventListener('click', async () => {
try {
await loadStations();
} catch (error) {
showAlert(error.message);
}
});
document.getElementById('stations-new').addEventListener('click', resetForm);
document.getElementById('stations-reset').addEventListener('click', () => {
window.setTimeout(resetForm, 0);
});
searchInput.addEventListener('input', renderStations);
document.getElementById('station-form').addEventListener('submit', async event => {
event.preventDefault();
try {
const saved = await requestJson('/api/stations', {
method: 'POST',
body: JSON.stringify(formPayload())
});
state.selectedStationId = saved.id ?? null;
await loadStations();
showAlert('Станция сохранена', 'success');
} catch (error) {
showAlert(error.message);
}
});
document.getElementById('stations-delete').addEventListener('click', async () => {
const stationId = document.getElementById('station-id').value.trim();
if (!stationId) {
showAlert('Сначала выберите станцию для удаления');
return;
}
if (!window.confirm(`Удалить станцию ${stationId}?`)) {
return;
}
try {
await requestJson(`/api/stations/${stationId}`, { method: 'DELETE' });
resetForm();
await loadStations();
showAlert('Станция удалена', 'success');
} catch (error) {
showAlert(error.message);
}
});
loadStations().catch(error => showAlert(error.message));
})();
@@ -0,0 +1,156 @@
<!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/complex-plan.css"/>
</head>
<body>
<th:block layout:fragment="content">
<div class="complex-plan-page py-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="mb-1">Ком.План</h2>
<div class="complex-plan-helper">История расчетов комплексного плана и параметры выбранного запуска.</div>
</div>
<button id="complex-plan-refresh" type="button" class="btn btn-outline-primary btn-sm">Обновить</button>
</div>
<div id="complex-plan-alert"></div>
<div class="row g-4">
<div class="col-12 col-xl-5">
<div class="complex-plan-panel">
<div class="complex-plan-panel-header">
<div>
<div class="complex-plan-section-title">Расчеты</div>
<div id="complex-plan-list-meta" class="complex-plan-helper mt-1">Загрузка...</div>
</div>
</div>
<div class="complex-plan-table-wrap">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>ID</th>
<th>Статус</th>
<th>Интервал</th>
<th>КА</th>
<th></th>
</tr>
</thead>
<tbody id="complex-plan-runs-body">
<tr>
<td colspan="5" class="text-center text-muted py-5">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
<div id="complex-plan-pagination" class="complex-plan-pagination"></div>
</div>
<div class="complex-plan-panel mt-3">
<div class="complex-plan-panel-header">
<div>
<div class="complex-plan-section-title">Запуск расчета</div>
<div class="complex-plan-helper mt-1">Создание новой версии комплексного плана.</div>
</div>
</div>
<form id="complex-plan-process-form" class="complex-plan-form">
<div class="mb-3">
<label for="complex-plan-interval-start" class="form-label">Начало интервала</label>
<input id="complex-plan-interval-start" type="datetime-local" class="form-control form-control-sm" required>
</div>
<div class="mb-3">
<label for="complex-plan-interval-end" class="form-label">Конец интервала</label>
<input id="complex-plan-interval-end" type="datetime-local" class="form-control form-control-sm" required>
</div>
<div class="mb-3">
<label for="complex-plan-satellite-ids" class="form-label">Satellite IDs</label>
<input id="complex-plan-satellite-ids"
type="text"
class="form-control form-control-sm"
required
placeholder="101, 202, 303">
</div>
<div class="row g-2">
<div class="col-12 col-md-6">
<label for="complex-plan-sun" class="form-label">Солнце</label>
<input id="complex-plan-sun"
type="number"
class="form-control form-control-sm"
min="-90"
max="90"
step="0.1"
placeholder="null">
</div>
<div class="col-12 col-md-6">
<label for="complex-plan-coverage-source" class="form-label">Источник покрытия</label>
<select id="complex-plan-coverage-source" class="form-select form-select-sm">
<option value="SLOTS" selected>SLOTS</option>
<option value="COVERAGE_SCHEME">COVERAGE_SCHEME</option>
</select>
</div>
</div>
<div class="row g-2 mt-1">
<div class="col-12 col-md-6">
<label for="complex-plan-coverage-strategy" class="form-label">Вариант покрытия слотами</label>
<select id="complex-plan-coverage-strategy" class="form-select form-select-sm">
<option value="GREEDY" selected>GREEDY</option>
<option value="CONTINUOUS">CONTINUOUS</option>
</select>
</div>
<div class="col-12 col-md-6">
<label for="complex-plan-count-lat" class="form-label">Объединение по широте</label>
<input id="complex-plan-count-lat"
type="number"
class="form-control form-control-sm"
min="1"
step="1"
placeholder="countLat">
</div>
</div>
<div class="row g-2 mt-1">
<div class="col-12 col-md-6">
<label for="complex-plan-count-long" class="form-label">Объединение по долготе</label>
<input id="complex-plan-count-long"
type="number"
class="form-control form-control-sm"
min="1"
step="1"
placeholder="countLong">
</div>
</div>
<button id="complex-plan-process-submit" type="submit" class="btn btn-primary btn-sm mt-3">
Запустить
</button>
</form>
</div>
</div>
<div class="col-12 col-xl-7">
<div class="complex-plan-panel">
<div class="complex-plan-panel-header">
<div>
<div class="complex-plan-section-title">Детали расчета</div>
<div id="complex-plan-detail-meta" class="complex-plan-helper mt-1">Выберите расчет слева.</div>
</div>
<button id="complex-plan-report"
type="button"
class="btn btn-outline-primary btn-sm"
disabled>
CSV
</button>
</div>
<div id="complex-plan-detail" class="complex-plan-detail">
<div class="text-muted py-5 text-center">Нет выбранного расчета</div>
</div>
</div>
</div>
</div>
</div>
<script src="/complex_plan_scripts.js"></script>
</th:block>
</body>
</html>
@@ -0,0 +1,164 @@
<!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/catalog.css"/>
<link rel="stylesheet" href="/css/current-plans.css"/>
</head>
<body>
<th:block layout:fragment="content">
<div id="current-plans-page" class="catalog-page current-plans-page py-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="mb-1">Текущие планы</h2>
<div class="catalog-helper">Просмотр миссий выбранного спутника и режимов работы внутри миссии.</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button id="current-plans-create-mission" type="button" class="btn btn-primary" disabled>Создать план</button>
<button id="current-plans-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
</div>
</div>
<div id="current-plans-alert"></div>
<div class="row g-4 current-plans-layout">
<div class="col-12 col-xl-4">
<div class="card catalog-card current-plans-sidebar h-100">
<div class="card-header">
<div class="catalog-section-title">Спутники</div>
<div class="catalog-helper mt-1">Выберите КА для просмотра текущих планов.</div>
</div>
<div class="card-body p-3">
<label for="current-plans-satellite-filter" class="form-label">Фильтр</label>
<input id="current-plans-satellite-filter"
type="search"
class="form-control form-control-sm"
autocomplete="off"
placeholder="ID, NORAD, код, имя, тип">
</div>
<div class="card-body catalog-list p-0">
<table class="table table-hover mb-0 current-plans-satellites-table">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Спутник</th>
<th>Тип</th>
</tr>
</thead>
<tbody id="current-plans-satellites-body"></tbody>
</table>
</div>
</div>
</div>
<div class="col-12 col-xl-8">
<div class="d-flex flex-column gap-4 h-100 current-plans-main-column">
<div class="card catalog-card current-plans-missions-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="current-plans-selected-satellite" class="catalog-helper mt-1">Выберите спутник слева.</div>
</div>
<div class="current-plans-pagination">
<button id="current-plans-missions-prev" type="button" class="btn btn-outline-secondary btn-sm" disabled>Назад</button>
<span id="current-plans-missions-page" class="catalog-helper mx-2">0 / 0</span>
<button id="current-plans-missions-next" type="button" class="btn btn-outline-secondary btn-sm" disabled>Вперёд</button>
</div>
</div>
<div class="card-body p-0">
<div id="current-plans-missions-empty" class="catalog-empty">Выберите спутник для загрузки миссий.</div>
<div class="table-responsive current-plans-table-wrap d-none" id="current-plans-missions-wrap">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Миссия</th>
<th>Начало</th>
<th>Конец</th>
<th>Станция</th>
<th>Статус</th>
<th class="current-plans-actions-col">Действия</th>
</tr>
</thead>
<tbody id="current-plans-missions-body"></tbody>
</table>
</div>
</div>
</div>
<div class="card catalog-card current-plans-modes-card flex-grow-1">
<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="current-plans-selected-mission" class="catalog-helper mt-1">Выберите миссию в верхней таблице.</div>
</div>
<button id="current-plans-export-csv" type="button" class="btn btn-outline-secondary btn-sm" disabled>Сохранить CSV</button>
</div>
<div class="card-body p-0">
<div id="current-plans-modes-empty" class="catalog-empty">Режимы появятся после выбора миссии.</div>
<div class="table-responsive current-plans-table-wrap d-none" id="current-plans-modes-wrap">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Тип</th>
<th>Начало</th>
<th>Виток</th>
<th>Длительность, с</th>
<th>Параметры</th>
<th>Статус / источник</th>
</tr>
</thead>
<tbody id="current-plans-modes-body"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="current-plans-create-modal" tabindex="-1" aria-labelledby="current-plans-create-modal-title" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form id="current-plans-create-form" class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="current-plans-create-modal-title">Создание текущего плана</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Спутник</label>
<input id="current-plans-create-satellite" type="text" class="form-control" readonly>
</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="current-plans-create-start" class="form-label">Начало миссии</label>
<input id="current-plans-create-start" type="datetime-local" class="form-control" required>
</div>
<div class="col-12 col-md-6">
<label for="current-plans-create-stop" class="form-label">Конец миссии</label>
<input id="current-plans-create-stop" type="datetime-local" class="form-control" required>
</div>
</div>
<div class="mt-3">
<label for="current-plans-create-station" class="form-label">Станция</label>
<input id="current-plans-create-station" type="text" class="form-control" maxlength="32" placeholder="Необязательно">
</div>
<div class="mt-3">
<label for="current-plans-create-status" class="form-label">Статус</label>
<input id="current-plans-create-status" type="text" class="form-control" maxlength="15" value="NEW">
</div>
<div class="catalog-helper mt-3">План будет создан для выбранного спутника. Расчёт съёмки и сбросов запускается кнопками в строке плана.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
<button id="current-plans-create-submit" type="submit" class="btn btn-primary">Создать</button>
</div>
</form>
</div>
</div>
<script src="/current_plans_scripts.js"></script>
</th:block>
</body>
</html>
@@ -0,0 +1,171 @@
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.w3.org/1999/xhtml"
layout:decorate="~{layouts/main-layout}">
<head>
<title>Dynamic Plan</title>
<link rel="stylesheet" href="/css/dynamic-plan.css"/>
</head>
<body>
<th:block layout:fragment="content">
<div class="dynamic-plan-page py-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="mb-1">Dynamic Plan</h2>
<div class="dynamic-plan-helper">Запуск расчета модели комплексного плана по одной заявке.</div>
</div>
</div>
<div id="dynamic-plan-alert"></div>
<div class="row g-4">
<div class="col-12 col-xl-5">
<div class="dynamic-plan-panel">
<div class="dynamic-plan-panel-header">
<div>
<div class="dynamic-plan-section-title">Параметры расчета</div>
<div class="dynamic-plan-helper mt-1">UUID заявки, выбранные КА и расчетный интервал.</div>
</div>
</div>
<form id="dynamic-plan-form" class="dynamic-plan-form">
<div class="mb-3">
<label for="dynamic-plan-request-id" class="form-label">UUID заявки</label>
<input id="dynamic-plan-request-id"
type="text"
class="form-control form-control-sm"
required
placeholder="00000000-0000-0000-0000-000000000000">
</div>
<div class="mb-3">
<label for="dynamic-plan-satellite-ids" class="form-label">Идентификаторы КА</label>
<input id="dynamic-plan-satellite-ids"
type="text"
class="form-control form-control-sm"
required
placeholder="56756, 62138">
</div>
<div class="mb-3">
<label class="form-label">Вариант расчета</label>
<div class="dynamic-plan-mode-group" role="group" aria-label="Вариант расчета">
<input id="dynamic-plan-mode-full"
type="radio"
class="btn-check"
name="dynamic-plan-mode"
value="FULL"
autocomplete="off"
checked>
<label class="btn btn-outline-primary btn-sm" for="dynamic-plan-mode-full">
Полный
</label>
<input id="dynamic-plan-mode-greedy"
type="radio"
class="btn-check"
name="dynamic-plan-mode"
value="GREEDY"
autocomplete="off">
<label class="btn btn-outline-primary btn-sm" for="dynamic-plan-mode-greedy">
Жадный
</label>
</div>
</div>
<div class="mb-3">
<label class="form-label">Виток для жадного расчета</label>
<div class="dynamic-plan-revolution-group" role="group" aria-label="Виток для жадного расчета">
<input id="dynamic-plan-revolution-both"
type="radio"
class="btn-check"
name="dynamic-plan-revolution-mode"
value="BOTH"
autocomplete="off"
checked>
<label class="btn btn-outline-primary btn-sm" for="dynamic-plan-revolution-both">
Оба
</label>
<input id="dynamic-plan-revolution-asc"
type="radio"
class="btn-check"
name="dynamic-plan-revolution-mode"
value="ASC"
autocomplete="off">
<label class="btn btn-outline-primary btn-sm" for="dynamic-plan-revolution-asc">
Восх.
</label>
<input id="dynamic-plan-revolution-desc"
type="radio"
class="btn-check"
name="dynamic-plan-revolution-mode"
value="DESC"
autocomplete="off">
<label class="btn btn-outline-primary btn-sm" for="dynamic-plan-revolution-desc">
Нисх.
</label>
</div>
</div>
<div class="form-check mb-3">
<input id="dynamic-plan-filter-covered-routes"
type="checkbox"
class="form-check-input">
<label for="dynamic-plan-filter-covered-routes" class="form-check-label">
Удалять маршруты по уже снятой территории
</label>
</div>
<div class="mb-3">
<label for="dynamic-plan-interval-start" class="form-label">Дата начала интервала</label>
<input id="dynamic-plan-interval-start"
type="date"
class="form-control form-control-sm"
required>
</div>
<div class="mb-3">
<label for="dynamic-plan-interval-end" class="form-label">Дата конца интервала</label>
<input id="dynamic-plan-interval-end"
type="date"
class="form-control form-control-sm"
required>
</div>
<button id="dynamic-plan-submit" type="submit" class="btn btn-primary btn-sm">
Запустить
</button>
</form>
</div>
<div class="dynamic-plan-panel mt-4">
<div class="dynamic-plan-panel-header">
<div>
<div class="dynamic-plan-section-title">История запусков</div>
<div id="dynamic-plan-runs-meta" class="dynamic-plan-helper mt-1">Последние расчеты.</div>
</div>
<button id="dynamic-plan-runs-refresh" type="button" class="btn btn-outline-secondary btn-sm">
Обновить
</button>
</div>
<div id="dynamic-plan-runs-body" class="dynamic-plan-runs">
<div class="text-muted py-4 text-center">Нет данных</div>
</div>
</div>
</div>
<div class="col-12 col-xl-7">
<div class="dynamic-plan-panel">
<div class="dynamic-plan-panel-header">
<div>
<div class="dynamic-plan-section-title">Запуск и результат</div>
<div id="dynamic-plan-result-meta" class="dynamic-plan-helper mt-1">Расчет еще не запускался.</div>
</div>
</div>
<div id="dynamic-plan-result" class="dynamic-plan-result">
<div class="text-muted py-5 text-center">Нет данных</div>
</div>
</div>
</div>
</div>
</div>
<script src="/dynamic_plan_map_layers.js"></script>
<script src="/dynamic_plan_scripts.js"></script>
</th:block>
</body>
</html>
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org">
<head th:fragment="head">
<meta charset="UTF-8">
<link rel="stylesheet"
href="/webjars/bootstrap/5.3.0/css/bootstrap.min.css"/>
</head>
<body>
</body>
<footer th:fragment="footer">
<script src="/webjars/jquery/3.6.2/jquery.min.js"></script>
<script src="/webjars/bootstrap/5.3.0/js/bootstrap.bundle.min.js"></script>
</footer>
</html>
@@ -0,0 +1,78 @@
<ul xmlns:th="http://www.thymeleaf.org"
th:fragment="menu-generator(elements)"
class="navbar-nav ml-auto">
<li
th:each="element: ${elements}"
class="nav-item">
<a class="nav-link"
th:href="${element[1]}">[[${element[0]}]]</a>
</li>
</ul>
<nav xmlns:th="http://www.thymeleaf.org"
th:fragment="menu"
class="navbar navbar-expand-lg static-top" style="background-color: #e3f2fd;">
<div class="container-fluid">
<a class="navbar-brand"
href="/">ПЦП</a>
<button class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarResponsive"
aria-controls="navbarResponsive"
aria-expanded="false"
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarResponsive">
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle"
href="#"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false">Группировки</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/groups">Управление</a></li>
<li><a class="dropdown-item" href="/group-state">Состояние ОГ</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle"
href="#"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false">Спутники</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/satellites">Управление</a></li>
<li><a class="dropdown-item" href="/satellites/pdcm">ПДЦМ</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle"
href="#"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false">Станции</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/stations">Управление</a></li>
<li><a class="dropdown-item" href="/rva">ЗРВ</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="/map">Карта</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/com-plan">Ком.План</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/dynamic-plan">Dynamic Plan</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/current-plans">Текущие планы</a>
</li>
</ul>
</div>
</div>
</nav>
@@ -0,0 +1,90 @@
<!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/catalog.css"/>
<link rel="stylesheet" href="/css/group-state.css"/>
</head>
<body>
<th:block layout:fragment="content">
<div id="group-state-page" class="catalog-page py-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="mb-1">Состояние ОГ</h2>
<div class="catalog-helper">Состояние орбитальной группировки на выбранный момент времени.</div>
</div>
<button id="group-state-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
</div>
<div id="group-state-alert"></div>
<div class="row g-4">
<div class="col-12 col-xl-4">
<div class="card catalog-card group-state-sidebar h-100">
<div class="card-header">
<div class="catalog-section-title">Доступные группировки</div>
</div>
<div class="card-body catalog-list p-0">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>ОГ</th>
<th>КА</th>
<th>Интервал</th>
</tr>
</thead>
<tbody id="group-state-list"></tbody>
</table>
</div>
</div>
</div>
<div class="col-12 col-xl-8">
<div class="d-flex flex-column gap-4">
<div class="card catalog-card">
<div class="card-header">
<div class="catalog-section-title">Время</div>
</div>
<div class="card-body">
<div id="group-state-summary" class="catalog-helper mb-3">Выберите группировку слева.</div>
<div class="group-state-time-grid">
<div>
<label for="group-state-time" class="form-label">Момент времени</label>
<input id="group-state-time" type="datetime-local" step="1" class="form-control"/>
</div>
<div class="d-flex align-items-end">
<button id="group-state-apply" type="button" class="btn btn-primary w-100">Показать состояние</button>
</div>
</div>
</div>
</div>
<div class="card catalog-card">
<div class="card-header">
<div class="catalog-section-title">Таблица</div>
</div>
<div class="card-body">
<div id="group-state-table-empty" class="catalog-empty">Выберите группировку с доступным интервалом расчета.</div>
<div id="group-state-table-wrap" class="group-state-table-wrap d-none"></div>
</div>
</div>
<div class="card catalog-card">
<div class="card-header">
<div class="catalog-section-title">График</div>
</div>
<div class="card-body">
<div id="group-state-charts-empty" class="catalog-empty">Графики появятся после выбора момента времени.</div>
<div id="group-state-charts" class="group-state-chart-grid d-none"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/group_state_scripts.js"></script>
</th:block>
</body>
</html>
@@ -0,0 +1,85 @@
<!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/catalog.css"/>
</head>
<body>
<th:block layout:fragment="content">
<div id="catalog-page" data-page="groups" class="catalog-page py-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="mb-1">Группировки</h2>
<div class="catalog-helper">Орбитальные группировки и управление их составом.</div>
</div>
<div class="d-flex gap-2">
<button id="groups-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
<button id="groups-new" type="button" class="btn btn-primary">Новая группировка</button>
</div>
</div>
<div id="groups-alert"></div>
<div class="row g-4">
<div class="col-12 col-xl-5">
<div class="card catalog-card h-100">
<div class="card-header">
<div class="catalog-section-title">Список группировок</div>
</div>
<div class="card-body catalog-list p-0">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Название</th>
<th>Состав</th>
<th>Спутники</th>
</tr>
</thead>
<tbody id="groups-table-body"></tbody>
</table>
</div>
</div>
</div>
<div class="col-12 col-xl-7">
<div class="card catalog-card h-100">
<div class="card-header">
<div class="catalog-section-title" id="group-form-title">Новая группировка</div>
</div>
<div class="card-body">
<form id="group-form" class="d-flex flex-column gap-3">
<input id="group-id" type="hidden"/>
<div>
<label for="group-name" class="form-label">Название</label>
<input id="group-name" class="form-control" required/>
</div>
<div>
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0">Состав группировки</label>
<span class="catalog-helper">Можно выбрать несколько спутников</span>
</div>
<div id="group-satellite-checklist" class="catalog-checkbox-list"></div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="submit" class="btn btn-primary">Сохранить</button>
<button id="groups-delete" type="button" class="btn btn-outline-danger">Удалить</button>
<button id="groups-reset" type="reset" class="btn btn-outline-secondary">Сбросить</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script src="/catalog_scripts.js"></script>
<script>
document.getElementById('groups-reset').addEventListener('click', function () {
document.getElementById('groups-new').click();
});
</script>
</th:block>
</body>
</html>
@@ -0,0 +1,12 @@
<!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>
</head>
<body>
<th:block layout:fragment="content">
</th:block>
</body>
</html>
@@ -0,0 +1,22 @@
<!DOCTYPE HTML>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.w3.org/1999/xhtml">
<head>
<th:block th:insert="~{fragments/base/general.html :: head}" />
</head>
<body>
<div class="vh-100">
<div style="width:100%; position:fixed; z-index:1;">
<div th:replace="~{fragments/base/menu.html :: menu}"></div>
</div>
<div class="container-fluid d-flex flex-column"
style="padding-top:4rem; height:calc(100% - 1rem);">
<div layout:fragment="content">
</div>
</div>
</div>
</body>
<th:block th:insert="~{fragments/base/general.html :: footer}"/>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,127 @@
<!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/rva.css"/>
</head>
<body>
<th:block layout:fragment="content">
<div class="rva-page py-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="mb-1">ЗРВ</h2>
<div class="rva-helper">Расчет зон радиовидимости по нескольким станциям с объединением интервалов или без него.</div>
</div>
</div>
<div id="rva-alert"></div>
<div class="row g-4">
<div class="col-12 col-xxl-4">
<div class="card rva-card">
<div class="card-header">
<div class="rva-section-title">Параметры расчета</div>
</div>
<div class="card-body">
<form id="rva-form" class="d-flex flex-column gap-3">
<div>
<label for="rva-satellite" class="form-label">Спутник</label>
<select id="rva-satellite" class="form-select" required>
<option value="">Выберите спутник</option>
<option th:each="satellite : ${satellites}"
th:value="${satellite.noradId}"
th:text="${satellite.name + ' (' + satellite.noradId + ')'}"></option>
</select>
</div>
<div>
<label for="rva-duration" class="form-label">Горизонт расчета, суток</label>
<input id="rva-duration" type="number" min="1" step="1" value="1" class="form-control" required/>
</div>
<div>
<div class="form-label">Вариант расчета</div>
<div class="d-flex flex-column gap-2">
<div class="form-check">
<input class="form-check-input" type="radio" name="rva-mode" id="rva-mode-common" value="common" checked>
<label class="form-check-label" for="rva-mode-common">Полный список ЗРВ</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="rva-mode" id="rva-mode-merge" value="merge">
<label class="form-check-label" for="rva-mode-merge">Объединенные интервалы</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="rva-mode" id="rva-mode-merge-on-rev" value="merge-on-rev">
<label class="form-check-label" for="rva-mode-merge-on-rev">Суммарная длительность на витке</label>
</div>
</div>
</div>
<div>
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0">Станции</label>
<button id="rva-select-all" type="button" class="btn btn-outline-secondary btn-sm">Выбрать все</button>
</div>
<div id="rva-stations" class="rva-stations">
<label class="form-check rva-station-item"
th:each="station : ${stations}">
<input class="form-check-input rva-station-checkbox"
type="checkbox"
th:value="${station.number}"
th:attr="data-number=${station.number}">
<span class="form-check-label rva-station-label">
<span class="fw-semibold" th:text="${station.name}"></span>
<span class="text-muted" th:text="${' (#' + station.number + ')'}"></span>
</span>
</label>
</div>
<div class="rva-helper mt-2">Нужно выбрать хотя бы одну станцию.</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="submit" class="btn btn-primary">Рассчитать</button>
<button id="rva-reset" type="reset" class="btn btn-outline-secondary">Сбросить</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-12 col-xxl-8">
<div class="card rva-card">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<div class="rva-section-title">Результаты</div>
<div id="rva-result-meta" class="rva-helper mt-1">Выберите параметры и запустите расчет.</div>
</div>
<button id="rva-download" type="button" class="btn btn-outline-primary btn-sm" disabled>Скачать CSV</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead id="rva-results-head"></thead>
<tbody id="rva-results-body">
<tr>
<td class="text-center text-muted py-5">Нет результатов</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script th:inline="javascript">
window.rvaPageData = {
satellites: /*[[${satellites}]]*/ [],
stations: /*[[${stations}]]*/ []
};
</script>
<script src="/rva_scripts.js"></script>
</th:block>
</body>
</html>
@@ -0,0 +1,73 @@
<!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/catalog.css"/>
<link rel="stylesheet" href="/css/satellite-pdcm.css"/>
</head>
<body>
<th:block layout:fragment="content">
<div id="satellite-pdcm-page" class="catalog-page py-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="mb-1">Параметры движения центра масс</h2>
<div class="catalog-helper">Табличное представление asc-node по выбранному спутнику.</div>
</div>
<button id="satellite-pdcm-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
</div>
<div id="satellite-pdcm-alert"></div>
<div class="row g-4">
<div class="col-12 col-lg-4 col-xl-3">
<div class="card catalog-card catalog-list-card satellite-pdcm-sidebar h-100">
<div class="card-header">
<div class="catalog-section-title">Спутники</div>
</div>
<div class="card-body catalog-list p-0">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Спутник</th>
<th>Интервал</th>
</tr>
</thead>
<tbody id="satellite-pdcm-list"></tbody>
</table>
</div>
</div>
</div>
<div class="col-12 col-lg-8 col-xl-9">
<div class="d-flex flex-column gap-4">
<div class="card catalog-card">
<div class="card-header">
<div class="catalog-section-title">Сводка</div>
</div>
<div class="card-body">
<div id="satellite-pdcm-summary" class="satellite-pdcm-summary-root">
<div class="catalog-helper">Выберите спутник слева.</div>
</div>
</div>
</div>
<div class="card catalog-card">
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div class="catalog-section-title">Asc-node</div>
<button id="satellite-pdcm-export" type="button" class="btn btn-sm btn-outline-primary" disabled>Скачать CSV</button>
</div>
<div class="card-body">
<div id="satellite-pdcm-table-empty" class="catalog-empty">Нет доступного интервала ПДЦМ для выбранного спутника.</div>
<div id="satellite-pdcm-table-wrap" class="satellite-pdcm-table-wrap d-none"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/satellite_pdcm_scripts.js"></script>
</th:block>
</body>
</html>
@@ -0,0 +1,273 @@
<!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/catalog.css"/>
</head>
<body>
<th:block layout:fragment="content">
<div id="catalog-page" data-page="satellites" class="catalog-page py-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="mb-1">Спутники</h2>
<div class="catalog-helper">Карточки спутников, observation profile и slot profile.</div>
</div>
<div class="d-flex gap-2">
<button id="satellites-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
<button id="satellites-new" type="button" class="btn btn-primary">Новый спутник</button>
</div>
</div>
<div id="satellites-alert"></div>
<div class="row g-4">
<div class="col-12 col-xxl-4">
<div class="card catalog-card catalog-list-card h-100">
<div class="card-header">
<div class="catalog-section-title">Список спутников</div>
</div>
<div class="card-body catalog-list-search p-3 border-bottom">
<input id="satellite-search" class="form-control" placeholder="Поиск по ID, имени, коду"/>
</div>
<div class="card-body catalog-list catalog-list-page-scroll p-0">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Код</th>
<th>Имя</th>
<th>Тип</th>
<th>Слоты</th>
</tr>
</thead>
<tbody id="satellites-table-body"></tbody>
</table>
</div>
</div>
</div>
<div class="col-12 col-xxl-8">
<div class="d-flex flex-column gap-4">
<div class="card catalog-card">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="catalog-section-title" id="satellite-form-title">Новый спутник</div>
<button id="satellite-delete" type="button" class="btn btn-outline-danger btn-sm">Удалить спутник</button>
</div>
<div class="card-body">
<form id="satellite-form" class="d-flex flex-column gap-3">
<div class="catalog-form-grid">
<div>
<label for="satellite-id" class="form-label">ID</label>
<input id="satellite-id" type="number" min="1" class="form-control" required/>
</div>
<div>
<label for="satellite-norad-id" class="form-label">NORAD ID</label>
<input id="satellite-norad-id" type="number" min="1" class="form-control"/>
</div>
<div>
<label for="satellite-code" class="form-label">Code</label>
<input id="satellite-code" class="form-control" required/>
</div>
<div>
<label for="satellite-name" class="form-label">Name</label>
<input id="satellite-name" class="form-control" required/>
</div>
<div>
<label for="satellite-type-code" class="form-label">Type code</label>
<input id="satellite-type-code" class="form-control" required/>
</div>
</div>
<div class="catalog-form-grid align-items-end">
<div class="form-check">
<input id="satellite-active" class="form-check-input" type="checkbox" checked>
<label class="form-check-label" for="satellite-active">Активен</label>
</div>
<div class="form-check">
<input id="satellite-scan-tle" class="form-check-input" type="checkbox">
<label class="form-check-label" for="satellite-scan-tle">Сканировать TLE</label>
</div>
<div>
<label for="satellite-red" class="form-label">Red</label>
<input id="satellite-red" type="number" min="0" max="255" class="form-control" value="255" required/>
</div>
<div>
<label for="satellite-green" class="form-label">Green</label>
<input id="satellite-green" type="number" min="0" max="255" class="form-control" value="0" required/>
</div>
<div>
<label for="satellite-blue" class="form-label">Blue</label>
<input id="satellite-blue" type="number" min="0" max="255" class="form-control" value="0" required/>
</div>
<div>
<label class="form-label">Preview</label>
<div id="satellite-color-preview" class="catalog-color-preview"></div>
</div>
</div>
<div>
<button type="submit" class="btn btn-primary">Сохранить карточку</button>
</div>
</form>
</div>
</div>
<div class="card catalog-card">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="catalog-section-title">Observation profile</div>
<button id="observation-delete" type="button" class="btn btn-outline-danger btn-sm">Удалить profile</button>
</div>
<div class="card-body">
<form id="observation-form" class="d-flex flex-column gap-3">
<div class="catalog-form-grid">
<div>
<label for="observation-capture-angle" class="form-label">Capture angle</label>
<input id="observation-capture-angle" type="number" step="any" class="form-control" required/>
</div>
<div>
<label for="observation-sun-angle-min" class="form-label">Sun angle min</label>
<input id="observation-sun-angle-min" type="number" step="any" class="form-control" required/>
</div>
<div>
<label for="observation-duration-min" class="form-label">Duration min, s</label>
<input id="observation-duration-min" type="number" min="0" class="form-control" required/>
</div>
<div>
<label for="observation-duration-max" class="form-label">Duration max, s</label>
<input id="observation-duration-max" type="number" min="0" class="form-control" required/>
</div>
<div>
<label for="observation-mmi" class="form-label">MMI, s</label>
<input id="observation-mmi" type="number" min="0" class="form-control" required/>
</div>
<div>
<label for="observation-daily-max" class="form-label">Daily max, s</label>
<input id="observation-daily-max" type="number" min="0" class="form-control" required/>
</div>
<div>
<label for="observation-revolution-max" class="form-label">Revolution max, s</label>
<input id="observation-revolution-max" type="number" min="0" class="form-control" required/>
</div>
</div>
<div>
<button type="submit" class="btn btn-primary">Сохранить observation profile</button>
</div>
</form>
</div>
</div>
<div class="card catalog-card">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="catalog-section-title">Slot profile</div>
<button id="slot-delete" type="button" class="btn btn-outline-danger btn-sm">Удалить profile</button>
</div>
<div class="card-body">
<form id="slot-ic-form" class="d-flex flex-column gap-3">
<div>
<div class="catalog-section-title">Начальные условия орбиты</div>
<div id="slot-ic-status" class="catalog-helper mt-2">Начальные условия не заданы.</div>
</div>
<div class="catalog-form-grid">
<div>
<label for="slot-ic-time" class="form-label">Time</label>
<input id="slot-ic-time" type="text" class="form-control" placeholder="dd.mm.yyyy hh:mm:ss.zzz" required/>
</div>
<div>
<label for="slot-ic-revolution" class="form-label">Revolution</label>
<input id="slot-ic-revolution" type="number" class="form-control" required/>
</div>
<div>
<label for="slot-ic-s-ball" class="form-label">S-ball</label>
<input id="slot-ic-s-ball" type="number" step="any" class="form-control" required/>
</div>
<div>
<label for="slot-ic-f81" class="form-label">F81</label>
<input id="slot-ic-f81" type="number" step="any" class="form-control" required/>
</div>
</div>
<div class="catalog-form-grid">
<div>
<label for="slot-ic-x" class="form-label">X</label>
<input id="slot-ic-x" type="number" step="any" class="form-control" required/>
</div>
<div>
<label for="slot-ic-y" class="form-label">Y</label>
<input id="slot-ic-y" type="number" step="any" class="form-control" required/>
</div>
<div>
<label for="slot-ic-z" class="form-label">Z</label>
<input id="slot-ic-z" type="number" step="any" class="form-control" required/>
</div>
</div>
<div class="catalog-form-grid">
<div>
<label for="slot-ic-vx" class="form-label">Vx</label>
<input id="slot-ic-vx" type="number" step="any" class="form-control" required/>
</div>
<div>
<label for="slot-ic-vy" class="form-label">Vy</label>
<input id="slot-ic-vy" type="number" step="any" class="form-control" required/>
</div>
<div>
<label for="slot-ic-vz" class="form-label">Vz</label>
<input id="slot-ic-vz" type="number" step="any" class="form-control" required/>
</div>
</div>
<div>
<button type="submit" class="btn btn-primary">Сохранить начальные условия</button>
</div>
</form>
<hr class="my-4">
<form id="slot-form" class="d-flex flex-column gap-3">
<div class="catalog-form-grid">
<div>
<label for="slot-cycle-revs" class="form-label">Cycle revs</label>
<input id="slot-cycle-revs" type="number" min="0" class="form-control" required/>
</div>
<div>
<label for="slot-tn-calc" class="form-label">TN calc</label>
<input id="slot-tn-calc" type="text" class="form-control" placeholder="dd.mm.yyyy hh:mm:ss.zzz"/>
</div>
<div>
<label for="slot-duration" class="form-label">Slot duration</label>
<input id="slot-duration" type="number" min="0" class="form-control" required/>
</div>
<div>
<label for="slot-duration-calc-days" class="form-label">Duration calc, days</label>
<input id="slot-duration-calc-days" type="number" min="0" class="form-control" required/>
</div>
<div>
<label for="slot-max-chain-length" class="form-label">Max chain length</label>
<input id="slot-max-chain-length" type="number" min="0" class="form-control" required/>
</div>
</div>
<div>
<label for="slot-default-angles" class="form-label">Default angles</label>
<textarea id="slot-default-angles" class="form-control" rows="8" placeholder="18.5, 21.58&#10;20.85, 23.82"></textarea>
<div class="catalog-helper mt-2">Один диапазон на строку: `angleBegin, angleEnd`.</div>
</div>
<div class="d-flex flex-wrap gap-2">
<button id="slot-calculate" type="button" class="btn btn-outline-secondary">Расчет слотов</button>
<button type="submit" class="btn btn-primary">Сохранить slot profile</button>
</div>
</form>
<div id="slot-calculation-summary" class="catalog-slot-summary mt-4"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/catalog_scripts.js"></script>
</th:block>
</body>
</html>
@@ -0,0 +1,110 @@
<!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/catalog.css"/>
</head>
<body>
<th:block layout:fragment="content">
<div id="stations-page" class="catalog-page py-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h2 class="mb-1">Станции</h2>
<div class="catalog-helper">CRUD-операции над наземными станциями через `stations-service`.</div>
</div>
<div class="d-flex gap-2">
<button id="stations-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
<button id="stations-new" type="button" class="btn btn-primary">Новая станция</button>
</div>
</div>
<div id="stations-alert"></div>
<div class="row g-4">
<div class="col-12 col-xl-5">
<div class="card catalog-card h-100">
<div class="card-header">
<div class="catalog-section-title">Список станций</div>
</div>
<div class="card-body p-3 border-bottom">
<input id="stations-search" class="form-control" placeholder="Поиск по номеру, имени"/>
</div>
<div class="card-body catalog-list p-0">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Номер</th>
<th>Название</th>
<th>Мин.угол</th>
<th>Макс.угол</th>
</tr>
</thead>
<tbody id="stations-table-body"></tbody>
</table>
</div>
</div>
</div>
<div class="col-12 col-xl-7">
<div class="card catalog-card h-100">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="catalog-section-title" id="station-form-title">Новая станция</div>
<button id="stations-delete" type="button" class="btn btn-outline-danger btn-sm">Удалить станцию</button>
</div>
<div class="card-body">
<form id="station-form" class="d-flex flex-column gap-3">
<input id="station-id" type="hidden"/>
<div class="catalog-form-grid">
<div>
<label for="station-number" class="form-label">Номер</label>
<input id="station-number" type="number" class="form-control" min="0" required/>
</div>
<div>
<label for="station-name" class="form-label">Название</label>
<input id="station-name" class="form-control" required/>
</div>
</div>
<div class="catalog-form-grid">
<div>
<label for="station-lat" class="form-label">Широта</label>
<input id="station-lat" type="number" step="any" class="form-control" required/>
</div>
<div>
<label for="station-long" class="form-label">Долгота</label>
<input id="station-long" type="number" step="any" class="form-control" required/>
</div>
<div>
<label for="station-height" class="form-label">Высота, м</label>
<input id="station-height" type="number" step="any" min="0" class="form-control" required/>
</div>
</div>
<div class="catalog-form-grid">
<div>
<label for="station-elevation-min" class="form-label">Мин.угол</label>
<input id="station-elevation-min" type="number" step="any" class="form-control" required/>
</div>
<div>
<label for="station-elevation-max" class="form-label">Макс.угол</label>
<input id="station-elevation-max" type="number" step="any" class="form-control" required/>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="submit" class="btn btn-primary">Сохранить</button>
<button id="stations-reset" type="reset" class="btn btn-outline-secondary">Сбросить</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script src="/stations_scripts.js"></script>
</th:block>
</body>
</html>
@@ -0,0 +1,524 @@
package space.nstart.pcp.slots_service.controller
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.doThrow
import org.mockito.Mockito.verify
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO
import space.nstart.pcp.slots_service.dto.groupstate.GroupStateAvailabilityDTO
import space.nstart.pcp.slots_service.dto.groupstate.GroupStateDetailsDTO
import space.nstart.pcp.slots_service.dto.groupstate.GroupStateMatrixCellDTO
import space.nstart.pcp.slots_service.dto.groupstate.GroupStateMatrixRowDTO
import space.nstart.pcp.slots_service.dto.groupstate.GroupStateSatelliteDTO
import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmAvailabilityDTO
import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmDetailsDTO
import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmSummaryDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCalculationSummaryDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteCreateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupCreateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteVisualizationDTO
import space.nstart.pcp.slots_service.service.GroupStateService
import space.nstart.pcp.slots_service.service.SatelliteCatalogService
import space.nstart.pcp.slots_service.service.SatellitePdcmService
import space.nstart.pcp.slots_service.service.SlotService
import space.nstart.pcp.slots_service.service.StationService
import java.time.LocalDateTime
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"spring.cloud.config.enabled=false",
"spring.cloud.config.import-check.enabled=false",
"spring.boot.admin.client.enabled=false"
]
)
class CatalogControllerTest {
@LocalServerPort
private var port: Int = 0
@MockitoBean
private lateinit var satelliteCatalogService: SatelliteCatalogService
@MockitoBean
private lateinit var stationService: StationService
@MockitoBean
private lateinit var slotService: SlotService
@MockitoBean
private lateinit var groupStateService: GroupStateService
@MockitoBean
private lateinit var satellitePdcmService: SatellitePdcmService
@Test
fun `groups page is rendered`() {
val body = WebClient.create("http://localhost:$port")
.get()
.uri("/groups")
.retrieve()
.bodyToMono(String::class.java)
.block()!!
assertTrue(body.contains("Группировки"))
assertTrue(body.contains("Новая группировка"))
assertTrue(body.contains("dropdown-toggle"))
assertTrue(body.contains(">Управление<"))
assertTrue(body.contains(">Состояние ОГ<"))
}
@Test
fun `group state page is rendered`() {
val body = WebClient.create("http://localhost:$port")
.get()
.uri("/group-state")
.retrieve()
.bodyToMono(String::class.java)
.block()!!
assertTrue(body.contains("Состояние ОГ"))
assertTrue(body.contains("Доступные группировки"))
assertTrue(body.contains("Показать состояние"))
}
@Test
fun `satellites page is rendered`() {
val body = WebClient.create("http://localhost:$port")
.get()
.uri("/satellites")
.retrieve()
.bodyToMono(String::class.java)
.block()!!
assertTrue(body.contains("Спутники"))
assertTrue(body.contains("Observation profile"))
assertTrue(body.contains("Slot profile"))
assertTrue(body.contains("Расчет слотов"))
assertTrue(body.contains("slot-calculation-summary"))
assertTrue(body.contains("Начальные условия орбиты"))
assertTrue(body.contains("Сохранить начальные условия"))
assertTrue(body.contains("<th>Слоты</th>"))
}
@Test
fun `satellite pdcm page is rendered`() {
val body = WebClient.create("http://localhost:$port")
.get()
.uri("/satellites/pdcm")
.retrieve()
.bodyToMono(String::class.java)
.block()!!
assertTrue(body.contains("Параметры движения центра масс"))
assertTrue(body.contains("Asc-node"))
assertTrue(body.contains("Спутники"))
assertTrue(body.contains("Скачать CSV"))
assertTrue(body.contains("/webjars/bootstrap/5.3.0/css/bootstrap.min.css"))
}
@Test
fun `stations page is rendered`() {
val body = WebClient.create("http://localhost:$port")
.get()
.uri("/stations")
.retrieve()
.bodyToMono(String::class.java)
.block()!!
assertTrue(body.contains("Станции"))
assertTrue(body.contains("Новая станция"))
assertTrue(body.contains("Мин.угол"))
assertTrue(body.contains("Макс.угол"))
assertTrue(body.contains(">Управление<"))
assertTrue(body.contains(">ЗРВ<"))
assertFalse(body.contains("<th>UUID</th>"))
}
@Test
fun `group create endpoint proxies request`() {
val response = SatelliteGroupDTO(
id = 71L,
name = "KONDOR",
satelliteIds = listOf(56756L, 62138L)
)
val request = SatelliteGroupCreateDTO(
name = "KONDOR",
satelliteIds = listOf(56756L, 62138L)
)
doReturn(response).`when`(satelliteCatalogService).createGroup(request)
val actual = WebClient.create("http://localhost:$port")
.post()
.uri("/api/catalog/groups")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToMono(SatelliteGroupDTO::class.java)
.block()!!
assertEquals(71L, actual.id)
assertEquals("KONDOR", actual.name)
verify(satelliteCatalogService).createGroup(request)
}
@Test
fun `group state endpoint proxies request`() {
val requestedTime = LocalDateTime.of(2026, 4, 24, 12, 15)
val response = GroupStateDetailsDTO(
groupId = 71L,
groupName = "KONDOR",
availableInterval = GroupStateAvailabilityDTO(
timeStart = LocalDateTime.of(2026, 4, 24, 10, 0),
timeStop = LocalDateTime.of(2026, 4, 24, 14, 0)
),
calculationTime = requestedTime,
satellites = listOf(
GroupStateSatelliteDTO(
satelliteId = 501L,
noradId = 56756L,
code = "SAT-501",
name = "Sat 501",
omegabDeg = 15.0,
uDeg = 45.0
)
),
matrix = listOf(
GroupStateMatrixRowDTO(
satelliteId = 501L,
cells = listOf(
GroupStateMatrixCellDTO(
rowSatelliteId = 501L,
columnSatelliteId = 501L,
deltaOmegabDeg = 0.0,
deltaUDeg = 0.0
)
)
)
)
)
doReturn(response).`when`(groupStateService).groupState(71L, requestedTime)
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/catalog/group-states/71?time=2026-04-24T12:15:00")
.retrieve()
.bodyToMono(GroupStateDetailsDTO::class.java)
.block()!!
assertEquals(71L, actual.groupId)
assertEquals(requestedTime, actual.calculationTime)
assertEquals(1, actual.satellites.size)
verify(groupStateService).groupState(71L, requestedTime)
}
@Test
fun `satellite pdcm endpoint proxies request`() {
val response = SatellitePdcmDetailsDTO(
satelliteId = 501L,
code = "SAT-501",
name = "Sat 501",
activeInterval = true,
availableInterval = SatellitePdcmAvailabilityDTO(
timeStart = LocalDateTime.of(2026, 4, 24, 10, 0),
timeStop = LocalDateTime.of(2026, 4, 24, 12, 0)
),
ascNodes = emptyList()
)
doReturn(response).`when`(satellitePdcmService).satelliteDetails(501L)
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/catalog/satellites/pdcm/501")
.retrieve()
.bodyToMono(SatellitePdcmDetailsDTO::class.java)
.block()!!
assertEquals(501L, actual.satelliteId)
assertTrue(actual.activeInterval)
verify(satellitePdcmService).satelliteDetails(501L)
}
@Test
fun `satellite pdcm list endpoint proxies request`() {
val response = listOf(
SatellitePdcmSummaryDTO(
satelliteId = 501L,
code = "SAT-501",
name = "Sat 501",
activeInterval = true,
availableInterval = SatellitePdcmAvailabilityDTO(
timeStart = LocalDateTime.of(2026, 4, 24, 10, 0),
timeStop = LocalDateTime.of(2026, 4, 24, 12, 0)
)
)
)
doReturn(response).`when`(satellitePdcmService).satelliteSummaries()
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/catalog/satellites/pdcm")
.retrieve()
.bodyToMono(Array<SatellitePdcmSummaryDTO>::class.java)
.block()!!
assertEquals(1, actual.size)
assertEquals(501L, actual[0].satelliteId)
verify(satellitePdcmService).satelliteSummaries()
}
@Test
fun `satellite create endpoint proxies request`() {
val request = SatelliteCreateDTO(
id = 501L,
noradId = 9501L,
code = "SAT-501",
name = "Sat 501",
typeCode = "TEST",
active = true,
scanTle = true,
visualization = SatelliteVisualizationDTO(red = 11, green = 22, blue = 33)
)
val response = SatelliteDTO(
id = 501L,
noradId = 9501L,
code = "SAT-501",
name = "Sat 501",
typeCode = "TEST",
active = true,
scanTle = true,
visualization = SatelliteVisualizationDTO(red = 11, green = 22, blue = 33)
)
doReturn(response).`when`(satelliteCatalogService).createSatellite(request)
val actual = WebClient.create("http://localhost:$port")
.post()
.uri("/api/catalog/satellites")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToMono(SatelliteDTO::class.java)
.block()!!
assertEquals(501L, actual.id)
assertEquals("SAT-501", actual.code)
verify(satelliteCatalogService).createSatellite(request)
}
@Test
fun `station save endpoint proxies request`() {
val stationId = UUID.fromString("2f1d1d8b-588d-4d4e-a456-80a5c93e09dd")
val request = StationDTO(
id = stationId,
number = 7,
name = "Station 7",
position = PositionDTO(
lat = 55.75,
long = 37.62,
height = 180.0
),
elevationMin = 5.0,
elevationMax = 88.0
)
doReturn(request).`when`(stationService).saveStation(anyStation())
val actual = WebClient.create("http://localhost:$port")
.post()
.uri("/api/stations")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToMono(StationDTO::class.java)
.block()!!
assertEquals(stationId, actual.id)
assertEquals("Station 7", actual.name)
verify(stationService).saveStation(anyStation())
}
@Test
fun `satellite initial conditions get endpoint returns no content when absent`() {
doReturn(null).`when`(slotService).satelliteInitialConditions(501L)
val status = WebClient.create("http://localhost:$port")
.get()
.uri("/api/catalog/satellites/501/initial-conditions")
.exchangeToMono { Mono.just(it.statusCode()) }
.block()!!
assertEquals(HttpStatus.NO_CONTENT, status)
verify(slotService).satelliteInitialConditions(501L)
}
@Test
fun `satellite slot calculation summaries endpoint proxies request`() {
val response = listOf(
SlotCalculationSummaryDTO.calculated(
satelliteId = 501L,
slotCount = 10L,
minTime = LocalDateTime.of(2026, 4, 20, 0, 0),
maxTime = LocalDateTime.of(2026, 4, 21, 0, 0),
minRevolution = 100L,
maxRevolution = 112L
)
)
doReturn(response).`when`(slotService).slotCalculationSummaries(listOf(501L, 502L))
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/catalog/satellites/slot-calculation-summaries?satelliteIds=501&satelliteIds=502")
.retrieve()
.bodyToMono(Array<SlotCalculationSummaryDTO>::class.java)
.block()!!
assertEquals(1, actual.size)
assertEquals(501L, actual[0].satelliteId)
assertTrue(actual[0].calculated)
assertEquals(12L, actual[0].revolutionInterval)
verify(slotService).slotCalculationSummaries(listOf(501L, 502L))
}
@Test
fun `satellite slot calculation summaries endpoint returns empty summaries when slot service fails`() {
doThrow(RuntimeException("slot service unavailable"))
.`when`(slotService).slotCalculationSummaries(listOf(501L, 502L))
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/catalog/satellites/slot-calculation-summaries?satelliteIds=501&satelliteIds=502")
.retrieve()
.bodyToMono(Array<SlotCalculationSummaryDTO>::class.java)
.block()!!
assertEquals(2, actual.size)
assertEquals(501L, actual[0].satelliteId)
assertFalse(actual[0].calculated)
assertEquals(502L, actual[1].satelliteId)
assertFalse(actual[1].calculated)
verify(slotService).slotCalculationSummaries(listOf(501L, 502L))
}
@Test
fun `satellite slot calculation summary endpoint proxies request`() {
val response = SlotCalculationSummaryDTO.calculated(
satelliteId = 501L,
slotCount = 10L,
minTime = LocalDateTime.of(2026, 4, 20, 0, 0),
maxTime = LocalDateTime.of(2026, 4, 21, 0, 0),
minRevolution = 100L,
maxRevolution = 112L
)
doReturn(response).`when`(slotService).slotCalculationSummary(501L)
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/catalog/satellites/501/slot-calculation-summary")
.retrieve()
.bodyToMono(SlotCalculationSummaryDTO::class.java)
.block()!!
assertEquals(501L, actual.satelliteId)
assertTrue(actual.calculated)
assertEquals(12L, actual.revolutionInterval)
verify(slotService).slotCalculationSummary(501L)
}
@Test
fun `satellite slot calculation summary endpoint returns empty summary when slot service fails`() {
doThrow(RuntimeException("slot service unavailable"))
.`when`(slotService).slotCalculationSummary(501L)
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/catalog/satellites/501/slot-calculation-summary")
.retrieve()
.bodyToMono(SlotCalculationSummaryDTO::class.java)
.block()!!
assertEquals(501L, actual.satelliteId)
assertFalse(actual.calculated)
assertEquals(0L, actual.slotCount)
verify(slotService).slotCalculationSummary(501L)
}
@Test
fun `satellite initial conditions save endpoint proxies request`() {
val request = InitialConditionsDTO(
orbPoint = OrbPointDTO(
time = LocalDateTime.of(2026, 4, 20, 10, 15),
revolution = 123L,
vx = 1.1,
vy = 2.2,
vz = 3.3,
x = 4.4,
y = 5.5,
z = 6.6
),
sBall = 0.07,
f81 = 145.2
)
val response = SatelliteICDTO(
satelliteId = 501L,
ic = request
)
doReturn(response).`when`(slotService).saveSatelliteInitialConditions(eq(501L), anyInitialConditions(), eq(true))
val actual = WebClient.create("http://localhost:$port")
.post()
.uri("/api/catalog/satellites/501/initial-conditions?create=true")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToMono(SatelliteICDTO::class.java)
.block()!!
assertEquals(501L, actual.satelliteId)
assertEquals(123L, actual.ic.orbPoint.revolution)
verify(slotService).saveSatelliteInitialConditions(eq(501L), anyInitialConditions(), eq(true))
}
@Test
fun `satellite slot calculation endpoint proxies request`() {
val request = SatelliteSlotProfileDTO(
cycleRevs = 243L,
slotDuration = 10L,
durationCalcDays = 16L,
maxChainLength = 2
)
val status = WebClient.create("http://localhost:$port")
.post()
.uri("/api/catalog/satellites/501/slot-calculation")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.exchangeToMono { Mono.just(it.statusCode()) }
.block()!!
assertEquals(HttpStatus.NO_CONTENT, status)
verify(slotService).startSlotCalculation(eq(501L), anySlotProfile())
}
private fun anyStation(): StationDTO = any(StationDTO::class.java) ?: StationDTO()
private fun anyInitialConditions(): InitialConditionsDTO = any(InitialConditionsDTO::class.java) ?: InitialConditionsDTO()
private fun anySlotProfile(): SatelliteSlotProfileDTO = any(SatelliteSlotProfileDTO::class.java) ?: SatelliteSlotProfileDTO()
}
@@ -0,0 +1,597 @@
package space.nstart.pcp.slots_service.controller
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.verify
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Flux
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
import space.nstart.pcp.slots_service.service.EarthService
import space.nstart.pcp.slots_service.service.ComplexMissionService
import space.nstart.pcp.slots_service.service.DynamicPlanService
import space.nstart.pcp.slots_service.service.SlotService
import space.nstart.pcp.slots_service.service.StationService
import space.nstart.pcp.slots_service.dto.dynamicplan.DynamicPlanCalculationRequestDTO
import space.nstart.pcp.slots_service.dto.dynamicplan.DynamicPlanCalculationModeDTO
import space.nstart.pcp.slots_service.dto.dynamicplan.DynamicPlanRevolutionModeDTO
import tools.jackson.databind.ObjectMapper
import java.time.LocalDateTime
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"spring.cloud.config.enabled=false",
"spring.cloud.config.import-check.enabled=false",
"spring.boot.admin.client.enabled=false"
]
)
class MapControllerComplexPlanTest {
@LocalServerPort
private var port: Int = 0
@MockitoBean
private lateinit var earthService: EarthService
@MockitoBean
private lateinit var complexMissionService: ComplexMissionService
@MockitoBean
private lateinit var dynamicPlanService: DynamicPlanService
@MockitoBean
private lateinit var stationService: StationService
@MockitoBean
private lateinit var slotService: SlotService
private val objectMapper = ObjectMapper()
@Test
fun `local post api requests delegates to earth service facade`() {
val responseId = UUID.fromString("11111111-1111-4111-8111-111111111111")
var capturedRequest: RequestDTO? = null
doAnswer { invocation ->
capturedRequest = invocation.getArgument<RequestDTO>(0)
RequestDTO(
requestId = responseId,
name = "created request",
importance = 7.5,
contour = "POLYGON ((0 0, 0 1, 1 1, 0 0))",
)
}.`when`(earthService).addRequest(anyRequest())
val actual = WebClient.create("http://localhost:$port")
.post()
.uri("/api/requests")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.bodyValue(
mapOf(
"name" to "map request",
"importance" to 7.5,
"geoType" to "POLYGON",
"contour" to "POLYGON ((0 0, 0 1, 1 1, 0 0))",
)
)
.retrieve()
.bodyToMono(String::class.java)
.map(objectMapper::readTree)
.block()!!
assertEquals(responseId.toString(), actual["requestId"].asText())
assertEquals("created request", actual["name"].asText())
assertEquals("map request", capturedRequest?.name)
assertEquals("POLYGON ((0 0, 0 1, 1 1, 0 0))", capturedRequest?.contour)
verify(earthService).addRequest(anyRequest())
}
@Test
fun `satellite slots endpoint proxies interval request`() {
val satelliteId = 101L
val timeStart = LocalDateTime.of(2026, 4, 8, 10, 0)
val timeStop = LocalDateTime.of(2026, 4, 8, 12, 0)
doReturn(
listOf(
SlotDTO(
cycle = 2,
satelliteId = satelliteId,
tn = timeStart,
tk = timeStart.plusMinutes(6),
roll = 10.5,
contour = "POLYGON ((0 0, 0 1, 1 1, 0 0))",
revolution = 55L,
revolutionSign = RevolutionSign.ASC,
slotNumber = 9L
)
)
).`when`(slotService).allSlotsByInterval(satelliteId, timeStart, timeStop)
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/satellites/$satelliteId/slots?timeStart=$timeStart&timeStop=$timeStop")
.retrieve()
.bodyToMono(String::class.java)
.map(objectMapper::readTree)
.block()!!
assertEquals(1, actual.size())
assertEquals(satelliteId, actual[0]["satelliteId"].asLong())
assertEquals(9L, actual[0]["slotNumber"].asLong())
verify(slotService).allSlotsByInterval(satelliteId, timeStart, timeStop)
}
@Test
fun `complex plan endpoint proxies process request`() {
val request = ComplexPlanProcessRequestDTO(
intervalStart = LocalDateTime.of(2026, 4, 8, 10, 0),
intervalEnd = LocalDateTime.of(2026, 4, 8, 12, 0),
satelliteIds = listOf(101L, 202L),
countLat = 2,
countLong = 3,
coverageStrategy = SlotCoverageStrategy.CONTINUOUS
)
val response = objectMapper.readTree(
"""
{
"runId": 17,
"status": "COMPLETED",
"durationMs": 60000,
"calculatedModesCount": 4,
"bookedSlotLinksCount": 9
}
""".trimIndent()
)
doReturn(response).`when`(complexMissionService).processComplexPlan(request)
val actual = WebClient.create("http://localhost:$port")
.post()
.uri("/api/com-plan/process")
.bodyValue(
mapOf(
"intervalStart" to "2026-04-08T10:00:00",
"intervalEnd" to "2026-04-08T12:00:00",
"satelliteIds" to listOf(101, 202),
"countLat" to 2,
"countLong" to 3,
"coverageStrategy" to "CONTINUOUS"
)
)
.retrieve()
.bodyToMono(String::class.java)
.map(objectMapper::readTree)
.block()!!
assertEquals(17, actual["runId"].asInt())
assertEquals("COMPLETED", actual["status"].asText())
assertEquals(60000, actual["durationMs"].asLong())
assertEquals(4, actual["calculatedModesCount"].asInt())
verify(complexMissionService).processComplexPlan(request)
}
@Test
fun `complex plan page is rendered and menu contains link after rva`() {
val body = WebClient.create("http://localhost:$port")
.get()
.uri("/com-plan")
.retrieve()
.bodyToMono(String::class.java)
.block()!!
assertTrue(body.contains("Ком.План"))
assertTrue(body.contains("complex-plan-runs-body"))
assertTrue(body.contains("complex-plan-pagination"))
assertTrue(body.contains("complex-plan-process-form"))
assertTrue(body.contains("complex-plan-satellite-ids"))
assertTrue(body.contains("complex-plan-interval-start"))
assertTrue(body.contains("complex-plan-coverage-strategy"))
assertTrue(body.contains("complex-plan-count-lat"))
assertTrue(body.contains("complex-plan-count-long"))
assertTrue(body.contains("complex-plan-report"))
assertTrue(body.contains("/complex_plan_scripts.js"))
assertTrue(body.indexOf("/rva") < body.indexOf("/com-plan"))
}
@Test
fun `complex plan script renders added survey count by satellite`() {
val body = WebClient.create("http://localhost:$port")
.get()
.uri("/complex_plan_scripts.js")
.retrieve()
.bodyToMono(String::class.java)
.block()!!
assertTrue(body.contains("Добавлено съемок по спутникам"))
assertTrue(body.contains("addedSurveyRowsBySatellite"))
assertTrue(body.contains("source === 'COMPLAN' || source === 'MIXED'"))
}
@Test
fun `dynamic plan page is rendered and menu contains separate link`() {
val body = WebClient.create("http://localhost:$port")
.get()
.uri("/dynamic-plan")
.retrieve()
.bodyToMono(String::class.java)
.block()!!
assertTrue(body.contains("Dynamic Plan"))
assertTrue(body.contains("dynamic-plan-form"))
assertTrue(body.contains("dynamic-plan-request-id"))
assertTrue(body.contains("dynamic-plan-satellite-ids"))
assertTrue(body.contains("dynamic-plan-interval-start"))
assertTrue(body.contains("dynamic-plan-interval-end"))
assertTrue(body.contains("dynamic-plan-runs-body"))
assertTrue(body.contains("dynamic-plan-runs-refresh"))
assertTrue(body.contains("/dynamic_plan_map_layers.js"))
assertTrue(body.contains("/dynamic_plan_scripts.js"))
assertTrue(body.contains("/com-plan"))
assertTrue(body.contains("/dynamic-plan"))
}
@Test
fun `map page loads dynamic plan map layer script`() {
doReturn(Flux.empty<RequestDTO>()).`when`(earthService).reqs()
doReturn(Flux.empty<SatelliteInfoDTO>()).`when`(complexMissionService).satellites()
doReturn(Flux.empty<StationDTO>()).`when`(stationService).stations()
val body = WebClient.create("http://localhost:$port")
.get()
.uri("/map")
.retrieve()
.bodyToMono(String::class.java)
.block()!!
assertTrue(body.contains("cesiumContainer"))
assertTrue(body.contains("dynamic_plan_map_layers.js"))
assertTrue(body.contains("PcpDynamicPlanMapLayers"))
verify(earthService).reqs()
}
@Test
fun `dynamic plan map layer keeps only one visible run`() {
val body = WebClient.create("http://localhost:$port")
.get()
.uri("/dynamic_plan_map_layers.js")
.retrieve()
.bodyToMono(String::class.java)
.block()!!
assertTrue(body.contains("parsed.slice(0, 1)"))
assertTrue(body.contains("writeRuns([normalizedRun])"))
}
@Test
fun `dynamic plan endpoint proxies calculation request`() {
val requestId = UUID.fromString("11111111-1111-4111-8111-111111111111")
val runId = UUID.fromString("22222222-2222-4222-8222-222222222222")
val request = DynamicPlanCalculationRequestDTO(
requestId = requestId,
satelliteIds = listOf(101L, 202L),
calculationStart = LocalDateTime.of(2026, 4, 8, 10, 0),
calculationEnd = LocalDateTime.of(2026, 4, 8, 12, 0),
calculationMode = DynamicPlanCalculationModeDTO.GREEDY,
revolutionMode = DynamicPlanRevolutionModeDTO.DESC,
filterCoveredRoutes = true
)
val response = objectMapper.readTree(
"""
{
"runId": "$runId",
"status": "PENDING",
"requestId": "$requestId",
"satelliteIds": [101, 202],
"calculationStart": "2026-04-08T10:00:00",
"calculationEnd": "2026-04-08T12:00:00",
"calculationMode": "GREEDY",
"revolutionMode": "DESC",
"filterCoveredRoutes": true,
"createdAt": "2026-04-08T09:59:59"
}
""".trimIndent()
)
doReturn(response).`when`(dynamicPlanService).calculate(request)
val actual = WebClient.create("http://localhost:$port")
.post()
.uri("/api/dynamic-plan/calc")
.bodyValue(
mapOf(
"requestId" to requestId.toString(),
"satelliteIds" to listOf(101, 202),
"calculationStart" to "2026-04-08T10:00:00",
"calculationEnd" to "2026-04-08T12:00:00",
"calculationMode" to "GREEDY",
"revolutionMode" to "DESC",
"filterCoveredRoutes" to true
)
)
.retrieve()
.bodyToMono(String::class.java)
.map(objectMapper::readTree)
.block()!!
assertEquals(runId.toString(), actual["runId"].asText())
assertEquals("PENDING", actual["status"].asText())
assertEquals("GREEDY", actual["calculationMode"].asText())
assertEquals("DESC", actual["revolutionMode"].asText())
assertEquals(true, actual["filterCoveredRoutes"].asBoolean())
verify(dynamicPlanService).calculate(request)
}
@Test
fun `dynamic plan runs endpoint proxies history request`() {
val runId = UUID.fromString("22222222-2222-4222-8222-222222222222")
val response = objectMapper.readTree(
"""
[
{
"runId": "$runId",
"status": "COMPLETED",
"routesCount": 4
}
]
""".trimIndent()
)
doReturn(response).`when`(dynamicPlanService).getRuns(null, 20, 0)
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/dynamic-plan/runs?limit=20&offset=0")
.retrieve()
.bodyToMono(String::class.java)
.map(objectMapper::readTree)
.block()!!
assertEquals(1, actual.size())
assertEquals(runId.toString(), actual[0]["runId"].asText())
assertEquals("COMPLETED", actual[0]["status"].asText())
verify(dynamicPlanService).getRuns(null, 20, 0)
}
@Test
fun `dynamic plan run endpoint proxies status request`() {
val runId = UUID.fromString("22222222-2222-4222-8222-222222222222")
val response = objectMapper.readTree(
"""
{
"runId": "$runId",
"status": "RUNNING",
"routesCount": null
}
""".trimIndent()
)
doReturn(response).`when`(dynamicPlanService).getRun(runId)
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/dynamic-plan/runs/$runId")
.retrieve()
.bodyToMono(String::class.java)
.map(objectMapper::readTree)
.block()!!
assertEquals(runId.toString(), actual["runId"].asText())
assertEquals("RUNNING", actual["status"].asText())
verify(dynamicPlanService).getRun(runId)
}
@Test
fun `dynamic plan result endpoint proxies result request`() {
val runId = UUID.fromString("22222222-2222-4222-8222-222222222222")
val requestId = UUID.fromString("11111111-1111-4111-8111-111111111111")
val response = objectMapper.readTree(
"""
{
"status": "COMPLETED",
"requestId": "$requestId",
"satelliteIds": [101, 202],
"missingSatelliteIds": [],
"routesCount": 4,
"durationMs": 5000
}
""".trimIndent()
)
doReturn(response).`when`(dynamicPlanService).getResult(runId)
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/dynamic-plan/runs/$runId/result")
.retrieve()
.bodyToMono(String::class.java)
.map(objectMapper::readTree)
.block()!!
assertEquals("COMPLETED", actual["status"].asText())
assertEquals(4, actual["routesCount"].asInt())
verify(dynamicPlanService).getResult(runId)
}
@Test
fun `dynamic plan routes endpoint proxies routes request`() {
val runId = UUID.fromString("22222222-2222-4222-8222-222222222222")
val response = objectMapper.readTree(
"""
[
{
"id": 1,
"runId": "$runId",
"requestId": "11111111-1111-4111-8111-111111111111",
"satelliteId": 101,
"startTime": "2026-04-08T10:10:00",
"endTime": "2026-04-08T10:10:30",
"duration": 30.0,
"revolution": 55,
"roll": 11.5,
"contourWkt": "POLYGON ((0 0, 0 1, 1 1, 0 0))"
}
]
""".trimIndent()
)
doReturn(response).`when`(dynamicPlanService).getRoutes(runId, 1000, 0)
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/dynamic-plan/runs/$runId/routes")
.retrieve()
.bodyToMono(String::class.java)
.map(objectMapper::readTree)
.block()!!
assertEquals(1, actual.size())
assertEquals(101, actual[0]["satelliteId"].asInt())
verify(dynamicPlanService).getRoutes(runId, 1000, 0)
}
@Test
fun `dynamic plan intervals endpoint proxies intervals request`() {
val runId = UUID.fromString("22222222-2222-4222-8222-222222222222")
val response = objectMapper.readTree(
"""
[
{
"id": 1,
"runId": "$runId",
"requestId": "11111111-1111-4111-8111-111111111111",
"intervalId": "33333333-3333-4333-8333-333333333333",
"regionId": "44444444-4444-4444-8444-444444444444",
"revSign": "ASC",
"contourWkt": "POLYGON ((0 0, 0 1, 1 1, 0 0))",
"observationParametersCount": 12
}
]
""".trimIndent()
)
doReturn(response).`when`(dynamicPlanService).getIntervals(runId, 1000, 0)
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/dynamic-plan/runs/$runId/intervals")
.retrieve()
.bodyToMono(String::class.java)
.map(objectMapper::readTree)
.block()!!
assertEquals(1, actual.size())
assertEquals("ASC", actual[0]["revSign"].asText())
assertEquals(12, actual[0]["observationParametersCount"].asInt())
verify(dynamicPlanService).getIntervals(runId, 1000, 0)
}
@Test
fun `complex plan runs endpoint proxies list request`() {
val response = objectMapper.readTree(
"""
[
{
"runId": 17,
"status": "COMPLETED",
"intervalStart": "2026-04-08T10:00:00",
"intervalEnd": "2026-04-08T12:00:00",
"satelliteIds": [101, 202]
}
]
""".trimIndent()
)
doReturn(response).`when`(complexMissionService).complexPlanRuns()
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/com-plan/runs")
.retrieve()
.bodyToMono(String::class.java)
.map(objectMapper::readTree)
.block()!!
assertEquals(1, actual.size())
assertEquals(17, actual[0]["runId"].asInt())
assertEquals("COMPLETED", actual[0]["status"].asText())
verify(complexMissionService).complexPlanRuns()
}
@Test
fun `complex plan run endpoint proxies detail request`() {
val response = objectMapper.readTree(
"""
{
"runId": 17,
"status": "COMPLETED",
"durationMs": 60000,
"requestPayload": "{}"
}
""".trimIndent()
)
doReturn(response).`when`(complexMissionService).complexPlanRun(17L)
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/com-plan/runs/17")
.retrieve()
.bodyToMono(String::class.java)
.map(objectMapper::readTree)
.block()!!
assertEquals(17, actual["runId"].asInt())
assertEquals(60000, actual["durationMs"].asLong())
verify(complexMissionService).complexPlanRun(17L)
}
@Test
fun `complex plan run modes endpoint proxies modes request`() {
val response = objectMapper.readTree(
"""
[
{
"id": 1,
"satelliteId": 101,
"startTime": "2026-04-08T10:00:00",
"endTime": "2026-04-08T10:05:00"
}
]
""".trimIndent()
)
doReturn(response).`when`(complexMissionService).complexPlanRunModes(17L)
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/com-plan/runs/17/modes")
.retrieve()
.bodyToMono(String::class.java)
.map(objectMapper::readTree)
.block()!!
assertEquals(1, actual.size())
assertEquals(101, actual[0]["satelliteId"].asInt())
verify(complexMissionService).complexPlanRunModes(17L)
}
@Test
fun `complex plan run delete endpoint proxies delete request`() {
WebClient.create("http://localhost:$port")
.delete()
.uri("/api/com-plan/runs/17")
.retrieve()
.toBodilessEntity()
.block()
verify(complexMissionService).deleteComplexPlanRun(17L)
}
private fun anyRequest(): RequestDTO {
any(RequestDTO::class.java)
return RequestDTO()
}
}
@@ -0,0 +1,166 @@
package space.nstart.pcp.slots_service.controller
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyList
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.verify
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.http.MediaType
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Flux
import space.nstart.pcp.pcp_types_lib.dto.ballistics.DurationOnRevDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.IntervalDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RadioVisibilityAreaDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TargetPositionDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
import space.nstart.pcp.slots_service.service.ComplexMissionService
import space.nstart.pcp.slots_service.service.SlotService
import space.nstart.pcp.slots_service.service.StationService
import java.time.LocalDateTime
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"spring.cloud.config.enabled=false",
"spring.cloud.config.import-check.enabled=false",
"spring.boot.admin.client.enabled=false"
]
)
class RvaControllerTest {
@LocalServerPort
private var port: Int = 0
@MockitoBean
private lateinit var complexMissionService: ComplexMissionService
@MockitoBean
private lateinit var stationService: StationService
@MockitoBean
private lateinit var slotService: SlotService
@Test
fun `rva page is rendered`() {
doReturn(Flux.just(SatelliteInfoDTO(noradId = 56756L, name = "Kondor"))).`when`(complexMissionService).satellites()
doReturn(Flux.just(sampleStation())).`when`(stationService).stations()
val body = WebClient.create("http://localhost:$port")
.get()
.uri("/rva")
.retrieve()
.bodyToMono(String::class.java)
.block()!!
assertTrue(body.contains("ЗРВ"))
assertTrue(body.contains("Рассчитать"))
assertTrue(body.contains("Скачать CSV"))
assertTrue(body.contains("Суммарная длительность на витке"))
assertTrue(body.contains("rva-station-label"))
}
@Test
fun `rva common endpoint proxies request`() {
val station = sampleStation()
val response = listOf(
RadioVisibilityAreaDTO(
stationId = 1L,
revolution = 42L,
onStart = TargetPositionDTO(time = LocalDateTime.of(2026, 4, 17, 12, 0)),
onMaximum = TargetPositionDTO(time = LocalDateTime.of(2026, 4, 17, 12, 3)),
onStop = TargetPositionDTO(time = LocalDateTime.of(2026, 4, 17, 12, 5)),
duration = 300.0
)
)
doReturn(response).`when`(slotService).rvaCommon(eq(56756L), eq(2L), anyStationList())
val actual = WebClient.create("http://localhost:$port")
.post()
.uri("/api/rva/common/56756/2")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(listOf(station))
.retrieve()
.bodyToFlux(RadioVisibilityAreaDTO::class.java)
.collectList()
.block()!!
assertEquals(1, actual.size)
assertEquals(42L, actual.first().revolution)
verify(slotService).rvaCommon(eq(56756L), eq(2L), anyStationList())
}
@Test
fun `rva merge endpoint proxies request`() {
val station = sampleStation()
val response = listOf(
IntervalDTO(
begin = LocalDateTime.of(2026, 4, 17, 12, 0),
end = LocalDateTime.of(2026, 4, 17, 12, 10),
durationSec = 600.0
)
)
doReturn(response).`when`(slotService).rvaMerge(eq(56756L), eq(2L), anyStationList())
val actual = WebClient.create("http://localhost:$port")
.post()
.uri("/api/rva/merge/56756/2")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(listOf(station))
.retrieve()
.bodyToFlux(IntervalDTO::class.java)
.collectList()
.block()!!
assertEquals(1, actual.size)
assertEquals(600.0, actual.first().durationSec)
verify(slotService).rvaMerge(eq(56756L), eq(2L), anyStationList())
}
@Test
fun `rva merge on rev endpoint proxies request`() {
val station = sampleStation()
val response = listOf(
DurationOnRevDTO(
revolution = 42L,
durationSec = 780.0
)
)
doReturn(response).`when`(slotService).rvaMergeOnRev(eq(56756L), eq(2L), anyStationList())
val actual = WebClient.create("http://localhost:$port")
.post()
.uri("/api/rva/merge-on-rev/56756/2")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(listOf(station))
.retrieve()
.bodyToFlux(DurationOnRevDTO::class.java)
.collectList()
.block()!!
assertEquals(1, actual.size)
assertEquals(42L, actual.first().revolution)
assertEquals(780.0, actual.first().durationSec)
verify(slotService).rvaMergeOnRev(eq(56756L), eq(2L), anyStationList())
}
private fun sampleStation() = StationDTO(
number = 1,
name = "Station 1",
position = PositionDTO(
lat = 55.75,
long = 37.62,
height = 200.0
),
elevationMin = 5.0,
elevationMax = 90.0
)
private fun anyStationList(): List<StationDTO> = anyList<StationDTO>() ?: emptyList()
}
@@ -0,0 +1,150 @@
package space.nstart.pcp.slots_service.rest
import org.junit.jupiter.api.Test
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.verify
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.web.reactive.function.client.WebClient
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.slots_service.czml.CZMLColor
import space.nstart.pcp.slots_service.czml.CZMLEntity
import space.nstart.pcp.slots_service.czml.CZMLMaterial
import space.nstart.pcp.slots_service.czml.CZMLPolygon
import space.nstart.pcp.slots_service.czml.CZMLPosition
import space.nstart.pcp.slots_service.service.CoverageSchemeModeRowDTO
import space.nstart.pcp.slots_service.service.CoverageSchemeService
import space.nstart.pcp.slots_service.service.CoverageSchemeUiResponseDTO
import space.nstart.pcp.slots_service.service.EarthService
import space.nstart.pcp.slots_service.service.ComplexMissionService
import space.nstart.pcp.slots_service.service.SlotService
import space.nstart.pcp.slots_service.service.StationService
import tools.jackson.databind.ObjectMapper
import java.time.LocalDateTime
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"spring.cloud.config.enabled=false",
"spring.cloud.config.import-check.enabled=false",
"spring.boot.admin.client.enabled=false"
]
)
class RequestRestControllerCoverageSchemeTest {
@LocalServerPort
private var port: Int = 0
@MockitoBean
private lateinit var earthService: EarthService
@MockitoBean
private lateinit var complexMissionService: ComplexMissionService
@MockitoBean
private lateinit var stationService: StationService
@MockitoBean
private lateinit var slotService: SlotService
@MockitoBean
private lateinit var coverageSchemeService: CoverageSchemeService
private val objectMapper = ObjectMapper()
@Test
fun `request coverage scheme endpoint returns map and table payload`() {
val requestId = "req-scheme"
val timeStart = LocalDateTime.of(2026, 4, 14, 10, 0)
val timeStop = LocalDateTime.of(2026, 4, 14, 12, 0)
val satellites = listOf(11L, 22L)
val rollStepDegrees = 2.5
val minimumTechnologyOverlap = 0.1
val strictContinuousCoverage = false
val allowPartialCoverage = true
val orientationToleranceDegrees = 7.5
val includeDebugInfo = false
doReturn(
CoverageSchemeUiResponseDTO(
cov = listOf(
CZMLEntity("document", "req_cov_scheme_$requestId", "1.1"),
CZMLEntity(
id = "coverage_mode_${requestId}_11_1",
name = "Режим схемы покрытия",
polygon = CZMLPolygon(
positions = CZMLPosition(cartographicDegrees = listOf(30.0, 10.0, 0.0, 40.0, 40.0, 0.0, 20.0, 40.0, 0.0, 30.0, 10.0, 0.0)),
outlineColor = CZMLColor(12, 34, 56, 255),
material = CZMLMaterial(12, 34, 56, 150)
)
)
),
slots = listOf(
CoverageSchemeModeRowDTO(
entityId = "coverage_mode_${requestId}_11_1",
sequence = 1,
satelliteId = 11L,
tn = timeStart,
tk = timeStart.plusMinutes(5),
roll = 12.5,
revolutionSign = RevolutionSign.ASC,
contour = "POLYGON((30 10, 40 40, 20 40, 30 10))",
selectionReason = "seed from WEST"
)
)
)
).`when`(coverageSchemeService).requestCoverageScheme(
requestId,
timeStart,
timeStop,
satellites,
rollStepDegrees,
minimumTechnologyOverlap,
strictContinuousCoverage,
allowPartialCoverage,
orientationToleranceDegrees,
includeDebugInfo
)
val actual = WebClient.create("http://localhost:$port")
.post()
.uri(
"/requests/req-with-coverage-scheme/$requestId" +
"?tn=2026-04-14T10:00:00" +
"&tk=2026-04-14T12:00:00" +
"&rollStepDegrees=2.5" +
"&minimumTechnologyOverlap=0.1" +
"&strictContinuousCoverage=false" +
"&allowPartialCoverage=true" +
"&orientationToleranceDegrees=7.5" +
"&includeDebugInfo=false" +
"&satellites=11" +
"&satellites=22"
)
.retrieve()
.bodyToMono(String::class.java)
.map(objectMapper::readTree)
.block()!!
assertEquals(2, actual["cov"].size())
assertEquals(1, actual["slots"].size())
assertEquals(1, actual["slots"][0]["sequence"].asInt())
assertEquals(11L, actual["slots"][0]["satelliteId"].asLong())
assertNotNull(actual["slots"][0]["entityId"])
verify(coverageSchemeService).requestCoverageScheme(
requestId,
timeStart,
timeStop,
satellites,
rollStepDegrees,
minimumTechnologyOverlap,
strictContinuousCoverage,
allowPartialCoverage,
orientationToleranceDegrees,
includeDebugInfo
)
}
}
@@ -0,0 +1,144 @@
package space.nstart.pcp.slots_service.rest
import org.junit.jupiter.api.Test
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.verify
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
import space.nstart.pcp.slots_service.service.EarthService
import space.nstart.pcp.slots_service.service.ComplexMissionService
import space.nstart.pcp.slots_service.service.SlotService
import space.nstart.pcp.slots_service.service.StationService
import tools.jackson.databind.ObjectMapper
import java.time.LocalDateTime
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"spring.cloud.config.enabled=false",
"spring.cloud.config.import-check.enabled=false",
"spring.boot.admin.client.enabled=false"
]
)
class RequestRestControllerCoverageSunTest {
@LocalServerPort
private var port: Int = 0
@MockitoBean
private lateinit var earthService: EarthService
@MockitoBean
private lateinit var complexMissionService: ComplexMissionService
@MockitoBean
private lateinit var stationService: StationService
@MockitoBean
private lateinit var slotService: SlotService
private val objectMapper = ObjectMapper()
@Test
fun `request coverage forwards sun parameter to slot service`() {
val requestId = "req-1"
val timeStart = LocalDateTime.of(2026, 4, 9, 10, 0)
val timeStop = LocalDateTime.of(2026, 4, 9, 12, 0)
val satellites = listOf(11L, 22L)
val sun = -45.5
doReturn(Flux.empty<SlotDTO>())
.`when`(slotService)
.slots(requestId, timeStart, timeStop, RevolutionSign.ASC, true, satellites, sun)
val actual = WebClient.create("http://localhost:$port")
.post()
.uri(
"/requests/req-with-covs/$requestId" +
"?tn=2026-04-09T10:00:00" +
"&tk=2026-04-09T12:00:00" +
"&sign=ASC" +
"&cov=true" +
"&sun=-45.5" +
"&satellites=11" +
"&satellites=22"
)
.retrieve()
.bodyToMono(String::class.java)
.map(objectMapper::readTree)
.block()!!
assertEquals(0, actual["slots"].size())
verify(slotService).slots(requestId, timeStart, timeStop, RevolutionSign.ASC, true, satellites, sun)
}
@Test
fun `request coverage uses satellite color for slot polygons`() {
val requestId = "req-color"
val timeStart = LocalDateTime.of(2026, 4, 9, 10, 0)
val timeStop = LocalDateTime.of(2026, 4, 9, 12, 0)
val slot = SlotDTO(
cycle = 7,
satelliteId = 11L,
tn = timeStart,
tk = timeStart.plusMinutes(10),
roll = 12.5,
contour = "POLYGON((30 10, 40 40, 20 40, 10 20, 30 10))",
revolution = 3,
revolutionSign = RevolutionSign.ASC,
slotNumber = 4,
state = SlotStatus.BOOKED
)
doReturn(Flux.just(slot))
.`when`(slotService)
.slots(requestId, timeStart, timeStop, null, true, null, null)
doReturn(
Mono.just(
SatelliteInfoDTO(
noradId = 11L,
name = "SAT-11",
red = 12,
green = 34,
blue = 56,
scanTLE = false
)
)
)
.`when`(complexMissionService)
.satellite(11L)
val actual = WebClient.create("http://localhost:$port")
.post()
.uri(
"/requests/req-with-covs/$requestId" +
"?tn=2026-04-09T10:00:00" +
"&tk=2026-04-09T12:00:00" +
"&cov=true"
)
.retrieve()
.bodyToMono(String::class.java)
.map(objectMapper::readTree)
.block()!!
val slotEntity = actual["cov"]?.get(1)
assertNotNull(slotEntity)
assertEquals(listOf(12, 34, 56, 255), slotEntity["polygon"]["outlineColor"]["rgba"].map { it.asInt() })
assertEquals(
listOf(12, 34, 56, 150),
slotEntity["polygon"]["material"]["solidColor"]["color"]["rgba"].map { it.asInt() }
)
verify(complexMissionService).satellite(11L)
}
}
@@ -0,0 +1,93 @@
package space.nstart.pcp.slots_service.rest
import org.junit.jupiter.api.Test
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.springframework.test.util.ReflectionTestUtils
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteInfoDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
import space.nstart.pcp.slots_service.czml.CZMLEntity
import space.nstart.pcp.slots_service.service.CZMLService
import space.nstart.pcp.slots_service.service.ComplexMissionService
import space.nstart.pcp.slots_service.service.SlotService
import java.time.LocalDateTime
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class RequestRestControllerSatFldTest {
@Test
fun `plan endpoint forwards runId to satellite plan`() {
val czmlService = mock(CZMLService::class.java)
val controller = RequestRestController()
val timeStart = LocalDateTime.of(2026, 4, 22, 10, 0)
val timeStop = LocalDateTime.of(2026, 4, 22, 12, 0)
doReturn(emptyList<CZMLEntity>()).`when`(czmlService)
.showSatPlan(101L, timeStart, timeStop, 17L)
ReflectionTestUtils.setField(controller, "czmlService", czmlService)
val result = controller.plan(
id = 101L,
tn = timeStart,
tk = timeStop,
runId = 17L
)
assertTrue(result.none())
verify(czmlService).showSatPlan(101L, timeStart, timeStop, 17L)
}
@Test
fun `sat endpoint forwards time interval to satFLD`() {
val czmlService = mock(CZMLService::class.java)
val controller = RequestRestController()
val timeStart = LocalDateTime.of(2026, 4, 21, 10, 0)
val timeStop = LocalDateTime.of(2026, 4, 21, 12, 0)
doReturn(emptyList<CZMLEntity>()).`when`(czmlService)
.satFLD(101L, false, true, true, true, timeStart, timeStop)
ReflectionTestUtils.setField(controller, "czmlService", czmlService)
val result = controller.sat(
id = 101L,
view = false,
orbit = true,
sw = true,
fl = true,
tn = timeStart,
tk = timeStop
)
assertTrue(result.none())
verify(czmlService).satFLD(101L, false, true, true, true, timeStart, timeStop)
}
@Test
fun `satellite plan czml does not depend on slots service`() {
val service = CZMLService()
val complexMissionService = mock(ComplexMissionService::class.java)
val slotService = mock(SlotService::class.java)
val timeStart = LocalDateTime.of(2026, 5, 18, 15, 20)
val timeStop = LocalDateTime.of(2026, 6, 1, 15, 20)
doReturn(Mono.just(SatelliteInfoDTO(noradId = 31L, name = "SAT-31")))
.`when`(complexMissionService)
.satellite(31L)
doReturn(Flux.empty<SatelliteModeResponseDTO>())
.`when`(complexMissionService)
.plan(31L, timeStart, timeStop, null)
ReflectionTestUtils.setField(service, "complexMissionService", complexMissionService)
ReflectionTestUtils.setField(service, "slotService", slotService)
val result = service.showSatPlan(31L, timeStart, timeStop).toList()
assertEquals("document", result.first().id)
verifyNoInteractions(slotService)
}
}
@@ -0,0 +1,117 @@
package space.nstart.pcp.slots_service.service
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.ObjectProvider
import org.springframework.test.util.ReflectionTestUtils
import org.springframework.web.reactive.function.client.WebClient
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import java.net.InetSocketAddress
import java.net.URI
import java.time.LocalDateTime
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class BallisticsServiceTest {
private var server: HttpServer? = null
@AfterEach
fun tearDown() {
server?.stop(0)
}
@Test
fun `sat data methods forward time interval to ballistics service`() {
val requests = CopyOnWriteArrayList<URI>()
val serverUrl = startServer(requests)
val service = createService(serverUrl)
val timeStart = LocalDateTime.of(2026, 4, 21, 10, 0)
val timeStop = LocalDateTime.of(2026, 4, 21, 12, 0)
service.ascNodes(101L, timeStart, timeStop).collectList().block()
service.flightLine(101L, timeStart, timeStop).collectList().block()
service.points(101L, timeStart, timeStop).collectList().block()
val availability = service.orbitAvailability()
val exactPoint = service.exactTimePoint(101L, timeStart)
assertEquals(
listOf(
"/api/satellites/101/asc-node",
"/api/satellites/101/flight-line",
"/api/satellites/101/orbit",
"/api/satellites/orbit/availability",
"/api/satellites/101/extract-time"
),
requests.map { it.path }
)
assertEquals(
listOf(
"time_start=2026-04-21T10:00&time_stop=2026-04-21T12:00",
"time_start=2026-04-21T10:00&time_stop=2026-04-21T12:00",
"time_start=2026-04-21T10:00&time_stop=2026-04-21T12:00",
null,
null
),
requests.map { it.query }
)
assertEquals(1, availability.size)
assertEquals(101L, availability.single().satelliteId)
assertNotNull(exactPoint)
assertEquals(timeStart, exactPoint.time)
}
private fun createService(serverUrl: String): BallisticsService {
val provider = object : ObjectProvider<WebClient.Builder> {
override fun getObject(vararg args: Any?): WebClient.Builder = WebClient.builder()
override fun getObject(): WebClient.Builder = WebClient.builder()
override fun getIfAvailable(): WebClient.Builder = WebClient.builder()
override fun getIfUnique(): WebClient.Builder = WebClient.builder()
override fun iterator(): MutableIterator<WebClient.Builder> = mutableListOf<WebClient.Builder>().iterator()
}
val service = BallisticsService(provider)
ReflectionTestUtils.setField(service, "url", serverUrl)
return service
}
private fun startServer(requests: MutableList<URI>): String {
val startedServer = HttpServer.create(InetSocketAddress(0), 0)
listOf(
"/api/satellites/101/asc-node",
"/api/satellites/101/flight-line",
"/api/satellites/101/orbit"
).forEach { path ->
startedServer.createContext(path) { exchange ->
requests.add(exchange.requestURI)
respond(exchange, "[]")
}
}
startedServer.createContext("/api/satellites/orbit/availability") { exchange ->
requests.add(exchange.requestURI)
respond(
exchange,
"""[{"satelliteId":101,"timeStart":"2026-04-21T10:00:00","timeStop":"2026-04-21T12:00:00"}]"""
)
}
startedServer.createContext("/api/satellites/101/extract-time") { exchange ->
requests.add(exchange.requestURI)
respond(
exchange,
"""[{"time":"2026-04-21T10:00:00","revolution":1,"vx":1.0,"vy":2.0,"vz":3.0,"x":4.0,"y":5.0,"z":6.0}]"""
)
}
startedServer.start()
server = startedServer
return "http://localhost:${startedServer.address.port}"
}
private fun respond(exchange: HttpExchange, body: String) {
exchange.responseHeaders.add("Content-Type", "application/json")
val bytes = body.toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.use { output -> output.write(bytes) }
}
}
@@ -0,0 +1,165 @@
package space.nstart.pcp.slots_service.service
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.ObjectProvider
import org.springframework.test.util.ReflectionTestUtils
import org.springframework.web.reactive.function.client.WebClient
import space.nstart.pcp.slots_service.configuration.WebClientConfig
import java.net.InetSocketAddress
import java.net.URI
import java.time.LocalDateTime
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.test.assertEquals
class ComplexMissionServiceTest {
private var server: HttpServer? = null
@AfterEach
fun tearDown() {
server?.stop(0)
}
@Test
fun `complex plan run methods call complex mission endpoints`() {
val requests = CopyOnWriteArrayList<URI>()
val methods = CopyOnWriteArrayList<String>()
val serverUrl = startServer(requests, methods)
val service = createService(serverUrl)
val runs = service.complexPlanRuns()
val run = service.complexPlanRun(17L)
val modes = service.complexPlanRunModes(17L)
service.deleteComplexPlanRun(17L)
assertEquals(1, runs.size())
assertEquals(17, runs[0]["runId"].asInt())
assertEquals(17, run["runId"].asInt())
assertEquals(0, modes.size())
assertEquals(
listOf(
"/api/com-plan/runs",
"/api/com-plan/runs/17",
"/api/com-plan/runs/17/modes",
"/api/com-plan/runs/17"
),
requests.map { it.path }
)
assertEquals(listOf("GET", "GET", "GET", "DELETE"), methods)
}
@Test
fun `complex plan run modes supports report payloads larger than previous webclient buffer`() {
val itemPayload = "x".repeat(1000)
val itemCount = 22_000
val modesBody = buildString {
append("[")
repeat(itemCount) { index ->
if (index > 0) {
append(",")
}
append("""{"slotNumber":$index,"payload":"$itemPayload"}""")
}
append("]")
}
val serverUrl = startServer(
requests = CopyOnWriteArrayList(),
modesBody = modesBody
)
val service = createService(serverUrl) {
WebClientConfig(32 * 1024 * 1024).webClientBuilder()
}
val modes = service.complexPlanRunModes(17L)
assertEquals(itemCount, modes.size())
assertEquals(itemPayload.length, modes[0]["payload"].asText().length)
}
@Test
fun `plan calls satellite scoped modes endpoint with runId`() {
val requests = CopyOnWriteArrayList<URI>()
val serverUrl = startServer(requests)
val service = createService(serverUrl)
val intervalStart = LocalDateTime.of(2026, 4, 22, 10, 0)
val intervalEnd = LocalDateTime.of(2026, 4, 22, 12, 0)
val response = service.plan(101L, intervalStart, intervalEnd, 17L)
.collectList()
.block()
.orEmpty()
assertEquals(emptyList(), response)
assertEquals("/api/satellites/101/modes", requests.single().path)
assertEquals(
"intervalStart=2026-04-22T10:00&intervalEnd=2026-04-22T12:00&runId=17",
requests.single().rawQuery
)
}
private fun createService(
serverUrl: String,
builderFactory: () -> WebClient.Builder = { WebClient.builder() }
): ComplexMissionService {
val provider = object : ObjectProvider<WebClient.Builder> {
override fun getObject(vararg args: Any?): WebClient.Builder = builderFactory()
override fun getObject(): WebClient.Builder = builderFactory()
override fun getIfAvailable(): WebClient.Builder = builderFactory()
override fun getIfUnique(): WebClient.Builder = builderFactory()
override fun iterator(): MutableIterator<WebClient.Builder> = mutableListOf<WebClient.Builder>().iterator()
}
val service = ComplexMissionService(provider)
ReflectionTestUtils.setField(service, "complexMissionUrl", serverUrl)
ReflectionTestUtils.setField(service, "satelliteCatalogUrl", serverUrl)
return service
}
private fun startServer(
requests: MutableList<URI>,
methods: MutableList<String> = mutableListOf(),
modesBody: String = "[]"
): String {
val startedServer = HttpServer.create(InetSocketAddress(0), 0)
startedServer.createContext("/api/com-plan/runs") { exchange ->
requests.add(exchange.requestURI)
methods.add(exchange.requestMethod)
if (exchange.requestMethod == "DELETE") {
respondNoContent(exchange)
} else {
respond(
exchange,
if (exchange.requestURI.path.endsWith("/modes")) {
modesBody
} else if (exchange.requestURI.path.endsWith("/17")) {
"""{"runId":17,"status":"COMPLETED"}"""
} else {
"""[{"runId":17,"status":"COMPLETED"}]"""
}
)
}
}
startedServer.createContext("/api/satellites/101/modes") { exchange ->
requests.add(exchange.requestURI)
methods.add(exchange.requestMethod)
respond(exchange, "[]")
}
startedServer.start()
server = startedServer
return "http://localhost:${startedServer.address.port}"
}
private fun respond(exchange: HttpExchange, body: String) {
exchange.responseHeaders.add("Content-Type", "application/json")
val bytes = body.toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.use { output -> output.write(bytes) }
}
private fun respondNoContent(exchange: HttpExchange) {
exchange.sendResponseHeaders(204, -1)
exchange.close()
}
}
@@ -0,0 +1,212 @@
package space.nstart.pcp.slots_service.service
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpHandler
import com.sun.net.httpserver.HttpServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.ObjectProvider
import org.springframework.test.util.ReflectionTestUtils
import org.springframework.web.reactive.function.client.WebClient
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.coverage.CoverageSchemeResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteObservationProfileDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteVisualizationDTO
import space.nstart.pcp.slots_service.configuration.CustomErrorException
import tools.jackson.databind.ObjectMapper
import java.net.InetSocketAddress
import java.time.LocalDateTime
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
class CoverageSchemeServiceTest {
private val objectMapper = ObjectMapper()
private var server: HttpServer? = null
@AfterEach
fun tearDown() {
server?.stop(0)
}
@Test
fun `requestCoverageScheme maps coverageScheme slots to rows and czml`() {
val timeStart = LocalDateTime.of(2026, 4, 14, 10, 0)
val timeStop = LocalDateTime.of(2026, 4, 14, 12, 0)
val serverUrl = startServer(
mapOf(
"/v1/requests/req-1/with-cells" to stringHandler(200, requestWithCellsJson()),
"/api/coverage-schemes/calculate" to jsonHandler(
CoverageSchemeResponseDTO(
targetPolygonWkt = "POLYGON ((30 10, 40 40, 20 40, 30 10))",
coverageScheme = listOf(
SurveySlotDTO(
satelliteId = 11L,
tn = timeStart,
tk = timeStart.plusMinutes(5),
roll = 0.0,
contour = "POLYGON((30 10, 40 40, 20 40, 30 10))",
revolutionSign = RevolutionSign.ASC
),
SurveySlotDTO(
satelliteId = 11L,
tn = timeStart,
tk = timeStart.plusMinutes(5),
roll = 0.0,
contour = "POLYGON((31 11, 32 12, 30 12, 31 11))",
revolutionSign = RevolutionSign.ASC
)
)
)
),
"/api/satellites/11" to jsonHandler(
SatelliteDTO(
id = 11L,
code = "SAT-11",
name = "Sat-11",
typeCode = "TEST",
visualization = SatelliteVisualizationDTO(
red = 12,
green = 34,
blue = 56
),
observationProfile = SatelliteObservationProfileDTO()
)
)
)
)
val service = createService(serverUrl)
val response = service.requestCoverageScheme(
requestId = "req-1",
timeStart = timeStart,
timeStop = timeStop,
satelliteIds = listOf(11L, 22L),
rollStepDegrees = 2.5,
minimumTechnologyOverlap = 0.1,
strictContinuousCoverage = false,
allowPartialCoverage = true,
orientationToleranceDegrees = 7.5,
includeDebugInfo = false
)
assertEquals(2, response.slots.size)
assertEquals(3, response.cov.size)
assertEquals("document", response.cov.first().id)
assertEquals(1, response.slots.first().sequence)
assertEquals(2, response.slots.last().sequence)
assertEquals(11L, response.slots.first().satelliteId)
assertEquals(0.0, response.slots.first().roll)
assertEquals(0.0, response.slots.last().roll)
assertEquals(RevolutionSign.ASC, response.slots.first().revolutionSign)
assertNotNull(response.cov[1].polygon)
assertEquals(listOf(12, 34, 56, 255), response.cov[1].polygon?.outlineColor?.rgba?.toList())
}
@Test
fun `requestCoverageScheme converts backend error to CustomErrorException`() {
val serverUrl = startServer(
mapOf(
"/v1/requests/req-1/with-cells" to stringHandler(200, requestWithCellsJson()),
"/api/coverage-schemes/calculate" to stringHandler(400, """{"message":"coverage failed"}""")
)
)
val service = createService(serverUrl)
val error = assertFailsWith<CustomErrorException> {
service.requestCoverageScheme(
requestId = "req-1",
timeStart = LocalDateTime.of(2026, 4, 14, 10, 0),
timeStop = LocalDateTime.of(2026, 4, 14, 12, 0),
satelliteIds = listOf(11L),
rollStepDegrees = null,
minimumTechnologyOverlap = null,
strictContinuousCoverage = null,
allowPartialCoverage = null,
orientationToleranceDegrees = null,
includeDebugInfo = null
)
}
assertEquals("coverage failed", error.message)
}
private fun createService(serverUrl: String): CoverageSchemeService {
val provider = object : ObjectProvider<WebClient.Builder> {
override fun getObject(vararg args: Any?): WebClient.Builder = WebClient.builder()
override fun getObject(): WebClient.Builder = WebClient.builder()
override fun getIfAvailable(): WebClient.Builder = WebClient.builder()
override fun getIfUnique(): WebClient.Builder = WebClient.builder()
override fun iterator(): MutableIterator<WebClient.Builder> = mutableListOf<WebClient.Builder>().iterator()
}
val service = CoverageSchemeService(provider)
val earthService = EarthService(provider)
val complexMissionService = ComplexMissionService(provider)
ReflectionTestUtils.setField(service, "earthService", earthService)
ReflectionTestUtils.setField(service, "complexMissionService", complexMissionService)
ReflectionTestUtils.setField(service, "url", serverUrl)
ReflectionTestUtils.setField(earthService, "url", serverUrl)
ReflectionTestUtils.setField(complexMissionService, "complexMissionUrl", serverUrl)
ReflectionTestUtils.setField(complexMissionService, "satelliteCatalogUrl", serverUrl)
return service
}
private fun startServer(handlers: Map<String, HttpHandler>): String {
val startedServer = HttpServer.create(InetSocketAddress(0), 0)
handlers.forEach { (path, handler) ->
startedServer.createContext(path, handler)
}
startedServer.start()
server = startedServer
return "http://localhost:${startedServer.address.port}"
}
private fun jsonHandler(body: Any) = HttpHandler { exchange ->
respond(exchange, 200, objectMapper.writeValueAsString(body))
}
private fun stringHandler(status: Int, body: String) = HttpHandler { exchange ->
respond(exchange, status, body)
}
private fun requestWithCellsJson(): String =
"""
{
"request": {
"id": "11111111-1111-4111-8111-111111111111",
"name": "coverage scheme request",
"status": "ACCEPTED",
"surveyType": "OPTICS",
"geometry": "POLYGON ((30 10, 40 40, 20 40, 30 10))",
"importance": 10.0,
"beginDateTime": "2026-01-01T00:00:00Z",
"endDateTime": "2026-01-02T00:00:00Z",
"kpp": [],
"highPriorityTransmit": false,
"optics": null,
"rsa": null,
"coverage": {
"requiredPercent": 100.0,
"currentPercent": 0.0
},
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z",
"deletedAt": null
},
"cells": []
}
""".trimIndent()
private fun respond(exchange: HttpExchange, status: Int, body: String) {
exchange.responseHeaders.add("Content-Type", "application/json")
val bytes = body.toByteArray()
exchange.sendResponseHeaders(status, bytes.size.toLong())
exchange.responseBody.use { output -> output.write(bytes) }
}
}
@@ -0,0 +1,543 @@
package space.nstart.pcp.slots_service.service
import com.sun.net.httpserver.HttpServer
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.ObjectProvider
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.web.reactive.function.client.ClientResponse
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.WebClientResponseException
import reactor.core.publisher.Mono
import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO
import tools.jackson.databind.ObjectMapper
import java.net.InetSocketAddress
import java.net.URI
import java.nio.charset.StandardCharsets
import java.util.UUID
import java.util.concurrent.atomic.AtomicReference
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class EarthServiceTest {
private val objectMapper = ObjectMapper()
@Test
fun `cells calls v1 cells with min importance`() {
val requestedUris = mutableListOf<URI>()
val service = earthService(
requestedUris = requestedUris,
responses = mutableListOf(cellsResponse())
)
service.cells(3.5).toList()
assertEquals("/v1/cells", requestedUris.single().path)
assertEquals("minImportance=3.5&page=0&size=500", requestedUris.single().query)
assertFalse(requestedUris.single().path.startsWith("/api/earth-grid"))
}
@Test
fun `cells maps response wrapper items to earth cell dto`() {
val service = earthService(
responses = mutableListOf(
cellsResponse(
"""
{
"items": [
{
"cellNum": 42,
"latitude": 55.0,
"longitude": 37.0,
"importance": 10.0,
"contour": "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))"
}
],
"page": 0,
"size": 500,
"totalItems": 1,
"totalPages": 1
}
""".trimIndent()
)
)
)
val cell = service.cells(0.0).single()
assertEquals(42L, cell.id)
assertEquals(42L, cell.num)
assertEquals(55.0, cell.latitude)
assertEquals(37.0, cell.longitude)
assertEquals(10.0, cell.importance)
assertEquals("POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", cell.contour)
}
@Test
fun `cells does not pass count lat and count long to v1 cells`() {
val requestedUris = mutableListOf<URI>()
val service = earthService(
requestedUris = requestedUris,
responses = mutableListOf(cellsResponse())
)
service.cells(1.0, countLat = 2, countLong = 3).toList()
val query = requestedUris.single().query
assertFalse(query.contains("countLat"))
assertFalse(query.contains("countLong"))
}
@Test
fun `cells fetches all pages from v1 cells`() {
val requestedUris = mutableListOf<URI>()
val service = earthService(
requestedUris = requestedUris,
responses = mutableListOf(
cellsResponse(
"""
{
"items": [
{"cellNum": 1, "latitude": 1.0, "longitude": 2.0, "importance": 3.0, "contour": "POLYGON EMPTY"}
],
"page": 0,
"size": 500,
"totalItems": 2,
"totalPages": 2
}
""".trimIndent()
),
cellsResponse(
"""
{
"items": [
{"cellNum": 2, "latitude": 4.0, "longitude": 5.0, "importance": 6.0, "contour": "POLYGON EMPTY"}
],
"page": 1,
"size": 500,
"totalItems": 2,
"totalPages": 2
}
""".trimIndent()
),
)
)
val cells = service.cells(0.0).toList()
assertEquals(listOf(1L, 2L), cells.map { cell -> cell.num })
assertEquals(listOf("page=0", "page=1"), requestedUris.map { uri -> uri.query.substringAfter("minImportance=0.0&").substringBefore("&size") })
}
@Test
fun `reqs calls v1 requests with page and size instead of legacy api requests`() {
val requestedUris = mutableListOf<URI>()
val service = earthService(
requestedUris = requestedUris,
responses = mutableListOf(requestsResponse())
)
service.reqs().collectList().block()
assertEquals("/v1/requests", requestedUris.single().path)
assertEquals("page=0&size=500", requestedUris.single().query)
assertFalse(requestedUris.any { uri -> uri.path == "/api/requests" })
}
@Test
fun `reqs unwraps request list items and maps full request geometry to request dto`() {
val requestId = UUID.fromString("11111111-1111-4111-8111-111111111111")
val service = earthService(
responses = mutableListOf(
requestsResponse(
"""
{
"items": [
{
"id": "$requestId",
"name": "summary request name",
"status": "ACCEPTED",
"surveyType": "OPTICS",
"importance": 7.0,
"beginDateTime": "2026-01-01T00:00:00Z",
"endDateTime": "2026-01-02T00:00:00Z",
"kpp": [1, 2],
"highPriorityTransmit": false,
"coveragePercent": 0.0
}
],
"page": 0,
"size": 500,
"totalItems": 1,
"totalPages": 1
}
""".trimIndent()
),
requestDetailsResponse(
"""
{
"id": "$requestId",
"name": "real request name",
"geometry": "POLYGON ((30 10, 40 40, 20 40, 30 10))",
"importance": 7.0,
"status": "ACCEPTED",
"surveyType": "OPTICS"
}
""".trimIndent()
),
)
)
val request = service.reqs().single().block()!!
assertEquals(requestId, request.requestId)
assertEquals("real request name", request.name)
assertFalse(request.name == requestId.toString())
assertEquals(7.0, request.importance)
assertEquals("POLYGON ((30 10, 40 40, 20 40, 30 10))", request.contour)
}
@Test
fun `addRequest posts new v1 create request and returns legacy request dto`() {
val responseId = UUID.fromString("33333333-3333-4333-8333-333333333333")
val responseName = "created request"
val responseImportance = 8.25
val requestBody = AtomicReference<String>()
val requestMethod = AtomicReference<String>()
val requestPath = AtomicReference<String>()
val server = HttpServer.create(InetSocketAddress(0), 0)
server.createContext("/") { exchange ->
requestMethod.set(exchange.requestMethod)
requestPath.set(exchange.requestURI.path)
requestBody.set(exchange.requestBody.readBytes().toString(StandardCharsets.UTF_8))
val response = """
{
"id": "$responseId",
"name": "$responseName",
"status": "ACCEPTED",
"surveyType": "OPTICS",
"importance": $responseImportance,
"message": "Заявка успешно создана"
}
""".trimIndent().toByteArray(StandardCharsets.UTF_8)
exchange.responseHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
exchange.sendResponseHeaders(HttpStatus.CREATED.value(), response.size.toLong())
exchange.responseBody.use { body -> body.write(response) }
}
server.start()
try {
val sourceRequest = RequestDTO(
name = "map request",
importance = 7.5,
contour = "POLYGON ((30 10, 40 40, 20 40, 30 10))",
)
val service = EarthService(provider(WebClient.builder()), url = "http://localhost:${server.address.port}")
val result = service.addRequest(sourceRequest)!!
assertEquals(responseId, result.requestId)
assertEquals(responseImportance, result.importance)
assertEquals(sourceRequest.contour, result.contour)
assertEquals(responseName, result.name)
assertEquals("POST", requestMethod.get())
assertEquals("/v1/requests", requestPath.get())
assertFalse(requestPath.get() == "/api/requests")
val bodyText = assertNotNull(requestBody.get())
val body = objectMapper.readTree(bodyText)
assertNotNull(UUID.fromString(body["id"].asText()))
assertEquals(sourceRequest.name, body["name"].asText())
assertEquals(sourceRequest.contour, body["geometry"].asText())
assertEquals(sourceRequest.importance, body["importance"].asDouble())
assertEquals("2026-01-01T00:00:00Z", body["beginDateTime"].asText())
assertEquals("2026-12-31T23:59:59Z", body["endDateTime"].asText())
assertEquals(0, body["kpp"].size())
assertFalse(body["highPriorityTransmit"].asBoolean())
assertEquals("PANCHROMATIC", body["optics"]["resultType"].asText())
assertEquals(5.0, body["optics"]["resolution"].asDouble())
assertEquals(10.0, body["optics"]["sunAngleMin"].asDouble())
assertEquals(90.0, body["optics"]["sunAngleMax"].asDouble())
assertEquals(100.0, body["optics"]["clouds"].asDouble())
assertTrue(!bodyText.contains("app_type"))
assertTrue(!bodyText.contains("appType"))
assertTrue(!bodyText.contains("AppType"))
assertTrue(!bodyText.contains("requestType"))
} finally {
server.stop(0)
}
}
@Test
fun `reqs fetches all pages and loads full request details for every summary`() {
val firstId = UUID.fromString("11111111-1111-4111-8111-111111111111")
val secondId = UUID.fromString("22222222-2222-4222-8222-222222222222")
val requestedUris = mutableListOf<URI>()
val service = earthService(
requestedUris = requestedUris,
responses = mutableListOf(
requestsResponse(
"""
{
"items": [
{
"id": "$firstId",
"name": "first request",
"status": "ACCEPTED",
"surveyType": "OPTICS",
"importance": 1.0,
"beginDateTime": "2026-01-01T00:00:00Z",
"endDateTime": "2026-01-02T00:00:00Z",
"kpp": [],
"highPriorityTransmit": false
}
],
"page": 0,
"size": 500,
"totalItems": 2,
"totalPages": 2
}
""".trimIndent()
),
requestsResponse(
"""
{
"items": [
{
"id": "$secondId",
"name": "second request",
"status": "ACCEPTED",
"surveyType": "RSA",
"importance": 2.0,
"beginDateTime": "2026-01-03T00:00:00Z",
"endDateTime": "2026-01-04T00:00:00Z",
"kpp": [3],
"highPriorityTransmit": true
}
],
"page": 1,
"size": 500,
"totalItems": 2,
"totalPages": 2
}
""".trimIndent()
),
requestDetailsResponse(
"""
{
"id": "$firstId",
"name": "first request",
"geometry": "POLYGON EMPTY",
"importance": 1.0
}
""".trimIndent()
),
requestDetailsResponse(
"""
{
"id": "$secondId",
"name": "second request",
"geometry": "POLYGON ((0 0, 1 0, 1 1, 0 0))",
"importance": 2.0
}
""".trimIndent()
),
)
)
val requests = service.reqs().collectList().block()!!
assertEquals(listOf(firstId, secondId), requests.map { request -> request.requestId })
assertEquals(
listOf(
"/v1/requests?page=0&size=500",
"/v1/requests?page=1&size=500",
"/v1/requests/$firstId",
"/v1/requests/$secondId",
),
requestedUris.map { uri -> "${uri.path}${uri.query?.let { "?$it" } ?: ""}" }
)
assertFalse(requestedUris.any { uri -> uri.path == "/api/requests" })
}
@Test
fun `reqcells calls v1 request with cells instead of legacy by number endpoint`() {
val requestId = UUID.fromString("11111111-1111-4111-8111-111111111111")
val requestedUris = mutableListOf<URI>()
val service = earthService(
requestedUris = requestedUris,
responses = mutableListOf(requestWithCellsResponse(requestId))
)
service.reqcells(requestId.toString())
assertEquals("/v1/requests/$requestId/with-cells", requestedUris.single().path)
assertFalse(requestedUris.any { uri -> uri.path.startsWith("/api/") })
}
@Test
fun `reqcells maps new wrapper to current request with cells dto`() {
val requestId = UUID.fromString("11111111-1111-4111-8111-111111111111")
val service = earthService(
responses = mutableListOf(
requestWithCellsResponse(
requestId = requestId,
requestName = "request name from service",
requestGeometry = "POLYGON ((30 10, 40 40, 20 40, 30 10))",
requestImportance = 10.0,
cellNum = 123L,
cellImportance = 8.5,
cellContour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))"
)
)
)
val result = service.reqcells(requestId.toString())!!
assertEquals(requestId, result.request.requestId)
assertEquals("request name from service", result.request.name)
assertFalse(result.request.name == requestId.toString())
assertEquals(10.0, result.request.importance)
assertEquals("POLYGON ((30 10, 40 40, 20 40, 30 10))", result.request.contour)
assertEquals(123L, result.cells.single().id)
assertEquals(123L, result.cells.single().num)
assertEquals(8.5, result.cells.single().importance)
assertEquals("POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", result.cells.single().contour)
assertEquals(0.0, result.cells.single().latitude)
assertEquals(0.0, result.cells.single().longitude)
assertFalse(result.cells.single().javaClass.declaredFields.any { field -> field.name == "name" })
}
@Test
fun `reqcells propagates not found response`() {
val requestId = UUID.fromString("11111111-1111-4111-8111-111111111111")
val service = earthService(
responses = mutableListOf(
ClientResponse.create(HttpStatus.NOT_FOUND)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.body("""{"code":"REQUEST_NOT_FOUND","message":"Заявка не найдена"}""")
.build()
)
)
val error = assertFailsWith<WebClientResponseException> {
service.reqcells(requestId.toString())
}
assertEquals(HttpStatus.NOT_FOUND, error.statusCode)
}
private fun earthService(
requestedUris: MutableList<URI> = mutableListOf(),
responses: MutableList<ClientResponse>,
): EarthService {
val builder = WebClient.builder()
.exchangeFunction { request ->
requestedUris += request.url()
Mono.just(responses.removeAt(0))
}
return EarthService(provider(builder), url = "http://pcp-request-service")
}
private fun cellsResponse(
body: String = """
{
"items": [],
"page": 0,
"size": 500,
"totalItems": 0,
"totalPages": 1
}
""".trimIndent(),
): ClientResponse =
ClientResponse.create(HttpStatus.OK)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.body(body)
.build()
private fun requestsResponse(
body: String = """
{
"items": [],
"page": 0,
"size": 500,
"totalItems": 0,
"totalPages": 1
}
""".trimIndent(),
): ClientResponse =
ClientResponse.create(HttpStatus.OK)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.body(body)
.build()
private fun requestDetailsResponse(body: String): ClientResponse =
ClientResponse.create(HttpStatus.OK)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.body(body)
.build()
private fun requestWithCellsResponse(
requestId: UUID,
requestName: String = "request name",
requestGeometry: String = "POLYGON EMPTY",
requestImportance: Double = 1.0,
cellNum: Long = 1L,
cellImportance: Double = 1.0,
cellContour: String = "POLYGON EMPTY",
): ClientResponse =
ClientResponse.create(HttpStatus.OK)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.body(
"""
{
"request": {
"id": "$requestId",
"name": "$requestName",
"status": "ACCEPTED",
"surveyType": "OPTICS",
"geometry": "$requestGeometry",
"importance": $requestImportance,
"beginDateTime": "2026-01-01T00:00:00Z",
"endDateTime": "2026-01-02T00:00:00Z",
"kpp": [],
"highPriorityTransmit": false,
"optics": null,
"rsa": null,
"coverage": {
"requiredPercent": 100.0,
"currentPercent": 0.0
},
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z",
"deletedAt": null
},
"cells": [
{
"cellNum": $cellNum,
"name": "cell name must be ignored",
"coveragePercent": 42.5,
"importance": $cellImportance,
"contour": "$cellContour"
}
]
}
""".trimIndent()
)
.build()
private fun provider(builder: WebClient.Builder): ObjectProvider<WebClient.Builder> =
object : ObjectProvider<WebClient.Builder> {
override fun getObject(vararg args: Any?): WebClient.Builder = builder
override fun getObject(): WebClient.Builder = builder
override fun getIfAvailable(): WebClient.Builder = builder
override fun getIfUnique(): WebClient.Builder = builder
override fun iterator(): MutableIterator<WebClient.Builder> = mutableListOf(builder).iterator()
}
}
@@ -0,0 +1,135 @@
package space.nstart.pcp.slots_service.service
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
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.satellite.SatelliteGroupDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO
import space.nstart.pcp.slots_service.configuration.CustomErrorException
import java.time.LocalDateTime
import kotlin.math.abs
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class GroupStateServiceTest {
private val satelliteCatalogService: SatelliteCatalogService = mock()
private val ballisticsService: BallisticsService = mock()
private val service = GroupStateService(satelliteCatalogService, ballisticsService)
@Test
fun `group summaries calculate availability intersection`() {
doReturn(listOf(primaryGroup(), groupWithoutCommonInterval())).`when`(satelliteCatalogService).allGroups()
doReturn(satelliteSummaries()).`when`(satelliteCatalogService).allSatellites()
doReturn(orbitAvailability()).`when`(ballisticsService).orbitAvailability()
val summaries = service.groupSummaries()
assertEquals(2, summaries.size)
assertNotNull(summaries[0].availableInterval)
assertEquals(LocalDateTime.of(2026, 4, 24, 10, 30), summaries[0].availableInterval?.timeStart)
assertEquals(LocalDateTime.of(2026, 4, 24, 11, 30), summaries[0].availableInterval?.timeStop)
assertNull(summaries[1].availableInterval)
}
@Test
fun `group state rejects time outside available interval`() {
doReturn(primaryGroup()).`when`(satelliteCatalogService).group(71L)
doReturn(satelliteSummaries()).`when`(satelliteCatalogService).allSatellites()
doReturn(orbitAvailability()).`when`(ballisticsService).orbitAvailability()
val exception = assertThrows<CustomErrorException> {
service.groupState(71L, LocalDateTime.of(2026, 4, 24, 10, 15))
}
assertTrue(exception.message!!.contains("не входит в доступный интервал"))
}
@Test
fun `group state builds pairwise matrix from orbital points`() {
val calculationTime = LocalDateTime.of(2026, 4, 24, 10, 45)
doReturn(primaryGroup()).`when`(satelliteCatalogService).group(71L)
doReturn(satelliteSummaries()).`when`(satelliteCatalogService).allSatellites()
doReturn(orbitAvailability()).`when`(ballisticsService).orbitAvailability()
doReturn(firstOrbitalPoint(calculationTime)).`when`(ballisticsService).exactTimePoint(501L, calculationTime)
doReturn(secondOrbitalPoint(calculationTime)).`when`(ballisticsService).exactTimePoint(502L, calculationTime)
val state = service.groupState(71L, calculationTime)
assertEquals(71L, state.groupId)
assertEquals(calculationTime, state.calculationTime)
assertEquals(2, state.satellites.size)
assertEquals(2, state.matrix.size)
state.satellites.forEach { satellite ->
assertTrue(!satellite.omegabDeg.isNaN())
assertTrue(!satellite.uDeg.isNaN())
assertTrue(satellite.omegabDeg in 0.0..360.0)
assertTrue(satellite.uDeg in 0.0..360.0)
}
val firstRow = state.matrix[0]
val secondRow = state.matrix[1]
assertEquals(0.0, firstRow.cells[0].deltaOmegabDeg)
assertEquals(0.0, secondRow.cells[1].deltaUDeg)
assertTrue(abs(firstRow.cells[1].deltaOmegabDeg + secondRow.cells[0].deltaOmegabDeg) < 0.0001)
assertTrue(abs(firstRow.cells[1].deltaUDeg + secondRow.cells[0].deltaUDeg) < 0.0001)
}
private fun primaryGroup() = SatelliteGroupDTO(
id = 71L,
name = "KONDOR",
satelliteIds = listOf(501L, 502L)
)
private fun groupWithoutCommonInterval() = SatelliteGroupDTO(
id = 72L,
name = "NO-INTERSECTION",
satelliteIds = listOf(501L, 503L)
)
private fun satelliteSummaries() = listOf(
SatelliteSummaryDTO(id = 501L, noradId = 56756L, code = "SAT-501", name = "Sat 501"),
SatelliteSummaryDTO(id = 502L, noradId = 62138L, code = "SAT-502", name = "Sat 502"),
SatelliteSummaryDTO(id = 503L, noradId = null, code = "SAT-503", name = "Sat 503")
)
private fun orbitAvailability() = listOf(
SatelliteOrbitAvailabilityDTO(
satelliteId = 501L,
timeStart = LocalDateTime.of(2026, 4, 24, 10, 0),
timeStop = LocalDateTime.of(2026, 4, 24, 12, 0)
),
SatelliteOrbitAvailabilityDTO(
satelliteId = 502L,
timeStart = LocalDateTime.of(2026, 4, 24, 10, 30),
timeStop = LocalDateTime.of(2026, 4, 24, 11, 30)
)
)
private fun firstOrbitalPoint(time: LocalDateTime) = OrbPointDTO(
time = time,
revolution = 1L,
vx = 941.8995373,
vy = 1150.6919822,
vz = 7544.9920840,
x = -5357933.7872,
y = 4328646.1787,
z = 0.0
)
private fun secondOrbitalPoint(time: LocalDateTime) = OrbPointDTO(
time = time,
revolution = 1499L,
vx = -272.89561674108205,
vy = -1460.1016031205972,
vz = 7539.209413654664,
x = 6782429.138044466,
y = -1205586.994919729,
z = 0.0
)
}
@@ -0,0 +1,95 @@
package space.nstart.pcp.slots_service.service
import org.junit.jupiter.api.Test
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import reactor.core.publisher.Flux
import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.KeplersDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteOrbitAvailabilityDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO
import java.time.LocalDateTime
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SatellitePdcmServiceTest {
private val satelliteCatalogService: SatelliteCatalogService = mock()
private val ballisticsService: BallisticsService = mock()
private val service = SatellitePdcmService(satelliteCatalogService, ballisticsService)
@Test
fun `satellite summaries mark active interval by current time`() {
val referenceTime = LocalDateTime.of(2026, 4, 24, 11, 0)
doReturn(satelliteSummaries()).`when`(satelliteCatalogService).allSatellites()
doReturn(orbitAvailability()).`when`(ballisticsService).orbitAvailability()
val summaries = service.satelliteSummaries(referenceTime)
assertEquals(2, summaries.size)
assertEquals(501L, summaries.first().satelliteId)
assertTrue(summaries.first().activeInterval)
assertFalse(summaries.last().activeInterval)
}
@Test
fun `satellite details request asc-node by satellite id and not norad id`() {
val referenceTime = LocalDateTime.of(2026, 4, 24, 11, 0)
val availability = orbitAvailability().first()
doReturn(satelliteSummaries()).`when`(satelliteCatalogService).allSatellites()
doReturn(orbitAvailability()).`when`(ballisticsService).orbitAvailability()
doReturn(
Flux.just(
AscNodeDTO(
time = LocalDateTime.of(2026, 4, 24, 10, 30),
revolution = 120,
long = 37.62,
height = 512.5,
x = 1.0,
y = 2.0,
z = 3.0,
vx = 4.0,
vy = 5.0,
vz = 6.0,
keps = KeplersDTO(
ael = 7000.0,
e = 0.001,
inkl = 97.6,
omega = 123.4,
w = 45.6
),
localMeanTime = LocalDateTime.of(2026, 4, 24, 13, 0)
)
)
).`when`(ballisticsService).ascNodes(501L, availability.timeStart, availability.timeStop)
val details = service.satelliteDetails(501L, referenceTime)
assertEquals(501L, details.satelliteId)
assertTrue(details.activeInterval)
assertEquals(1, details.ascNodes.size)
assertEquals(7000.0, details.ascNodes.first().keps?.ael)
assertEquals(LocalDateTime.of(2026, 4, 24, 13, 0), details.ascNodes.first().localMeanTime)
verify(ballisticsService).ascNodes(501L, availability.timeStart, availability.timeStop)
}
private fun satelliteSummaries() = listOf(
SatelliteSummaryDTO(id = 501L, noradId = 95678L, code = "SAT-501", name = "Sat 501"),
SatelliteSummaryDTO(id = 502L, noradId = 95679L, code = "SAT-502", name = "Sat 502")
)
private fun orbitAvailability() = listOf(
SatelliteOrbitAvailabilityDTO(
satelliteId = 501L,
timeStart = LocalDateTime.of(2026, 4, 24, 10, 0),
timeStop = LocalDateTime.of(2026, 4, 24, 12, 0)
),
SatelliteOrbitAvailabilityDTO(
satelliteId = 502L,
timeStart = LocalDateTime.of(2026, 4, 24, 12, 30),
timeStop = LocalDateTime.of(2026, 4, 24, 14, 0)
)
)
}

Some files were not shown because too many files have changed in this diff Show More