Init
This commit is contained in:
@@ -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"]
|
||||
@@ -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")}")
|
||||
}
|
||||
}
|
||||
+11
@@ -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)
|
||||
}
|
||||
+96
@@ -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)
|
||||
}
|
||||
}
|
||||
+46
@@ -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)
|
||||
}
|
||||
}
|
||||
+14
@@ -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
|
||||
}
|
||||
}
|
||||
+28
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+157
@@ -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()
|
||||
}
|
||||
}
|
||||
+44
@@ -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)
|
||||
}
|
||||
}
|
||||
+47
@@ -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)
|
||||
}
|
||||
+67
@@ -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)
|
||||
}
|
||||
+103
@@ -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()
|
||||
// )
|
||||
// }
|
||||
}
|
||||
+60
@@ -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)
|
||||
}
|
||||
+46
@@ -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()
|
||||
}
|
||||
}
|
||||
+25
@@ -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)
|
||||
}
|
||||
+35
@@ -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()
|
||||
)
|
||||
|
||||
}
|
||||
+21
@@ -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))
|
||||
}
|
||||
+29
@@ -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)
|
||||
) {
|
||||
|
||||
}
|
||||
+33
@@ -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
|
||||
){
|
||||
|
||||
}
|
||||
+22
@@ -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()
|
||||
) {
|
||||
}
|
||||
+28
@@ -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
|
||||
) {
|
||||
}
|
||||
+22
@@ -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))
|
||||
|
||||
}
|
||||
+26
@@ -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"
|
||||
) {
|
||||
}
|
||||
+107
@@ -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
|
||||
}
|
||||
}
|
||||
+23
@@ -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)
|
||||
){
|
||||
|
||||
}
|
||||
+23
@@ -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
|
||||
) {
|
||||
}
|
||||
+30
@@ -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
|
||||
) {
|
||||
}
|
||||
+26
@@ -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
|
||||
){
|
||||
}
|
||||
+21
@@ -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))
|
||||
}
|
||||
+16
@@ -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>
|
||||
)
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package space.nstart.pcp.slots_service.dto.dynamicplan
|
||||
|
||||
enum class DynamicPlanCalculationModeDTO {
|
||||
FULL,
|
||||
GREEDY
|
||||
}
|
||||
+14
@@ -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
|
||||
)
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package space.nstart.pcp.slots_service.dto.dynamicplan
|
||||
|
||||
enum class DynamicPlanRevolutionModeDTO {
|
||||
BOTH,
|
||||
ASC,
|
||||
DESC
|
||||
}
|
||||
+45
@@ -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()
|
||||
)
|
||||
+26
@@ -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()
|
||||
)
|
||||
+119
@@ -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
|
||||
)
|
||||
|
||||
}
|
||||
+81
@@ -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()
|
||||
}
|
||||
}
|
||||
+565
@@ -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
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+167
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
+194
@@ -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
|
||||
)
|
||||
+122
@@ -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)
|
||||
}
|
||||
}
|
||||
+298
@@ -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 = "",
|
||||
)
|
||||
+160
@@ -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
|
||||
}
|
||||
}
|
||||
+116
@@ -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()
|
||||
|
||||
}
|
||||
+187
@@ -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)
|
||||
}
|
||||
}
|
||||
+75
@@ -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
|
||||
)
|
||||
}
|
||||
+285
@@ -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)
|
||||
}
|
||||
}
|
||||
+77
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
})();
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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));
|
||||
})();
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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>Ω</th>
|
||||
<th>ω</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 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>
|
||||
+524
@@ -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()
|
||||
}
|
||||
+597
@@ -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()
|
||||
}
|
||||
}
|
||||
+166
@@ -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()
|
||||
}
|
||||
+150
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+144
@@ -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)
|
||||
}
|
||||
}
|
||||
+93
@@ -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)
|
||||
}
|
||||
}
|
||||
+117
@@ -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) }
|
||||
}
|
||||
}
|
||||
+165
@@ -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()
|
||||
}
|
||||
}
|
||||
+212
@@ -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) }
|
||||
}
|
||||
}
|
||||
+543
@@ -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()
|
||||
}
|
||||
}
|
||||
+135
@@ -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
|
||||
)
|
||||
}
|
||||
+95
@@ -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
Reference in New Issue
Block a user