diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/entities/lastPositions/LastPositionEntity.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/entities/lastPositions/LastPositionEntity.kt new file mode 100644 index 0000000000..e6d5f6d464 --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/entities/lastPositions/LastPositionEntity.kt @@ -0,0 +1,17 @@ +package fr.gouv.cacem.monitorenv.domain.entities.lastPositions + +import org.locationtech.jts.geom.Point +import java.time.ZonedDateTime + +data class LastPositionEntity( + val course: Double?, + val destination: String?, + val geom: Point?, + val heading: Double?, + val id: Int, + val mmsi: Int?, + val status: String?, + val speed: Double?, + val shipname: String?, + val timestamp: ZonedDateTime?, +) diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/entities/vessels/Vessel.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/entities/vessels/VesselEntity.kt similarity index 83% rename from backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/entities/vessels/Vessel.kt rename to backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/entities/vessels/VesselEntity.kt index c587adae9f..d65cdf4abf 100644 --- a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/entities/vessels/Vessel.kt +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/entities/vessels/VesselEntity.kt @@ -1,23 +1,19 @@ package fr.gouv.cacem.monitorenv.domain.entities.vessels +import fr.gouv.cacem.monitorenv.domain.entities.lastPositions.LastPositionEntity import java.math.BigDecimal -data class Vessel( - val id: Int, - val shipId: Int, - val status: String?, +data class VesselEntity( val category: String?, + val commercialName: String?, + val flag: String?, + val id: Int, val isBanned: Boolean, val imo: String?, - val mmsi: String?, val immatriculation: String?, - val shipName: String?, - val flag: String?, - val portOfRegistry: String?, - val professionalType: String?, val leisureType: String?, - val commercialName: String?, val length: BigDecimal?, + val mmsi: String?, val ownerLastName: String?, val ownerFirstName: String?, val ownerDateOfBirth: String?, @@ -31,4 +27,10 @@ data class Vessel( val ownerLegalStatus: String?, val ownerLegalStatusLabel: String?, val ownerStartDate: String?, + val portOfRegistry: String?, + val professionalType: String?, + val shipId: Int?, + val shipName: String?, + val status: String?, + val lastPositions: MutableList, ) diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/repositories/ILastPositionRepository.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/repositories/ILastPositionRepository.kt new file mode 100644 index 0000000000..67e6b99545 --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/repositories/ILastPositionRepository.kt @@ -0,0 +1,7 @@ +package fr.gouv.cacem.monitorenv.domain.repositories + +import fr.gouv.cacem.monitorenv.domain.entities.lastPositions.LastPositionEntity + +interface ILastPositionRepository { + fun findAll(shipId: Int): List +} diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/repositories/IVesselRepository.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/repositories/IVesselRepository.kt index 614f0b0d93..b94de58e43 100644 --- a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/repositories/IVesselRepository.kt +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/repositories/IVesselRepository.kt @@ -1,9 +1,9 @@ package fr.gouv.cacem.monitorenv.domain.repositories -import fr.gouv.cacem.monitorenv.domain.entities.vessels.Vessel +import fr.gouv.cacem.monitorenv.domain.entities.vessels.VesselEntity interface IVesselRepository { - fun findVesselById(id: Int): Vessel? + fun findVesselById(id: Int): VesselEntity? - fun search(searched: String): List + fun search(searched: String): List } diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/lastPositions/GetLastPositions.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/lastPositions/GetLastPositions.kt new file mode 100644 index 0000000000..7d3f00419d --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/lastPositions/GetLastPositions.kt @@ -0,0 +1,12 @@ +package fr.gouv.cacem.monitorenv.domain.use_cases.lastPositions + +import fr.gouv.cacem.monitorenv.config.UseCase +import fr.gouv.cacem.monitorenv.domain.entities.lastPositions.LastPositionEntity +import fr.gouv.cacem.monitorenv.domain.repositories.ILastPositionRepository + +@UseCase +class GetLastPositions( + private val lastPositionRepository: ILastPositionRepository, +) { + fun execute(shipId: Int): List = lastPositionRepository.findAll(shipId) +} diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/GetVesselById.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/GetVesselById.kt index 3abb01021a..f5f6dd6b0e 100644 --- a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/GetVesselById.kt +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/GetVesselById.kt @@ -1,21 +1,27 @@ package fr.gouv.cacem.monitorenv.domain.use_cases.vessels import fr.gouv.cacem.monitorenv.config.UseCase -import fr.gouv.cacem.monitorenv.domain.entities.vessels.Vessel +import fr.gouv.cacem.monitorenv.domain.entities.vessels.VesselEntity import fr.gouv.cacem.monitorenv.domain.exceptions.BackendUsageErrorCode import fr.gouv.cacem.monitorenv.domain.exceptions.BackendUsageException +import fr.gouv.cacem.monitorenv.domain.repositories.ILastPositionRepository import fr.gouv.cacem.monitorenv.domain.repositories.IVesselRepository import org.slf4j.LoggerFactory @UseCase class GetVesselById( private val vesselRepository: IVesselRepository, + private val lastPositionRepository: ILastPositionRepository, ) { private val logger = LoggerFactory.getLogger(GetVesselById::class.java) - fun execute(id: Int): Vessel { + fun execute(id: Int): VesselEntity { vesselRepository.findVesselById(id)?.let { vessel -> logger.info("GET vessel ${vessel.id}") + vessel.shipId?.let { shipId -> + val lastPositions = lastPositionRepository.findAll(shipId) + vessel.lastPositions.addAll(lastPositions) + } return vessel } throw BackendUsageException( diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/SearchVessels.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/SearchVessels.kt index 0cefcef942..c59ed3287e 100644 --- a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/SearchVessels.kt +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/SearchVessels.kt @@ -1,12 +1,12 @@ package fr.gouv.cacem.monitorenv.domain.use_cases.vessels import fr.gouv.cacem.monitorenv.config.UseCase -import fr.gouv.cacem.monitorenv.domain.entities.vessels.Vessel +import fr.gouv.cacem.monitorenv.domain.entities.vessels.VesselEntity import fr.gouv.cacem.monitorenv.domain.repositories.IVesselRepository @UseCase class SearchVessels( private val vesselRepository: IVesselRepository, ) { - fun execute(searched: String): List = vesselRepository.search(searched) + fun execute(searched: String): List = vesselRepository.search(searched) } diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/adapters/bff/outputs/lastPositions/LastPositionOutput.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/adapters/bff/outputs/lastPositions/LastPositionOutput.kt new file mode 100644 index 0000000000..11e559f420 --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/adapters/bff/outputs/lastPositions/LastPositionOutput.kt @@ -0,0 +1,34 @@ +package fr.gouv.cacem.monitorenv.infrastructure.api.adapters.bff.outputs.lastPositions + +import fr.gouv.cacem.monitorenv.domain.entities.lastPositions.LastPositionEntity +import org.locationtech.jts.geom.Point +import java.time.ZonedDateTime + +data class LastPositionOutput( + val course: Double?, + val destination: String?, + val geom: Point?, + val heading: Double?, + val id: Int, + val mmsi: Int?, + val shipname: String?, + val status: String?, + val speed: Double?, + val timestamp: ZonedDateTime?, +) { + companion object { + fun toLastPositionOutput(lastPosition: LastPositionEntity) = + LastPositionOutput( + course = lastPosition.course, + destination = lastPosition.destination, + geom = lastPosition.geom, + heading = lastPosition.heading, + id = lastPosition.id, + mmsi = lastPosition.mmsi, + shipname = lastPosition.shipname, + status = lastPosition.status, + speed = lastPosition.speed, + timestamp = lastPosition.timestamp, + ) + } +} diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/adapters/bff/outputs/vessels/VesselDataOutput.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/adapters/bff/outputs/vessels/VesselDataOutput.kt index c56879ab5c..256a998653 100644 --- a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/adapters/bff/outputs/vessels/VesselDataOutput.kt +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/adapters/bff/outputs/vessels/VesselDataOutput.kt @@ -1,6 +1,7 @@ package fr.gouv.cacem.monitorenv.infrastructure.api.adapters.bff.outputs.vessels -import fr.gouv.cacem.monitorenv.domain.entities.vessels.Vessel +import fr.gouv.cacem.monitorenv.domain.entities.vessels.VesselEntity +import fr.gouv.cacem.monitorenv.infrastructure.api.adapters.bff.outputs.lastPositions.LastPositionOutput import java.math.BigDecimal data class VesselDataOutput( @@ -28,9 +29,10 @@ data class VesselDataOutput( val ownerBusinessSegmentLabel: String?, val ownerLegalStatusLabel: String?, val ownerStartDate: String?, + val lastPositions: List, ) { companion object { - fun fromVessel(vessel: Vessel): VesselDataOutput = + fun fromVessel(vessel: VesselEntity): VesselDataOutput = VesselDataOutput( id = vessel.id, status = vessel.status, @@ -44,6 +46,7 @@ data class VesselDataOutput( leisureType = vessel.leisureType, professionalType = vessel.professionalType, commercialName = vessel.commercialName, + lastPositions = vessel.lastPositions.map { LastPositionOutput.toLastPositionOutput(it) }, length = vessel.length, ownerLastName = vessel.ownerLastName, ownerFirstName = vessel.ownerFirstName, diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/adapters/bff/outputs/vessels/VesselIdentityDataOutput.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/adapters/bff/outputs/vessels/VesselIdentityDataOutput.kt index ad69c3d7b1..cb97c621d1 100644 --- a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/adapters/bff/outputs/vessels/VesselIdentityDataOutput.kt +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/adapters/bff/outputs/vessels/VesselIdentityDataOutput.kt @@ -1,20 +1,22 @@ package fr.gouv.cacem.monitorenv.infrastructure.api.adapters.bff.outputs.vessels -import fr.gouv.cacem.monitorenv.domain.entities.vessels.Vessel +import fr.gouv.cacem.monitorenv.domain.entities.vessels.VesselEntity data class VesselIdentityDataOutput( - val id: Int, + val category: String?, val flag: String?, - val mmsi: String?, + val id: Int, val imo: String?, val immatriculation: String?, + val mmsi: String?, + val shipId: Int?, val shipName: String?, - val category: String?, ) { companion object { - fun fromVessel(vessel: Vessel): VesselIdentityDataOutput = + fun fromVessel(vessel: VesselEntity): VesselIdentityDataOutput = VesselIdentityDataOutput( id = vessel.id, + shipId = vessel.shipId, flag = vessel.flag, mmsi = vessel.mmsi, imo = vessel.imo, diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/endpoints/bff/v1/LastPositions.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/endpoints/bff/v1/LastPositions.kt new file mode 100644 index 0000000000..7cc3fce28a --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/endpoints/bff/v1/LastPositions.kt @@ -0,0 +1,27 @@ +package fr.gouv.cacem.monitorenv.infrastructure.api.endpoints.bff.v1 + +import fr.gouv.cacem.monitorenv.domain.use_cases.lastPositions.GetLastPositions +import fr.gouv.cacem.monitorenv.infrastructure.api.adapters.bff.outputs.lastPositions.LastPositionOutput +import fr.gouv.cacem.monitorenv.infrastructure.api.adapters.bff.outputs.lastPositions.LastPositionOutput.Companion.toLastPositionOutput +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.websocket.server.PathParam +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.RestController + +@RestController +@RequestMapping("/bff/v1/last_positions") +@Tag(name = "APIs for Vessel's last positions") +class LastPositions( + private val getLastPositions: GetLastPositions, +) { + @GetMapping("/{shipId}") + @Operation(summary = "Get last position of vessel by ship id") + fun getLastPositionsByShipId( + @PathParam("Ship ID") + @PathVariable(name = "shipId") + shipId: Int, + ): List = getLastPositions.execute(shipId).map { toLastPositionOutput(it) } +} diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/model/AISPositionModel.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/model/AISPositionModel.kt index 6b21e6a5b8..774f2cc49a 100644 --- a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/model/AISPositionModel.kt +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/model/AISPositionModel.kt @@ -27,17 +27,59 @@ data class AISPositionModel( val course: Short?, val heading: Short?, val speed: Short?, + val imo: String?, + val callsign: String?, + val shipname: String?, + val shiptype: Int?, + val toBow: Short?, + val toStern: Short?, + val toPort: Short?, + val toStarboard: Short?, + val draught: Short?, + val destination: String?, ) { companion object { fun toAISPositionModel(aisPosition: AISPayload): AISPositionModel = AISPositionModel( - id = AISPositionPK(mmsi = aisPosition.mmsi, ts = aisPosition.ts), + id = AISPositionPK(mmsi = aisPosition.mmsi, ts = aisPosition.features?.ais?.ts), coord = aisPosition.coord.let { WKTReader().read(it) }, status = aisPosition.status, - course = aisPosition.course?.let { (it * 100).roundToInt().toShort() }, - speed = aisPosition.speed?.let { (it * 100).roundToInt().toShort() }, - heading = aisPosition.heading?.let { (it * 100).roundToInt().toShort() }, + course = aisPosition.course?.let(toShort()), + speed = aisPosition.speed?.let(toShort()), + heading = aisPosition.heading?.let(toShort()), + imo = aisPosition.features?.ais?.imo, + callsign = aisPosition.features?.ais?.callsign, + shipname = aisPosition.features?.ais?.shipname, + shiptype = aisPosition.features?.ais?.shiptype, + toBow = + aisPosition.features + ?.ais + ?.toBow + ?.let(toShort()), + toStern = + aisPosition.features + ?.ais + ?.toStern + ?.let(toShort()), + toPort = + aisPosition.features + ?.ais + ?.toPort + ?.let(toShort()), + toStarboard = + aisPosition.features + ?.ais + ?.toStarboard + ?.let(toShort()), + draught = + aisPosition.features + ?.ais + ?.draught + ?.let(toShort()), + destination = aisPosition.features?.ais?.destination, ) + + private fun toShort(): (Double) -> Short = { (it * 100).roundToInt().toShort() } } } diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/model/LastPositionModel.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/model/LastPositionModel.kt new file mode 100644 index 0000000000..dc0134d3d4 --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/model/LastPositionModel.kt @@ -0,0 +1,47 @@ +package fr.gouv.cacem.monitorenv.infrastructure.database.model + +import fr.gouv.cacem.monitorenv.domain.entities.lastPositions.LastPositionEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.locationtech.jts.geom.Point +import java.time.ZonedDateTime + +@Entity +@Table(name = "last_positions") +data class LastPositionModel( + val course: Short?, + val destination: String?, + @Id + val id: Int, + @Column(name = "coord") + val geom: Point?, + val heading: Short?, + val mmsi: Int?, + @Column(name = "vessel_id") + val shipId: Int, + val shipname: String?, + val speed: Short?, + val status: String?, + @Column(name = "ts") + val timestamp: ZonedDateTime?, +) { + fun toLastPosition(): LastPositionEntity = + LastPositionEntity( + course = course?.let(toDouble()), + destination = destination, + id = id, + geom = geom, + heading = heading?.let(toDouble()), + mmsi = mmsi, + shipname = shipname, + status = status, + speed = speed?.let(toDouble()), + timestamp = timestamp, + ) + + companion object { + private fun toDouble(): (Short) -> Double = { (it.toDouble() / 100) } + } +} diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/model/VesselModel.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/model/VesselModel.kt index 4e87ca8546..dec49a1dca 100644 --- a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/model/VesselModel.kt +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/model/VesselModel.kt @@ -1,6 +1,6 @@ package fr.gouv.cacem.monitorenv.infrastructure.database.model -import fr.gouv.cacem.monitorenv.domain.entities.vessels.Vessel +import fr.gouv.cacem.monitorenv.domain.entities.vessels.VesselEntity import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.Id @@ -10,25 +10,21 @@ import java.math.BigDecimal @Entity @Table(name = "latest_vessels") data class VesselModel( + val batchId: Int?, + val category: String?, + val commercialName: String?, + val flag: String?, @Id val id: Int, - val shipId: Int, - val status: String?, - val category: String?, val isBanned: Boolean, @Column(name = "imo_number") val imo: String?, - @Column(name = "mmsi_number") - val mmsi: String?, val immatriculation: String?, - val shipName: String?, - val flag: String?, - val portOfRegistry: String?, - val professionalType: String?, val leisureType: String?, - val commercialName: String?, @Column(precision = 5, scale = 2) val length: BigDecimal?, + @Column(name = "mmsi_number") + val mmsi: String?, val ownerLastName: String?, val ownerFirstName: String?, val ownerDateOfBirth: String?, @@ -40,14 +36,18 @@ data class VesselModel( val ownerBusinessSegment: String?, val ownerLegalStatus: String?, val ownerStartDate: String?, - val batchId: Int?, + val portOfRegistry: String?, + val professionalType: String?, val rowNumber: Int?, + val shipId: Int?, + val shipName: String?, + val status: String?, ) { fun toVessel( nafLabel: String? = null, legalStatusLabel: String? = null, - ): Vessel = - Vessel( + ): VesselEntity = + VesselEntity( id = id, shipId = shipId, status = status, @@ -76,5 +76,6 @@ data class VesselModel( ownerLegalStatusLabel = legalStatusLabel, ownerLegalStatus = ownerLegalStatus, ownerStartDate = ownerStartDate, + lastPositions = mutableListOf(), ) } diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/repositories/JpaLastPositionRepository.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/repositories/JpaLastPositionRepository.kt new file mode 100644 index 0000000000..1f49e049d2 --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/repositories/JpaLastPositionRepository.kt @@ -0,0 +1,18 @@ +package fr.gouv.cacem.monitorenv.infrastructure.database.repositories + +import fr.gouv.cacem.monitorenv.domain.entities.lastPositions.LastPositionEntity +import fr.gouv.cacem.monitorenv.domain.repositories.ILastPositionRepository +import fr.gouv.cacem.monitorenv.infrastructure.database.repositories.interfaces.IDBLastPositionRepository +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Repository + +@Repository +class JpaLastPositionRepository( + private val dbLastPositionRepository: IDBLastPositionRepository, +) : ILastPositionRepository { + private val logger: Logger = LoggerFactory.getLogger(JpaLastPositionRepository::class.java) + + override fun findAll(shipId: Int): List = + dbLastPositionRepository.findAllByShipIdOrderByTimestampDesc(shipId = shipId).map { it.toLastPosition() } +} diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/repositories/JpaVesselRepository.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/repositories/JpaVesselRepository.kt index 3624014294..6803f9c067 100644 --- a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/repositories/JpaVesselRepository.kt +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/repositories/JpaVesselRepository.kt @@ -1,6 +1,6 @@ package fr.gouv.cacem.monitorenv.infrastructure.database.repositories -import fr.gouv.cacem.monitorenv.domain.entities.vessels.Vessel +import fr.gouv.cacem.monitorenv.domain.entities.vessels.VesselEntity import fr.gouv.cacem.monitorenv.domain.repositories.IVesselRepository import fr.gouv.cacem.monitorenv.infrastructure.database.repositories.interfaces.IDBLegalStatusRepository import fr.gouv.cacem.monitorenv.infrastructure.database.repositories.interfaces.IDBNafRepository @@ -18,7 +18,7 @@ class JpaVesselRepository( ) : IVesselRepository { private val logger: Logger = LoggerFactory.getLogger(JpaVesselRepository::class.java) - override fun findVesselById(id: Int): Vessel? = + override fun findVesselById(id: Int): VesselEntity? = dbVesselRepository.findByIdOrNull(id)?.let { val nafLabel = if (!it.ownerBusinessSegment.isNullOrBlank()) { @@ -35,7 +35,7 @@ class JpaVesselRepository( return@let it.toVessel(nafLabel = nafLabel, legalStatusLabel = legalStatusLabel) } - override fun search(searched: String): List { + override fun search(searched: String): List { if (searched.isEmpty()) { return listOf() } diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/repositories/interfaces/IDBLastPositionRepository.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/repositories/interfaces/IDBLastPositionRepository.kt new file mode 100644 index 0000000000..6dbae811ac --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/repositories/interfaces/IDBLastPositionRepository.kt @@ -0,0 +1,8 @@ +package fr.gouv.cacem.monitorenv.infrastructure.database.repositories.interfaces + +import fr.gouv.cacem.monitorenv.infrastructure.database.model.LastPositionModel +import org.springframework.data.jpa.repository.JpaRepository + +interface IDBLastPositionRepository : JpaRepository { + fun findAllByShipIdOrderByTimestampDesc(shipId: Int): List +} diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/repositories/interfaces/IDBVesselRepository.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/repositories/interfaces/IDBVesselRepository.kt index f8287272ab..a9c05c0ec0 100644 --- a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/repositories/interfaces/IDBVesselRepository.kt +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/repositories/interfaces/IDBVesselRepository.kt @@ -1,11 +1,11 @@ package fr.gouv.cacem.monitorenv.infrastructure.database.repositories.interfaces import fr.gouv.cacem.monitorenv.infrastructure.database.model.VesselModel +import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query -import org.springframework.data.repository.CrudRepository import org.springframework.data.repository.query.Param -interface IDBVesselRepository : CrudRepository { +interface IDBVesselRepository : JpaRepository { @Query( value = """ diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/AISProducer.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/AISProducer.kt index 98124477a8..ba84c4dbcd 100644 --- a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/AISProducer.kt +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/AISProducer.kt @@ -47,6 +47,7 @@ class AISProducer( heading = Random.nextDouble(), speed = Random.nextDouble(), ts = ZonedDateTime.now(), + features = null, ), ) } catch (ex: Exception) { diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/adapters/AISMessage.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/adapters/AISMessage.kt new file mode 100644 index 0000000000..d38959d1df --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/adapters/AISMessage.kt @@ -0,0 +1,17 @@ +package fr.gouv.cacem.monitorenv.infrastructure.kafka.adapters + +import java.time.ZonedDateTime + +class AISMessage( + val imo: String?, + val callsign: String?, + val shipname: String?, + val shiptype: Int?, + val toBow: Double?, + val toStern: Double?, + val toPort: Double?, + val toStarboard: Double?, + val draught: Double?, + val destination: String?, + val ts: ZonedDateTime?, +) diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/adapters/AISPayload.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/adapters/AISPayload.kt index 31bc1c2dbd..ac47f36a00 100644 --- a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/adapters/AISPayload.kt +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/adapters/AISPayload.kt @@ -3,11 +3,12 @@ package fr.gouv.cacem.monitorenv.infrastructure.kafka.adapters import java.time.ZonedDateTime class AISPayload( - val mmsi: Int?, val coord: String?, - val status: String?, val course: Double?, + val features: Feature?, val heading: Double?, + val mmsi: Int?, val speed: Double?, + val status: String?, val ts: ZonedDateTime?, ) diff --git a/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/adapters/Feature.kt b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/adapters/Feature.kt new file mode 100644 index 0000000000..844bde5971 --- /dev/null +++ b/backend/src/main/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/adapters/Feature.kt @@ -0,0 +1,5 @@ +package fr.gouv.cacem.monitorenv.infrastructure.kafka.adapters + +class Feature( + val ais: AISMessage?, +) diff --git a/backend/src/main/resources/db/migration/internal/V0.207__add_columns_ais_position.sql b/backend/src/main/resources/db/migration/internal/V0.207__add_columns_ais_position.sql new file mode 100644 index 0000000000..bfabfb5545 --- /dev/null +++ b/backend/src/main/resources/db/migration/internal/V0.207__add_columns_ais_position.sql @@ -0,0 +1,23 @@ +ALTER TABLE public.last_positions + ADD COLUMN callsign varchar(100), + ADD COLUMN imo varchar(100), + ADD COLUMN to_bow SMALLINT, + ADD COLUMN to_stern SMALLINT, + ADD COLUMN to_port SMALLINT, + ADD COLUMN to_starboard SMALLINT, + ADD COLUMN draught SMALLINT, + ADD COLUMN destination varchar(100), + ADD COLUMN shipname varchar(100), + ADD COLUMN shiptype INTEGER; + +ALTER TABLE public.ais_positions + ADD COLUMN callsign varchar(100), + ADD COLUMN imo varchar(100), + ADD COLUMN to_bow SMALLINT, + ADD COLUMN to_stern SMALLINT, + ADD COLUMN to_port SMALLINT, + ADD COLUMN to_starboard SMALLINT, + ADD COLUMN draught SMALLINT, + ADD COLUMN destination varchar(100), + ADD COLUMN shipname varchar(100), + ADD COLUMN shiptype INTEGER; \ No newline at end of file diff --git a/backend/src/main/resources/db/testdata/V666.26__insert_last_positions.sql b/backend/src/main/resources/db/testdata/V666.26__insert_last_positions.sql new file mode 100644 index 0000000000..2665fc6148 --- /dev/null +++ b/backend/src/main/resources/db/testdata/V666.26__insert_last_positions.sql @@ -0,0 +1,4 @@ +INSERT INTO public.last_positions(id, mmsi, vessel_id, coord, status, course, heading, speed, ts, destination, shipname) +VALUES (nextval('last_positions_id_seq'), '123456789', '11', 'POINT (-003.2196 47.6400)', 'STATUS_1', + 3020, 0, + 2010, now(), 'BRE', 'SHIPNAME 1'); \ No newline at end of file diff --git a/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/GetVesselByIdUTest.kt b/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/GetVesselByIdUTest.kt index a683668585..bc876371cf 100644 --- a/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/GetVesselByIdUTest.kt +++ b/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/GetVesselByIdUTest.kt @@ -4,33 +4,56 @@ import com.nhaarman.mockitokotlin2.given import com.nhaarman.mockitokotlin2.mock import fr.gouv.cacem.monitorenv.domain.exceptions.BackendUsageErrorCode import fr.gouv.cacem.monitorenv.domain.exceptions.BackendUsageException +import fr.gouv.cacem.monitorenv.domain.repositories.ILastPositionRepository import fr.gouv.cacem.monitorenv.domain.repositories.IVesselRepository +import fr.gouv.cacem.monitorenv.domain.use_cases.vessels.fixtures.LastPositionFixture import fr.gouv.cacem.monitorenv.domain.use_cases.vessels.fixtures.VesselFixture.Companion.aVessel import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mockito.verifyNoInteractions import org.springframework.boot.test.system.CapturedOutput import org.springframework.boot.test.system.OutputCaptureExtension @ExtendWith(OutputCaptureExtension::class) class GetVesselByIdUTest { private val vesselRepository: IVesselRepository = mock() + private val lastPositionRepository: ILastPositionRepository = mock() - val getVesselById = GetVesselById(vesselRepository) + val getVesselById = GetVesselById(vesselRepository, lastPositionRepository) @Test - fun `execute should retrieve a vessel by its id`(log: CapturedOutput) { + fun `execute should retrieve a vessel by id and last positions by shipId`(log: CapturedOutput) { // Given val vesselId = 1 val expectedVessel = aVessel() given(vesselRepository.findVesselById(vesselId)).willReturn(expectedVessel) + val lastPositions = mutableListOf(LastPositionFixture.aLastPosition()) + given(lastPositionRepository.findAll(expectedVessel.shipId!!)).willReturn(lastPositions) // When val vessel = getVesselById.execute(vesselId) // Then + assertThat(vessel).isEqualTo(expectedVessel.copy(lastPositions = lastPositions)) + assertThat(log.out).contains("GET vessel $vesselId") + } + + @Test + fun `execute should retrieve a vessel by id and not last positions by shipId when it is null`(log: CapturedOutput) { + // Given + val vesselId = 1 + + val expectedVessel = aVessel(shipId = null) + given(vesselRepository.findVesselById(vesselId)).willReturn(expectedVessel) + + // When + val vessel = getVesselById.execute(vesselId) + + // Then + verifyNoInteractions(lastPositionRepository) assertThat(vessel).isEqualTo(expectedVessel) assertThat(log.out).contains("GET vessel $vesselId") } diff --git a/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/fixtures/LastPositionFixture.kt b/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/fixtures/LastPositionFixture.kt new file mode 100644 index 0000000000..c25abeb3e1 --- /dev/null +++ b/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/fixtures/LastPositionFixture.kt @@ -0,0 +1,21 @@ +package fr.gouv.cacem.monitorenv.domain.use_cases.vessels.fixtures + +import fr.gouv.cacem.monitorenv.domain.entities.lastPositions.LastPositionEntity + +class LastPositionFixture { + companion object { + fun aLastPosition() = + LastPositionEntity( + course = null, + destination = null, + geom = null, + heading = null, + id = 1, + mmsi = null, + speed = null, + status = null, + timestamp = null, + shipname = null, + ) + } +} diff --git a/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/fixtures/VesselFixture.kt b/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/fixtures/VesselFixture.kt index 48ff14c005..d26ba7c07a 100644 --- a/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/fixtures/VesselFixture.kt +++ b/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/domain/use_cases/vessels/fixtures/VesselFixture.kt @@ -1,13 +1,13 @@ package fr.gouv.cacem.monitorenv.domain.use_cases.vessels.fixtures -import fr.gouv.cacem.monitorenv.domain.entities.vessels.Vessel +import fr.gouv.cacem.monitorenv.domain.entities.vessels.VesselEntity class VesselFixture { companion object { - fun aVessel() = - Vessel( + fun aVessel(shipId: Int? = 1): VesselEntity = + VesselEntity( id = 1, - shipId = 1, + shipId = shipId, status = null, category = null, isBanned = false, @@ -34,6 +34,7 @@ class VesselFixture { ownerLegalStatus = null, ownerLegalStatusLabel = null, ownerStartDate = null, + lastPositions = mutableListOf(), ) } } diff --git a/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/endpoints/bff/v1/VesselsITest.kt b/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/endpoints/bff/v1/VesselsITest.kt index 113a9aba0b..70c64194cb 100644 --- a/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/endpoints/bff/v1/VesselsITest.kt +++ b/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/infrastructure/api/endpoints/bff/v1/VesselsITest.kt @@ -48,6 +48,7 @@ class VesselsITest { .andExpect(status().isOk) .andExpect(jsonPath("$.length()", equalTo(1))) .andExpect(jsonPath("$[0].id", equalTo(vessel.id))) + .andExpect(jsonPath("$[0].shipId", equalTo(vessel.shipId))) .andExpect(jsonPath("$[0].flag", equalTo(vessel.flag))) .andExpect(jsonPath("$[0].mmsi", equalTo(vessel.mmsi))) .andExpect(jsonPath("$[0].imo", equalTo(vessel.imo))) @@ -98,7 +99,6 @@ class VesselsITest { fun `Should return 404 when vessel is not found`() { // Given val id = 1 - val vessel = aVessel() given(getVesselById.execute(id)).willThrow(BackendUsageException(BackendUsageErrorCode.ENTITY_NOT_FOUND)) // When diff --git a/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/repositories/JpaLastPositionRepositoryTest.kt b/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/repositories/JpaLastPositionRepositoryTest.kt new file mode 100644 index 0000000000..e12da6e3b9 --- /dev/null +++ b/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/infrastructure/database/repositories/JpaLastPositionRepositoryTest.kt @@ -0,0 +1,34 @@ +package fr.gouv.cacem.monitorenv.infrastructure.database.repositories + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired + +class JpaLastPositionRepositoryTest : AbstractDBTests() { + @Autowired + private lateinit var jpaLastPositionRepository: JpaLastPositionRepository + + @Test + fun `findAll should return all last positions from shipId`() { + // Given + val shipId = 11 + + // When + val lastPositions = jpaLastPositionRepository.findAll(shipId) + + // Then + assertThat(lastPositions).hasSize(1) + } + + @Test + fun `findAll should return empty from shipId when shipIp is unknown`() { + // Given + val shipId = 999999 + + // When + val lastPositions = jpaLastPositionRepository.findAll(shipId) + + // Then + assertThat(lastPositions).isEmpty() + } +} diff --git a/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/AISListenerITests.kt b/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/AISListenerITests.kt index ca41f185a5..b21d382ad1 100644 --- a/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/AISListenerITests.kt +++ b/backend/src/test/kotlin/fr/gouv/cacem/monitorenv/infrastructure/kafka/AISListenerITests.kt @@ -4,7 +4,9 @@ import fr.gouv.cacem.monitorenv.config.KafkaAISProperties import fr.gouv.cacem.monitorenv.infrastructure.database.model.AISPositionPK import fr.gouv.cacem.monitorenv.infrastructure.database.repositories.AbstractKafkaTests import fr.gouv.cacem.monitorenv.infrastructure.database.repositories.interfaces.IDBAISPositionRepository +import fr.gouv.cacem.monitorenv.infrastructure.kafka.adapters.AISMessage import fr.gouv.cacem.monitorenv.infrastructure.kafka.adapters.AISPayload +import fr.gouv.cacem.monitorenv.infrastructure.kafka.adapters.Feature import jakarta.transaction.Transactional import org.assertj.core.api.Assertions.assertThat import org.awaitility.Awaitility @@ -42,7 +44,24 @@ class AISListenerITests : AbstractKafkaTests() { course = 12.12, heading = 10.12, speed = 10.12, - ts = ts, + ts = null, + features = + Feature( + ais = + AISMessage( + imo = "IMO", + callsign = "CALLSIGN", + shipname = "SHIPNAME", + shiptype = 1, + toBow = 2.0, + toStern = 1.0, + toPort = 0.0, + toStarboard = 20.00, + draught = 99.99, + destination = "BRE", + ts = ts, + ), + ), ) kafkaTemplate.send(kafkaAISProperties.topic, aisPosition).get(10, TimeUnit.SECONDS) @@ -55,12 +74,21 @@ class AISListenerITests : AbstractKafkaTests() { val saved = dbAISPositionRepository.findByIdOrNull(AISPositionPK(mmsi = mmsi, ts = ts)) assertThat(saved).isNotNull() assertThat(saved?.id?.mmsi).isEqualTo(aisPosition.mmsi) - assertThat(saved?.id?.ts).isEqualTo(aisPosition.ts) + assertThat(saved?.id?.ts).isEqualTo(aisPosition.features?.ais?.ts) assertThat(saved?.coord).isEqualTo(WKTReader().read(coord) as Point) assertThat(saved?.status).isEqualTo(aisPosition.status) assertThat(saved?.course).isEqualTo(1212) assertThat(saved?.heading).isEqualTo(1012) - assertThat(saved?.speed).isEqualTo(1012) + assertThat(saved?.imo).isEqualTo("IMO") + assertThat(saved?.callsign).isEqualTo("CALLSIGN") + assertThat(saved?.shipname).isEqualTo("SHIPNAME") + assertThat(saved?.shiptype).isEqualTo(1) + assertThat(saved?.toBow).isEqualTo(200) + assertThat(saved?.toStern).isEqualTo(100) + assertThat(saved?.toPort).isEqualTo(0) + assertThat(saved?.toStarboard).isEqualTo(2000) + assertThat(saved?.draught).isEqualTo(9999) + assertThat(saved?.destination).isEqualTo("BRE") } } } diff --git a/frontend/cypress/e2e/main_window/vessel/vessel-search.spec.ts b/frontend/cypress/e2e/main_window/vessel/vessel-search.spec.ts index 40404cead2..6b9dd9ee4f 100644 --- a/frontend/cypress/e2e/main_window/vessel/vessel-search.spec.ts +++ b/frontend/cypress/e2e/main_window/vessel/vessel-search.spec.ts @@ -1,4 +1,5 @@ -import { FAKE_MAPBOX_RESPONSE } from '../../constants' +import { Layers } from '../../../../src/domain/entities/layers/constants' +import { FAKE_MAPBOX_RESPONSE, PAGE_CENTER_PIXELS } from '../../constants' context('Search Places', () => { beforeEach(() => { @@ -12,7 +13,64 @@ context('Search Places', () => { cy.getDataCy('vessel-search-input').type('shipname', { delay: 400 }) cy.wait(500) cy.get('.rs-auto-complete-item').first().click() - cy.getDataCy('vessel-resume-SHIPNAME 1').should('be.visible') + cy.getDataCy('vessel-resume-SHIPNAME 1') + .should('be.visible') + .within(() => { + cy.contains('Latitude') + cy.contains('47° 38.400′ N') + cy.contains('Longitude') + cy.contains('003° 13.176′ W') + cy.contains('Vitesse') + cy.contains('20.1 Nds') + cy.contains('Dernier signal') + // No assertions for last signal + cy.contains("Port d'arrivée") + cy.contains('BRE') + cy.contains('MMSI') + cy.contains('123456789') + cy.contains('IMO') + cy.contains('IMO1111') + cy.contains('Immatriculation') + cy.contains('IMMAT111111') + cy.contains("Quartier d'immat") + cy.contains('ALGER') + cy.contains('Longueur') + cy.contains('12.12m') + cy.contains('Pavillon') + cy.contains('Algérie') + cy.contains('Catégorie') + cy.contains('Professionnel') + cy.contains('Type') + cy.contains('Porte-conteneur') + cy.contains('Désignation commerciale') + cy.contains('COMMERCIAL NAME') + cy.clickButton('Propriétaire(s)') + cy.get('header').contains('Informations propriétaire') + cy.contains('Identité de la personne') + cy.contains('MICHEL DURAND') + cy.contains('Coordonnées') + cy.contains('0102030405') + cy.contains('email@gmail.com') + cy.contains('Date de naissance') + cy.contains('1998-07-12') + cy.contains('Adresse postale') + cy.contains('82 STADE DE FRANCE') + cy.contains('Nationalité') + cy.contains('France') + cy.contains('Raison sociale') + cy.contains('COMPANY NAME 1') + cy.contains("Secteur d'activité") + cy.contains('Commerce et réparation de motocycles') + cy.contains('Statut juridique') + cy.contains('Société commerciale étrangère immatriculée au RCS') + cy.contains('Début de la propriété') + cy.contains('2000-01-01') + }) + cy.getFeaturesFromLayer(Layers.LAST_POSITIONS.code, PAGE_CENTER_PIXELS).should(features => { + expect(features).to.have.length(1) + expect(features?.[0]?.get('shipname')).to.equal('SHIPNAME 1') + }) + cy.clickButton('Fermer la fiche navire') cy.getDataCy('vessel-resume-SHIPNAME 1').should('not.exist') }) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f51c7166ce..2b4fbfe45c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "AGPL-3.0", "dependencies": { - "@mtes-mct/monitor-ui": "24.39.0", + "@mtes-mct/monitor-ui": "24.41.0", "@react-pdf/renderer": "4.3.0", "@reduxjs/toolkit": "2.8.2", "@sentry/browser": "8.54.0", @@ -4036,9 +4036,9 @@ "license": "BSD-2-Clause" }, "node_modules/@mtes-mct/monitor-ui": { - "version": "24.39.0", - "resolved": "https://registry.npmjs.org/@mtes-mct/monitor-ui/-/monitor-ui-24.39.0.tgz", - "integrity": "sha512-TM5IUq3bI63MBW7/eaQ2JFBjg31FTG36+CtkAIkHArl0hysn1KvfTHPdTh19zjjNJmEzDCNeMkD/nTvSoPVPwA==", + "version": "24.41.0", + "resolved": "https://registry.npmjs.org/@mtes-mct/monitor-ui/-/monitor-ui-24.41.0.tgz", + "integrity": "sha512-ODzNcfcTRTJMpSuLG0wJC3YLYRgOt9seMMmv1LPE48eDEUfjNPY5PlGeHRQL9+MKQBI0IeKtPfJ6A1oHkDfLhQ==", "license": "AGPL-3.0", "dependencies": { "@babel/runtime": "7.28.4", diff --git a/frontend/package.json b/frontend/package.json index 7c39107165..22975282c0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,7 +26,7 @@ "test:unit:watch": "npm run test:unit -- --watch" }, "dependencies": { - "@mtes-mct/monitor-ui": "24.39.0", + "@mtes-mct/monitor-ui": "24.41.0", "@react-pdf/renderer": "4.3.0", "@reduxjs/toolkit": "2.8.2", "@sentry/browser": "8.54.0", diff --git a/frontend/public/icons/boat.png b/frontend/public/icons/boat.png new file mode 100644 index 0000000000..08ecf93d32 Binary files /dev/null and b/frontend/public/icons/boat.png differ diff --git a/frontend/public/icons/owner.svg b/frontend/public/icons/owner.svg new file mode 100755 index 0000000000..5e8fa26faa --- /dev/null +++ b/frontend/public/icons/owner.svg @@ -0,0 +1,6 @@ + + + diff --git a/frontend/public/icons/resume.svg b/frontend/public/icons/resume.svg new file mode 100755 index 0000000000..5346c51b49 --- /dev/null +++ b/frontend/public/icons/resume.svg @@ -0,0 +1,6 @@ + + + diff --git a/frontend/src/api/lastPositionsApi.ts b/frontend/src/api/lastPositionsApi.ts new file mode 100644 index 0000000000..815add0eab --- /dev/null +++ b/frontend/src/api/lastPositionsApi.ts @@ -0,0 +1,17 @@ +import { monitorenvPrivateApi } from '@api/api' +import { FrontendApiError } from '@libs/FrontendApiError' + +import type { Vessel } from '@features/Vessel/types' + +const GET_LAST_POSITIONS_ERROR_MESSAGE = "Nous n'avons pas pu récupérer les dernières positions de ce navire." + +export const lastPositionsApi = monitorenvPrivateApi.injectEndpoints({ + endpoints: builder => ({ + getLastPositions: builder.query({ + query: id => `/v1/last_positions/${id}`, + transformErrorResponse: response => new FrontendApiError(GET_LAST_POSITIONS_ERROR_MESSAGE, response) + }) + }) +}) + +export const { useGetLastPositionsQuery } = lastPositionsApi diff --git a/frontend/src/api/vesselApi.ts b/frontend/src/api/vesselsApi.ts similarity index 90% rename from frontend/src/api/vesselApi.ts rename to frontend/src/api/vesselsApi.ts index acd2da245c..7afe0fb790 100644 --- a/frontend/src/api/vesselApi.ts +++ b/frontend/src/api/vesselsApi.ts @@ -6,7 +6,7 @@ import type { Vessel } from '@features/Vessel/types' const GET_VESSEL_ERROR_MESSAGE = "Nous n'avons pas pu récupérer les informations de ce navire." const SEARCH_VESSELS_ERROR_MESSAGE = "Nous n'avons pas pu récupérer les navires correspondants à cette recherche." -export const vesselApi = monitorenvPrivateApi.injectEndpoints({ +export const vesselsApi = monitorenvPrivateApi.injectEndpoints({ endpoints: builder => ({ getVessel: builder.query({ query: id => `/v1/vessels/${id}`, @@ -19,4 +19,4 @@ export const vesselApi = monitorenvPrivateApi.injectEndpoints({ }) }) -export const { useGetVesselQuery, useLazyGetVesselQuery, useSearchVesselsQuery } = vesselApi +export const { useGetVesselQuery, useLazyGetVesselQuery, useSearchVesselsQuery } = vesselsApi diff --git a/frontend/src/domain/entities/layers/constants.ts b/frontend/src/domain/entities/layers/constants.ts index c86b79c8c6..999e02a981 100644 --- a/frontend/src/domain/entities/layers/constants.ts +++ b/frontend/src/domain/entities/layers/constants.ts @@ -43,6 +43,7 @@ export enum MonitorEnvLayers { FAO = 'FAO', HOVERED_MISSION = 'HOVERED_MISSION', INTEREST_POINT = 'INTEREST_POINT', + LAST_POSITIONS = 'LAST_POSITIONS', LOCALIZED_AREAS = 'LOCALIZED_AREAS', LOCATE_ON_MAP = 'LOCATE_ON_MAP', LOW_WATER_LINE = 'LOW_WATER_LINE', @@ -338,6 +339,10 @@ export const Layers: Record = { [MonitorEnvLayers.LOCATE_ON_MAP]: { code: MonitorEnvLayers.LOCATE_ON_MAP, zIndex: 1500 + }, + [MonitorEnvLayers.LAST_POSITIONS]: { + code: MonitorEnvLayers.LAST_POSITIONS, + zIndex: 1500 } } diff --git a/frontend/src/features/Dashboard/components/Pdf/Reportings/index.tsx b/frontend/src/features/Dashboard/components/Pdf/Reportings/index.tsx index 47228cadc6..e637ba2f77 100644 --- a/frontend/src/features/Dashboard/components/Pdf/Reportings/index.tsx +++ b/frontend/src/features/Dashboard/components/Pdf/Reportings/index.tsx @@ -1,11 +1,11 @@ import { getFormattedReportingId } from '@features/Reportings/utils' import { THEME } from '@mtes-mct/monitor-ui' import { G, Path, Rect, StyleSheet, Svg, Text, View } from '@react-pdf/renderer' -import { formatCoordinates } from '@utils/coordinates' +import { formatCoordinatesAsText } from '@utils/coordinates' import { getDateAsLocalizedStringCompact } from '@utils/getDateAsLocalizedString' import { displaySubThemes } from '@utils/getThemesAsOptions' import { CoordinatesFormat } from 'domain/entities/map/constants' -import { getReportingStatus, ReportingStatusEnum, ReportingTypeEnum, type Reporting } from 'domain/entities/reporting' +import { getReportingStatus, type Reporting, ReportingStatusEnum, ReportingTypeEnum } from 'domain/entities/reporting' import { ReportingTargetTypeLabels } from 'domain/entities/targetType' import { vehicleTypeLabels } from 'domain/entities/vehicleType' import { vesselTypeLabel } from 'domain/entities/vesselType' @@ -225,7 +225,7 @@ export function Reportings({ reportings }: { reportings: Reporting[] }) { {reporting.geom?.coordinates && reporting.geom?.coordinates.length > 0 && ( { - formatCoordinates( + formatCoordinatesAsText( reporting.geom.coordinates[0] as Coordinate, CoordinatesFormat.DEGREES_MINUTES_SECONDS ) diff --git a/frontend/src/features/Dashboard/useCases/exportBrief.ts b/frontend/src/features/Dashboard/useCases/exportBrief.ts index cc3b47ae2f..c6c42df6f2 100644 --- a/frontend/src/features/Dashboard/useCases/exportBrief.ts +++ b/frontend/src/features/Dashboard/useCases/exportBrief.ts @@ -20,7 +20,7 @@ import { getVigilanceAreaColorWithAlpha } from '@features/VigilanceArea/componen import { VigilanceArea } from '@features/VigilanceArea/types' import { endingOccurenceText, frequencyText } from '@features/VigilanceArea/utils' import { CoordinatesFormat, customDayjs, getLocalizedDayjs, Level, THEME } from '@mtes-mct/monitor-ui' -import { formatCoordinates } from '@utils/coordinates' +import { formatCoordinatesAsText } from '@utils/coordinates' import { formatDateLabel } from '@utils/getDateAsLocalizedString' import { getRegulatoryAreaTitle } from '@utils/getRegulatoryAreaTitle' import { displayTags } from '@utils/getTagsAsOptions' @@ -224,7 +224,7 @@ export const exportBrief = const formattedReportings = dashboard.reportingIds ? (Object.values(reportings?.entities ?? []) as Reporting[]).map(reporting => { - const localization = formatCoordinates( + const localization = formatCoordinatesAsText( reporting?.geom?.coordinates[0] as Coordinate, CoordinatesFormat.DEGREES_MINUTES_SECONDS ) diff --git a/frontend/src/features/LocateOnMap/index.tsx b/frontend/src/features/LocateOnMap/index.tsx index 07f5995ab5..57e5cccfc3 100644 --- a/frontend/src/features/LocateOnMap/index.tsx +++ b/frontend/src/features/LocateOnMap/index.tsx @@ -1,6 +1,6 @@ import { SearchLocation } from '@features/LocateOnMap/SearchLocation' import { SearchSwitcher, SearchType } from '@features/LocateOnMap/SearchSwitcher' -import { SearchVessel } from '@features/Vessel/SearchVessels' +import { SearchVessel } from '@features/Vessel/components/VesselSearch' import { vesselAction } from '@features/Vessel/slice' import { isVesselsEnabled } from '@features/Vessel/utils' import { useAppDispatch } from '@hooks/useAppDispatch' diff --git a/frontend/src/features/Mission/components/MissionForm/ActionForm/ControlForm/InfractionForm/InfractionFormHeaderVehicle.tsx b/frontend/src/features/Mission/components/MissionForm/ActionForm/ControlForm/InfractionForm/InfractionFormHeaderVehicle.tsx index e461fc287f..1bcc5e2840 100644 --- a/frontend/src/features/Mission/components/MissionForm/ActionForm/ControlForm/InfractionForm/InfractionFormHeaderVehicle.tsx +++ b/frontend/src/features/Mission/components/MissionForm/ActionForm/ControlForm/InfractionForm/InfractionFormHeaderVehicle.tsx @@ -1,4 +1,4 @@ -import { VesselForm } from '@features/Vessel/VesselForm' +import { VesselForm } from '@features/Vessel/components/MissionVesselForm' import { FormikTextInput, LinkButton } from '@mtes-mct/monitor-ui' import { useField } from 'formik' import styled from 'styled-components' diff --git a/frontend/src/features/Mission/components/MissionForm/ActionForm/ControlForm/MultiPointPicker.tsx b/frontend/src/features/Mission/components/MissionForm/ActionForm/ControlForm/MultiPointPicker.tsx index a3e1f2b97a..3a032171c5 100644 --- a/frontend/src/features/Mission/components/MissionForm/ActionForm/ControlForm/MultiPointPicker.tsx +++ b/frontend/src/features/Mission/components/MissionForm/ActionForm/ControlForm/MultiPointPicker.tsx @@ -1,6 +1,6 @@ import { ZoneWrapper } from '@components/ZonePicker/DrawedPolygonWithCenterButton' import { Accent, Button, Icon, IconButton, Label, Message } from '@mtes-mct/monitor-ui' -import { formatCoordinates } from '@utils/coordinates' +import { formatCoordinatesAsText } from '@utils/coordinates' import { centerOnMap } from 'domain/use_cases/map/centerOnMap' import { useField } from 'formik' import { isEqual } from 'lodash' @@ -86,7 +86,7 @@ export function MultiPointPicker({ actionIndex, isGeomSameAsAttachedReportingGeo // eslint-disable-next-line react/no-array-index-key - {formatCoordinates(coordinates, coordinatesFormat)} + {formatCoordinatesAsText(coordinates, coordinatesFormat)} void setMustIncreaseValidity: (value: boolean) => void } + export function Footer({ isAutoSaveEnabled, onClose, @@ -96,15 +97,15 @@ export function Footer({ const isMultiPolygon = values.geom?.type === 'MultiPolygon' const coordinates = values.geom?.coordinates - let formattedCoordinates + let formattedCoordinates: string | undefined if (isMultiPoint && coordinates) { - formattedCoordinates = formatCoordinates(coordinates[0] as Coordinate, coordinatesFormat) + formattedCoordinates = formatCoordinatesAsText(coordinates[0] as Coordinate, coordinatesFormat) } if (isMultiPolygon && coordinates && coordinates[0]) { const multiPolygon = new MultiPolygon(coordinates as Coordinate[][][]) const centroid = getCenter(multiPolygon.getExtent()) - formattedCoordinates = `${formatCoordinates( + formattedCoordinates = `${formatCoordinatesAsText( centroid as Coordinate, coordinatesFormat )} (calculées depuis le centroïde de la zone du signalement)` diff --git a/frontend/src/features/Reportings/components/ReportingForm/FormComponents/Position/PointPicker.tsx b/frontend/src/features/Reportings/components/ReportingForm/FormComponents/Position/PointPicker.tsx index 8e7f7fc983..b9d9f2dec5 100644 --- a/frontend/src/features/Reportings/components/ReportingForm/FormComponents/Position/PointPicker.tsx +++ b/frontend/src/features/Reportings/components/ReportingForm/FormComponents/Position/PointPicker.tsx @@ -3,7 +3,7 @@ import { useAppDispatch } from '@hooks/useAppDispatch' import { useAppSelector } from '@hooks/useAppSelector' import { useListenForDrawedGeometry } from '@hooks/useListenForDrawing' import { Accent, Icon, IconButton } from '@mtes-mct/monitor-ui' -import { formatCoordinates } from '@utils/coordinates' +import { formatCoordinatesAsText } from '@utils/coordinates' import { InteractionListener, OLGeometryType } from 'domain/entities/map/constants' import { drawPoint } from 'domain/use_cases/draw/drawGeometry' import { centerOnMap } from 'domain/use_cases/map/centerOnMap' @@ -52,7 +52,7 @@ export function PointPicker() { {value?.coordinates?.length > 0 && value.type === OLGeometryType.MULTIPOINT && ( - {formatCoordinates(value.coordinates[0] as Coordinate, coordinatesFormat)} + {formatCoordinatesAsText(value.coordinates[0] as Coordinate, coordinatesFormat)} { const coordinatesToCenter: Coordinate[] = diff --git a/frontend/src/features/Vessel/VesselResume.tsx b/frontend/src/features/Vessel/VesselResume.tsx deleted file mode 100644 index b5d8a74581..0000000000 --- a/frontend/src/features/Vessel/VesselResume.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { useGetVesselQuery } from '@api/vesselApi' -import { vesselAction } from '@features/Vessel/slice' -import { Vessel } from '@features/Vessel/types' -import { useAppDispatch } from '@hooks/useAppDispatch' -import { Icon, MapMenuDialog } from '@mtes-mct/monitor-ui' -import countries from 'i18n-iso-countries' -import styled from 'styled-components' - -import { Flag } from './VesselSearchItem' - -type VesselResumeProps = { - vesselId: number -} - -const UNKNOWN = '-' - -export function VesselResume({ vesselId }: VesselResumeProps) { - const dispatch = useAppDispatch() - const { data: vessel } = useGetVesselQuery(vesselId) - - if (!vessel) { - return null - } - const countryName = vessel.flag ? countries.getName(vessel.flag.substring(0, 2).toLowerCase(), 'fr') : UNKNOWN - const nationalityName = vessel.ownerNationality - ? countries.getName(vessel.ownerNationality.substring(0, 2).toLowerCase(), 'fr') - : UNKNOWN - - return ( - - - - - - {vessel.shipName} - - - { - dispatch(vesselAction.setSelectedVesselId(undefined)) - }} - title="Fermer la fiche navire" - /> - - - -
MMSI
-
{vessel.mmsi ?? UNKNOWN}
-
Immatriculation
-
{vessel.immatriculation ?? UNKNOWN}
-
IMO
-
{vessel.imo ?? UNKNOWN}
-
Quartier d'immat.
-
{vessel.portOfRegistry ?? UNKNOWN}
-
- -
Longueur
-
{vessel.length ? `${vessel.length}m` : UNKNOWN}
-
Pavillon
-
{countryName || UNKNOWN}
-
- -
Catégorie
-
{vessel.category ? Vessel.CategoryLabel[vessel.category] : UNKNOWN}
-
Type
-
{(vessel.category === 'PLA' ? vessel.leisureType : vessel.professionalType) ?? UNKNOWN}
- {vessel.commercialName && ( - <> -
Désignation commerciale
-
{vessel.commercialName}
- - )} -
- -
Informations propriétaire
- -
Identité de la personne
-
- {!vessel.ownerFirstName && !vessel.ownerLastName - ? UNKNOWN - : `${vessel.ownerFirstName} ${vessel.ownerLastName}`} -
-
Coordonnées
- {vessel.ownerPhone || vessel.ownerEmail ? ( -
- {vessel.ownerPhone &&

{vessel.ownerPhone}

} - {vessel.ownerEmail &&

{vessel.ownerEmail}

} -
- ) : ( -
{UNKNOWN}
- )} -
Date de naissance
-
{vessel.ownerDateOfBirth ?? UNKNOWN}
-
Adresse postale
-
{vessel.ownerPostalAddress ?? UNKNOWN}
-
Nationalité
-
{nationalityName}
-
Raison sociale
-
{vessel.ownerCompanyName ?? UNKNOWN}
-
Secteur d'activité
-
{vessel.ownerBusinessSegmentLabel ?? UNKNOWN}
-
Statut juridique
-
{vessel.ownerLegalStatusLabel ?? UNKNOWN}
-
Début de la propriété
-
{vessel.ownerStartDate ?? UNKNOWN}
-
-
-
-
- ) -} - -const VesselIdentity = styled.dl` - display: grid; - grid-template-columns: 1fr 1fr 1.5fr 1.5fr; - gap: 4px 8px; - flex-wrap: wrap; - background-color: ${p => p.theme.color.white}; - padding: 16px 20px; - - dt { - color: ${p => p.theme.color.slateGray}; - font-weight: 400; - margin: 0 0 auto 0; - } - - dd { - color: ${p => p.theme.color.gunMetal}; - font-weight: 500; - margin: 0 0 auto 0; - } -` - -const VesselType = styled(VesselIdentity)` - grid-template-columns: 1fr 1.5fr; -` - -const TitleWrapper = styled.span` - display: flex; - gap: 8px; - font-size: 22px; -` - -const StyledMapMenuDialogContainer = styled(MapMenuDialog.Container)` - display: flex; - margin-left: -6px; - position: absolute; - top: 55px; - right: 50px; - width: 500px; - max-height: calc(100% - 64px); - overflow: auto; - background-color: ${p => p.theme.color.gainsboro}; -` -const OwnerSection = styled.section` - header { - background-color: ${p => p.theme.color.lightGray}; - padding: 10px 20px; - color: ${p => p.theme.color.slateGray}; - font-weight: 500; - } -` - -const OwnerIdentity = styled(VesselIdentity)` - grid-template-columns: 1fr 1.5fr; -` diff --git a/frontend/src/features/Vessel/VesselSearchForm.tsx b/frontend/src/features/Vessel/components/MissionVesselForm/VesselSearchForm.tsx similarity index 93% rename from frontend/src/features/Vessel/VesselSearchForm.tsx rename to frontend/src/features/Vessel/components/MissionVesselForm/VesselSearchForm.tsx index 50ea71376f..4ab1124cf0 100644 --- a/frontend/src/features/Vessel/VesselSearchForm.tsx +++ b/frontend/src/features/Vessel/components/MissionVesselForm/VesselSearchForm.tsx @@ -1,4 +1,4 @@ -import { useGetVesselQuery, useLazyGetVesselQuery } from '@api/vesselApi' +import { useGetVesselQuery, useLazyGetVesselQuery } from '@api/vesselsApi' import { LoadingIcon } from '@components/style' import { StyledLinkButton, @@ -6,18 +6,18 @@ import { VesselSearchWrapper } from '@features/Mission/components/MissionForm/ActionForm/ControlForm/InfractionForm/InfractionFormHeaderVehicle' import { HistoryOfInfractions } from '@features/Reportings/components/ReportingForm/FormComponents/Target/HistoryOfInfractions' -import { SearchVessel } from '@features/Vessel/SearchVessels' +import { SearchVessel } from '@features/Vessel/components/VesselSearch' +import { VesselSearchDescription } from '@features/Vessel/components/VesselSearch/VesselSearchDescription' import { vesselAction } from '@features/Vessel/slice' import { isVesselsEnabled } from '@features/Vessel/utils' -import { VesselSearchDescription } from '@features/Vessel/VesselSearchDescription' import { useAppDispatch } from '@hooks/useAppDispatch' import { Checkbox } from '@mtes-mct/monitor-ui' import { skipToken } from '@reduxjs/toolkit/query' import { useField, useFormikContext } from 'formik' import { useCallback, useEffect, useState } from 'react' +import type { Mission } from '../../../../domain/entities/missions' import type { Vessel } from '@features/Vessel/types' -import type { Mission } from 'domain/entities/missions' type VesselSearchFormProps = { envActionId: string | undefined @@ -32,7 +32,7 @@ export function VesselSearchForm({ envActionId, isUnknown, onIsUnknown, path, ve const [mmsi] = useField(`${path}.mmsi`) const [selectedVessel, setSelectedVessel] = useState() const [getVessel, { isLoading }] = useLazyGetVesselQuery() - const { data: vessel } = useGetVesselQuery(vesselId === undefined ? skipToken : vesselId) + const { data: vessel } = useGetVesselQuery(vesselId ?? skipToken) const { setFieldValue } = useFormikContext() useEffect(() => { diff --git a/frontend/src/features/Vessel/VesselForm.tsx b/frontend/src/features/Vessel/components/MissionVesselForm/index.tsx similarity index 95% rename from frontend/src/features/Vessel/VesselForm.tsx rename to frontend/src/features/Vessel/components/MissionVesselForm/index.tsx index ef9679b146..992903a20f 100644 --- a/frontend/src/features/Vessel/VesselForm.tsx +++ b/frontend/src/features/Vessel/components/MissionVesselForm/index.tsx @@ -1,12 +1,12 @@ import { VesselTypeSelector } from '@features/commonComponents/VesselTypeSelector' import { StyledVesselForm } from '@features/Mission/components/MissionForm/ActionForm/ControlForm/InfractionForm/InfractionFormHeaderVehicle' +import { VesselSearchForm } from '@features/Vessel/components/MissionVesselForm/VesselSearchForm' import { isVesselsEnabled } from '@features/Vessel/utils' -import { VesselSearchForm } from '@features/Vessel/VesselSearchForm' import { FormikNumberInput, FormikTextInput } from '@mtes-mct/monitor-ui' import { useField } from 'formik' import { useState } from 'react' -import type { Infraction } from '../../domain/entities/missions' +import type { Infraction } from '../../../../domain/entities/missions' type VesselFormProps = { envActionIndex: number diff --git a/frontend/src/features/Vessel/components/VesselResume/LastPositionResume.tsx b/frontend/src/features/Vessel/components/VesselResume/LastPositionResume.tsx new file mode 100644 index 0000000000..04b7e63131 --- /dev/null +++ b/frontend/src/features/Vessel/components/VesselResume/LastPositionResume.tsx @@ -0,0 +1,111 @@ +import { addMainWindowBanner } from '@features/MainWindow/useCases/addMainWindowBanner' +import { VesselIdentity } from '@features/Vessel/components/VesselResume/styles' +import { Vessel } from '@features/Vessel/types' +import { useAppDispatch } from '@hooks/useAppDispatch' +import { useAppSelector } from '@hooks/useAppSelector' +import { Accent, customDayjs, Icon, IconButton, Level } from '@mtes-mct/monitor-ui' +import { formatCoordinates, formatCoordinatesAsText } from '@utils/coordinates' +import { useCallback } from 'react' +import styled from 'styled-components' + +import { UNKNOWN } from '.' + +import type { Coordinate } from 'ol/coordinate' + +type SummaryProps = { + lastPositions: Vessel.LastPosition[] +} + +export function LastPositionResume({ lastPositions }: SummaryProps) { + const dispatch = useAppDispatch() + const coordinatesFormat = useAppSelector(state => state.map.coordinatesFormat) + + const lastPosition = lastPositions[0] + + const diff = customDayjs(lastPosition?.timestamp).fromNow(true) + + const [latitude, longitude] = formatCoordinates(lastPosition?.geom?.coordinates, coordinatesFormat) + + const copyCoordinates = useCallback(() => { + if (!lastPosition?.geom?.coordinates) { + return + } + const formattedText = formatCoordinatesAsText(lastPosition.geom?.coordinates as Coordinate, coordinatesFormat) + navigator.clipboard + .writeText(formattedText) + .then(() => { + const bannerProps = { + children: 'Coordonnées du navire copiées dans le presse papier', + isClosable: true, + isFixed: true, + level: Level.SUCCESS, + withAutomaticClosing: true + } + + return dispatch(addMainWindowBanner(bannerProps)) + }) + .catch(() => { + const errorBannerProps = { + children: "Les coordonnées du navire n'ont pas pu être copiés dans le presse papier", + isClosable: true, + isFixed: true, + level: Level.ERROR, + withAutomaticClosing: true + } + + return dispatch(addMainWindowBanner(errorBannerProps)) + }) + }, [coordinatesFormat, dispatch, lastPosition?.geom?.coordinates]) + + return ( + + +
Latitude
+
{latitude ?? UNKNOWN}
+
Longitude
+
{longitude ?? UNKNOWN}
+ +
+ +
Vitesse
+
{lastPosition?.speed ? `${lastPosition?.speed} Nds` : UNKNOWN}
+
Dernier signal
+
{lastPosition?.timestamp ? diff : UNKNOWN}
+
+ +
Port d'arrivée
+
{lastPosition?.destination ?? UNKNOWN}
+
+
+ ) +} + +const Wrapper = styled.div` + display: grid; + grid-template-columns: 8fr 7fr 9fr; + gap: 10px; +` +const LastPositionIdentity = styled(VesselIdentity)` + align-items: center; + display: flex; + flex-direction: column; + grid-template-columns: none; + justify-content: center; + position: relative; + + dd:not(:last-of-type) { + margin-bottom: 12px; + } +` + +const CopyCoordinates = styled(IconButton)` + position: absolute; + top: 5px; + right: 0; + color: ${({ theme }) => theme.color.lightGray}; +` diff --git a/frontend/src/features/Vessel/components/VesselResume/Owner.tsx b/frontend/src/features/Vessel/components/VesselResume/Owner.tsx new file mode 100644 index 0000000000..39951d9ea9 --- /dev/null +++ b/frontend/src/features/Vessel/components/VesselResume/Owner.tsx @@ -0,0 +1,67 @@ +import { VesselIdentity } from '@features/Vessel/components/VesselResume/styles' +import countries from 'i18n-iso-countries' +import styled from 'styled-components' + +import { UNKNOWN } from '.' + +import type { Vessel } from '@features/Vessel/types' + +type OwnerResumeProps = { + vessel: Vessel.Vessel +} + +export function Owner({ vessel }: OwnerResumeProps) { + const nationalityName = vessel.ownerNationality + ? countries.getName(vessel.ownerNationality.substring(0, 2).toLowerCase(), 'fr') + : UNKNOWN + + return ( + +
Informations propriétaire
+ +
Identité de la personne
+
+ {!vessel.ownerFirstName && !vessel.ownerLastName + ? UNKNOWN + : `${vessel.ownerFirstName} ${vessel.ownerLastName}`} +
+
Coordonnées
+ {vessel.ownerPhone || vessel.ownerEmail ? ( +
+ {vessel.ownerPhone &&

{vessel.ownerPhone}

} + {vessel.ownerEmail &&

{vessel.ownerEmail}

} +
+ ) : ( +
{UNKNOWN}
+ )} +
Date de naissance
+
{vessel.ownerDateOfBirth ?? UNKNOWN}
+
Adresse postale
+
{vessel.ownerPostalAddress ?? UNKNOWN}
+
Nationalité
+
{nationalityName}
+
Raison sociale
+
{vessel.ownerCompanyName ?? UNKNOWN}
+
Secteur d'activité
+
{vessel.ownerBusinessSegmentLabel ?? UNKNOWN}
+
Statut juridique
+
{vessel.ownerLegalStatusLabel ?? UNKNOWN}
+
Début de la propriété
+
{vessel.ownerStartDate ?? UNKNOWN}
+
+
+ ) +} + +const OwnerSection = styled.section` + header { + background-color: ${p => p.theme.color.lightGray}; + padding: 10px 20px; + color: ${p => p.theme.color.slateGray}; + font-weight: 500; + } +` + +const OwnerIdentity = styled(VesselIdentity)` + grid-template-columns: 1fr 1.5fr; +` diff --git a/frontend/src/features/Vessel/components/VesselResume/Summary.tsx b/frontend/src/features/Vessel/components/VesselResume/Summary.tsx new file mode 100644 index 0000000000..26e0915169 --- /dev/null +++ b/frontend/src/features/Vessel/components/VesselResume/Summary.tsx @@ -0,0 +1,51 @@ +import { VesselIdentity } from '@features/Vessel/components/VesselResume/styles' +import { Vessel } from '@features/Vessel/types' +import countries from 'i18n-iso-countries' +import styled from 'styled-components' + +import { UNKNOWN } from '.' + +type SummaryProps = { + vessel: Vessel.Vessel +} + +export function Summary({ vessel }: SummaryProps) { + const countryName = vessel.flag ? countries.getName(vessel.flag.substring(0, 2).toLowerCase(), 'fr') : UNKNOWN + + return ( + <> + +
MMSI
+
{vessel.mmsi ?? UNKNOWN}
+
Immatriculation
+
{vessel.immatriculation ?? UNKNOWN}
+
IMO
+
{vessel.imo ?? UNKNOWN}
+
Quartier d'immat.
+
{vessel.portOfRegistry ?? UNKNOWN}
+
+ +
Longueur
+
{vessel.length ? `${vessel.length}m` : UNKNOWN}
+
Pavillon
+
{countryName || UNKNOWN}
+
+ +
Catégorie
+
{vessel.category ? Vessel.CategoryLabel[vessel.category] : UNKNOWN}
+
Type
+
{(vessel.category === 'PLA' ? vessel.leisureType : vessel.professionalType) ?? UNKNOWN}
+ {vessel.commercialName && ( + <> +
Désignation commerciale
+
{vessel.commercialName}
+ + )} +
+ + ) +} + +const VesselType = styled(VesselIdentity)` + grid-template-columns: 1fr 1.5fr; +` diff --git a/frontend/src/features/Vessel/components/VesselResume/Tabs.tsx b/frontend/src/features/Vessel/components/VesselResume/Tabs.tsx new file mode 100644 index 0000000000..7920936f05 --- /dev/null +++ b/frontend/src/features/Vessel/components/VesselResume/Tabs.tsx @@ -0,0 +1,66 @@ +import { Icon } from '@mtes-mct/monitor-ui' +import { useState } from 'react' +import styled from 'styled-components' + +import type { VesselResumePages } from '.' + +type TabProps = { + onTabChange: (tab: VesselResumePages) => void +} + +export function Tabs({ onTabChange }: TabProps) { + const [tabOpen, setTabOpen] = useState('RESUME') + + const changeTab = (tab: VesselResumePages) => { + setTabOpen(tab) + onTabChange(tab) + } + + return ( + + changeTab('RESUME')} role="tab"> + + Résumé + + changeTab('OWNER')} role="tab"> + + Propriétaire(s) + + + ) +} + +const Tab = styled.button<{ + $isActive: boolean + $isLast?: boolean +}>` + align-items: center; + background: ${p => (p.$isActive ? p.theme.color.blueGray : p.theme.color.charcoal)}; + ${p => (p.$isLast ? null : `border-right: 1px solid ${p.theme.color.lightGray};`)} + color: ${p => (p.$isActive ? p.theme.color.white : p.theme.color.lightGray)}; + display: flex; + flex-direction: column; + font-size: 10px; + gap: 8px; + justify-content: center; + padding: 12px 0 8px; + + &:hover, + &:focus { + color: ${p => p.theme.color.white}; + background: ${p => p.theme.color.blueYonder}; + ${p => (p.$isLast ? null : `border-right: 1px solid ${p.theme.color.lightGray};`)} + } + + &:active { + color: ${p => p.theme.color.white}; + background: ${p => p.theme.color.blueGray}; + ${p => (p.$isLast ? null : `border-right: 1px solid ${p.theme.color.lightGray};`)} + } +` + +const TabList = styled.div` + border-top: 1px solid ${p => p.theme.color.lightGray}; + display: grid; + grid-template-columns: 1fr 1fr; +` diff --git a/frontend/src/features/Vessel/components/VesselResume/index.tsx b/frontend/src/features/Vessel/components/VesselResume/index.tsx new file mode 100644 index 0000000000..4f90296f5a --- /dev/null +++ b/frontend/src/features/Vessel/components/VesselResume/index.tsx @@ -0,0 +1,170 @@ +import { useGetVesselQuery } from '@api/vesselsApi' +import { LastPositionResume } from '@features/Vessel/components/VesselResume/LastPositionResume' +import { Owner } from '@features/Vessel/components/VesselResume/Owner' +import { Summary } from '@features/Vessel/components/VesselResume/Summary' +import { Tabs } from '@features/Vessel/components/VesselResume/Tabs' +import { vesselAction } from '@features/Vessel/slice' +import { useAppDispatch } from '@hooks/useAppDispatch' +import { Icon, IconButton, MapMenuDialog, OPENLAYERS_PROJECTION, WSG84_PROJECTION } from '@mtes-mct/monitor-ui' +import countries from 'i18n-iso-countries' +import { boundingExtent } from 'ol/extent' +import { transformExtent } from 'ol/proj' +import { useCallback, useEffect, useState } from 'react' +import styled from 'styled-components' + +import { setFitToExtent } from '../../../../domain/shared_slices/Map' +import { Flag } from '../VesselSearch/VesselSearchItem' + +import type { Vessel } from '@features/Vessel/types' +import type { Coordinate } from 'ol/coordinate' + +export type VesselResumePages = 'RESUME' | 'OWNER' + +export const UNKNOWN = '-' + +type VesselResumeProps = { + id: number +} + +export function VesselResume({ id }: VesselResumeProps) { + const dispatch = useAppDispatch() + const { data: vessel } = useGetVesselQuery(id) + const [page, setPage] = useState('RESUME') + + const focusVessel = useCallback( + (lastPosition: Vessel.LastPosition | undefined) => { + if (lastPosition?.geom) { + const vesselCoordinates = [lastPosition.geom.coordinates[0], lastPosition.geom.coordinates[1]] as Coordinate + if (vesselCoordinates) { + const extent = transformExtent(boundingExtent([vesselCoordinates]), WSG84_PROJECTION, OPENLAYERS_PROJECTION) + dispatch(setFitToExtent(extent)) + } + } + }, + [dispatch] + ) + + useEffect(() => { + focusVessel(vessel?.lastPositions?.[0]) + }, [focusVessel, vessel?.lastPositions]) + + if (!vessel) { + return null + } + const countryName = vessel.flag ? countries.getName(vessel.flag.substring(0, 2).toLowerCase(), 'fr') : UNKNOWN + + return ( + + {vessel.lastPositions && vessel.lastPositions.length > 0 && ( + +
  • + { + focusVessel(vessel.lastPositions?.[0]) + }} + title="Centrer sur le navire" + /> +
  • +
    + )} + + + + + + + {vessel.shipName} + + + { + dispatch(vesselAction.setSelectedVesselId(undefined)) + }} + title="Fermer la fiche navire" + /> + + {vessel.lastPositions && vessel.lastPositions.length > 0 ? ( + <> + { + setPage(tab) + }} + /> + + {page === 'RESUME' && ( + <> + {vessel.lastPositions && vessel.lastPositions.length > 0 && ( + + )} + + + )} + {page === 'OWNER' && } + + + ) : ( + + + + Navire non rattaché à l’AIS + + + + + )} + + + ) +} + +const TitleWrapper = styled.span` + display: flex; + font-size: 22px; + gap: 8px; +` + +const DialogWrapper = styled.div` + display: flex; + position: absolute; + right: 50px; + top: 55px; +` + +const ButtonsWrapper = styled.ul` + display: flex; + flex-direction: column; + gap: 4px; + left: 0; + padding: 4px; + position: relative; + top: 110px; +` + +const StyledMapMenuDialogContainer = styled(MapMenuDialog.Container)` + background-color: ${p => p.theme.color.gainsboro}; + display: flex; + max-height: calc(100% - 64px); + overflow: auto; + position: relative; + right: 0; + top: 0; + width: 500px; +` + +export const AisInformationMessage = styled.div` + align-items: center; + background-color: ${p => p.theme.color.blueGray25}; + border-color: ${p => p.theme.color.blueGrayBorder}; + color: ${p => p.theme.color.blueYonder}; + display: flex; + font-weight: bold; + gap: 8px; + margin-bottom: 10px; + padding: 16px 20px; +` diff --git a/frontend/src/features/Vessel/components/VesselResume/styles.ts b/frontend/src/features/Vessel/components/VesselResume/styles.ts new file mode 100644 index 0000000000..c3b8d28ed3 --- /dev/null +++ b/frontend/src/features/Vessel/components/VesselResume/styles.ts @@ -0,0 +1,21 @@ +import styled from 'styled-components' + +export const VesselIdentity = styled.dl` + background-color: ${p => p.theme.color.white}; + display: grid; + flex-wrap: wrap; + gap: 4px 8px; + grid-template-columns: 1fr 1fr 1.5fr 1.5fr; + padding: 16px 20px; + + dt { + color: ${p => p.theme.color.slateGray}; + font-weight: 400; + } + + dd { + color: ${p => p.theme.color.gunMetal}; + font-weight: 500; + margin: 0 0 auto 0; + } +` diff --git a/frontend/src/features/Vessel/VesselSearchDescription.tsx b/frontend/src/features/Vessel/components/VesselSearch/VesselSearchDescription.tsx similarity index 93% rename from frontend/src/features/Vessel/VesselSearchDescription.tsx rename to frontend/src/features/Vessel/components/VesselSearch/VesselSearchDescription.tsx index f3b92f67e8..74135be271 100644 --- a/frontend/src/features/Vessel/VesselSearchDescription.tsx +++ b/frontend/src/features/Vessel/components/VesselSearch/VesselSearchDescription.tsx @@ -1,9 +1,9 @@ import { useField } from 'formik' import styled from 'styled-components' -import { Vessel } from './types' +import { Vessel } from '../../types' -import type { Infraction } from '../../domain/entities/missions' +import type { Infraction } from '../../../../domain/entities/missions' type VesselSearchDescriptionProps = { category?: string @@ -45,10 +45,10 @@ export function VesselSearchDescription({ category, path }: VesselSearchDescript } const Wrapper = styled.div` - font-size: 13px; display: flex; - gap: 4px 8px; flex-wrap: wrap; + font-size: 13px; + gap: 4px 8px; ` const Description = styled.span` @@ -58,6 +58,6 @@ const Description = styled.span` const Value = styled.span` display: flex; flex-direction: row; - gap: 4px; font-weight: 400; + gap: 4px; ` diff --git a/frontend/src/features/Vessel/VesselSearchItem.tsx b/frontend/src/features/Vessel/components/VesselSearch/VesselSearchItem.tsx similarity index 100% rename from frontend/src/features/Vessel/VesselSearchItem.tsx rename to frontend/src/features/Vessel/components/VesselSearch/VesselSearchItem.tsx index 7e16788071..2b90a6d0d5 100644 --- a/frontend/src/features/Vessel/VesselSearchItem.tsx +++ b/frontend/src/features/Vessel/components/VesselSearch/VesselSearchItem.tsx @@ -109,16 +109,16 @@ const Header = styled.header` ` const Name = styled.span<{ $isUnknown?: boolean }>` - display: flex; align-items: center; - gap: 8px; - font-weight: 500; + display: flex; ${p => p.$isUnknown && `font-style: italic;`} + font-weight: 500; + gap: 8px; ` const Identities = styled.span` - display: flex; color: ${p => p.theme.color.slateGray}; + display: flex; justify-content: space-between; ` @@ -138,7 +138,7 @@ const Description = styled.span` ` const Category = styled(Tooltip)` + font-size: 12px; white-space: nowrap; z-index: 99999; - font-size: 12px; ` diff --git a/frontend/src/features/Vessel/SearchVessels.tsx b/frontend/src/features/Vessel/components/VesselSearch/index.tsx similarity index 96% rename from frontend/src/features/Vessel/SearchVessels.tsx rename to frontend/src/features/Vessel/components/VesselSearch/index.tsx index abf0f2ef0c..2a83a97954 100644 --- a/frontend/src/features/Vessel/SearchVessels.tsx +++ b/frontend/src/features/Vessel/components/VesselSearch/index.tsx @@ -1,6 +1,6 @@ +import { VesselSearchItem } from '@features/Vessel/components/VesselSearch/VesselSearchItem' import { useVessels } from '@features/Vessel/hooks/useVessels' import { toOptions } from '@features/Vessel/utils' -import { VesselSearchItem } from '@features/Vessel/VesselSearchItem' import { CustomSearch, Search, Size } from '@mtes-mct/monitor-ui' import { useMemo, useRef, useState } from 'react' import styled from 'styled-components' @@ -15,7 +15,7 @@ type SearchVesselsProps = { isSideWindow?: boolean onChange?: (vessel: Vessel.Identity | undefined) => void optionsWidth?: string - value?: Vessel.Identity | undefined + value?: Vessel.Identity } export function SearchVessel({ diff --git a/frontend/src/features/Vessel/hooks/useVessels.ts b/frontend/src/features/Vessel/hooks/useVessels.ts index 383150cd52..c04a769a85 100644 --- a/frontend/src/features/Vessel/hooks/useVessels.ts +++ b/frontend/src/features/Vessel/hooks/useVessels.ts @@ -1,4 +1,4 @@ -import { useSearchVesselsQuery } from '@api/vesselApi' +import { useSearchVesselsQuery } from '@api/vesselsApi' import { toOptions } from '@features/Vessel/utils' import { skipToken } from '@reduxjs/toolkit/query' import { useEffect, useState } from 'react' diff --git a/frontend/src/features/Vessel/layer/index.tsx b/frontend/src/features/Vessel/layer/index.tsx new file mode 100644 index 0000000000..6a421af744 --- /dev/null +++ b/frontend/src/features/Vessel/layer/index.tsx @@ -0,0 +1,97 @@ +import { useGetVesselQuery } from '@api/vesselsApi' +import { useAppSelector } from '@hooks/useAppSelector' +import { OPENLAYERS_PROJECTION, THEME, WSG84_PROJECTION } from '@mtes-mct/monitor-ui' +import { skipToken } from '@reduxjs/toolkit/query' +import { Feature } from 'ol' +import { GeoJSON } from 'ol/format' +import { type Geometry } from 'ol/geom' +import VectorLayer from 'ol/layer/Vector' +import VectorSource from 'ol/source/Vector' +import { Icon, Style } from 'ol/style' +import React, { useEffect, useRef } from 'react' + +import { Layers } from '../../../domain/entities/layers/constants' + +import type { VectorLayerWithName } from '../../../domain/types/layer' +import type { BaseMapChildrenProps } from '@features/map/BaseMap' +import type { FeatureLike } from 'ol/Feature' + +export function LastPositionsLayer({ map }: BaseMapChildrenProps) { + const selectedVesselId = useAppSelector(state => state.vessel.selectedVesselId) + + const { data: vessel } = useGetVesselQuery(selectedVesselId || skipToken) + + const vectorSourceRef = useRef(new VectorSource()) as React.MutableRefObject>> + const vectorLayerRef = useRef( + new VectorLayer({ + renderBuffer: 7, + source: vectorSourceRef.current, + style: getSelectedVesselStyle(), + zIndex: Layers.LAST_POSITIONS.zIndex + }) + ) as React.MutableRefObject + vectorLayerRef.current.name = Layers.LAST_POSITIONS.code + + useEffect(() => { + if (map) { + vectorSourceRef.current.clear(true) + + if (selectedVesselId && vessel?.lastPositions && vessel?.lastPositions.length > 0) { + const features = vessel?.lastPositions.map(lastPosition => { + const geoJSON = new GeoJSON() + const geometry = geoJSON.readGeometry(lastPosition.geom, { + dataProjection: WSG84_PROJECTION, + featureProjection: OPENLAYERS_PROJECTION + }) + const feature = new Feature({ geometry }) + feature.setId(`${Layers.LAST_POSITIONS.code}:${lastPosition.id}`) + feature.setProperties({ + ...lastPosition, + geom: null + }) + + return feature + }) + + if (features) { + vectorSourceRef.current.addFeatures(features) + } + } + } + }, [map, selectedVesselId, vessel?.lastPositions]) + + useEffect(() => { + map.getLayers().push(vectorLayerRef.current) + + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + map.removeLayer(vectorLayerRef.current) + } + }, [map]) + + useEffect(() => { + vectorLayerRef.current?.setVisible(true) + }, []) +} + +export const getSelectedVesselStyle = () => (feature: FeatureLike) => { + const course = feature.get('course') + + const vesselStyle = new Style({ + image: new Icon({ + color: THEME.color.charcoal, + offset: [0, 50], + opacity: 1, + rotation: degreesToRadian(course), + scale: 0.8, + size: [50, 50], + src: 'icons/boat.png' + }) + }) + + return [vesselStyle] +} + +export function degreesToRadian(course: number) { + return (course * Math.PI) / 180 +} diff --git a/frontend/src/features/Vessel/types.ts b/frontend/src/features/Vessel/types.ts index 40b1ce1ac6..eb23732b18 100644 --- a/frontend/src/features/Vessel/types.ts +++ b/frontend/src/features/Vessel/types.ts @@ -1,3 +1,5 @@ +import type { GeoJSON } from '../../domain/types/GeoJSON' + export namespace Vessel { export interface Identity { category?: string @@ -6,11 +8,13 @@ export namespace Vessel { immatriculation?: string imo?: string mmsi?: string + shipId?: number shipName?: string } export interface Vessel extends Identity { commercialName?: string + lastPositions?: LastPosition[] leisureType?: string length?: number ownerBusinessSegmentLabel?: string @@ -38,4 +42,17 @@ export namespace Vessel { PLA = 'Plaisance', PRO = 'Professionnel' } + + export interface LastPosition { + course?: number + destination?: string + geom?: GeoJSON.Point + heading?: number + id: number + mmsi?: number + shipName?: string + speed?: number + status?: string + timestamp?: string + } } diff --git a/frontend/src/features/map/index.tsx b/frontend/src/features/map/index.tsx index f5201f26ec..e2ec8800f1 100644 --- a/frontend/src/features/map/index.tsx +++ b/frontend/src/features/map/index.tsx @@ -22,6 +22,7 @@ import { RecentActivityLayerEvents } from '@features/RecentActivity/components/L import { RecentControlsActivityLayer } from '@features/RecentActivity/components/Layers/RecentControlsActivityLayer' import { RecentActvityOverlay } from '@features/RecentActivity/components/Overlays' import { RecentActivityLegend } from '@features/RecentActivity/components/RecentActivityLegend' +import { LastPositionsLayer } from '@features/Vessel/layer' import { VigilanceAreasLayer } from '@features/VigilanceArea/components/VigilanceAreaLayer' import { DrawVigilanceAreaLayer } from '@features/VigilanceArea/components/VigilanceAreaLayer/DrawVigilanceAreaLayer' import { EditingVigilanceAreaLayer } from '@features/VigilanceArea/components/VigilanceAreaLayer/EditingVigilanceAreaLayer' @@ -216,7 +217,10 @@ export function Map() { // @ts-ignore , // @ts-ignore - + , + // LAST POSITIONS + // @ts-ignore + ] : [] diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 1b30490e0b..648f1f1f7a 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -10,7 +10,7 @@ import { REPORTING_EVENT_UNSYNCHRONIZED_PROPERTIES } from '@features/Reportings/ import { useListenReportingEventUpdates } from '@features/Reportings/components/ReportingForm/hooks/useListenReportingEventUpdates' import { reportingActions } from '@features/Reportings/slice' import { SideWindowStatus } from '@features/SideWindow/slice' -import { VesselResume } from '@features/Vessel/VesselResume' +import { VesselResume } from '@features/Vessel/components/VesselResume' import { useAppDispatch } from '@hooks/useAppDispatch' import { omit } from 'lodash' import { useCallback, useEffect, useMemo } from 'react' @@ -128,7 +128,7 @@ export function HomePage() { {displayLocateOnMap && } {isControlUnitDialogVisible && isSuperUser && } - {selectedVesselId && } + {selectedVesselId && } diff --git a/frontend/src/utils/coordinates.ts b/frontend/src/utils/coordinates.ts index 595fad36ce..c3036755f9 100644 --- a/frontend/src/utils/coordinates.ts +++ b/frontend/src/utils/coordinates.ts @@ -2,9 +2,10 @@ import { getCoordinates, WSG84_PROJECTION } from '@mtes-mct/monitor-ui' import { CoordinatesFormat } from '../domain/entities/map/constants' +import type { GeoJSON } from '../domain/types/GeoJSON' import type { Coordinate } from 'ol/coordinate' -export const formatCoordinates = (coordinates: Coordinate, coordinatesFormat: CoordinatesFormat) => { +export const formatCoordinatesAsText = (coordinates: Coordinate, coordinatesFormat: CoordinatesFormat) => { const transformedCoordinates = getCoordinates(coordinates, WSG84_PROJECTION, coordinatesFormat) if (Array.isArray(transformedCoordinates) && transformedCoordinates.length === 2) { @@ -13,3 +14,16 @@ export const formatCoordinates = (coordinates: Coordinate, coordinatesFormat: Co return '' } + +export function formatCoordinates(coordinates: GeoJSON.Position | undefined, coordinatesFormat: CoordinatesFormat) { + if (!coordinates) { + return [] + } + const transformedCoordinates = getCoordinates(coordinates, WSG84_PROJECTION, coordinatesFormat) + + if (Array.isArray(transformedCoordinates) && transformedCoordinates.length === 2) { + return [transformedCoordinates[0], transformedCoordinates[1]] + } + + return [] +}