diff --git a/.gitignore b/.gitignore index 5ea2266f..3e3b1963 100644 --- a/.gitignore +++ b/.gitignore @@ -145,9 +145,6 @@ venv.bak/ .spyderproject .spyproject -# VS Code -.vscode - # Rope project settings .ropeproject diff --git a/backend/alembic/versions/c32d65d6e6fd_create_fct_metrics.py b/backend/alembic/versions/c32d65d6e6fd_create_fct_metrics.py new file mode 100644 index 00000000..eb6e48b8 --- /dev/null +++ b/backend/alembic/versions/c32d65d6e6fd_create_fct_metrics.py @@ -0,0 +1,50 @@ +"""create fct metrics + +Revision ID: c32d65d6e6fd +Revises: 06bb3c26076f +Create Date: 2024-10-18 11:13:02.468934 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import Inspector +import geoalchemy2 + +# revision identifiers, used by Alembic. +revision = 'c32d65d6e6fd' +down_revision = 'd9d279892b5c' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + + # Création de la table fct_metrics + op.create_table( + 'fct_metrics', + sa.Column('timestamp', sa.DateTime, primary_key=True), + sa.Column('vessel_id', sa.Integer, primary_key=True), + sa.Column('type', sa.String, nullable=False), + sa.Column('vessel_mmsi', sa.Integer, nullable=False), + sa.Column('ship_name', sa.String, nullable=False), + sa.Column('vessel_country_iso3', sa.String, nullable=False), + sa.Column('vessel_imo', sa.Integer), + sa.Column('duration_total', sa.FLOAT, nullable=False), + sa.Column('duration_fishing', sa.FLOAT, nullable=True), + sa.Column("zone_name", sa.String, primary_key=True), + sa.Column('zone_sub_category', sa.String, nullable=True), + ) + + +def downgrade() -> None: + # Suppression de la table fct_metrics en cas de rollback + conn = op.get_bind() + inspector = Inspector.from_engine(conn) + sql_tables = inspector.get_table_names() + tables = [ + "fct_metrics", + ] + for t in tables: + if t in sql_tables: + op.drop_table(t) + diff --git a/backend/alembic/versions/d9d279892b5c_add_task_execution.py b/backend/alembic/versions/d9d279892b5c_add_task_execution.py new file mode 100644 index 00000000..1a7bb9c3 --- /dev/null +++ b/backend/alembic/versions/d9d279892b5c_add_task_execution.py @@ -0,0 +1,89 @@ +"""add task execution + +Revision ID: d9d279892b5c +Revises: 7ba4634af5ad +Create Date: 2024-11-30 12:54:22.318425 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import func + + +# revision identifiers, used by Alembic. +revision = 'd9d279892b5c' +down_revision = '7ba4634af5ad' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """ + Create a new task_executions table that permit to store historical data + for each task execution + The goal is to be able to detect timeframe not covered by Spire API interrogation + """ + # drop constraint from existing table to free the constraint name + op.drop_constraint(table_name="tasks_executions", + constraint_name="tasks_executions_pkey") + # rename existing table to keep existing data + op.rename_table('tasks_executions','tasks_executions_tmp') + # create the new task_executions table with id and active columns in addition + op.create_table("tasks_executions", + sa.Column("id", sa.Integer(),sa.Identity(), primary_key=True, index=True), + sa.Column("task_name", sa.String), + sa.Column("point_in_time", sa.DateTime(timezone=True)), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ), + sa.Column("updated_at", sa.DateTime(timezone=True), onupdate=func.now()), + sa.Column("delta", + sa.Interval, + index=True, + nullable=True), + sa.Column("active", + sa.Boolean, + index=True, + default=False), + ) + # copy of existing data to new table with active=True + op.execute("insert into tasks_executions " + +"(task_name,point_in_time,created_at,updated_at,active) " + +"select task_name,point_in_time,created_at,updated_at,true " + +"from tasks_executions_tmp") + # drop old table + op.drop_table('tasks_executions_tmp') + + # Retrieve all historical spire api interrogation from spire_ais_data table + op.execute( + """insert into public.tasks_executions (task_name,point_in_time,created_at,delta,active) + select distinct + 'load_spire_data_from_api' as "task_name", + T1.created_at as "point_in_time", + T1.created_at, + T1.created_at-(select distinct created_at from spire_ais_data where created_at < T1.created_at group by created_at order by created_at desc limit 1) as "delta", + case when T1.created_at = (select MAX(created_at) from spire_ais_data) and not EXISTS(select 1 from public.tasks_executions where task_name = 'load_spire_data_from_api' and active = True) then true else false end as "active" + from spire_ais_data T1 + where T1.created_at not in (select point_in_time from public.tasks_executions where task_name = 'load_spire_data_from_api') + group by T1.created_at + order by T1.created_at desc + """) + pass + + +def downgrade() -> None: + # delete all lines active=False as they have no equivalent in old task_executions + op.execute("delete from tasks_executions where active=False") + # drop active and id table + op.drop_column("tasks_executions","delta") + op.drop_column("tasks_executions","active") + op.drop_column("tasks_executions","id") + # recreate the primary unique key constraint of old table + op.create_unique_constraint("tasks_executions_pkey", + "tasks_executions", + ["task_name"], + ) + pass diff --git a/backend/bloom/container.py b/backend/bloom/container.py index 6afefbb8..26efb6cf 100644 --- a/backend/bloom/container.py +++ b/backend/bloom/container.py @@ -9,6 +9,8 @@ from bloom.infra.repositories.repository_vessel_position import VesselPositionRepository from bloom.infra.repositories.repository_segment import SegmentRepository from bloom.infra.repositories.repository_zone import ZoneRepository +from bloom.infra.repositories.repository_metrics import MetricsRepository + from bloom.services.GetVesselsFromSpire import GetVesselsFromSpire from bloom.services.metrics import MetricsService from bloom.usecase.GenerateAlerts import GenerateAlerts @@ -89,3 +91,8 @@ class UseCases(containers.DeclarativeContainer): MetricsService, session_factory=db.provided.session, ) + + metrics_repository = providers.Factory( + MetricsRepository, + session_factory=db.provided.session, + ) \ No newline at end of file diff --git a/backend/bloom/domain/metrics.py b/backend/bloom/domain/metrics.py index bdafa269..4d7ab27d 100644 --- a/backend/bloom/domain/metrics.py +++ b/backend/bloom/domain/metrics.py @@ -5,6 +5,21 @@ from enum import Enum from bloom.domain.vessel import Vessel,VesselListView from bloom.domain.zone import Zone,ZoneListView +from typing import Union + +class Metrics(BaseModel) : + model_config = ConfigDict(arbitrary_types_allowed=True) + timestamp : datetime + vessel_id: int + type : str + vessel_mmsi: int + ship_name: str + vessel_country_iso3: str + vessel_imo: int + duration_total : float + duration_fishing: Optional[float] = None + zone_name : str + zone_sub_category : Union[str, None] class TotalTimeActivityTypeEnum(str, Enum): total_time_at_sea: str = "Total Time at Sea" @@ -31,6 +46,12 @@ class ResponseMetricsZoneVisitingTimeByVesselSchema(BaseModel): vessel: VesselListView zone_visiting_time_by_vessel: timedelta + +class ResponseMetricsVesselVisitingTimeByZoneSchema(BaseModel): + zone: ZoneListView + vessel: VesselListView + vessel_visiting_time_by_zone: timedelta + class ResponseMetricsVesselTotalTimeActivityByActivityTypeSchema(BaseModel): vessel_id : int activity: str diff --git a/backend/bloom/infra/database/sql_model.py b/backend/bloom/infra/database/sql_model.py index 57a3f855..0fed68dd 100644 --- a/backend/bloom/infra/database/sql_model.py +++ b/backend/bloom/infra/database/sql_model.py @@ -10,7 +10,8 @@ Integer, Interval, String, - PrimaryKeyConstraint + PrimaryKeyConstraint, + Identity ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.sql import func, select @@ -224,10 +225,13 @@ class Segment(Base): class TaskExecution(Base): __tablename__ = "tasks_executions" - task_name = Column("task_name", String, primary_key=True) + id = Column("id", Integer, Identity(), primary_key=True) + task_name = Column("task_name", String) point_in_time = Column("point_in_time", DateTime(timezone=True)) created_at = Column("created_at", DateTime(timezone=True), server_default=func.now()) updated_at = Column("updated_at", DateTime(timezone=True), onupdate=func.now()) + delta = Column("delta", Interval, nullable=False) + active = Column("active", Boolean, nullable=False) class RelSegmentZone(Base): @@ -256,3 +260,18 @@ class MetricsVesselInActivity(Base): #vessel_id: Mapped[Optional[int]] #total_time_at_sea: Mapped[Optional[timedelta]] + +class Metrics(Base): + __tablename__ = "fct_metrics" + timestamp = Column("timestamp", DateTime(timezone=True), primary_key=True) + vessel_id= Column("vessel_id", Integer, ForeignKey("dim_vessel.id"), primary_key=True) + type = Column("type", String, nullable=False) # Ajouté comme clé primaire + vessel_mmsi= Column("vessel_mmsi", Integer, ForeignKey("dim_vessel.mmsi"), nullable=False) + ship_name= Column("ship_name", String, ForeignKey("dim_vessel.ship_name"), nullable=False) + vessel_country_iso3= Column("vessel_country_iso3", String, ForeignKey("dim_vessel.country_iso3"), nullable=False) + vessel_imo= Column("vessel_imo", Integer, ForeignKey("dim_vessel.imo")) + duration_total= Column("duration_total", Double, nullable= False) + duration_fishing= Column("duration_fishing", Double) + zone_name= Column("zone_name", String, ForeignKey("dim_zone.name"),primary_key=True) + zone_sub_category= Column("zone_sub_category", String, ForeignKey("dim_zone.sub_category")) + diff --git a/backend/bloom/infra/repositories/repository_excursion.py b/backend/bloom/infra/repositories/repository_excursion.py index dba05a78..4b70e204 100644 --- a/backend/bloom/infra/repositories/repository_excursion.py +++ b/backend/bloom/infra/repositories/repository_excursion.py @@ -1,16 +1,23 @@ from contextlib import AbstractContextManager -from typing import Any, List, Union +from typing import Any, List, Union, Optional import pandas as pd +from bloom.routers.requests import DatetimeRangeRequest from dependency_injector.providers import Callable from geoalchemy2.shape import from_shape, to_shape -from sqlalchemy import desc +from sqlalchemy import desc, and_, or_ from sqlalchemy import select from sqlalchemy.orm import Session +from sqlalchemy.sql.expression import and_,or_, asc, desc from bloom.domain.excursion import Excursion from bloom.infra.database import sql_model +from bloom.routers.requests import ( DatetimeRangeRequest, + OrderByRequest, + PageParams, + OrderByEnum) + class ExcursionRepository: def __init__( @@ -34,9 +41,29 @@ def get_param_from_last_excursion(self, session: Session, vessel_id: int) -> Uni return None return {"arrival_port_id": result.arrival_port_id, "arrival_position": result.arrival_position} - def get_excursions_by_vessel_id(self, session: Session, vessel_id: int) -> List[Excursion]: + def get_excursions_by_vessel_id(self, + session: Session, + vessel_id: int, + datetime_range: DatetimeRangeRequest, + order: OrderByRequest, + pagination: PageParams + ) -> List[Excursion]: """Recheche l'excursion en cours d'un bateau, c'est-à-dire l'excursion qui n'a pas de date d'arrivée""" - stmt = select(sql_model.Excursion).where(sql_model.Excursion.vessel_id == vessel_id) + stmt = select(sql_model.Excursion).where( + and_( + sql_model.Excursion.vessel_id == vessel_id, + sql_model.Excursion.departure_at < datetime_range.end_at, + or_( + sql_model.Excursion.arrival_at > datetime_range.start_at, + sql_model.Excursion.arrival_at == None + ), + ) + ) + stmt = stmt.order_by(asc(sql_model.Excursion.departure_at))\ + if order.order == OrderByEnum.ascending \ + else stmt.order_by(desc(sql_model.Excursion.departure_at)) + stmt = stmt.offset(pagination.offset) if pagination.offset != None else stmt + stmt = stmt.limit(pagination.limit) if pagination.limit != None else stmt result = session.execute(stmt).scalars().all() if not result: return [] diff --git a/backend/bloom/infra/repositories/repository_metrics.py b/backend/bloom/infra/repositories/repository_metrics.py new file mode 100644 index 00000000..97c81b1c --- /dev/null +++ b/backend/bloom/infra/repositories/repository_metrics.py @@ -0,0 +1,91 @@ +from bloom.domain.metrics import Metrics +from contextlib import AbstractContextManager +from typing import Any, List, Union + +import pandas as pd +from dependency_injector.providers import Callable +from sqlalchemy.orm import Session + +from bloom.infra.database import sql_model + +from sqlalchemy import and_, or_, select, update, text, join + + + + +class MetricsRepository: + + def __init__( + self, + session_factory: Callable, + ) -> Callable[..., AbstractContextManager]: + self.session_factory = session_factory + + # def get_vessel_excursion_segment_by_id(self, session, segment_id: int) -> pd.DataFrame: + # stmt = select( + # sql_model.Vessel.id, + # sql_model.Vessel.mmsi, + # sql_model.Vessel.ship_name + # sql_model.Vessel.country_iso3 + # sql_model.Vessel.imo + # ).join( + # sql_model.Excursion, + # sql_model.Excursion.vessel_id == sql_model.Vessel.id + # ).join( + # sql_model.Segment, + # sql_model.Segment.excursion_id == sql_model.Excursion.id + # ).where( + # sql_model.Segment.id == segment_id + # ) + + # result = session.execute(stmt) + # if not result: + # return None + # df = pd.DataFrame(result, columns=["vessel_id", "vessel_mmsi", "ship_name", "vessel_country_iso3","vessel_imo"]) + # return df + + def batch_create_metrics( + self, session: Session, metricss: list[Metrics] + ) -> list[Metrics]: + orm_list = [MetricsRepository.map_to_orm(metrics) for metrics in metricss] + session.add_all(orm_list) + return [MetricsRepository.map_to_domain(orm) for orm in orm_list] + + + @staticmethod + def map_to_orm(metrics: Metrics) -> sql_model.Metrics: + return sql_model.Metrics( + timestamp=metrics.timestamp, + vessel_id=metrics.vessel_id, + type=metrics.type, + vessel_mmsi=metrics.vessel_mmsi, + ship_name=metrics.ship_name, + vessel_country_iso3=metrics.vessel_country_iso3, + vessel_imo=metrics.vessel_imo, + duration_total=metrics.duration_total, + duration_fishing=metrics.duration_fishing, + zone_name=metrics.zone_name, + zone_sub_category=metrics.zone_sub_category, + ) + + + @staticmethod + def map_to_domain(metrics: sql_model.Metrics) -> Metrics: + return Metrics( + timestamp=metrics.timestamp, + vessel_id=metrics.vessel_id, + type=metrics.type, + vessel_mmsi=metrics.vessel_mmsi, + ship_name=metrics.ship_name, + vessel_country_iso3=metrics.vessel_country_iso3, + vessel_imo=metrics.vessel_imo, + duration_total=metrics.duration_total, + duration_fishing=metrics.duration_fishing, + zone_name=metrics.zone_name, + zone_sub_category=metrics.zone_sub_category, + ) + + + + + diff --git a/backend/bloom/infra/repositories/repository_segment.py b/backend/bloom/infra/repositories/repository_segment.py index 1aa95e3a..6c6c72ce 100644 --- a/backend/bloom/infra/repositories/repository_segment.py +++ b/backend/bloom/infra/repositories/repository_segment.py @@ -12,6 +12,8 @@ from bloom.logger import logger from bloom.domain.segment import Segment +from bloom.domain.vessel import Vessel + from bloom.domain.vessel_last_position import VesselLastPosition from bloom.domain.zone import Zone from bloom.infra.database import sql_model @@ -180,25 +182,43 @@ def get_vessel_attribute_by_segment(self, session: Session, segment_id: int) -> return result - def get_vessel_attribute_by_segment_created_updated_after(self, session: Session, segment_id: int, created_updated_after: datetime) -> str: - stmt = select( - sql_model.Vessel.country_iso3 - ).select_from( - sql_model.Segment - ).join( - sql_model.Excursion, sql_model.Segment.excursion_id == sql_model.Excursion.id - ).join( - sql_model.Vessel, sql_model.Excursion.vessel_id == sql_model.Vessel.id - ).filter( - sql_model.Segment.id == segment_id - ) - - result = session.execute(stmt).scalar() + def get_vessel_attribute_by_segment_created_updated_after(self, session: Session, segment_id: int, created_updated_after: datetime) -> Vessel: + stmt = ( + select( + sql_model.Vessel + + #ql_model.Vessel.id, + #sql_model.Vessel.mmsi, + #sql_model.Vessel.ship_name, + #sql_model.Vessel.country_iso3, + #sql_model.Vessel.imo + ) + .where( + or_( + and_( + sql_model.Segment.updated_at.is_(None), + sql_model.Segment.created_at > created_updated_after + ), + sql_model.Segment.updated_at > created_updated_after + ) + ) + .select_from(sql_model.Segment) + .join( + sql_model.Excursion, sql_model.Segment.excursion_id == sql_model.Excursion.id + ) + .join( + sql_model.Vessel, sql_model.Excursion.vessel_id == sql_model.Vessel.id + ) + .filter(sql_model.Segment.id == segment_id) + ) - return result -#.where( -# sql_model.Segment.updated_at > created_updated_after -# ) + vessel = session.execute(stmt).scalar() + if not vessel: + return None + else: + return VesselRepository.map_to_domain(vessel) + #df = pd.DataFrame(result, columns=["vessel_id", "vessel_mmsi", "ship_name", "vessel_country_iso3","vessel_imo"]) + #return df def batch_create_segment( self, session: Session, segments: list[Segment] diff --git a/backend/bloom/infra/repositories/repository_task_execution.py b/backend/bloom/infra/repositories/repository_task_execution.py index b3bdefae..46d88141 100644 --- a/backend/bloom/infra/repositories/repository_task_execution.py +++ b/backend/bloom/infra/repositories/repository_task_execution.py @@ -3,20 +3,37 @@ from bloom.infra.database import sql_model from sqlalchemy import select from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.sql.expression import update,asc,desc from sqlalchemy.orm import Session class TaskExecutionRepository: @staticmethod def get_point_in_time(session: Session, task_name: str) -> datetime: - stmt = select(sql_model.TaskExecution).where(sql_model.TaskExecution.task_name == task_name) - e = session.execute(stmt).scalar() + stmt = select(sql_model.TaskExecution)\ + .where(sql_model.TaskExecution.task_name == task_name)\ + .where(sql_model.TaskExecution.active == True) + e = session.execute(stmt).scalar_one_or_none() if not e: return datetime.fromtimestamp(0, timezone.utc) else: return e.point_in_time def set_point_in_time(session: Session, task_name: str, pit: datetime) -> None: - stmt = insert(sql_model.TaskExecution).values(task_name=task_name, point_in_time=pit).on_conflict_do_update( - index_elements=["task_name"], set_=dict(point_in_time=pit, updated_at=datetime.now(timezone.utc))) + stmt= ( update(sql_model.TaskExecution) + .where(sql_model.TaskExecution.task_name==task_name) + .where(sql_model.TaskExecution.active==True) + .values(active=False) + ) + session.execute(stmt) + subquery_delta=select(pit-sql_model.TaskExecution.point_in_time)\ + .select_from(sql_model.TaskExecution)\ + .where(sql_model.TaskExecution.task_name==task_name)\ + .order_by(desc(sql_model.TaskExecution.point_in_time))\ + .limit(1).subquery() + stmt = insert(sql_model.TaskExecution).values( + task_name=task_name, + point_in_time=pit, + delta=subquery_delta, + active=True) session.execute(stmt) diff --git a/backend/bloom/routers/requests.py b/backend/bloom/routers/requests.py index fd076880..79ac095d 100644 --- a/backend/bloom/routers/requests.py +++ b/backend/bloom/routers/requests.py @@ -55,7 +55,7 @@ class OrderByEnum(str, Enum): class DatetimeRangeRequest(BaseModel): start_at: datetime = Field(default=datetime.now()-timedelta(days=7)) - end_at: datetime = datetime.now() + end_at: datetime= datetime.now() class OrderByRequest(BaseModel): order: OrderByEnum = OrderByEnum.ascending diff --git a/backend/bloom/routers/v1/metrics.py b/backend/bloom/routers/v1/metrics.py index c19a9c19..0f3e6cdf 100644 --- a/backend/bloom/routers/v1/metrics.py +++ b/backend/bloom/routers/v1/metrics.py @@ -20,6 +20,10 @@ ResponseMetricsVesselTotalTimeActivityByActivityTypeSchema) from bloom.routers.requests import DatetimeRangeRequest,OrderByRequest,PageParams,CachedRequest from bloom.dependencies import ( X_API_KEY_HEADER, check_apikey,cache) +from bloom.routers.requests import ( + RangeHeader, + RangeHeaderParser +) from bloom.domain.metrics import TotalTimeActivityTypeRequest from fastapi.encoders import jsonable_encoder @@ -42,25 +46,43 @@ async def read_metrics_vessels_in_activity_total(request: Request, payload=MetricsService.getVesselsInActivity(datetime_range=datetime_range, pagination=pagination, order=order) - + return jsonable_encoder(payload) + +@router.get("/metrics/vessels-at-sea") +async def list_vessel_at_sea( + request: Request, + datetime_range: DatetimeRangeRequest = Depends(), + key: str = Depends(X_API_KEY_HEADER), # used by @cache +): + check_apikey(key) + use_cases = UseCases() + MetricsService = use_cases.metrics_service() + return MetricsService.getVesselsAtSea(datetime_range=datetime_range) + + @router.get("/metrics/zone-visited") @cache -async def read_zone_visited_total(request: Request, - datetime_range: DatetimeRangeRequest = Depends(), - pagination: PageParams = Depends(), - order: OrderByRequest = Depends(), - caching: CachedRequest = Depends(), - key: str = Depends(X_API_KEY_HEADER),): +async def read_zone_visited_total( + request: Request, + datetime_range: DatetimeRangeRequest = Depends(), + category: Optional[str] = None, + pagination: PageParams = Depends(), + order: OrderByRequest = Depends(), + caching: CachedRequest = Depends(), + key: str = Depends(X_API_KEY_HEADER), +): check_apikey(key) use_cases = UseCases() MetricsService=use_cases.metrics_service() payload=MetricsService.getZoneVisited(datetime_range=datetime_range, + category=category, pagination=pagination, order=order) return jsonable_encoder(payload) + @router.get("/metrics/zones/{zone_id}/visiting-time-by-vessel") @cache async def read_metrics_zone_visiting_time_by_vessel(request: Request, @@ -80,20 +102,24 @@ async def read_metrics_zone_visiting_time_by_vessel(request: Request, order=order) return jsonable_encoder(payload) -""" -@router.get("/metrics/vessels/{vessel_id}/activity/{activity_type}") -@cache -async def read_metrics_vessels_visits_by_activity_type(request: Request, - vessel_id: int, - activity_type: TotalTimeActivityTypeRequest = Depends(), +@router.get("/metrics/vessels/time-by-zone") +# @cache +async def read_metrics_all_vessels_visiting_time_by_zone(request: Request, + vessel_id: Optional[int] = None, + category: Optional[str] = None, + sub_category: Optional[str] = None, datetime_range: DatetimeRangeRequest = Depends(), - caching: CachedRequest = Depends(), + pagination: PageParams = Depends(), + order: OrderByRequest = Depends(), key: str = Depends(X_API_KEY_HEADER),): check_apikey(key) use_cases = UseCases() MetricsService=use_cases.metrics_service() - payload=MetricsService.getVesselVisitsByActivityType( + payload=MetricsService.getVesselVisitingTimeByZone( vessel_id=vessel_id, - activity_type=activity_type, - datetime_range=datetime_range) - return jsonable_encoder(payload)""" \ No newline at end of file + datetime_range=datetime_range, + pagination=pagination, + order=order, + category=category, + sub_category=sub_category) + return jsonable_encoder(payload) diff --git a/backend/bloom/routers/v1/vessels.py b/backend/bloom/routers/v1/vessels.py index 24d7242c..424f7e63 100644 --- a/backend/bloom/routers/v1/vessels.py +++ b/backend/bloom/routers/v1/vessels.py @@ -2,7 +2,7 @@ from redis import Redis from bloom.config import settings from bloom.container import UseCases -from typing import Any +from typing import Any, Optional import json from bloom.config import settings from bloom.container import UseCases @@ -120,7 +120,7 @@ async def get_vessel_last_position(request: Request, # used by @cache async def list_vessel_excursions(request: Request, # used by @cache vessel_id: int, nocache:bool=False, # used by @cache - datetime_range: DatetimeRangeRequest = Depends(), + datetime_range: DatetimeRangeRequest= Depends(), pagination: PageParams = Depends(), order: OrderByRequest = Depends(), key: str = Depends(X_API_KEY_HEADER)): @@ -132,7 +132,12 @@ async def list_vessel_excursions(request: Request, # used by @cache json_data={} with db.session() as session: json_data = [json.loads(p.model_dump_json() if p else "{}") - for p in excursion_repository.get_excursions_by_vessel_id(session,vessel_id)] + for p in excursion_repository.get_excursions_by_vessel_id( + session, + vessel_id, + datetime_range, + pagination=pagination, + order=order)] return json_data diff --git a/backend/bloom/services/metrics.py b/backend/bloom/services/metrics.py index 854c518b..e1e8fa9e 100644 --- a/backend/bloom/services/metrics.py +++ b/backend/bloom/services/metrics.py @@ -1,10 +1,13 @@ from pydantic import BaseModel, Field from contextlib import AbstractContextManager from dependency_injector.providers import Callable -from sqlalchemy import select, func, and_, or_, text, literal_column +from sqlalchemy import select, func, and_, or_, text, literal_column, asc, desc from bloom.infra.database import sql_model from bloom.infra.database.database_manager import Base from bloom.routers.requests import DatetimeRangeRequest,OrderByRequest,OrderByEnum, PageParams +from bloom.routers.requests import RangeHeader, PaginatedResult +from typing import Any, List, Union, Optional +from sqlalchemy.orm import Session from fastapi.encoders import jsonable_encoder from bloom.domain.vessel import Vessel,VesselListView @@ -16,7 +19,8 @@ from bloom.domain.metrics import (ResponseMetricsVesselInActivitySchema, ResponseMetricsZoneVisitedSchema, ResponseMetricsZoneVisitingTimeByVesselSchema, - ResponseMetricsVesselTotalTimeActivityByActivityTypeSchema) + ResponseMetricsVesselTotalTimeActivityByActivityTypeSchema, + ResponseMetricsVesselVisitingTimeByZoneSchema) class MetricsService(): def __init__( @@ -36,22 +40,37 @@ def getVesselsInActivity(self, # arrival time in range start_at/end_at # departure <= start_at and ( arrival == None or arrival >= end_at) stmt=select(sql_model.Vessel, - func.sum(sql_model.Excursion.total_time_at_sea).label("total_time_at_sea") + func.sum(sql_model.Segment.segment_duration).label("total_time_in_mpa") + func.sum(sql_model.Segment.segment_duration).label("total_time_in_mpa") )\ .select_from(sql_model.Segment)\ - .join(sql_model.Excursion, sql_model.Segment.excursion_id == sql_model.Excursion.id)\ - .join(sql_model.Vessel, sql_model.Excursion.vessel_id == sql_model.Vessel.id)\ + .join(sql_model.Excursion,sql_model.Segment.excursion_id==sql_model.Excursion.id)\ + .join(sql_model.Vessel,sql_model.Vessel.id ==sql_model.Excursion.vessel_id)\ + .join(sql_model.Excursion,sql_model.Segment.excursion_id==sql_model.Excursion.id)\ + .join(sql_model.Vessel,sql_model.Vessel.id ==sql_model.Excursion.vessel_id)\ .where( - or_( - sql_model.Excursion.arrival_at.between(datetime_range.start_at,datetime_range.end_at), - and_(sql_model.Excursion.departure_at <= datetime_range.end_at, - or_(sql_model.Excursion.arrival_at == None, sql_model.Excursion.arrival_at >= datetime_range.end_at ))) + sql_model.Segment.in_amp_zone == True + sql_model.Segment.in_amp_zone == True )\ - .group_by(sql_model.Vessel.id,sql_model.Excursion.total_time_at_sea) + .where( + and_( + sql_model.Segment.timestamp_start <= datetime_range.end_at, + or_(sql_model.Segment.timestamp_end>= datetime_range.start_at, + sql_model.Segment.timestamp_end == None + ) + ) + )\ + .group_by(sql_model.Vessel) + .where( + sql_model.Segment.timestamp_start.between(datetime_range.start_at,datetime_range.end_at), + sql_model.Segment.timestamp_end.between(datetime_range.start_at,datetime_range.end_at),)\ + .group_by(sql_model.Vessel) stmt = stmt.offset(pagination.offset) if pagination.offset != None else stmt - stmt = stmt.order_by(sql_model.Excursion.total_time_at_sea.asc())\ + stmt = stmt.order_by(asc("total_time_in_mpa"))\ + stmt = stmt.order_by(asc("total_time_in_mpa"))\ if order.order == OrderByEnum.ascending \ - else stmt.order_by(sql_model.Excursion.total_time_at_sea.desc()) + else stmt.order_by(desc("total_time_in_mpa")) + else stmt.order_by(desc("total_time_in_mpa")) stmt = stmt.limit(pagination.limit) if pagination.limit != None else stmt payload=session.execute(stmt).all() # payload contains a list of sets(Vessel,datetime.timedelta) @@ -64,10 +83,35 @@ def getVesselsInActivity(self, )\ for item in payload] + def getVesselsAtSea(self, + datetime_range: DatetimeRangeRequest, + ): + with self.session_factory() as session: + stmt = ( + select(func.count(distinct(sql_model.Vessel.id))) + .select_from(sql_model.Excursion) + .join( + sql_model.Vessel, + sql_model.Excursion.vessel_id == sql_model.Vessel.id, + ) + .where( + or_( + sql_model.Excursion.arrival_at.between( + datetime_range.start_at, datetime_range.end_at + ), + sql_model.Excursion.arrival_at == None, + ) + ) + ) + return session.execute(stmt).scalar() + + def getZoneVisited(self, datetime_range: DatetimeRangeRequest, pagination: PageParams, - order: OrderByRequest): + order: OrderByRequest, + category: Optional[str]=None, + ): payload=[] with self.session_factory() as session: stmt=select( @@ -78,11 +122,16 @@ def getZoneVisited(self, .join(sql_model.RelSegmentZone,sql_model.RelSegmentZone.zone_id == sql_model.Zone.id)\ .join(sql_model.Segment,sql_model.RelSegmentZone.segment_id == sql_model.Segment.id)\ .where( - or_( - sql_model.Segment.timestamp_start.between(datetime_range.start_at,datetime_range.end_at), - sql_model.Segment.timestamp_end.between(datetime_range.start_at,datetime_range.end_at),) - )\ + and_( + sql_model.Segment.timestamp_start <= datetime_range.end_at, + or_(sql_model.Segment.timestamp_end>= datetime_range.start_at, + sql_model.Segment.timestamp_end == None + ) + ) + )\ .group_by(sql_model.Zone.id) + if (category): + stmt = stmt.where(sql_model.Zone.category == category) stmt = stmt.order_by(func.sum(sql_model.Segment.segment_duration).asc())\ if order.order == OrderByEnum.ascending \ else stmt.order_by(func.sum(sql_model.Segment.segment_duration).desc()) @@ -102,8 +151,8 @@ def getZoneVisited(self, def getZoneVisitingTimeByVessel(self, zone_id: int, datetime_range: DatetimeRangeRequest, - pagination: PageParams, - order: OrderByRequest): + order: OrderByRequest, + pagination: PageParams,): payload=[] with self.session_factory() as session: @@ -117,11 +166,14 @@ def getZoneVisitingTimeByVessel(self, .join(sql_model.Segment, sql_model.RelSegmentZone.segment_id == sql_model.Segment.id)\ .join(sql_model.Excursion, sql_model.Excursion.id == sql_model.Segment.excursion_id)\ .join(sql_model.Vessel, sql_model.Excursion.vessel_id == sql_model.Vessel.id)\ + .where(sql_model.Zone.id == zone_id)\ .where( - and_(sql_model.Zone.id == zone_id, - or_( - sql_model.Segment.timestamp_start.between(datetime_range.start_at,datetime_range.end_at), - sql_model.Segment.timestamp_end.between(datetime_range.start_at,datetime_range.end_at),)) + and_( + sql_model.Segment.timestamp_start <= datetime_range.end_at, + or_(sql_model.Segment.timestamp_end>= datetime_range.start_at, + sql_model.Segment.timestamp_end == None + ) + ) )\ .group_by(sql_model.Zone.id,sql_model.Vessel.id) @@ -144,6 +196,54 @@ def getZoneVisitingTimeByVessel(self, )\ for item in payload] + def getVesselVisitingTimeByZone(self, + order: OrderByRequest, + datetime_range: DatetimeRangeRequest, + pagination: PageParams, + vessel_id: int = None, + category:Optional[str]=None, + sub_category:Optional[str]=None, + ): + payload=[] + with self.session_factory() as session: + stmt=select(sql_model.Vessel, + sql_model.Zone, + func.sum(sql_model.Segment.segment_duration).label("vessel_visiting_time_by_zone") + )\ + .select_from(sql_model.Vessel)\ + .join(sql_model.Excursion, sql_model.Excursion.vessel_id == sql_model.Vessel.id)\ + .join(sql_model.Segment, sql_model.Segment.excursion_id == sql_model.Excursion.id)\ + .join(sql_model.RelSegmentZone, sql_model.RelSegmentZone.segment_id == sql_model.Segment.id)\ + .join(sql_model.Zone, sql_model.Zone.id == sql_model.RelSegmentZone.zone_id)\ + .where( + and_( + sql_model.Segment.timestamp_start <= datetime_range.end_at, + or_(sql_model.Segment.timestamp_end>= datetime_range.start_at, + sql_model.Segment.timestamp_end == None + ) + ) + )\ + .group_by(sql_model.Vessel, + sql_model.Zone,) + if category: + stmt = stmt.where(sql_model.Zone.category == category) + if sub_category: + stmt = stmt.where(sql_model.Zone.sub_category == sub_category) + stmt = stmt.order_by(func.sum(sql_model.Segment.segment_duration).asc())\ + if order.order == OrderByEnum.ascending \ + else stmt.order_by(func.sum(sql_model.Segment.segment_duration).desc()) + stmt = stmt.offset(pagination.offset) if pagination.offset != None else stmt + stmt = stmt.limit(pagination.limit) if pagination.limit != None else stmt + if vessel_id is not None: + stmt=stmt.where(sql_model.Vessel.id==vessel_id) + + + return [ResponseMetricsVesselVisitingTimeByZoneSchema( + vessel=VesselListView(**VesselRepository.map_to_domain(model[0]).model_dump()), + zone=ZoneListView(**ZoneRepository.map_to_domain(model[1]).model_dump()), + vessel_visiting_time_by_zone=model[2]) for model in session.execute(stmt).all()] + + def getVesselVisitsByActivityType(self, vessel_id: int, activity_type: TotalTimeActivityTypeRequest, @@ -155,12 +255,15 @@ def getVesselVisitsByActivityType(self, func.sum(sql_model.Excursion.total_time_at_sea).label("total_activity_time") )\ .select_from(sql_model.Excursion)\ + .where(sql_model.Excursion.vessel_id == vessel_id)\ .where( - and_(sql_model.Excursion.vessel_id == vessel_id, - or_( - sql_model.Excursion.departure_at.between(datetime_range.start_at,datetime_range.end_at), - sql_model.Excursion.arrival_at.between(datetime_range.start_at,datetime_range.end_at),)) - )\ + and_( + sql_model.Excursion.departure_at <= datetime_range.end_at, + or_(sql_model.Excursion.arrival_at>= datetime_range.start_at, + sql_model.Excursion.arrival_at == None + ) + ) + )\ .group_by(sql_model.Excursion.vessel_id)\ .union(select( literal_column(vessel_id), diff --git a/backend/bloom/tasks/create_update_excursions_segments.py b/backend/bloom/tasks/create_update_excursions_segments.py index 1411a5da..3f97bb27 100644 --- a/backend/bloom/tasks/create_update_excursions_segments.py +++ b/backend/bloom/tasks/create_update_excursions_segments.py @@ -17,7 +17,9 @@ from bloom.logger import logger from bloom.domain.rel_segment_zone import RelSegmentZone from bloom.infra.repositories.repository_rel_segment_zone import RelSegmentZoneRepository -#from bloom.infra.repositories.repository_port import PortRepository +from bloom.infra.repositories.repository_port import PortRepository +from bloom.domain.metrics import Metrics #1 + warnings.filterwarnings("ignore") # minimal distance to consider a vessel being in a port (in meters) @@ -101,7 +103,8 @@ def run(): vessel_position_repository = use_cases.vessel_position_repository() port_repository = use_cases.port_repository() excursion_repository = use_cases.excursion_repository() - + metrics_repository = use_cases.metrics_repository() #1 + metrics_repository = use_cases.metrics_repository() #1 nb_created_excursion = 0 nb_closed_excursion = 0 @@ -307,30 +310,62 @@ def get_time_of_departure(): new_rels = [] excursions = {} segments = [] + new_metricss=[] + new_metricss=[] max_created_updated = point_in_time + i=0 for segment, zones in result.items(): segment_in_zone = False + vessel_attributes= segment_repository.get_vessel_attribute_by_segment_created_updated_after(session, segment.id, point_in_time)#metrics_repository.get_vessel_excursion_segment_by_id(session,segment.id) #1 + types='AT_SEA' + zones_names=[] for zone in zones: if segment.type == "DEFAULT_AIS": # Issue 234: ne pas créer les relations pour les segments en default AIS + types='DEFAULT_AIS' continue segment_in_zone = True new_rels.append(RelSegmentZone(segment_id=segment.id, zone_id=zone.id)) if zone.category == "amp": segment.in_amp_zone = True + types='in_amp' elif zone.category == "Fishing coastal waters (6-12 NM)": - country_iso3 = segment_repository.get_vessel_attribute_by_segment_created_updated_after(session, segment.id, point_in_time) + country_iso3 = vessel_attributes.country_iso3#df.loc[0, "vessel_country_iso3"] beneficiaries = zone.json_data.get("beneficiaries", []) if country_iso3 not in beneficiaries: segment.in_zone_with_no_fishing_rights = True + types='in_zone_with_no_fishing_rights' elif zone.category == "Clipped territorial seas": - country_iso3 = segment_repository.get_vessel_attribute_by_segment_created_updated_after(session, segment.id, point_in_time) + country_iso3 = vessel_attributes.country_iso3#df.loc[0, "vessel_country_iso3"] if country_iso3 != "FRA": segment.in_zone_with_no_fishing_rights = True + types='in_zone_with_no_fishing_rights' elif zone.category == "Territorial seas": segment.in_territorial_waters = True + types="in_territorial_water" #1 + #elif zone.category == "white zone": #prospectif + # segment.in_white_zone = True + # types="white_zone" + duration_total_seconds = segment.segment_duration.total_seconds() + + new_metrics= Metrics(#1 + timestamp = segment.timestamp_start, #1 + vessel_id = vessel_attributes.id,#df.loc[0, 'vessel_id'] if not df.empty else None, #1 + vessel_mmsi = vessel_attributes.mmsi,#df.loc[0,'vessel_mmsi'] if not df.empty else None, #1 + ship_name = vessel_attributes.ship_name,#df.loc[0,'ship_name'] if not df.empty else None, #1 + vessel_country_iso3=vessel_attributes.country_iso3,# df.loc[0,'vessel_country_iso3'] if not df.empty else None, + vessel_imo=vessel_attributes.imo,#df.loc[0,'vessel_imo'] if not df.empty else None, + type = types, #1 + duration_total = duration_total_seconds, #fonctionne si 1 segment = zone max #1 + duration_fishing = duration_total_seconds if segment.type == 'FISHING' else None, #1 + zone_name = zone.name,#1 + zone_sub_category=zone.sub_category + ) #1 if segment_in_zone: segments.append(segment) + + new_metricss.append(new_metrics) #1 + # Mise à jour de l'excursion avec le temps passé dans chaque type de zone excursion = excursions.get(segment.excursion_id, excursion_repository.get_excursion_by_id(session, segment.excursion_id)) @@ -377,6 +412,8 @@ def get_time_of_departure(): logger.info(f"{len(segments)} segments mis à jour") RelSegmentZoneRepository.batch_create_rel_segment_zone(session, new_rels) logger.info(f"{len(new_rels)} associations(s) créées") + metrics_repository.batch_create_metrics(session, new_metricss) #1 + logger.info(f"{len(new_metricss)} metrics(s) créés") #1 vessels_ids = set(exc.vessel_id for exc in excursions.values()) nb_last = segment_repository.update_last_segments(session, vessels_ids) logger.info(f"{nb_last} derniers segments mis à jour") diff --git a/backend/bloom/tasks/load_dim_zone_amp_from_csv.py b/backend/bloom/tasks/load_dim_zone_amp_from_csv.py index 38b9a794..3adbc2a1 100644 --- a/backend/bloom/tasks/load_dim_zone_amp_from_csv.py +++ b/backend/bloom/tasks/load_dim_zone_amp_from_csv.py @@ -9,7 +9,7 @@ from bloom.domain.zone import Zone from bloom.logger import logger -FIC_ZONE = ["french_metropolitan_mpas.csv", "fishing_coastal_waters.csv", "territorial_seas.csv"] +FIC_ZONE = ["french_metropolitan_mpas.csv","fishing_coastal_waters.csv", "territorial_seas.csv","clipped_territorial_seas.csv"] def map_to_domain(row: pd.Series) -> Zone: diff --git a/backend/bloom/tasks/load_spire_data_from_api.py b/backend/bloom/tasks/load_spire_data_from_api.py index 4a6ee28c..6948f9bf 100644 --- a/backend/bloom/tasks/load_spire_data_from_api.py +++ b/backend/bloom/tasks/load_spire_data_from_api.py @@ -9,6 +9,7 @@ from bloom.infra.http.spire_api_utils import map_raw_vessels_to_domain from bloom.logger import logger from pydantic import ValidationError +from bloom.infra.repositories.repository_task_execution import TaskExecutionRepository def run(dump_path: str) -> None: @@ -24,9 +25,10 @@ def run(dump_path: str) -> None: vessels: list[Vessel] = vessel_repository.get_vessels_list(session) if len(vessels) > 0: raw_vessels = spire_traffic_usecase.get_raw_vessels_from_spire(vessels) + current_datetime=datetime.now(timezone.utc) if dump_path is not None: try: - now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") + now =current_datetime.strftime("%Y-%m-%dT%H:%M:%S") dump_file = Path(args.dump_path, f"spire_{now}").with_suffix(".json") with dump_file.open("wt") as handle: json.dump(raw_vessels, handle) @@ -38,6 +40,9 @@ def run(dump_path: str) -> None: spire_ais_data, session, ) + TaskExecutionRepository.set_point_in_time(session, + "load_spire_data_from_api", + current_datetime) session.commit() except ValidationError as e: logger.error("Erreur de validation des données JSON") diff --git a/frontend/.env.local b/frontend/.env.local deleted file mode 100644 index 1152a549..00000000 --- a/frontend/.env.local +++ /dev/null @@ -1,5 +0,0 @@ -NEXT_PUBLIC_MAPTILER_TO=3IWed9ZbNv0p8UVD6Ogv -NEXT_PUBLIC_DOMAIN=http://localhost:3000 - -NEXT_PUBLIC_BACKEND_BASE_URL=http://localhost:8000/api/v1 -NEXT_PUBLIC_BACKEND_API_KEY=bloom \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index 6165e9f6..f34b6d03 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -28,6 +28,7 @@ yarn-error.log* .env.development.local .env.test.local .env.production.local +.env.local # turbo .turbo @@ -35,4 +36,5 @@ yarn-error.log* .contentlayer .env -package-lock.json \ No newline at end of file +package-lock.json +.clever.json \ No newline at end of file diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 00000000..82c07764 --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,21 @@ +{ + "files.autoSave": "onWindowChange", + "editor.tabSize": 2, + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[html]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[css]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/frontend/README.md b/frontend/README.md index 7a260a2e..ec150e63 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -11,7 +11,7 @@ npx create-next-app -e https://github.com/shadcn/next-template ## Features - Next.js 13 App Directory -- [Radix UI]() Primitives +- [Shadcn UI](https://ui.shadcn.com/) - [Tailwind CSS](https://tailwindcss.com/docs/installation) - Icons from [Lucide](https://lucide.dev) and [Heroicons](https://heroicons.com/) - Dark mode with `next-themes` @@ -22,13 +22,13 @@ npx create-next-app -e https://github.com/shadcn/next-template ## Getting started -1. First unzip all archives in `./public/data/geometries` folder +- First unzip all archives in `./public/data/geometries` folder -```shell -gunzip *.gz -``` + ```shell + gunzip *.gz + ``` -2. Then as usual, install dependencies and start dev server +- Then as usual, install dependencies and start dev server ```shell npm install @@ -40,6 +40,15 @@ npm run dev For now, GeoJSON files are served via a public endpoint provided by NextJS. Soon, this will be moved to NextJS server routes to enhance caching. Some geographic features will be displayed as DeckGL GeoJsonLayer in this first version but to enhance vizualization performance these static layers (e.g harbour locations, marine protected areas... and even historic vessels tracks) should be served as vector tiles for example with [PMTiles](https://docs.protomaps.com/pmtiles/) files stored in the cloud (a MinIO instance for example). PMTiles suuport in DeckGL [should be evaluated](https://github.com/visgl/deck.gl/discussions/7861), MVT/MBTiles [are already supported](https://deck.gl/docs/api-reference/geo-layers/mvt-layer). +## Clever Cloud + +Deploy with Clever Cloud: + +```bash +cd frontend +clever deploy -f +``` + ## License Licensed under the [MIT license](https://github.com/shadcn/ui/blob/main/LICENSE.md). diff --git a/frontend/app/api/login/route.ts b/frontend/app/api/login/route.ts new file mode 100644 index 00000000..ed1c3cb2 --- /dev/null +++ b/frontend/app/api/login/route.ts @@ -0,0 +1,21 @@ +import { cookies } from "next/headers" +import { NextResponse } from "next/server" + +export async function POST(request: Request) { + const { password } = await request.json() + + if (password === process.env.APP_PASSWORD) { + const cookieStore = cookies() + + cookieStore.set("auth-token", "authenticated", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + }) + + return new NextResponse("Logged in", { status: 200 }) + } + + return new NextResponse("Invalid password", { status: 401 }) +} diff --git a/frontend/app/api/logout/route.ts b/frontend/app/api/logout/route.ts new file mode 100644 index 00000000..a947c8e2 --- /dev/null +++ b/frontend/app/api/logout/route.ts @@ -0,0 +1,16 @@ +import { cookies } from "next/headers" +import { NextResponse } from "next/server" + +export async function POST(request: Request) { + const cookieStore = cookies() + + // Remove the auth cookie by setting it to expire immediately + cookieStore.set("auth-token", "", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + expires: new Date(0), // Setting to past date effectively deletes the cookie + }) + + return new NextResponse("Logged out", { status: 200 }) +} diff --git a/frontend/app/api/vessels/positions/route.ts b/frontend/app/api/vessels/positions/route.ts new file mode 100644 index 00000000..8c3ad7ef --- /dev/null +++ b/frontend/app/api/vessels/positions/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server" +import { getVesselsLatestPositions } from "@/services/backend-rest-client" + +export async function GET() { + try { + const response = await getVesselsLatestPositions() + return NextResponse.json(response?.data) + } catch (error) { + console.error( + "An error occurred while fetching vessels latest positions:", + error + ) + return NextResponse.json([], { status: 500 }) + } +} diff --git a/frontend/app/api/vessels/route.ts b/frontend/app/api/vessels/route.ts new file mode 100644 index 00000000..8a55496b --- /dev/null +++ b/frontend/app/api/vessels/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server" +import { getVessels } from "@/services/backend-rest-client" + +export async function GET() { + try { + const response = await getVessels() + return NextResponse.json(response?.data) + } catch (error) { + console.error("An error occurred while fetching vessels:", error) + return NextResponse.json([], { status: 500 }) + } +} diff --git a/frontend/app/api/zones/route.ts b/frontend/app/api/zones/route.ts new file mode 100644 index 00000000..dd2d7bba --- /dev/null +++ b/frontend/app/api/zones/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server" +import { getZones } from "@/services/backend-rest-client" + +export async function GET() { + try { + const response = await getZones() + return NextResponse.json(response?.data) + } catch (error) { + console.error("Error fetching zones:", error) + return NextResponse.json([], { status: 500 }) + } +} diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 38b356f9..e04fb377 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -1,84 +1,53 @@ -import DashboardHeader from "@/components/dashboard/dashboard-header" -import DashboardOverview from "@/components/dashboard/dashboard-overview" - -import { getTopVesselsInActivity, getTopZonesVisited } from "@/services/backend-rest-client" -import { format } from "@/libs/dateUtils"; +"use client" -const DAYS_SINCE_TODAY = 360 -const TOP_ITEMS_SIZE = 5 +import { useMemo, useState } from "react" +import { useDashboardData } from "@/services/dashboard.service" -async function fetchTopVesselsInActivity() { - try { - let today = new Date(); - let startAt = new Date(new Date().setDate(today.getDate() - DAYS_SINCE_TODAY)); - const response = await getTopVesselsInActivity(format(startAt), format(today), TOP_ITEMS_SIZE); - return response?.data; - - } catch(error) { - console.log("An error occured while fetching top vessels in activity : " + error) - return []; - } -} - -async function fetchTopAmpsVisited() { - try { - let today = new Date(); - let startAt = new Date(new Date().setDate(today.getDate() - DAYS_SINCE_TODAY)); - const response = await getTopZonesVisited(format(startAt), format(today), TOP_ITEMS_SIZE); - return response?.data; - - } catch(error) { - console.log("An error occured while fetching top amps visited: " + error) - return []; - } -} +import { getDateRange } from "@/libs/dateUtils" +import DashboardHeader from "@/components/dashboard/dashboard-header" +import DashboardOverview from "@/components/dashboard/dashboard-overview" -async function fetchTotalVesselsInActivity() { - try { - let today = new Date(); - let startAt = new Date(new Date().setDate(today.getDate() - DAYS_SINCE_TODAY)); - // TODO(CT): replace with new endpoint (waiting for Hervé) - const response = await getTopVesselsInActivity(format(startAt), format(today), 10000); // high value to capture all data - return response?.data?.length; - - } catch(error) { - console.log("An error occured while fetching top amps visited: " + error) - return 0; - } -} +export default function DashboardPage() { + const [selectedDays, setSelectedDays] = useState(7) + const { startAt, endAt } = useMemo(() => { + return getDateRange(selectedDays) + }, [selectedDays]) -async function fetchTotalAmpsVisited() { - try { - let today = new Date(); - let startAt = new Date(new Date().setDate(today.getDate() - DAYS_SINCE_TODAY)); - // TODO(CT): replace with new endpoint (waiting for Hervé) - const response = await getTopZonesVisited(format(startAt), format(today), 10000); // high value to capture all data - return response?.data?.length; - - } catch(error) { - console.log("An error occured while fetching top amps visited: " + error) - return 0; - } -} + const { + topVesselsInActivity, + topAmpsVisited, + totalVesselsInActivity, + totalAmpsVisited, + totalVesselsTracked, + isLoading, + } = useDashboardData(startAt, endAt) -export default async function DashboardPage() { - const topVesselsInActivity = await fetchTopVesselsInActivity(); - const topAmpsVisited = await fetchTopAmpsVisited(); - const totalVesselsInActivity = await fetchTotalVesselsInActivity(); - const totalAmpsVisited = await fetchTotalAmpsVisited(); + console.log("totalVesselsTracked", totalVesselsTracked) return ( -
-
- -
- -
- +
+
+
+ +
+ +
+ { + setSelectedDays(Number(value)) + }} + topVesselsInActivityLoading={isLoading.topVesselsInActivity} + topAmpsVisitedLoading={isLoading.topAmpsVisited} + totalVesselsActiveLoading={isLoading.totalVesselsInActivity} + totalAmpsVisitedLoading={isLoading.totalAmpsVisited} + totalVesselsTrackedLoading={isLoading.totalVesselsTracked} + /> +
) diff --git a/frontend/app/details/amp/[id]/page.tsx b/frontend/app/details/amp/[id]/page.tsx index 582a9e63..5d024935 100644 --- a/frontend/app/details/amp/[id]/page.tsx +++ b/frontend/app/details/amp/[id]/page.tsx @@ -1,9 +1,61 @@ -import mockData from "@/public/data/mock-data-details.json" +"use client" +import { useMemo, useState } from "react" +import { getZoneDetails } from "@/services/backend-rest-client" +import { swrOptions } from "@/services/swr" +import { getCountryNameFromIso3 } from "@/utils/vessel.utils" +import useSWR from "swr" + +import { convertDurationInHours, getDateRange } from "@/libs/dateUtils" import DetailsContainer from "@/components/details/details-container" -export default function AmpDetailsPage() { - const ampDetails = mockData["ampDetails"] +export default function AmpDetailsPage({ params }: { params: { id: string } }) { + const [selectedDays, setSelectedDays] = useState(7) + + const { startAt, endAt } = useMemo(() => { + return getDateRange(selectedDays) + }, [selectedDays]) + + const { data: zoneVisits = [], isLoading } = useSWR( + [params.id, startAt, endAt], + () => getZoneDetails(params.id, startAt, endAt).then((res) => res.data), + swrOptions + ) + + const zoneDetails = useMemo(() => { + if (!zoneVisits[0]) { + return null + } + + const { zone } = zoneVisits[0] + return { + id: zone.id.toString(), + label: zone.name, + description: zone.sub_category, + relatedItemsType: "Vessels", + relatedItems: zoneVisits.map((visit) => { + const { vessel, zone_visiting_time_by_vessel } = visit + return { + id: vessel.id.toString(), + title: `${vessel.ship_name} - ${getCountryNameFromIso3(vessel.country_iso3)}`, + description: `IMO: ${vessel.imo} - MMSI: ${vessel.mmsi} - Type: ${vessel.type} - Length: ${vessel.length} m`, + value: `${convertDurationInHours(zone_visiting_time_by_vessel)}h`, + type: "vessels", + } + }), + } + }, [zoneVisits]) - return + return ( +
+ { + setSelectedDays(Number(value)) + }} + defaultDateRange={"7"} + isLoading={isLoading} + /> +
+ ) } diff --git a/frontend/app/details/layout.tsx b/frontend/app/details/layout.tsx index 4b81a286..804c41d1 100644 --- a/frontend/app/details/layout.tsx +++ b/frontend/app/details/layout.tsx @@ -6,9 +6,11 @@ interface RootLayoutProps { export default function Layout({ children }: RootLayoutProps) { return ( -
+
-
{children}
+
+ {children} +
) } diff --git a/frontend/app/details/vessel/[id]/page.tsx b/frontend/app/details/vessel/[id]/page.tsx index 1359fa8f..db01e0bb 100644 --- a/frontend/app/details/vessel/[id]/page.tsx +++ b/frontend/app/details/vessel/[id]/page.tsx @@ -1,9 +1,11 @@ -import mockData from "@/public/data/mock-data-details.json" - import DetailsContainer from "@/components/details/details-container" -export default function VesselDetailsPage() { - const vesselDetails = mockData["vesselDetails"] +export default async function VesselDetailsPage({ + params, +}: { + params: { id: string } +}) { + // const vesselDetails = await getVesselDetails(params.id) - return + return } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 0a602642..76c39297 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -48,7 +48,7 @@ export default function RootLayout({ children }: RootLayoutProps) { /> - +
{children}
diff --git a/frontend/app/loading.tsx b/frontend/app/loading.tsx new file mode 100644 index 00000000..95a9dde4 --- /dev/null +++ b/frontend/app/loading.tsx @@ -0,0 +1,15 @@ +import Image from "next/image" + +export default function Loading() { + return ( +
+ Loading animation +
+ ) +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 00000000..70003c2b --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,79 @@ +"use client" + +import { useState } from "react" +import Image from "next/image" +import { useRouter } from "next/navigation" + +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +export default function Login() { + const router = useRouter() + const [password, setPassword] = useState("") + const [error, setError] = useState("") + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + + try { + const response = await fetch("/api/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }) + + if (response.ok) { + router.push("/dashboard") // or wherever you want to redirect after login + } else { + setError("Invalid password") + } + } catch (err) { + setError("An error occurred. Please try again.") + } + } + + return ( +
+
+
+ Login waves visual +
+
+
+

Welcome to TrawlWatch

+
+
+

Enter your password to access your account

+ + setPassword(e.target.value)} + required + /> + {error &&

{error}

} +
+ +
+
+
+ ) +} diff --git a/frontend/app/map/page.tsx b/frontend/app/map/page.tsx index b5b07611..f2f43b46 100644 --- a/frontend/app/map/page.tsx +++ b/frontend/app/map/page.tsx @@ -1,44 +1,105 @@ +"use client" + +import { useEffect, useState } from "react" +import { + getVessels, + getVesselsLatestPositions, +} from "@/services/backend-rest-client" + +import { Vessel, VesselPosition } from "@/types/vessel" +import { ZoneWithGeometry } from "@/types/zone" +import Spinner from "@/components/ui/custom/spinner" import LeftPanel from "@/components/core/left-panel" import MapControls from "@/components/core/map-controls" -import DemoMap from "@/components/core/map/main-map" +import Map from "@/components/core/map/main-map" import PositionPreview from "@/components/core/map/position-preview" -import { getVessels, getVesselsLatestPositions } from "@/services/backend-rest-client" async function fetchVessels() { try { - const response = await getVessels(); - return response?.data; - - } catch(error) { - console.log("An error occured while fetching vessels: " + error) - return []; - } + const response = await fetch("/api/vessels", { + cache: "force-cache", + }) + return await response.json() + } catch (error) { + console.log("An error occurred while fetching vessels: " + error) + return [] + } } -// TODO(CT): move this logic within a cron job -async function fetchLatestPositions() { +async function fetchZones() { try { - const response = await getVesselsLatestPositions(); - return response?.data; - - } catch(error) { - console.log("An error occured while fetching vessels latest positions: " + error) - return []; + const response = await fetch("/api/zones", { + cache: "force-cache", + }) + return await response.json() + } catch (error) { + console.error("An error occurred while fetching zones:", error) + return [] } } - -export default async function MapPage() { - const vessels = await fetchVessels(); - const latestPositions = await fetchLatestPositions(); - // TODO: create new client component dedicated to update store - // -> then Map + LeftPanel can just use storeProvider +export default function MapPage() { + const [vessels, setVessels] = useState([]) + const [latestPositions, setLatestPositions] = useState([]) + const [zones, setZones] = useState([]) + const [isLoadingVessels, setIsLoadingVessels] = useState(true) + const [isLoadingPositions, setIsLoadingPositions] = useState(true) + const [isLoadingZones, setIsLoadingZones] = useState(true) + + useEffect(() => { + const loadVessels = async () => { + const vesselsData = await fetchVessels() + setVessels(vesselsData) + setIsLoadingVessels(false) + } + loadVessels() + }, []) + + useEffect(() => { + const loadPositions = async () => { + const response = await fetch("/api/vessels/positions", { + cache: "force-cache", + next: { revalidate: 900 }, // 15 minutes + }) + const positionsData = await response.json() + setLatestPositions(positionsData) + setIsLoadingPositions(false) + } + loadPositions() + }, []) + + useEffect(() => { + const loadZones = async () => { + const zonesData = await fetchZones() + setZones(zonesData) + setIsLoadingZones(false) + } + if (zones.length === 0) { + loadZones() + } + }, [isLoadingZones, zones.length]) + + const isLoading = isLoadingVessels || isLoadingPositions || isLoadingZones + return ( <> - - + + + {isLoading && ( +
+ +
+ )} ) } diff --git a/frontend/components/core/command/vessel-finder.tsx b/frontend/components/core/command/vessel-finder.tsx index e6649b36..ab2deaba 100644 --- a/frontend/components/core/command/vessel-finder.tsx +++ b/frontend/components/core/command/vessel-finder.tsx @@ -1,6 +1,7 @@ "use client" import { useState } from "react" +import { getVesselFirstExcursionSegments } from "@/services/backend-rest-client" import { FlyToInterpolator } from "deck.gl" import { Vessel, VesselPosition } from "@/types/vessel" @@ -15,7 +16,6 @@ import { } from "@/components/ui/command" import { useMapStore } from "@/components/providers/map-store-provider" import { useVesselsStore } from "@/components/providers/vessels-store-provider" -import { getVesselFirstExcursionSegments } from "@/services/backend-rest-client" type Props = { wideMode: boolean @@ -33,14 +33,14 @@ export function VesselFinderDemo({ wideMode }: Props) { viewState, setViewState, } = useMapStore((state) => state) - const { vessels: allVessels } = useVesselsStore((state) => state); - const { latestPositions } = useMapStore((state) => state); + const { vessels: allVessels } = useVesselsStore((state) => state) + const { latestPositions } = useMapStore((state) => state) const onSelectVessel = async (vesselIdentifier: string) => { const vesselId = parseInt(vesselIdentifier.split(SEPARATOR)[3]) - const response = await getVesselFirstExcursionSegments(vesselId); + const response = await getVesselFirstExcursionSegments(vesselId) if (vesselId && !trackedVesselIDs.includes(vesselId)) { - addTrackedVessel(vesselId, response.data); + addTrackedVessel(vesselId, response) } if (vesselId) { const selectedVesselLatestPosition = latestPositions.find( diff --git a/frontend/components/core/left-panel.tsx b/frontend/components/core/left-panel.tsx index d0797f9c..ed7a321d 100644 --- a/frontend/components/core/left-panel.tsx +++ b/frontend/components/core/left-panel.tsx @@ -6,12 +6,13 @@ import TrawlWatchLogo from "@/public/trawlwatch.svg" import { ChartBarIcon } from "@heroicons/react/24/outline" import { motion, useAnimationControls } from "framer-motion" +import { Vessel } from "@/types/vessel" import NavigationLink from "@/components/ui/navigation-link" import { VesselFinderDemo } from "@/components/core/command/vessel-finder" +import { useVesselsStore } from "@/components/providers/vessels-store-provider" +import Spinner from "../ui/custom/spinner" import TrackedVesselsPanel from "./tracked-vessels-panel" -import { useVesselsStore } from "@/components/providers/vessels-store-provider" -import { Vessel } from "@/types/vessel" const containerVariants = { close: { @@ -42,24 +43,25 @@ const svgVariants = { } type LeftPanelProps = { - vessels: Vessel[]; + vessels: Vessel[] + isLoading: boolean } -export default function({ vessels }: LeftPanelProps) { +export default function LeftPanel({ vessels, isLoading }: LeftPanelProps) { const [isOpen, setIsOpen] = useState(false) const containerControls = useAnimationControls() const svgControls = useAnimationControls() - const { setVessels } = useVesselsStore((state) => state); - + const { setVessels } = useVesselsStore((state) => state) + useEffect(() => { - setVessels(vessels); - }, [vessels]); + setVessels(vessels) + }, [setVessels, vessels]) useEffect(() => { - const control = isOpen ? "open" : "close"; - containerControls.start(control); - svgControls.start(control); - }, [isOpen]) + const control = isOpen ? "open" : "close" + containerControls.start(control) + svgControls.start(control) + }, [containerControls, isOpen, svgControls]) const handleOpenClose = () => { setIsOpen(!isOpen) @@ -114,7 +116,7 @@ export default function({ vessels }: LeftPanelProps) {
- + {isLoading ? : }
state) - // Use a piece of state that changes when `activePosition` changes to force re-render - const [layerKey, setLayerKey] = useState(0) - function getColorFromValue(value: number): [number, number, number] { const scale = chroma.scale(["yellow", "red", "black"]).domain([0, 15]) const color = scale(value).rgb() return [Math.round(color[0]), Math.round(color[1]), Math.round(color[2])] } - useEffect(() => { - // This will change the key of the layer, forcing it to re-render when `activePosition` changes - setLayerKey((prevKey) => prevKey + 1) - }, [activePosition, trackedVesselIDs]) + const isVesselSelected = (vp: VesselPosition) => { + return ( + vp.vessel.id === activePosition?.vessel.id || + trackedVesselIDs.includes(vp.vessel.id) + ) + } + + // useEffect(() => { + // // This will change the key of the layer, forcing it to re-render when `activePosition` changes + // setLayerKey((prevKey) => prevKey + 1) + // }, [activePosition, trackedVesselIDs]) useEffect(() => { - setLatestPositions(vesselsPositions); - }, [vesselsPositions]) + setLatestPositions(vesselsPositions) + }, [setLatestPositions, vesselsPositions]) + + const onMapClick = ({ layer }: PickingInfo) => { + if (layer?.id !== 'vessels-latest-positions') { + setActivePosition(null); + } + } - const latestPositions = new ScatterplotLayer({ - id: `vessels-latest-positions-${layerKey}`, + const onVesselClick = ({ object }: PickingInfo) => { + setActivePosition(object as VesselPosition); + } + + const onZoneClick = () => { + return; + } + + const latestPositions = new IconLayer({ + id: `vessels-latest-positions`, data: vesselsPositions, getPosition: (vp: VesselPosition) => [ vp?.position?.coordinates[0], vp?.position?.coordinates[1], ], - stroked: true, - radiusUnits: "meters", - getRadius: (vp: VesselPosition) => vp.vessel.length, - radiusMinPixels: 3, - radiusMaxPixels: 25, - radiusScale: 200, - getFillColor: (vp: VesselPosition) => { - return vp.vessel.id === activePosition?.vessel.id || - trackedVesselIDs.includes(vp.vessel.id) - ? [128, 16, 189, 210] - : [16, 181, 16, 210] + getAngle: (vp: VesselPosition) => (vp.heading ? Math.round(vp.heading) : 0), + getIcon: () => "default", + iconAtlas: "../../../img/map-vessel.png", + iconMapping: { + default: { + x: 0, + y: 0, + width: 35, + height: 27, + mask: true, + }, + }, + getSize: 16, + getColor: (vp: VesselPosition) => { + return new Uint8ClampedArray( + isVesselSelected(vp) ? TRACKED_VESSEL_COLOR : VESSEL_COLOR + ) }, - getLineColor: [0, 0, 0], - getLineWidth: 3, + pickable: true, - onClick: ({ object }) => { - setActivePosition(object as VesselPosition) - setViewState({ - ...viewState, - longitude: object?.position?.coordinates[0], - latitude: object?.position?.coordinates[1], - zoom: 7, - pitch: 40, - transitionInterpolator: new FlyToInterpolator({ speed: 2 }), - transitionDuration: "auto", - }) + onClick: onVesselClick, + updateTriggers: { + getColor: [activePosition?.vessel.id, trackedVesselIDs], }, }) const tracksByVesselAndVoyage = trackedVesselSegments - .map(segments => toSegmentsGeo(segments)) - .map((segmentsGeo: VesselExcursionSegmentsGeo) => { - return new GeoJsonLayer({ - id: `${segmentsGeo.vesselId}_vessel_trail_${layerKey}`, + .map((segments) => toSegmentsGeo(segments)) + .map((segmentsGeo: VesselExcursionSegmentsGeo) => { + return new GeoJsonLayer({ + id: `${segmentsGeo.vesselId}_vessel_trail`, data: segmentsGeo, - getFillColor: (properties) => getColorFromValue(properties?.speed), - getLineColor: (properties) => getColorFromValue(properties?.speed), + getFillColor: (feature) => getColorFromValue(feature.properties?.speed), + getLineColor: (feature) => getColorFromValue(feature.properties?.speed), pickable: false, - stroked: false, - filled: true, - getLineWidth: 1, + stroked: true, + filled: false, + getLineWidth: 0.5, lineWidthMinPixels: 0.5, lineWidthMaxPixels: 3, lineWidthUnits: "pixels", lineWidthScale: 2, getPointRadius: 4, getTextSize: 12, + }) }) - }); - const positions_mesh_layer = new SimpleMeshLayer({ - id: `vessels-positions-mesh-layer-${layerKey}`, - data: vesselsPositions, - mesh: MESH_URL_LOCAL, - getPosition: (vp: VesselPosition) => [ - vp?.position?.coordinates[0], - vp?.position?.coordinates[1], - ], - getColor: (vp: VesselPosition) => { - return vp.vessel.id === activePosition?.vessel.id || - trackedVesselIDs.includes(vp.vessel.id) - ? [128, 16, 189, 210] - : [16, 181, 16, 210] + const getObjectType = ( + object: VesselPosition | ZoneWithGeometry | undefined + ) => { + if (!object) return null + return "vessel" in object ? "vessel" : "zone" + } + + const zoneLayer = new PolygonLayer({ + id: `zones-layer`, + data: zones, + getPolygon: (d: ZoneWithGeometry) => { + // Handle both Polygon and MultiPolygon types + if (d.geometry.type === "MultiPolygon") { + // Return the first polygon's coordinates for MultiPolygon + return d.geometry.coordinates[0] + } + // For single Polygon, return just the first coordinate ring (outer boundary) + return d.geometry.coordinates }, - getOrientation: (vp: VesselPosition) => [ - 0, - Math.round(vp.heading ? vp.heading : 0), - 90, - ], - getScale: (vp: VesselPosition) => [ - vp.vessel.length, - vp.vessel.length * 1.5, - vp.vessel.length / 1.5, - ], - scaleUnits: "pixels", - sizeScale: 100, - pickable: true, - onClick: ({ object }) => { - setActivePosition(object as VesselPosition) - setViewState({ - ...viewState, - longitude: object?.position?.coordinates[0], - latitude: object?.position?.coordinates[1], - zoom: 7, - transitionInterpolator: new FlyToInterpolator({ speed: 2 }), - transitionDuration: "auto", - }) + getFillColor: (d: ZoneWithGeometry) => { + switch (d.category) { + case "territorial_seas": + return [0, 0, 255, 50] + case "fishing_coastal_waters": + return [0, 255, 0, 50] + case "amp": + default: + return [255, 0, 0, 50] + } + }, + getLineColor: [0, 0, 0, 128], // reduced opacity for borders + getLineWidth: 1, + lineWidthUnits: "pixels", + lineWidthMinPixels: 1, + pickable: true, // disable picking if not needed + stroked: false, + filled: true, + wireframe: false, // disable wireframe for better performance + extruded: false, + parameters: { + depthTest: false, + blend: true, + blendFunc: [770, 771], // standard transparency blending }, - loaders: [OBJLoader], + onClick: onZoneClick }) const layers = [ - tracksByVesselAndVoyage, - latestPositions, - positions_mesh_layer, - ] + !isLoading.zones && combinedZonesLayer, + !isLoading.vessels && !isLoading.positions && tracksByVesselAndVoyage, + !isLoading.positions && latestPositions, + ].filter(Boolean) as Layer[] - useEffect(() => { - setTheme("light") - }, [setTheme]) + const getTooltip = ({ object }: Partial>) => { + const objectType = getObjectType(object) + const style = { + backgroundColor: "#fff", + fontSize: "0.8em", + borderRadius: "10px", + overflow: "hidden", + padding: "0px", + } + let element: React.ReactNode + if (objectType === "vessel") { + const vesselInfo = object as VesselPosition + element = + } else if (objectType === "zone") { + const zoneInfo = object as ZoneWithGeometry + element = + } + return { + html: renderToString(element), + style, + } + } return ( setViewState(e.viewState as MapViewState)} - getTooltip={({ object }: PickingInfo) => - object - ? { - html: renderToString(), - style: { - backgroundColor: "#fff", - fontSize: "0.8em", - borderRadius: "10px", - overflow: "hidden", - padding: "0px", - }, - } - : null - } + getCursor={({ isHovering, isDragging }) => { + return isDragging ? "move" : isHovering ? "pointer" : "grab" + }} + onClick={onMapClick} + getTooltip={({ + object, + }: PickingInfo) => { + if (!object) return null + return getTooltip({ object }) + }} > @@ -200,11 +257,10 @@ function toSegmentsGeo({ segments, vesselId }: VesselExcursionSegments): any { type: "LineString", coordinates: [ segment.start_position.coordinates, - segment.end_position.coordinates - ] - } + segment.end_position.coordinates, + ], + }, } }) - return { vesselId, type: "FeatureCollection", features: segmentsGeo ?? [] }; + return { vesselId, type: "FeatureCollection", features: segmentsGeo ?? [] } } - diff --git a/frontend/components/core/map/preview-card.tsx b/frontend/components/core/map/preview-card.tsx index 021bb388..dd127241 100644 --- a/frontend/components/core/map/preview-card.tsx +++ b/frontend/components/core/map/preview-card.tsx @@ -1,12 +1,12 @@ import Image from "next/image" import Link from "next/link" +import { getVesselFirstExcursionSegments } from "@/services/backend-rest-client" import { XIcon } from "lucide-react" +import { VesselPosition } from "@/types/vessel" import { Button } from "@/components/ui/button" import IconButton from "@/components/ui/icon-button" import { useMapStore } from "@/components/providers/map-store-provider" -import { VesselPosition } from "@/types/vessel" -import { getVesselFirstExcursionSegments } from "@/services/backend-rest-client" export interface PreviewCardTypes { vesselInfo: VesselPosition @@ -19,28 +19,33 @@ const PreviewCard: React.FC = ({ vesselInfo }) => { trackedVesselIDs, removeTrackedVessel, } = useMapStore((state) => state) - const { vessel: { id: vesselId, mmsi, ship_name, imo, length }, timestamp } = vesselInfo + const { + vessel: { id: vesselId, mmsi, ship_name, imo, length }, + timestamp, + } = vesselInfo const isVesselTracked = (vesselId: number) => { return trackedVesselIDs.includes(vesselId) } const handleDisplayTrail = async (vesselId: number) => { if (isVesselTracked(vesselId)) { - removeTrackedVessel(vesselId); - return; + removeTrackedVessel(vesselId) + return } - const response = await getVesselFirstExcursionSegments(vesselId); - addTrackedVessel(vesselId, response.data); + const response = await getVesselFirstExcursionSegments(vesselId) + addTrackedVessel(vesselId, response) } return ( -
- + default fishing vessel image -
+
{ship_name} @@ -65,18 +70,20 @@ const PreviewCard: React.FC = ({ vesselInfo }) => {

Last position:

-

- {timestamp} -

+

{timestamp}

- {isVesselTracked(vesselId) && Show track details} + {isVesselTracked(vesselId) && ( + + Show track details + + )}
-
+
setActivePosition(null)} diff --git a/frontend/components/core/tracked-vessels-panel.tsx b/frontend/components/core/tracked-vessels-panel.tsx index 9703337f..6072e16c 100644 --- a/frontend/components/core/tracked-vessels-panel.tsx +++ b/frontend/components/core/tracked-vessels-panel.tsx @@ -17,8 +17,10 @@ export default function TrackedVesselsPanel({ parentIsOpen, openParent, }: Props) { - const { trackedVesselIDs, removeTrackedVessel } = useMapStore((state) => state); - const { vessels: allVessels } = useVesselsStore((state) => state); + const { trackedVesselIDs, removeTrackedVessel } = useMapStore( + (state) => state + ) + const { vessels: allVessels } = useVesselsStore((state) => state) const [displayTrackedVessels, setDisplayTrackedVessels] = useState(false) const [trackedVesselsDetails, setTrackedVesselsDetails] = useState() @@ -34,7 +36,7 @@ export default function TrackedVesselsPanel({ trackedVesselIDs.includes(vessel.id) ) setTrackedVesselsDetails(vesselsDetails) - }, [trackedVesselIDs]) + }, [allVessels, trackedVesselIDs]) return ( <> @@ -53,11 +55,15 @@ export default function TrackedVesselsPanel({ parentIsOpen && trackedVesselsDetails?.map((vessel: Vessel) => { return ( -
+
{vessel.ship_name}
- IMO {vessel.imo} / MMSI {vessel.mmsi} / Length {vessel.length}m + IMO {vessel.imo} / MMSI {vessel.mmsi} / Length {vessel.length} + m
+ + + + + +
) } diff --git a/frontend/components/dashboard/dashboard-overview.tsx b/frontend/components/dashboard/dashboard-overview.tsx index c9b6edea..7f4c3c1d 100644 --- a/frontend/components/dashboard/dashboard-overview.tsx +++ b/frontend/components/dashboard/dashboard-overview.tsx @@ -1,64 +1,83 @@ "use client" -import Dropdown from "@/components/ui/dropdown" +import { TOTAL_AMPS, TOTAL_VESSELS } from "@/constants/totals.constants" + +import { Item } from "@/types/item" import ListCard from "@/components/ui/list-card" import KPICard from "@/components/dashboard/kpi-card" -import { ZoneVisitTimeDto } from "@/types/zone" -import { VesselTrackingTimeDto } from "@/types/vessel" -import { convertVesselDtoToItem, convertZoneDtoToItem } from "@/libs/mapper"; -const TOTAL_VESSELS = 1700; -const TOTAL_AMPS = 720; +import { DateRangeSelector } from "../ui/date-range-selector" type Props = { - topVesselsInActivity: VesselTrackingTimeDto[]; - topAmpsVisited: ZoneVisitTimeDto[]; - totalVesselsActive: number; - totalAmpsVisited: number; + topVesselsInActivity: Item[] + topVesselsInActivityLoading: boolean + topAmpsVisited: Item[] + topAmpsVisitedLoading: boolean + totalVesselsActive: number + totalVesselsActiveLoading: boolean + totalAmpsVisited: number + totalAmpsVisitedLoading: boolean + totalVesselsTracked: number + totalVesselsTrackedLoading: boolean + onDateRangeChange: (value: string) => void } -export default function DashboardOverview(props : Props) { - const { topVesselsInActivity, topAmpsVisited, totalVesselsActive, totalAmpsVisited } = props; - const topVesselsInActivityToItems = convertVesselDtoToItem(topVesselsInActivity); - const topAmpsVisitedToItems = convertZoneDtoToItem(topAmpsVisited); - +export default function DashboardOverview({ + topVesselsInActivity, + topVesselsInActivityLoading, + topAmpsVisited, + topAmpsVisitedLoading, + totalVesselsActive, + totalVesselsActiveLoading, + totalAmpsVisited, + totalAmpsVisitedLoading, + totalVesselsTracked, + totalVesselsTrackedLoading, + onDateRangeChange, +}: Props) { return (
-
- console.log("selected: " + value)} - /> +
+
-
+
-
+
-
+
diff --git a/frontend/components/dashboard/kpi-card.tsx b/frontend/components/dashboard/kpi-card.tsx index 430c9352..c565f31e 100644 --- a/frontend/components/dashboard/kpi-card.tsx +++ b/frontend/components/dashboard/kpi-card.tsx @@ -1,22 +1,41 @@ +import { Loader2 } from "lucide-react" + +import Spinner from "../ui/custom/spinner" +import { Skeleton } from "../ui/skeleton" + type Props = { title: string kpiValue: number kpiUnit?: string totalValue: number totalUnit?: string + loading?: boolean } -export default function KPICard(props: Props) { - let { title, kpiValue, kpiUnit, totalUnit, totalValue } = props - kpiUnit = kpiUnit ?? "" - totalUnit = totalUnit ?? "" - +export default function KPICard({ + title, + kpiValue, + kpiUnit, + totalUnit, + totalValue, + loading, +}: Props) { return ( -
-
{title}
-
{`${kpiValue}`}
-
- {`${kpiUnit}`} / {`${totalValue} ${totalUnit}`} +
+
{title}
+
+
+ {loading ? ( +
+ +
+ ) : ( + `${kpiValue}` + )} +
+
+ {`${kpiUnit || ""} / ${totalValue} ${totalUnit || ""}`} +
) diff --git a/frontend/components/details/details-container.tsx b/frontend/components/details/details-container.tsx index e4abdb73..90667908 100644 --- a/frontend/components/details/details-container.tsx +++ b/frontend/components/details/details-container.tsx @@ -1,23 +1,66 @@ +import Image from "next/image" + import { ItemDetails } from "@/types/item" import ListCard from "@/components/ui/list-card" +import { Card } from "../ui/card" +import { DateRangeSelector } from "../ui/date-range-selector" + type Props = { - details: ItemDetails + details: ItemDetails | null + onDateRangeChange: (value: string) => void + defaultDateRange: string + isLoading: boolean } -export default function DetailsContainer({ details }: Props) { +export default function DetailsContainer({ + details, + onDateRangeChange, + defaultDateRange, + isLoading, +}: Props) { + if (!details) { + return null + } + const { label, description, relatedItemsType, relatedItems } = details return ( -
-
-
{label}
-
- {description} +
+
+
+
{label}
+
+ {description} +
+
+
+ + {"Zone + +
+
+ +
+
+ +
+
-
-
-
) diff --git a/frontend/components/ui/back-navigator.tsx b/frontend/components/ui/back-navigator.tsx index f1792025..f8c984d1 100644 --- a/frontend/components/ui/back-navigator.tsx +++ b/frontend/components/ui/back-navigator.tsx @@ -11,7 +11,7 @@ export default function BackNavigator() { } return ( -
+
left arrow +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/frontend/components/ui/custom/button.tsx b/frontend/components/ui/custom/button-custom.tsx similarity index 87% rename from frontend/components/ui/custom/button.tsx rename to frontend/components/ui/custom/button-custom.tsx index 5f28698a..c75ac443 100644 --- a/frontend/components/ui/custom/button.tsx +++ b/frontend/components/ui/custom/button-custom.tsx @@ -15,7 +15,7 @@ export default function Button({ title, withArrowIcon, onClick }: Props) { )}
diff --git a/frontend/components/ui/select.tsx b/frontend/components/ui/select.tsx new file mode 100644 index 00000000..c94fa86d --- /dev/null +++ b/frontend/components/ui/select.tsx @@ -0,0 +1,158 @@ +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/libs/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/frontend/components/ui/skeleton.tsx b/frontend/components/ui/skeleton.tsx new file mode 100644 index 00000000..6bd0f9ed --- /dev/null +++ b/frontend/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/libs/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/frontend/components/ui/tooltip-map-template.tsx b/frontend/components/ui/tooltip-map-template.tsx index ac4a4aff..5bb94c43 100644 --- a/frontend/components/ui/tooltip-map-template.tsx +++ b/frontend/components/ui/tooltip-map-template.tsx @@ -1,6 +1,7 @@ -import { VesselPosition } from "@/types/vessel" import Image from "next/image" +import { VesselPosition } from "@/types/vessel" + export interface MapTooltipTypes { vesselInfo: VesselPosition orientation?: "landscape" | "portrait" @@ -10,13 +11,16 @@ const MapTooltip = ({ vesselInfo, orientation = "portrait", }: MapTooltipTypes) => { - const { vessel: { mmsi, ship_name, imo, length }, timestamp } = vesselInfo + const { + vessel: { mmsi, ship_name, imo, length }, + timestamp, + } = vesselInfo return ( <> {orientation === "portrait" && (
- default fishing vessel image + /> */}
{ship_name} diff --git a/frontend/components/ui/zone-map-tooltip.tsx b/frontend/components/ui/zone-map-tooltip.tsx new file mode 100644 index 00000000..9aa1bf28 --- /dev/null +++ b/frontend/components/ui/zone-map-tooltip.tsx @@ -0,0 +1,27 @@ + +import { ZoneWithGeometry } from "@/types/zone" + +export interface ZoneMapTooltipProps { + zoneInfo: ZoneWithGeometry +} + +const ZoneMapTooltip = ({ zoneInfo }: ZoneMapTooltipProps) => { + const { name, category, sub_category } = zoneInfo + + return ( + <> +
+
+
+ {name} +
+

+ {category} / {sub_category} +

+
+
+ + ) +} + +export default ZoneMapTooltip diff --git a/frontend/constants/countries-iso3.constants.ts b/frontend/constants/countries-iso3.constants.ts new file mode 100644 index 00000000..3d98103f --- /dev/null +++ b/frontend/constants/countries-iso3.constants.ts @@ -0,0 +1,1247 @@ +export const COUNTRIES_ISO3 = [ + { + code: "AFG", + name: "Afghanistan", + eu: false, + }, + { + code: "ALA", + name: "Åland Islands", + eu: false, + }, + { + code: "ALB", + name: "Albania", + eu: false, + }, + { + code: "DZA", + name: "Algeria", + eu: false, + }, + { + code: "ASM", + name: "American Samoa", + eu: false, + }, + { + code: "AND", + name: "Andorra", + eu: false, + }, + { + code: "AGO", + name: "Angola", + eu: false, + }, + { + code: "AIA", + name: "Anguilla", + eu: false, + }, + { + code: "ATA", + name: "Antarctica", + eu: false, + }, + { + code: "ATG", + name: "Antigua and Barbuda", + eu: false, + }, + { + code: "ARG", + name: "Argentina", + eu: false, + }, + { + code: "ARM", + name: "Armenia", + eu: false, + }, + { + code: "ABW", + name: "Aruba", + eu: false, + }, + { + code: "AUS", + name: "Australia", + eu: false, + }, + { + code: "AUT", + name: "Austria", + eu: true, + }, + { + code: "AZE", + name: "Azerbaijan", + eu: false, + }, + { + code: "BHS", + name: "Bahamas", + eu: false, + }, + { + code: "BHR", + name: "Bahrain", + eu: false, + }, + { + code: "BGD", + name: "Bangladesh", + eu: false, + }, + { + code: "BRB", + name: "Barbados", + eu: false, + }, + { + code: "BLR", + name: "Belarus", + eu: false, + }, + { + code: "BEL", + name: "Belgium", + eu: true, + }, + { + code: "BLZ", + name: "Belize", + eu: false, + }, + { + code: "BEN", + name: "Benin", + eu: false, + }, + { + code: "BMU", + name: "Bermuda", + eu: false, + }, + { + code: "BTN", + name: "Bhutan", + eu: false, + }, + { + code: "BOL", + name: "Bolivia, Plurinational State of", + eu: false, + }, + { + code: "BES", + name: "Bonaire, Sint Eustatius and Saba", + eu: false, + }, + { + code: "BIH", + name: "Bosnia and Herzegovina", + eu: false, + }, + { + code: "BWA", + name: "Botswana", + eu: false, + }, + { + code: "BVT", + name: "Bouvet Island", + eu: false, + }, + { + code: "BRA", + name: "Brazil", + eu: false, + }, + { + code: "IOT", + name: "British Indian Ocean Territory", + eu: false, + }, + { + code: "BRN", + name: "Brunei Darussalam", + eu: false, + }, + { + code: "BGR", + name: "Bulgaria", + eu: true, + }, + { + code: "BFA", + name: "Burkina Faso", + eu: false, + }, + { + code: "BDI", + name: "Burundi", + eu: false, + }, + { + code: "KHM", + name: "Cambodia", + eu: false, + }, + { + code: "CMR", + name: "Cameroon", + eu: false, + }, + { + code: "CAN", + name: "Canada", + eu: false, + }, + { + code: "CPV", + name: "Cape Verde", + eu: false, + }, + { + code: "CYM", + name: "Cayman Islands", + eu: false, + }, + { + code: "CAF", + name: "Central African Republic", + eu: false, + }, + { + code: "TCD", + name: "Chad", + eu: false, + }, + { + code: "CHL", + name: "Chile", + eu: false, + }, + { + code: "CHN", + name: "China", + eu: false, + }, + { + code: "CXR", + name: "Christmas Island", + eu: false, + }, + { + code: "CCK", + name: "Cocos (Keeling) Islands", + eu: false, + }, + { + code: "COL", + name: "Colombia", + eu: false, + }, + { + code: "COM", + name: "Comoros", + eu: false, + }, + { + code: "COG", + name: "Congo", + eu: false, + }, + { + code: "COD", + name: "Congo, the Democratic Republic of the", + eu: false, + }, + { + code: "COK", + name: "Cook Islands", + eu: false, + }, + { + code: "CRI", + name: "Costa Rica", + eu: false, + }, + { + code: "CIV", + name: "Côte d'Ivoire", + eu: false, + }, + { + code: "HRV", + name: "Croatia", + eu: true, + }, + { + code: "CUB", + name: "Cuba", + eu: false, + }, + { + code: "CUW", + name: "Curaçao", + eu: false, + }, + { + code: "CYP", + name: "Cyprus", + eu: true, + }, + { + code: "CZE", + name: "Czech Republic", + eu: true, + }, + { + code: "DNK", + name: "Denmark", + eu: true, + }, + { + code: "DJI", + name: "Djibouti", + eu: false, + }, + { + code: "DMA", + name: "Dominica", + eu: false, + }, + { + code: "DOM", + name: "Dominican Republic", + eu: false, + }, + { + code: "ECU", + name: "Ecuador", + eu: false, + }, + { + code: "EGY", + name: "Egypt", + eu: false, + }, + { + code: "SLV", + name: "El Salvador", + eu: false, + }, + { + code: "GNQ", + name: "Equatorial Guinea", + eu: false, + }, + { + code: "ERI", + name: "Eritrea", + eu: false, + }, + { + code: "EST", + name: "Estonia", + eu: true, + }, + { + code: "ETH", + name: "Ethiopia", + eu: false, + }, + { + code: "FLK", + name: "Falkland Islands (Malvinas)", + eu: false, + }, + { + code: "FRO", + name: "Faroe Islands", + eu: false, + }, + { + code: "FJI", + name: "Fiji", + eu: false, + }, + { + code: "FIN", + name: "Finland", + eu: true, + }, + { + code: "FRA", + name: "France", + eu: true, + }, + { + code: "GUF", + name: "French Guiana", + eu: false, + }, + { + code: "PYF", + name: "French Polynesia", + eu: false, + }, + { + code: "ATF", + name: "French Southern Territories", + eu: false, + }, + { + code: "GAB", + name: "Gabon", + eu: false, + }, + { + code: "GMB", + name: "Gambia", + eu: false, + }, + { + code: "GEO", + name: "Georgia", + eu: false, + }, + { + code: "DEU", + name: "Germany", + eu: true, + }, + { + code: "GHA", + name: "Ghana", + eu: false, + }, + { + code: "GIB", + name: "Gibraltar", + eu: false, + }, + { + code: "GRC", + name: "Greece", + eu: true, + }, + { + code: "GRL", + name: "Greenland", + eu: false, + }, + { + code: "GRD", + name: "Grenada", + eu: false, + }, + { + code: "GLP", + name: "Guadeloupe", + eu: false, + }, + { + code: "GUM", + name: "Guam", + eu: false, + }, + { + code: "GTM", + name: "Guatemala", + eu: false, + }, + { + code: "GGY", + name: "Guernsey", + eu: false, + }, + { + code: "GIN", + name: "Guinea", + eu: false, + }, + { + code: "GNB", + name: "Guinea-Bissau", + eu: false, + }, + { + code: "GUY", + name: "Guyana", + eu: false, + }, + { + code: "HTI", + name: "Haiti", + eu: false, + }, + { + code: "HMD", + name: "Heard Island and McDonald Islands", + eu: false, + }, + { + code: "VAT", + name: "Holy See (Vatican City State)", + eu: false, + }, + { + code: "HND", + name: "Honduras", + eu: false, + }, + { + code: "HKG", + name: "Hong Kong", + eu: false, + }, + { + code: "HUN", + name: "Hungary", + eu: true, + }, + { + code: "ISL", + name: "Iceland", + eu: false, + }, + { + code: "IND", + name: "India", + eu: false, + }, + { + code: "IDN", + name: "Indonesia", + eu: false, + }, + { + code: "IRN", + name: "Iran, Islamic Republic of", + eu: false, + }, + { + code: "IRQ", + name: "Iraq", + eu: false, + }, + { + code: "IRL", + name: "Ireland", + eu: true, + }, + { + code: "IMN", + name: "Isle of Man", + eu: false, + }, + { + code: "ISR", + name: "Israel", + eu: false, + }, + { + code: "ITA", + name: "Italy", + eu: true, + }, + { + code: "JAM", + name: "Jamaica", + eu: false, + }, + { + code: "JPN", + name: "Japan", + eu: false, + }, + { + code: "JEY", + name: "Jersey", + eu: false, + }, + { + code: "JOR", + name: "Jordan", + eu: false, + }, + { + code: "KAZ", + name: "Kazakhstan", + eu: false, + }, + { + code: "KEN", + name: "Kenya", + eu: false, + }, + { + code: "KIR", + name: "Kiribati", + eu: false, + }, + { + code: "PRK", + name: "Korea, Democratic People's Republic of", + eu: false, + }, + { + code: "KOR", + name: "Korea, Republic of", + eu: false, + }, + { + code: "KWT", + name: "Kuwait", + eu: false, + }, + { + code: "KGZ", + name: "Kyrgyzstan", + eu: false, + }, + { + code: "LAO", + name: "Lao People's Democratic Republic", + eu: false, + }, + { + code: "LVA", + name: "Latvia", + eu: true, + }, + { + code: "LBN", + name: "Lebanon", + eu: false, + }, + { + code: "LSO", + name: "Lesotho", + eu: false, + }, + { + code: "LBR", + name: "Liberia", + eu: false, + }, + { + code: "LBY", + name: "Libya", + eu: false, + }, + { + code: "LIE", + name: "Liechtenstein", + eu: false, + }, + { + code: "LTU", + name: "Lithuania", + eu: true, + }, + { + code: "LUX", + name: "Luxembourg", + eu: true, + }, + { + code: "MAC", + name: "Macao", + eu: false, + }, + { + code: "MKD", + name: "Macedonia, the former Yugoslav Republic of", + eu: false, + }, + { + code: "MDG", + name: "Madagascar", + eu: false, + }, + { + code: "MWI", + name: "Malawi", + eu: false, + }, + { + code: "MYS", + name: "Malaysia", + eu: false, + }, + { + code: "MDV", + name: "Maldives", + eu: false, + }, + { + code: "MLI", + name: "Mali", + eu: false, + }, + { + code: "MLT", + name: "Malta", + eu: true, + }, + { + code: "MHL", + name: "Marshall Islands", + eu: false, + }, + { + code: "MTQ", + name: "Martinique", + eu: false, + }, + { + code: "MRT", + name: "Mauritania", + eu: false, + }, + { + code: "MUS", + name: "Mauritius", + eu: false, + }, + { + code: "MYT", + name: "Mayotte", + eu: false, + }, + { + code: "MEX", + name: "Mexico", + eu: false, + }, + { + code: "FSM", + name: "Micronesia, Federated States of", + eu: false, + }, + { + code: "MDA", + name: "Moldova, Republic of", + eu: false, + }, + { + code: "MCO", + name: "Monaco", + eu: false, + }, + { + code: "MNG", + name: "Mongolia", + eu: false, + }, + { + code: "MNE", + name: "Montenegro", + eu: false, + }, + { + code: "MSR", + name: "Montserrat", + eu: false, + }, + { + code: "MAR", + name: "Morocco", + eu: false, + }, + { + code: "MOZ", + name: "Mozambique", + eu: false, + }, + { + code: "MMR", + name: "Myanmar", + eu: false, + }, + { + code: "NAM", + name: "Namibia", + eu: false, + }, + { + code: "NRU", + name: "Nauru", + eu: false, + }, + { + code: "NPL", + name: "Nepal", + eu: false, + }, + { + code: "NLD", + name: "Netherlands", + eu: true, + }, + { + code: "NCL", + name: "New Caledonia", + eu: false, + }, + { + code: "NZL", + name: "New Zealand", + eu: false, + }, + { + code: "NIC", + name: "Nicaragua", + eu: false, + }, + { + code: "NER", + name: "Niger", + eu: false, + }, + { + code: "NGA", + name: "Nigeria", + eu: false, + }, + { + code: "NIU", + name: "Niue", + eu: false, + }, + { + code: "NFK", + name: "Norfolk Island", + eu: false, + }, + { + code: "MNP", + name: "Northern Mariana Islands", + eu: false, + }, + { + code: "NOR", + name: "Norway", + eu: false, + }, + { + code: "OMN", + name: "Oman", + eu: false, + }, + { + code: "PAK", + name: "Pakistan", + eu: false, + }, + { + code: "PLW", + name: "Palau", + eu: false, + }, + { + code: "PSE", + name: "Palestinian Territory, Occupied", + eu: false, + }, + { + code: "PAN", + name: "Panama", + eu: false, + }, + { + code: "PNG", + name: "Papua New Guinea", + eu: false, + }, + { + code: "PRY", + name: "Paraguay", + eu: false, + }, + { + code: "PER", + name: "Peru", + eu: false, + }, + { + code: "PHL", + name: "Philippines", + eu: false, + }, + { + code: "PCN", + name: "Pitcairn", + eu: false, + }, + { + code: "POL", + name: "Poland", + eu: true, + }, + { + code: "PRT", + name: "Portugal", + eu: true, + }, + { + code: "PRI", + name: "Puerto Rico", + eu: false, + }, + { + code: "QAT", + name: "Qatar", + eu: false, + }, + { + code: "REU", + name: "Réunion", + eu: false, + }, + { + code: "ROU", + name: "Romania", + eu: true, + }, + { + code: "RUS", + name: "Russian Federation", + eu: false, + }, + { + code: "RWA", + name: "Rwanda", + eu: false, + }, + { + code: "BLM", + name: "Saint Barthélemy", + eu: false, + }, + { + code: "SHN", + name: "Saint Helena, Ascension and Tristan da Cunha", + eu: false, + }, + { + code: "KNA", + name: "Saint Kitts and Nevis", + eu: false, + }, + { + code: "LCA", + name: "Saint Lucia", + eu: false, + }, + { + code: "MAF", + name: "Saint Martin (French part)", + eu: false, + }, + { + code: "SPM", + name: "Saint Pierre and Miquelon", + eu: false, + }, + { + code: "VCT", + name: "Saint Vincent and the Grenadines", + eu: false, + }, + { + code: "WSM", + name: "Samoa", + eu: false, + }, + { + code: "SMR", + name: "San Marino", + eu: false, + }, + { + code: "STP", + name: "Sao Tome and Principe", + eu: false, + }, + { + code: "SAU", + name: "Saudi Arabia", + eu: false, + }, + { + code: "SEN", + name: "Senegal", + eu: false, + }, + { + code: "SRB", + name: "Serbia", + eu: false, + }, + { + code: "SYC", + name: "Seychelles", + eu: false, + }, + { + code: "SLE", + name: "Sierra Leone", + eu: false, + }, + { + code: "SGP", + name: "Singapore", + eu: false, + }, + { + code: "SXM", + name: "Sint Maarten (Dutch part)", + eu: false, + }, + { + code: "SVK", + name: "Slovakia", + eu: true, + }, + { + code: "SVN", + name: "Slovenia", + eu: true, + }, + { + code: "SLB", + name: "Solomon Islands", + eu: false, + }, + { + code: "SOM", + name: "Somalia", + eu: false, + }, + { + code: "ZAF", + name: "South Africa", + eu: false, + }, + { + code: "SGS", + name: "South Georgia and the South Sandwich Islands", + eu: false, + }, + { + code: "SSD", + name: "South Sudan", + eu: false, + }, + { + code: "ESP", + name: "Spain", + eu: true, + }, + { + code: "LKA", + name: "Sri Lanka", + eu: false, + }, + { + code: "SDN", + name: "Sudan", + eu: false, + }, + { + code: "SUR", + name: "Suriname", + eu: false, + }, + { + code: "SJM", + name: "Svalbard and Jan Mayen", + eu: false, + }, + { + code: "SWZ", + name: "Swaziland", + eu: false, + }, + { + code: "SWE", + name: "Sweden", + eu: true, + }, + { + code: "CHE", + name: "Switzerland", + eu: false, + }, + { + code: "SYR", + name: "Syrian Arab Republic", + eu: false, + }, + { + code: "TWN", + name: "Taiwan, Province of China", + eu: false, + }, + { + code: "TJK", + name: "Tajikistan", + eu: false, + }, + { + code: "TZA", + name: "Tanzania, United Republic of", + eu: false, + }, + { + code: "THA", + name: "Thailand", + eu: false, + }, + { + code: "TLS", + name: "Timor-Leste", + eu: false, + }, + { + code: "TGO", + name: "Togo", + eu: false, + }, + { + code: "TKL", + name: "Tokelau", + eu: false, + }, + { + code: "TON", + name: "Tonga", + eu: false, + }, + { + code: "TTO", + name: "Trinidad and Tobago", + eu: false, + }, + { + code: "TUN", + name: "Tunisia", + eu: false, + }, + { + code: "TUR", + name: "Turkey", + eu: false, + }, + { + code: "TKM", + name: "Turkmenistan", + eu: false, + }, + { + code: "TCA", + name: "Turks and Caicos Islands", + eu: false, + }, + { + code: "TUV", + name: "Tuvalu", + eu: false, + }, + { + code: "UGA", + name: "Uganda", + eu: false, + }, + { + code: "UKR", + name: "Ukraine", + eu: false, + }, + { + code: "ARE", + name: "United Arab Emirates", + eu: false, + }, + { + code: "GBR", + name: "United Kingdom", + eu: true, + }, + { + code: "USA", + name: "United States", + eu: false, + }, + { + code: "UMI", + name: "United States Minor Outlying Islands", + eu: false, + }, + { + code: "URY", + name: "Uruguay", + eu: false, + }, + { + code: "UZB", + name: "Uzbekistan", + eu: false, + }, + { + code: "VUT", + name: "Vanuatu", + eu: false, + }, + { + code: "VEN", + name: "Venezuela, Bolivarian Republic of", + eu: false, + }, + { + code: "VNM", + name: "Viet Nam", + eu: false, + }, + { + code: "VGB", + name: "Virgin Islands, British", + eu: false, + }, + { + code: "VIR", + name: "Virgin Islands, U.S.", + eu: false, + }, + { + code: "WLF", + name: "Wallis and Futuna", + eu: false, + }, + { + code: "ESH", + name: "Western Sahara", + eu: false, + }, + { + code: "YEM", + name: "Yemen", + eu: false, + }, + { + code: "ZMB", + name: "Zambia", + eu: false, + }, + { + code: "ZWE", + name: "Zimbabwe", + eu: false, + }, +] diff --git a/frontend/constants/totals.constants.ts b/frontend/constants/totals.constants.ts new file mode 100644 index 00000000..a3bfa498 --- /dev/null +++ b/frontend/constants/totals.constants.ts @@ -0,0 +1,2 @@ +export const TOTAL_VESSELS = 1700 +export const TOTAL_AMPS = 720 diff --git a/frontend/libs/dateUtils.ts b/frontend/libs/dateUtils.ts index e0f851af..d6d8ac0a 100644 --- a/frontend/libs/dateUtils.ts +++ b/frontend/libs/dateUtils.ts @@ -1,29 +1,43 @@ +export function convertDurationInHours(durationPattern: string): number { + const matches = durationPattern.match( + /P(?:(\d+)Y)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?/ + ) -import { Temporal } from '@js-temporal/polyfill'; + if (!matches) { + throw new Error("Invalid duration pattern") + } -export function convertDurationInHours(durationPattern: string): number { - const duration = Temporal.Duration.from(durationPattern); - const durationHours = duration.total({ relativeTo: Temporal.Now.plainDateISO(), unit: "hours"}) - return ~~durationHours; // round without decimals -} + const [ + _, + years = "0", + days = "0", + hours = "0", + minutes = "0", + seconds = "0", + ] = matches + + const totalHours = + parseInt(years) * 365 * 24 + // converting years to hours (approximate) + parseInt(days) * 24 + + parseInt(hours) + + parseInt(minutes) / 60 + + parseFloat(seconds) / 3600 -// format date like: 2023-07-15T17:16:27 -export function format(date: Date) { - return ( - [ - date.getFullYear(), - padTwoDigits(date.getMonth() + 1), - padTwoDigits(date.getDate()), - ].join("-") + - "T" + - [ - padTwoDigits(date.getHours()), - padTwoDigits(date.getMinutes()), - padTwoDigits(date.getSeconds()), - ].join(":") - ); + return Math.floor(totalHours) } function padTwoDigits(num: number) { - return num.toString().padStart(2, "0"); + return num.toString().padStart(2, "0") +} + +export function getDateRange(days: number) { + const today = new Date() + const start = new Date(today) + start.setDate(today.getDate() - days) + start.setHours(0, 0, 0, 0) + + return { + startAt: start.toISOString(), + endAt: today.toISOString(), + } } diff --git a/frontend/libs/mapper.tsx b/frontend/libs/mapper.tsx index 5af91d82..19054524 100644 --- a/frontend/libs/mapper.tsx +++ b/frontend/libs/mapper.tsx @@ -1,29 +1,35 @@ -import { Item } from "@/types/item"; -import { VesselTrackingTimeDto } from "@/types/vessel"; -import { ZoneVisitTimeDto } from "@/types/zone"; +import { Item } from "@/types/item" +import { VesselMetrics } from "@/types/vessel" +import { ZoneMetrics, ZoneVisitTimeDto } from "@/types/zone" +import { convertDurationInHours } from "@/libs/dateUtils" -import { convertDurationInHours } from "@/libs/dateUtils"; - -export function convertVesselDtoToItem(vesselDtos: VesselTrackingTimeDto[]): Item[] { - return vesselDtos?.map((vesselDto: VesselTrackingTimeDto) => { - return { - id: `${vesselDto.vessel_id}`, - title: vesselDto.vessel_ship_name, - description: `IMO ${vesselDto.vessel_imo} / MMSI ${vesselDto.vessel_mmsi} / ${vesselDto.vessel_length} mètres`, - value: `${convertDurationInHours(vesselDto.total_time_at_sea)}h`, - type: "vessel" - } - }); +export function convertVesselDtoToItem(metrics: VesselMetrics[]): Item[] { + return metrics + ?.map((vesselMetrics) => { + const vessel = vesselMetrics.vessel + return { + id: `${vessel.id}`, + title: vessel.ship_name, + description: `IMO ${vessel.imo} / MMSI ${vessel.mmsi} / ${vessel.length} m`, + value: `${convertDurationInHours(vesselMetrics.total_time_at_sea)}h`, + type: "vessel", + countryIso3: vessel.country_iso3, + } + }) + .sort((a, b) => { + return Number(b.value.split("h")[0]) - Number(a.value.split("h")[0]) + }) } -export function convertZoneDtoToItem(zoneDtos: ZoneVisitTimeDto[]): Item[] { - return zoneDtos?.map((zoneDto: ZoneVisitTimeDto) => { +export function convertZoneDtoToItem(zoneMetrics: ZoneMetrics[]): Item[] { + return zoneMetrics?.map((zoneMetrics) => { + const { zone, visiting_duration } = zoneMetrics return { - id: `${zoneDto.zone_id}`, - title: zoneDto.zone_name, - description: zoneDto.zone_sub_category, - value: `${convertDurationInHours(zoneDto.visiting_duration)}h`, - type: "amp" + id: `${zone.id}`, + title: zone.name, + description: zone.sub_category, + value: `${convertDurationInHours(visiting_duration)}h`, + type: "amp", } }) } diff --git a/frontend/middleware.ts b/frontend/middleware.ts new file mode 100644 index 00000000..5c0c9fbb --- /dev/null +++ b/frontend/middleware.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" + +export function middleware(request: NextRequest) { + const token = request.cookies.get("auth-token") + + // Skip middleware for login page and api routes + if ( + request.nextUrl.pathname.startsWith("/login") || + request.nextUrl.pathname.startsWith("/api") + ) { + return NextResponse.next() + } + + if (!token) { + return NextResponse.redirect(new URL("/login", request.url)) + } + + if (request.nextUrl.pathname === "/") { + return NextResponse.redirect(new URL("/dashboard", request.url)) + } + + return NextResponse.next() +} + +export const config = { + matcher: + "/((?!api|_next/static|_next/image|img|icons|favicon.ico|sitemap.xml|robots.txt).*)", +} diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 0e72169a..bc71f136 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,15 +1,9 @@ -// @ts-check /** @type {import('next').NextConfig} */ const nextConfig = { - async redirects() { - return [ - { - source: "/", - destination: "/dashboard", - permanent: true, - }, - ] + experimental: { + serverMinification: false }, + staticPageGenerationTimeout: 1000 * 60 * 5 // 5 minutes } export default nextConfig diff --git a/frontend/package.json b/frontend/package.json index fd95c70c..b1d761be 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,8 +4,9 @@ "private": true, "scripts": { "dev": "next dev", - "build": "find . -path './node_modules' -prune -o -name '*.gz' -exec gzip --decompress {} + && next build", - "start": "next start", + "build": "next build", + "start": "next start -p 8080", + "install": "next build", "lint": "next lint", "lint:fix": "next lint --fix", "preview": "next build && next start", @@ -14,31 +15,34 @@ "format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache" }, "dependencies": { + "@deck.gl/extensions": "^9.0.36", "@heroicons/react": "^2.1.3", - "@js-temporal/polyfill": "^0.4.4", "@loaders.gl/core": "^4.2.0", "@loaders.gl/obj": "^4.2.0", "@radix-ui/react-dialog": "^1.0.5", - "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-slot": "^1.1.0", "@types/chroma-js": "^2.4.4", "axios": "^1.7.2", "chroma-js": "^2.4.2", "class-variance-authority": "^0.4.0", "clsx": "^1.2.1", "cmdk": "^1.0.0", - "deck.gl": "^9.0.1", + "deck.gl": "^9.0.36", "framer-motion": "^11.1.3", "jotai": "^2.8.0", "jotai-zustand": "^0.3.0", "lucide-react": "^0.371.0", "maplibre-gl": "^4.1.1", - "next": "^13.4.8", - "next-template": "link:", + "next": "^13.5.7", "next-themes": "^0.2.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-map-gl": "^7.1.7", "sharp": "^0.31.3", + "swr": "^2.2.5", "tailwind-merge": "^1.13.2", "tailwindcss-animate": "^1.0.6", "zustand": "^4.5.2" diff --git a/frontend/public/icons/boat-animated.svg b/frontend/public/icons/boat-animated.svg new file mode 100644 index 00000000..c92415ce --- /dev/null +++ b/frontend/public/icons/boat-animated.svg @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/public/img/login-cover.jpg b/frontend/public/img/login-cover.jpg new file mode 100644 index 00000000..b3a13956 Binary files /dev/null and b/frontend/public/img/login-cover.jpg differ diff --git a/frontend/public/img/map-vessel.png b/frontend/public/img/map-vessel.png new file mode 100644 index 00000000..7e7153ee Binary files /dev/null and b/frontend/public/img/map-vessel.png differ diff --git a/frontend/public/img/placeholder-zone.png b/frontend/public/img/placeholder-zone.png new file mode 100644 index 00000000..dfdaa49b Binary files /dev/null and b/frontend/public/img/placeholder-zone.png differ diff --git a/frontend/public/trawlwatch.svg b/frontend/public/trawlwatch.svg index c9914027..24d69d1f 100644 --- a/frontend/public/trawlwatch.svg +++ b/frontend/public/trawlwatch.svg @@ -1,20 +1,3 @@ - - - - - - - - - - - - - - - - - - - + + diff --git a/frontend/services/backend-rest-client.ts b/frontend/services/backend-rest-client.ts index 9c015e9d..26f695f0 100644 --- a/frontend/services/backend-rest-client.ts +++ b/frontend/services/backend-rest-client.ts @@ -1,62 +1,140 @@ +import { TOTAL_AMPS } from "@/constants/totals.constants" +import axios, { InternalAxiosRequestConfig } from "axios" -import { Vessel, VesselExcursion, VesselExcursionSegment, VesselPositions, VesselTrackingTimeDto } from "@/types/vessel"; -import { ZoneVisitTimeDto } from "@/types/zone"; -import axios, { InternalAxiosRequestConfig } from "axios"; -import { log } from "console"; +import { + Vessel, + VesselExcursion, + VesselExcursionSegment, + VesselMetrics, + VesselPositions, +} from "@/types/vessel" +import { ZoneMetrics, ZoneVesselMetrics, ZoneWithGeometry } from "@/types/zone" -const BASE_URL = process.env.NEXT_PUBLIC_BACKEND_BASE_URL; -const API_KEY = process.env.NEXT_PUBLIC_BACKEND_API_KEY ?? 'no-key-found'; +const BASE_URL = process.env.NEXT_PUBLIC_BACKEND_BASE_URL +const API_KEY = process.env.NEXT_PUBLIC_BACKEND_API_KEY ?? "no-key-found" +const CACHE_CONTROL_HEADER = "public, max-age=2592000" // 30 days in seconds // Authenticate all requests to Bloom backend axios.interceptors.request.use((request: InternalAxiosRequestConfig) => { - request.headers.set('x-key', API_KEY); - return request; -}); + request.headers.set("x-key", API_KEY) + return request +}) export function getVessels() { - const url = `${BASE_URL}/vessels`; - console.log(`GET ${url}`); - return axios.get(url); + const url = `${BASE_URL}/vessels` + console.log(`GET ${url}`) + return axios.get(url) +} + +export function getVesselsAtSea(startAt: string, endAt: string) { + const url = `${BASE_URL}/metrics/vessels-at-sea?start_at=${startAt}&end_at=${endAt}` + console.log(`GET ${url}`) + return axios.get(url) +} + +export function getVesselsTrackedCount() { + const url = `${BASE_URL}/vessels/trackedCount` + console.log(`GET ${url}`) + return axios.get(url) } export function getVesselsLatestPositions() { - const url = `${BASE_URL}/vessels/all/positions/last`; - console.log(`GET ${url}`); - return axios.get(url); + const url = `${BASE_URL}/vessels/all/positions/last` + console.log(`GET ${url}`) + return axios.get(url) } export function getVesselExcursion(vesselId: number) { - const url = `${BASE_URL}/vessels/${vesselId}/excursions`; - console.log(`GET ${url}`); - return axios.get(url); + const url = `${BASE_URL}/vessels/${vesselId}/excursions` + console.log(`GET ${url}`) + return axios.get(url) } export function getVesselSegments(vesselId: number, excursionId: number) { - const url = `${BASE_URL}/vessels/${vesselId}/excursions/${excursionId}/segments`; - console.log(`GET ${url}`); - return axios.get(url); + const url = `${BASE_URL}/vessels/${vesselId}/excursions/${excursionId}/segments` + console.log(`GET ${url}`) + return axios.get(url) } export async function getVesselFirstExcursionSegments(vesselId: number) { try { - const response = await getVesselExcursion(vesselId); - const excursionId = response?.data[0]?.id; - return !!excursionId ? getVesselSegments(vesselId, excursionId) : []; - - } catch(error) { - console.error(error); - return []; + const response = await getVesselExcursion(vesselId) + const excursionId = response?.data[0]?.id + if (!!excursionId) { + const segments = await getVesselSegments(vesselId, excursionId) + return segments.data + } + return [] + } catch (error) { + console.error(error) + return [] } } -export function getTopVesselsInActivity(startAt: string, endAt: string, topVesselsLimit: number) { - const url = `${BASE_URL}/metrics/vessels-in-activity?start_at=${startAt}&end_at=${endAt}&limit=${topVesselsLimit}&order=DESC`; - console.log(`GET ${url}`); - return axios.get(url); +export function getTopVesselsInActivity( + startAt: string, + endAt: string, + topVesselsLimit: number +) { + const url = `${BASE_URL}/metrics/vessels-in-activity?start_at=${startAt}&end_at=${endAt}&limit=${topVesselsLimit}&order=DESC` + console.log(`GET ${url}`) + return axios.get(url) +} + +export function getTopZonesVisited( + startAt: string, + endAt: string, + topZonesLimit: number, + category?: string +) { + const url = `${BASE_URL}/metrics/zone-visited?${ + category ? `category=${category}&` : "" + }start_at=${startAt}&end_at=${endAt}&limit=${topZonesLimit}&order=DESC` + console.log(`GET ${url}`) + return axios.get(url) +} + +export async function getZoneDetails( + zoneId: string, + startAt: string, + endAt: string +) { + const url = `${BASE_URL}/metrics/zones/${zoneId}/visiting-time-by-vessel?start_at=${startAt}&end_at=${endAt}&order=DESC&limit=10` + console.log(`GET ${url}`) + const response = await axios.get(url) + console.log(response) + return response } -export function getTopZonesVisited(startAt: string, endAt: string, topZonesLimit: number) { - const url = `${BASE_URL}/metrics/zone-visited?start_at=${startAt}&end_at=${endAt}&limit=${topZonesLimit}&order=DESC`; - console.log(`GET ${url}`); - return axios.get(url); +export async function getZones() { + const url = `${BASE_URL}/zones` + console.log(`GET ${url}`) + + // Calculate ranges for batches of 10 + const ranges = [] + for (let i = 0; i < TOTAL_AMPS; i += 100) { + const end = Math.min(i + 99, TOTAL_AMPS - 1) + ranges.push(`items=${i}-${end}`) + } + + // Add start time + const startTime = performance.now() + + // Make parallel requests for each range + const responses = await Promise.all( + ranges.map((range) => + axios.get(url, { + headers: { range, "Cache-Control": CACHE_CONTROL_HEADER }, + }) + ) + ) + + // Calculate and log duration + const duration = performance.now() - startTime + console.log(`Fetched zones in ${(duration / 1000).toFixed(2)}s`) + + // Combine all responses + return { + data: responses.flatMap((response) => response.data), + } } diff --git a/frontend/services/dashboard.service.ts b/frontend/services/dashboard.service.ts new file mode 100644 index 00000000..a5bf271d --- /dev/null +++ b/frontend/services/dashboard.service.ts @@ -0,0 +1,153 @@ +import { TOTAL_VESSELS } from "@/constants/totals.constants" +import { + getTopVesselsInActivity, + getTopZonesVisited, + getVesselsAtSea, + getVesselsTrackedCount, +} from "@/services/backend-rest-client" +import { swrOptions } from "@/services/swr" +import useSWR from "swr" + +import { convertVesselDtoToItem, convertZoneDtoToItem } from "@/libs/mapper" + +const TOP_ITEMS_SIZE = 5 + +type DashboardData = { + topVesselsInActivity: any[] + topAmpsVisited: any[] + totalVesselsInActivity: number + totalAmpsVisited: number + totalVesselsTracked: number + isLoading: { + topVesselsInActivity: boolean + topAmpsVisited: boolean + totalVesselsInActivity: boolean + totalAmpsVisited: boolean + totalVesselsTracked: boolean + } +} + +export const useDashboardData = ( + startAt: string, + endAt: string +): DashboardData => { + const { + data: topVesselsInActivity = [], + isLoading: topVesselsInActivityLoading, + } = useSWR( + `topVesselsInActivity-${startAt}-${endAt}`, + async () => { + try { + const response = await getTopVesselsInActivity( + startAt, + endAt, + TOP_ITEMS_SIZE + ) + return convertVesselDtoToItem(response?.data || []) + } catch (error) { + console.log( + "An error occurred while fetching top vessels in activity: " + error + ) + return [] + } + }, + swrOptions + ) + + const { data: topAmpsVisited = [], isLoading: topAmpsVisitedLoading } = + useSWR( + `topAmpsVisited-${startAt}-${endAt}`, + async () => { + try { + const response = await getTopZonesVisited( + startAt, + endAt, + TOP_ITEMS_SIZE, + "amp" + ) + return convertZoneDtoToItem(response?.data || []) + } catch (error) { + console.log( + "An error occurred while fetching top amps visited: " + error + ) + return [] + } + }, + swrOptions + ) + + const { + data: totalVesselsInActivity = 0, + isLoading: totalVesselsInActivityLoading, + } = useSWR( + `totalVesselsInActivity-${startAt}-${endAt}`, + async () => { + try { + const response = await getVesselsAtSea(startAt, endAt) + return response?.data + } catch (error) { + console.log( + "An error occurred while fetching total vessels in activity: " + error + ) + return 0 + } + }, + swrOptions + ) + + const { data: totalAmpsVisited = 0, isLoading: totalAmpsVisitedLoading } = + useSWR( + `totalAmpsVisited-${startAt}-${endAt}`, + async () => { + try { + const response = await getTopZonesVisited( + startAt, + endAt, + 100000, + "amp" + ) + return response?.data?.length + } catch (error) { + console.log( + "An error occurred while fetching total amps visited: " + error + ) + return 0 + } + }, + swrOptions + ) + + const { + data: totalVesselsTracked = TOTAL_VESSELS, + isLoading: totalVesselsTrackedLoading, + } = useSWR( + "vesselsTrackedCount", + async () => { + try { + const response = await getVesselsTrackedCount() + return response?.data + } catch (error) { + console.log( + "An error occurred while fetching vessels tracked count: " + error + ) + return TOTAL_VESSELS + } + }, + swrOptions + ) + + return { + topVesselsInActivity, + topAmpsVisited, + totalVesselsInActivity, + totalAmpsVisited, + totalVesselsTracked, + isLoading: { + topVesselsInActivity: topVesselsInActivityLoading, + topAmpsVisited: topAmpsVisitedLoading, + totalVesselsInActivity: totalVesselsInActivityLoading, + totalAmpsVisited: totalAmpsVisitedLoading, + totalVesselsTracked: totalVesselsTrackedLoading, + }, + } +} diff --git a/frontend/services/swr.ts b/frontend/services/swr.ts new file mode 100644 index 00000000..4133e5ab --- /dev/null +++ b/frontend/services/swr.ts @@ -0,0 +1,7 @@ +export const swrOptions = { + refreshInterval: 1000 * 60 * 60, + revalidateOnFocus: false, + dedupingInterval: 1000 * 60 * 60, + keepPreviousData: true, + revalidateOnMount: true, +} diff --git a/frontend/styles/globals.css b/frontend/styles/globals.css index fbb6eaf4..03f874d2 100644 --- a/frontend/styles/globals.css +++ b/frontend/styles/globals.css @@ -4,7 +4,7 @@ @layer base { :root { - --background: 0 0% 100%; + --background: 223 33% 22%; --foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; @@ -19,13 +19,13 @@ --card: 0 0% 100%; --card-foreground: 222.2 47.4% 11.2%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; + --primary: 164 76% 53%; + --primary-foreground: 222.2 47.4% 11.2%; --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; - --accent: 210 40% 96.1%; + --accent: 223 33% 26%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 100% 50%; @@ -75,7 +75,7 @@ @apply border-border; } body { - @apply font-unito bg-background text-foreground; + @apply bg-background font-unito text-foreground; font-feature-settings: "rlig" 1, "calt" 1; diff --git a/frontend/types/item.tsx b/frontend/types/item.ts similarity index 91% rename from frontend/types/item.tsx rename to frontend/types/item.ts index 0a60290d..233a17ac 100644 --- a/frontend/types/item.tsx +++ b/frontend/types/item.ts @@ -4,6 +4,7 @@ export type Item = { description: string value: string type: string + countryIso3?: string } export type ItemDetails = { diff --git a/frontend/types/vessel.ts b/frontend/types/vessel.ts new file mode 100644 index 00000000..f504340b --- /dev/null +++ b/frontend/types/vessel.ts @@ -0,0 +1,131 @@ +export type Vessel = { + id: number + mmsi: number + ship_name: string + width: number + length: number + country_iso3: string + type: string | null + imo: number | null + cfr: string + external_marking: string + ircs: string + length_class: string +} + +export type VesselMetrics = { + vessel: VesselDetails + total_time_at_sea: string +} + +export type VesselDetails = { + id: number + imo: number + mmsi: number + ship_name: string + width: number | null + length: number + country_iso3: string + type: string + external_marking: string + ircs: string + tracking_activated: boolean + tracking_status: string + created_at: string + updated_at: string | null + check: string + length_class: string +} + +export type VesselPositions = VesselPosition[] + +export interface VesselPosition { + arrival_port: string + excurision_id: number + heading: number + position: VesselPositionCoordinates + speed: number + timestamp: string + vessel: Vessel +} + +export interface VesselPositionCoordinates { + coordinates: number[] + type: string +} + +export type VesselExcursionSegmentGeo = { + speed: number + heading?: number + navigational_status: string + geometry: { + type: string + coordinates: number[][] + } +} + +export type VesselExcursionSegmentsGeo = { + vesselId: number + type: any + features: any +} + +export type VesselExcursionSegments = { + vesselId: number + segments: VesselExcursionSegment[] +} + +export type VesselExcursion = { + id: number + vessel_id: number + departure_port_id: number + departure_at: string + departure_position: { + coordinates: number[] + } + arrival_port_id: number + arrival_at: number + arrival_position: number + excursion_duration: number + total_time_at_sea: string + total_time_in_amp: string + total_time_in_territorial_waters: string + total_time_in_costal_waters: string + total_time_fishing: string + total_time_fishing_in_amp: string + total_time_fishing_in_territorial_waters: string + total_time_fishing_in_costal_waters: string + total_time_extincting_amp: string + created_at: string + updated_at: String +} + +export type VesselExcursionSegment = { + id: number + vessel_id: number + excursion_id: number + timestamp_start: string + timestamp_end: string + segment_duration: string + start_position: Position + end_position: Position + course: number + distance: number + average_speed: number + speed_at_start: number + speed_at_end: number + heading_at_start: number + heading_at_end: number + type: string + in_amp_zone: boolean + in_territorial_waters: boolean + in_costal_waters: boolean + last_vessel_segment: boolean + created_at: string + updated_at: string +} + +export type Position = { + type: string + coordinates: number[] +} diff --git a/frontend/types/vessel.tsx b/frontend/types/vessel.tsx deleted file mode 100644 index d51ef047..00000000 --- a/frontend/types/vessel.tsx +++ /dev/null @@ -1,128 +0,0 @@ -export type Vessel = { - id: number - mmsi: number - ship_name: string - width: number - length: number - country_iso3: string - type: string | null - imo: number | null - cfr: string - external_marking: string - ircs: string - length_class: string -} - -export type VesselTrackingTimeDto = { - vessel_id: number; - vessel_mmsi: number; - vessel_ship_name: string; - vessel_width: number; - vessel_length: number; - vessel_country_iso3: string; - vessel_type: string; - vessel_imo: number; - vessel_cfr: string; - vessel_external_marking: string; - vessel_ircs: string; - vessel_home_port_id: number; - vessel_details: string; - vessel_tracking_activated: boolean - vessel_tracking_status: string; - vessel_length_class: string; - vessel_check: string; - total_time_at_sea: string; -} - -export type VesselPositions = VesselPosition[] - -export interface VesselPosition { - arrival_port: string; - excurision_id: number; - heading: number; - position: VesselPositionCoordinates; - speed: number; - timestamp: string; - vessel: Vessel; -} - -export interface VesselPositionCoordinates { - coordinates: number[]; - type: string; -} - -export type VesselExcursionSegmentGeo = { - speed: number - heading?: number - navigational_status: string - geometry: { - type: string; - coordinates: number[][]; - } -} - -export type VesselExcursionSegmentsGeo = { - vesselId: number - type: any; - features: any; -} - -export type VesselExcursionSegments = { - vesselId: number; - segments: VesselExcursionSegment[]; -} - -export type VesselExcursion = { - id: number; - vessel_id: number; - departure_port_id: number; - departure_at: string; - departure_position: { - coordinates: number[]; - } - arrival_port_id: number; - arrival_at: number; - arrival_position: number; - excursion_duration: number; - total_time_at_sea: string; - total_time_in_amp: string; - total_time_in_territorial_waters: string; - total_time_in_costal_waters: string; - total_time_fishing: string; - total_time_fishing_in_amp: string; - total_time_fishing_in_territorial_waters: string; - total_time_fishing_in_costal_waters: string; - total_time_extincting_amp: string; - created_at: string; - updated_at: String; -} - -export type VesselExcursionSegment = { - id: number; - vessel_id: number; - excursion_id: number; - timestamp_start: string; - timestamp_end: string; - segment_duration: string; - start_position: Position - end_position: Position; - course: number; - distance: number; - average_speed: number; - speed_at_start: number; - speed_at_end: number; - heading_at_start: number; - heading_at_end: number; - type: string; - in_amp_zone: boolean; - in_territorial_waters: boolean; - in_costal_waters: boolean; - last_vessel_segment: boolean; - created_at: string; - updated_at: string; -} - -export type Position = { - type: string; - coordinates: number[] -} \ No newline at end of file diff --git a/frontend/types/zone.ts b/frontend/types/zone.ts new file mode 100644 index 00000000..1fd99e82 --- /dev/null +++ b/frontend/types/zone.ts @@ -0,0 +1,43 @@ +import { VesselDetails } from "./vessel" + +export enum ZoneCategory { + AMP = "amp", + TERRITORIAL_SEAS = "Territorial seas", + FISHING_COASTAL_WATERS = "Fishing coastal waters (6-12 NM)", +} + +export type ZoneVisitTimeDto = { + zone_id: number + zone_category: string + zone_sub_category: string + zone_name: string + visiting_duration: string +} + +export interface ZoneDetails { + id: number + created_at: string + updated_at: string + category: string + sub_category: string + name: string +} + +export interface ZoneMetrics { + zone: ZoneDetails + visiting_duration: string + vessel_visiting_time_by_zone: string +} + +export type ZoneVesselMetrics = { + zone: ZoneDetails + vessel: VesselDetails + zone_visiting_time_by_vessel: string +} + +export type ZoneWithGeometry = ZoneDetails & { + geometry: { + type: string + coordinates: number[][][] + } +} diff --git a/frontend/types/zone.tsx b/frontend/types/zone.tsx deleted file mode 100644 index 73b13334..00000000 --- a/frontend/types/zone.tsx +++ /dev/null @@ -1,8 +0,0 @@ - -export type ZoneVisitTimeDto = { - zone_id: number; - zone_category: string; - zone_sub_category: string; - zone_name: string; - visiting_duration: string; -} \ No newline at end of file diff --git a/frontend/utils/vessel.utils.ts b/frontend/utils/vessel.utils.ts new file mode 100644 index 00000000..6b4ec00f --- /dev/null +++ b/frontend/utils/vessel.utils.ts @@ -0,0 +1,10 @@ +import { COUNTRIES_ISO3 } from "@/constants/countries-iso3.constants" + +export const getCountryNameFromIso3 = ( + countryIso3: string | null | undefined +): string => { + if (!countryIso3) return "" + return ( + COUNTRIES_ISO3.find((country) => country.code === countryIso3)?.name ?? "" + ) +}