diff --git a/docker/frontend/dev.Dockerfile b/docker/frontend/dev.Dockerfile index bb61dacc..f6b581c6 100644 --- a/docker/frontend/dev.Dockerfile +++ b/docker/frontend/dev.Dockerfile @@ -5,6 +5,8 @@ WORKDIR ${APP_DIR} # Install dependencies based on the preferred package manager COPY frontend/package.json frontend/yarn.lock* frontend/package-lock.json* frontend/pnpm-lock.yaml* ./ +RUN mkdir -p ./app + RUN \ if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ elif [ -f package-lock.json ]; then npm ci; \ diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index 8adc2d2a..55730f8c 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -12,7 +12,8 @@ "@next/next/no-page-custom-font": "off", "react/jsx-key": "off", "tailwindcss/no-custom-classname": "off", - "tailwindcss/classnames-order": "off" + "tailwindcss/classnames-order": "off", + "react-hooks/exhaustive-deps": "off" }, "settings": { "tailwindcss": { diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index e04fb377..95bd06e8 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -22,8 +22,6 @@ export default function DashboardPage() { isLoading, } = useDashboardData(startAt, endAt) - console.log("totalVesselsTracked", totalVesselsTracked) - return (
diff --git a/frontend/app/details/amp/[id]/page.tsx b/frontend/app/details/amp/[id]/page.tsx index 04a00c81..2b6f2eba 100644 --- a/frontend/app/details/amp/[id]/page.tsx +++ b/frontend/app/details/amp/[id]/page.tsx @@ -2,11 +2,10 @@ 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 { convertDurationInHoursStr, getDateRange } from "@/libs/dateUtils" import DetailsContainer from "@/components/details/details-container" export default function AmpDetailsPage({ params }: { params: { id: string } }) { @@ -42,7 +41,7 @@ export default function AmpDetailsPage({ params }: { params: { id: string } }) { 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`, + value: `${convertDurationInHoursStr(zone_visiting_time_by_vessel)}h`, type: "vessels", } }), diff --git a/frontend/app/details/vessel/[id]/page.tsx b/frontend/app/details/vessel/[id]/page.tsx index 19d8fcad..615959f0 100644 --- a/frontend/app/details/vessel/[id]/page.tsx +++ b/frontend/app/details/vessel/[id]/page.tsx @@ -1,14 +1,11 @@ "use client" import { useMemo, useState } from "react" -import { - getTimeByZone, - getTopZonesVisited, -} from "@/services/backend-rest-client" +import { getTimeByZone } from "@/services/backend-rest-client" import { getCountryNameFromIso3 } from "@/utils/vessel.utils" import useSWR from "swr" -import { convertDurationInHours, getDateRange } from "@/libs/dateUtils" +import { convertDurationInHoursStr, getDateRange } from "@/libs/dateUtils" import DetailsContainer from "@/components/details/details-container" export default function VesselDetailsPage({ @@ -51,7 +48,7 @@ export default function VesselDetailsPage({ id: zone.id.toString(), title: zone.name, description: zone.sub_category, - value: `${convertDurationInHours(vessel_visiting_time_by_zone)}h`, + value: `${convertDurationInHoursStr(vessel_visiting_time_by_zone)}h`, type: "zones", } }), diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 76c39297..b721d661 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,4 +1,5 @@ import "@/styles/globals.css" +import "react-day-picker/dist/style.css" import { Metadata } from "next" diff --git a/frontend/app/map/layout.tsx b/frontend/app/map/layout.tsx index 942c95e8..60c31148 100644 --- a/frontend/app/map/layout.tsx +++ b/frontend/app/map/layout.tsx @@ -3,8 +3,6 @@ import "@/styles/globals.css" import { Metadata } from "next" import { siteConfig } from "@/config/site" -import { MapStoreProvider } from "@/components/providers/map-store-provider" -import { VesselsStoreProvider } from "@/components/providers/vessels-store-provider" export const metadata: Metadata = { title: { @@ -30,11 +28,7 @@ interface RootLayoutProps { export default function RootLayout({ children }: RootLayoutProps) { return (
- - - {children} - - + {children}
) } diff --git a/frontend/app/map/page.tsx b/frontend/app/map/page.tsx index c86d6656..27eed7c9 100644 --- a/frontend/app/map/page.tsx +++ b/frontend/app/map/page.tsx @@ -1,7 +1,14 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" +import { + getVesselExcursions, + getVessels, + getVesselSegments, + getVesselsLatestPositions, +} from "@/services/backend-rest-client" import useSWR from "swr" +import { useShallow } from 'zustand/react/shallow' import { Vessel, VesselPosition } from "@/types/vessel" import { ZoneWithGeometry } from "@/types/zone" @@ -9,6 +16,10 @@ import LeftPanel from "@/components/core/left-panel" import MapControls from "@/components/core/map-controls" import Map from "@/components/core/map/main-map" import PositionPreview from "@/components/core/map/position-preview" +import { useMapStore } from "@/libs/stores/map-store" +import { useVesselsStore } from "@/libs/stores/vessels-store" +import { useLoaderStore } from "@/libs/stores/loader-store" +import { useTrackModeOptionsStore } from "@/libs/stores/track-mode-options-store" const fetcher = async (url: string) => { const response = await fetch(url, { @@ -18,6 +29,19 @@ const fetcher = async (url: string) => { } export default function MapPage() { + const setVessels = useVesselsStore((state) => state.setVessels) + + const { setZonesLoading, setPositionsLoading, setVesselsLoading, setExcursionsLoading } = useLoaderStore(useShallow((state) => ({ + setZonesLoading: state.setZonesLoading, + setPositionsLoading: state.setPositionsLoading, + setVesselsLoading: state.setVesselsLoading, + setExcursionsLoading: state.setExcursionsLoading, + }))) + + const { mode: mapMode } = useMapStore(useShallow((state) => ({ + mode: state.mode + }))) + const { data: vessels = [], isLoading: isLoadingVessels } = useSWR( "/api/vessels", fetcher, @@ -28,6 +52,12 @@ export default function MapPage() { } ) + useEffect(() => { + if (!isLoadingVessels) { + setVessels(vessels); + } + }, [vessels, isLoadingVessels]); + const { data: zones = [], isLoading: isLoadingZones } = useSWR< ZoneWithGeometry[] >("/api/zones", fetcher, { @@ -46,21 +76,45 @@ export default function MapPage() { refreshInterval: 900000, // 15 minutes in milliseconds }) - const isLoading = isLoadingVessels || isLoadingPositions || isLoadingZones + const { startDate, endDate, trackedVesselIDs, setVesselExcursions } = useTrackModeOptionsStore(useShallow((state) => ({ + startDate: state.startDate, + endDate: state.endDate, + trackedVesselIDs: state.trackedVesselIDs, + setVesselExcursions: state.setVesselExcursions, + }))) + + useEffect(() => { + setZonesLoading(isLoadingZones) + setPositionsLoading(isLoadingPositions) + setVesselsLoading(isLoadingVessels) + }, [isLoadingZones, isLoadingPositions, isLoadingVessels]) + + useEffect(() => { + const resetExcursions = async () => { + setExcursionsLoading(true); + for (const vesselID of trackedVesselIDs) { + const vesselExcursions = await getVesselExcursions(vesselID, startDate, endDate); + for (const excursion of vesselExcursions.data) { + const segments = await getVesselSegments(vesselID, excursion.id); + excursion.segments = segments.data; + } + setVesselExcursions(vesselID, vesselExcursions.data); + } + setExcursionsLoading(false); + } + if (mapMode === "track") { + resetExcursions(); + } + }, [startDate, endDate, mapMode, trackedVesselIDs]) return ( <> - + - + ) diff --git a/frontend/components/core/command/vessel-finder.tsx b/frontend/components/core/command/vessel-finder.tsx index ab2deaba..7232bf7a 100644 --- a/frontend/components/core/command/vessel-finder.tsx +++ b/frontend/components/core/command/vessel-finder.tsx @@ -1,5 +1,6 @@ "use client" +import { useShallow } from "zustand/react/shallow" import { useState } from "react" import { getVesselFirstExcursionSegments } from "@/services/backend-rest-client" import { FlyToInterpolator } from "deck.gl" @@ -14,8 +15,9 @@ import { CommandList, CommandSeparator, } from "@/components/ui/command" -import { useMapStore } from "@/components/providers/map-store-provider" -import { useVesselsStore } from "@/components/providers/vessels-store-provider" +import { useMapStore } from "@/libs/stores/map-store" +import { useTrackModeOptionsStore } from "@/libs/stores/track-mode-options-store" +import { useVesselsStore } from "@/libs/stores/vessels-store" type Props = { wideMode: boolean @@ -26,21 +28,39 @@ const SEPARATOR = "___" export function VesselFinderDemo({ wideMode }: Props) { const [open, setOpen] = useState(false) const [search, setSearch] = useState("") + + const { addTrackedVessel, trackedVesselIDs } = useTrackModeOptionsStore( + useShallow((state) => ({ + addTrackedVessel: state.addTrackedVessel, + trackedVesselIDs: state.trackedVesselIDs, + })) + ) + const { - addTrackedVessel, - trackedVesselIDs, setActivePosition, viewState, + latestPositions, setViewState, - } = useMapStore((state) => state) - const { vessels: allVessels } = useVesselsStore((state) => state) - const { latestPositions } = useMapStore((state) => state) + } = useMapStore( + useShallow((state) => ({ + viewState: state.viewState, + latestPositions: state.latestPositions, + setActivePosition: state.setActivePosition, + setViewState: state.setViewState, + })) + ) + + const { vessels: allVessels } = useVesselsStore( + useShallow((state) => ({ + vessels: state.vessels, + })) + ) const onSelectVessel = async (vesselIdentifier: string) => { const vesselId = parseInt(vesselIdentifier.split(SEPARATOR)[3]) const response = await getVesselFirstExcursionSegments(vesselId) if (vesselId && !trackedVesselIDs.includes(vesselId)) { - addTrackedVessel(vesselId, response) + addTrackedVessel(vesselId) } if (vesselId) { const selectedVesselLatestPosition = latestPositions.find( diff --git a/frontend/components/core/left-panel.tsx b/frontend/components/core/left-panel.tsx index 1c0075c5..bed9423b 100644 --- a/frontend/components/core/left-panel.tsx +++ b/frontend/components/core/left-panel.tsx @@ -5,14 +5,16 @@ import Image from "next/image" import TrawlWatchLogo from "@/public/trawlwatch.svg" import { ChartBarIcon } from "@heroicons/react/24/outline" import { motion, useAnimationControls } from "framer-motion" +import { ChevronRightIcon } from "lucide-react" +import { useShallow } from "zustand/react/shallow" -import { Vessel } from "@/types/vessel" +import { useLoaderStore } from "@/libs/stores/loader-store" +import { useMapStore } from "@/libs/stores/map-store" 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 TrackedVesselsPanel from "./tracked-vessel/tracked-vessels-panel" const containerVariants = { close: { @@ -24,7 +26,7 @@ const containerVariants = { }, }, open: { - width: "16rem", + width: "24rem", transition: { type: "spring", damping: 15, @@ -42,29 +44,36 @@ const svgVariants = { }, } -type LeftPanelProps = { - vessels: Vessel[] - isLoading: boolean -} - -export default function LeftPanel({ vessels, isLoading }: LeftPanelProps) { - const [isOpen, setIsOpen] = useState(false) +export default function LeftPanel() { const containerControls = useAnimationControls() const svgControls = useAnimationControls() - const { setVessels } = useVesselsStore((state) => state) - useEffect(() => { - setVessels(vessels) - }, [setVessels, vessels]) + const { vesselsLoading } = useLoaderStore( + useShallow((state) => ({ + vesselsLoading: state.vesselsLoading, + })) + ) + + const { + mode: mapMode, + leftPanelOpened, + setLeftPanelOpened, + } = useMapStore( + useShallow((state) => ({ + mode: state.mode, + leftPanelOpened: state.leftPanelOpened, + setLeftPanelOpened: state.setLeftPanelOpened, + })) + ) useEffect(() => { - const control = isOpen ? "open" : "close" + const control = leftPanelOpened ? "open" : "close" containerControls.start(control) svgControls.start(control) - }, [containerControls, isOpen, svgControls]) + }, [containerControls, leftPanelOpened, svgControls]) const handleOpenClose = () => { - setIsOpen(!isOpen) + setLeftPanelOpened(!leftPanelOpened) } return ( @@ -76,60 +85,54 @@ export default function LeftPanel({ vessels, isLoading }: LeftPanelProps) { className="absolute left-0 top-0 z-10 flex max-h-screen flex-col gap-3 rounded-br-lg bg-color-3 shadow shadow-color-2" >
- {!!isOpen && ( - Trawlwatch logo - )} - -
-
- - - + variants={svgVariants} + initial="close" + > + + + +
-
- {isLoading ? ( -
- + {mapMode === "position" && ( + <> +
+ + +
- ) : ( - - )} -
-
- setIsOpen(true)} - /> +
+ {vesselsLoading ? ( +
+ +
+ ) : ( + + )} +
+ + )} +
!leftPanelOpened && setLeftPanelOpened(true)} + > +
diff --git a/frontend/components/core/map-controls.tsx b/frontend/components/core/map-controls.tsx index 94dbfccd..33dc2124 100644 --- a/frontend/components/core/map-controls.tsx +++ b/frontend/components/core/map-controls.tsx @@ -2,9 +2,10 @@ import React from "react" import { Layers, Minus, Plus, SlidersHorizontal } from "lucide-react" +import { useShallow } from "zustand/react/shallow" +import { useMapStore } from "@/libs/stores/map-store" import IconButton from "@/components/ui/icon-button" -import { useMapStore } from "@/components/providers/map-store-provider" import ZoneFilterModal from "./map/zone-filter-modal" @@ -14,7 +15,12 @@ interface MapControlsProps { const MapControls = ({ zoneLoading }: MapControlsProps) => { const { viewState, setZoom, displayedZones, setDisplayedZones } = useMapStore( - (state) => state + useShallow((state) => ({ + viewState: state.viewState, + setZoom: state.setZoom, + displayedZones: state.displayedZones, + setDisplayedZones: state.setDisplayedZones, + })) ) const handleZoomIn = () => { diff --git a/frontend/components/core/map/main-map.tsx b/frontend/components/core/map/main-map.tsx index f6e28b0d..879d1479 100644 --- a/frontend/components/core/map/main-map.tsx +++ b/frontend/components/core/map/main-map.tsx @@ -8,34 +8,36 @@ import { GeoJsonLayer } from "@deck.gl/layers" import DeckGL from "@deck.gl/react" import chroma from "chroma-js" import { IconLayer, Layer, MapViewState, PolygonLayer } from "deck.gl" +import type { Feature, Geometry } from "geojson" import { renderToString } from "react-dom/server" import { Map as MapGL } from "react-map-gl/maplibre" +import { useShallow } from "zustand/react/shallow" import { + VesselExcursion, VesselExcursionSegment, VesselExcursionSegmentGeo, - VesselExcursionSegments, VesselExcursionSegmentsGeo, VesselPosition, VesselPositions, } from "@/types/vessel" import { ZoneCategory, ZoneWithGeometry } from "@/types/zone" +import { getVesselColorRGB } from "@/libs/colors" +import { useLoaderStore } from "@/libs/stores/loader-store" +import { useMapStore } from "@/libs/stores/map-store" +import { useTrackModeOptionsStore } from "@/libs/stores/track-mode-options-store" import MapTooltip from "@/components/ui/tooltip-map-template" import ZoneMapTooltip from "@/components/ui/zone-map-tooltip" -import { useMapStore } from "@/components/providers/map-store-provider" type CoreMapProps = { vesselsPositions: VesselPositions zones: ZoneWithGeometry[] - isLoading: { - vessels: boolean - positions: boolean - zones: boolean - } } -const VESSEL_COLOR = [94, 141, 185] -const TRACKED_VESSEL_COLOR = [30, 224, 171] +// Add a type to distinguish zones +type ZoneWithType = ZoneWithGeometry & { + renderType: "amp" | "territorial" | "fishing" +} const getOpacityFromTimestamp = (timestamp: string) => { const now = new Date() @@ -49,40 +51,93 @@ const getOpacityFromTimestamp = (timestamp: string) => { return 102 // 40% opacity } -export default function CoreMap({ - vesselsPositions, - zones, - isLoading, -}: CoreMapProps) { +export default function CoreMap({ vesselsPositions, zones }: CoreMapProps) { const { + trackedVesselIDs, + vesselsIDsHidden, + excursions, + excursionsIDsHidden, + focusedExcursionID, + setFocusedExcursionID, + } = useTrackModeOptionsStore( + useShallow((state) => ({ + trackedVesselIDs: state.trackedVesselIDs, + vesselsIDsHidden: state.vesselsIDsHidden, + excursionsIDsHidden: state.excursionsIDsHidden, + excursions: state.excursions, + focusedExcursionID: state.focusedExcursionID, + setFocusedExcursionID: state.setFocusedExcursionID, + })) + ) + + const { + mode: mapMode, viewState, - setViewState, activePosition, - setActivePosition, - trackedVesselIDs, - trackedVesselSegments, - setLatestPositions, displayedZones, - } = useMapStore((state) => state) + setActivePosition, + setViewState, + setLeftPanelOpened, + } = useMapStore( + useShallow((state) => ({ + mode: state.mode, + viewState: state.viewState, + activePosition: state.activePosition, + displayedZones: state.displayedZones, + setViewState: state.setViewState, + setActivePosition: state.setActivePosition, + setLeftPanelOpened: state.setLeftPanelOpened, + })) + ) + + const { zonesLoading, positionsLoading, vesselsLoading, excursionsLoading } = + useLoaderStore( + useShallow((state) => ({ + zonesLoading: state.zonesLoading, + positionsLoading: state.positionsLoading, + vesselsLoading: state.vesselsLoading, + excursionsLoading: state.excursionsLoading, + })) + ) const [coordinates, setCoordinates] = useState("-°N -°E") + const [mapTransitioning, setMapTransitioning] = useState(false) - 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])] - } + const VESSEL_COLOR = [94, 141, 185] + const TRACKED_VESSEL_COLOR = [30, 224, 171] const isVesselSelected = (vp: VesselPosition) => { - return ( - vp.vessel.id === activePosition?.vessel.id || - trackedVesselIDs.includes(vp.vessel.id) - ) + let vesselSelected = vp.vessel.id === activePosition?.vessel.id + if (mapMode === "position") { + vesselSelected = vesselSelected || trackedVesselIDs.includes(vp.vessel.id) + } + return vesselSelected } - useEffect(() => { - setLatestPositions(vesselsPositions) - }, [setLatestPositions, vesselsPositions]) + const trackedVessels = useMemo(() => { + return vesselsPositions + .map((vp) => vp.vessel) + .filter((vessel) => trackedVesselIDs.includes(vessel.id)) + }, [vesselsPositions, trackedVesselIDs]) + + const trackedAndShownVessels = useMemo(() => { + return trackedVessels.filter( + (vessel) => !vesselsIDsHidden.includes(vessel.id) + ) + }, [trackedVessels, vesselsIDsHidden]) + + const trackedAndShownExcursions = useMemo(() => { + const trackedAndShownExcursions: VesselExcursion[] = [] + trackedAndShownVessels.forEach((vessel) => { + const vesselExcursions = excursions[vessel.id] || [] + trackedAndShownExcursions.push( + ...vesselExcursions.filter( + (excursion) => !excursionsIDsHidden.includes(excursion.id) + ) + ) + }) + return trackedAndShownExcursions + }, [trackedAndShownVessels, excursions, excursionsIDsHidden]) const onMapClick = ({ layer }: PickingInfo) => { if (layer?.id !== "vessels-latest-positions") { @@ -90,6 +145,163 @@ export default function CoreMap({ } } + const onVesselClick = ({ object }: PickingInfo) => { + setActivePosition(object as VesselPosition) + } + + const getVesselColor = (vp: VesselPosition) => { + let colorRgb = VESSEL_COLOR + + if (isVesselSelected(vp)) { + colorRgb = TRACKED_VESSEL_COLOR + } + + if (mapMode === "track") { + const listIndex = trackedVesselIDs.indexOf(vp.vessel.id) + colorRgb = getVesselColorRGB(listIndex) + } + + const opacity = getOpacityFromTimestamp(vp.timestamp) + + return [colorRgb[0], colorRgb[1], colorRgb[2], opacity] + } + + const latestPositions = useMemo(() => { + let displayedPositions = vesselsPositions + if (mapMode === "track") { + displayedPositions = displayedPositions.filter((vp) => + trackedVesselIDs.includes(vp.vessel.id) + ) + } + return new IconLayer({ + id: `vessels-latest-positions`, + data: displayedPositions, + getPosition: (vp: VesselPosition) => [ + vp?.position?.coordinates[0], + vp?.position?.coordinates[1], + ], + 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: (vp: VesselPosition) => { + const length = vp.vessel.length || 0 + if (length > 80) return 30 // Large vessels + if (length > 40) return 20 // Small vessels + return 14 // Medium vessels (default) + }, + getColor: (vp: VesselPosition) => { + return new Uint8ClampedArray(getVesselColor(vp)) + }, + + pickable: true, + onClick: onVesselClick, + updateTriggers: { + getColor: [activePosition?.vessel.id, trackedVesselIDs], + }, + }) + }, [ + mapMode, + vesselsPositions, + activePosition?.vessel.id, + trackedVesselIDs, + isVesselSelected, + ]) + + const [segmentsLayer, setSegmentsLayer] = useState([]) + + function getSegmentsColor( + feature: Feature + ) { + const listIndex = trackedVesselIDs.indexOf(feature.properties.vessel_id) + return getVesselColorRGB(listIndex) + } + + function toSegmentsGeo( + vesselId: number, + segments: VesselExcursionSegment[] | undefined + ): VesselExcursionSegmentsGeo { + if (!segments) return { type: "FeatureCollection", features: [] } + const segmentsGeo = segments?.map((segment: VesselExcursionSegment) => { + return { + type: "Feature", + geometry: { + type: "LineString", + coordinates: [ + segment.start_position.coordinates, + segment.end_position.coordinates, + ], + }, + properties: { + vessel_id: vesselId, + excursion_id: segment.excursion_id, + speed: segment.average_speed, + navigational_status: "unknown", + }, + } as Feature + }) + return { type: "FeatureCollection", features: segmentsGeo ?? [] } + } + + function onSegmentClick({ object }: PickingInfo) { + const segment = object as Feature + if (focusedExcursionID !== segment.properties.excursion_id) { + setFocusedExcursionID(segment.properties.excursion_id) + } else { + focusOnExcursion(segment.properties.excursion_id) + } + setLeftPanelOpened(true) + } + + function excursionToSegmentsLayer(excursion: VesselExcursion) { + const segmentsGeo = toSegmentsGeo(excursion.vessel_id, excursion.segments) + return new GeoJsonLayer({ + id: `${excursion.vessel_id}_vessel_trail`, + data: segmentsGeo, + getFillColor: (feature) => { + return getSegmentsColor(feature) + }, + getLineColor: (feature) => { + const color = getSegmentsColor(feature) + return new Uint8ClampedArray(color) + }, + pickable: true, + stroked: false, + filled: true, + getLineWidth: 0.5, + lineWidthMinPixels: 0.5, + lineWidthMaxPixels: 3, + lineWidthUnits: "pixels", + lineWidthScale: 2, + getPointRadius: 4, + getTextSize: 12, + onClick: onSegmentClick, + }) + } + + useEffect(() => { + if (mapMode === "track") { + const layers: Layer[] = [] + + trackedAndShownExcursions.forEach((excursion) => { + layers.push(excursionToSegmentsLayer(excursion)) + }) + + setSegmentsLayer(layers) + } else { + setSegmentsLayer([]) + } + }, [mapMode, trackedAndShownExcursions, viewState]) + const onMapHover = ({ coordinate }: PickingInfo) => { coordinate && setCoordinates( @@ -100,91 +312,80 @@ export default function CoreMap({ ) } - const onVesselClick = ({ object }: PickingInfo) => { - setActivePosition(object as VesselPosition) - } + const focusOnExcursion = (excursionID: number) => { + const focusedExcursion = Object.values(excursions) + .flat() + .find((excursion) => excursion.id === focusedExcursionID) - const latestPositions = useMemo( - () => - new IconLayer({ - id: `vessels-latest-positions`, - data: vesselsPositions, - getPosition: (vp: VesselPosition) => [ - vp?.position?.coordinates[0], - vp?.position?.coordinates[1], - ], - 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: (vp: VesselPosition) => { - const length = vp.vessel.length || 0 - if (length > 80) return 30 // Large vessels - if (length > 40) return 20 // Small vessels - return 14 // Medium vessels (default) - }, - getColor: (vp: VesselPosition) => { - const baseColor = isVesselSelected(vp) - ? TRACKED_VESSEL_COLOR - : VESSEL_COLOR - const opacity = getOpacityFromTimestamp(vp.timestamp) - return new Uint8ClampedArray([ - baseColor[0], - baseColor[1], - baseColor[2], - opacity, - ]) - }, - pickable: true, - onClick: onVesselClick, - updateTriggers: { - getColor: [activePosition?.vessel.id, trackedVesselIDs], + if (focusedExcursion) { + // Get all coordinates from excursion segments + const coordinates = focusedExcursion?.segments?.map( + (segment) => segment.start_position.coordinates + ) + + if (!coordinates) return + + // Find bounds + const bounds = coordinates.reduce( + (acc, coord) => { + return { + minLng: Math.min(acc.minLng, coord[0]), + maxLng: Math.max(acc.maxLng, coord[0]), + minLat: Math.min(acc.minLat, coord[1]), + maxLat: Math.max(acc.maxLat, coord[1]), + } }, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - vesselsPositions, - activePosition?.vessel.id, - trackedVesselIDs, - isVesselSelected, - ] - ) + { + minLng: Infinity, + maxLng: -Infinity, + minLat: Infinity, + maxLat: -Infinity, + } + ) - const tracksByVesselAndVoyage = useMemo( - () => - trackedVesselSegments - .map((segments) => toSegmentsGeo(segments)) - .map((segmentsGeo: VesselExcursionSegmentsGeo) => { - return new GeoJsonLayer({ - id: `${segmentsGeo.vesselId}_vessel_trail`, - data: segmentsGeo, - getFillColor: (feature) => - getColorFromValue(feature.properties?.speed), - getLineColor: (feature) => - getColorFromValue(feature.properties?.speed), - pickable: false, - stroked: true, - filled: false, - getLineWidth: 0.5, - lineWidthMinPixels: 0.5, - lineWidthMaxPixels: 3, - lineWidthUnits: "pixels", - lineWidthScale: 2, - getPointRadius: 4, - getTextSize: 12, - }) - }), - [trackedVesselSegments] - ) + // Add padding + const padding = 0.5 // degrees + bounds.minLng -= padding + bounds.maxLng += padding + bounds.minLat -= padding + bounds.maxLat += padding + + // Calculate center and zoom + const center = [ + (bounds.minLng + bounds.maxLng) / 2, + (bounds.minLat + bounds.maxLat) / 2, + ] + + const latDiff = bounds.maxLat - bounds.minLat + const lngDiff = bounds.maxLng - bounds.minLng + const zoom = Math.min( + Math.floor(9 - Math.log2(Math.max(latDiff, lngDiff))), + 20 // max zoom + ) + + setViewState({ + ...viewState, + longitude: center[0], + latitude: center[1], + zoom: zoom, + transitionDuration: 500, + onTransitionStart: () => setMapTransitioning(true), + onTransitionEnd: () => setMapTransitioning(false), + }) + } + } + + useEffect(() => { + if (focusedExcursionID) { + focusOnExcursion(focusedExcursionID) + } + }, [focusedExcursionID]) + + useEffect(() => { + if (!mapTransitioning) { + setFocusedExcursionID(null) + } + }, [viewState.latitude, viewState.longitude]) const getObjectType = ( object: VesselPosition | ZoneWithGeometry | undefined @@ -344,26 +545,26 @@ export default function CoreMap({ const layers = useMemo( () => [ - !isLoading.zones && [ + !zonesLoading && [ ampMultiZonesLayer, ampSingleZonesLayer, territorialZonesLayer, fishingZonesLayer, ], - !isLoading.vessels && !isLoading.positions && tracksByVesselAndVoyage, - !isLoading.positions && latestPositions, + !vesselsLoading && !positionsLoading && segmentsLayer, + !positionsLoading && latestPositions, ] .flat() .filter(Boolean) as Layer[], [ - isLoading.zones, - isLoading.vessels, - isLoading.positions, + zonesLoading, + vesselsLoading, + positionsLoading, ampMultiZonesLayer, ampSingleZonesLayer, territorialZonesLayer, fishingZonesLayer, - tracksByVesselAndVoyage, + segmentsLayer, latestPositions, ] ) @@ -425,19 +626,3 @@ export default function CoreMap({ ) } -function toSegmentsGeo({ segments, vesselId }: VesselExcursionSegments): any { - const segmentsGeo = segments?.map((segment: VesselExcursionSegment) => { - return { - speed: segment.average_speed, - navigational_status: "unknown", - geometry: { - type: "LineString", - coordinates: [ - segment.start_position.coordinates, - segment.end_position.coordinates, - ], - }, - } - }) - return { vesselId, type: "FeatureCollection", features: segmentsGeo ?? [] } -} diff --git a/frontend/components/core/map/position-preview.tsx b/frontend/components/core/map/position-preview.tsx index bcf78f33..abf60ee3 100644 --- a/frontend/components/core/map/position-preview.tsx +++ b/frontend/components/core/map/position-preview.tsx @@ -6,12 +6,13 @@ import { } from "framer-motion" import PreviewCard from "@/components/core/map/preview-card" -import { useMapStore } from "@/components/providers/map-store-provider" +import { useMapStore } from "@/libs/stores/map-store" export interface PositionPreviewTypes {} const PositionPreview: React.FC = () => { - const { activePosition } = useMapStore((state) => state) + const activePosition = useMapStore((state) => state.activePosition); + return ( {activePosition && ( diff --git a/frontend/components/core/map/preview-card.tsx b/frontend/components/core/map/preview-card.tsx index dd127241..2c84da1f 100644 --- a/frontend/components/core/map/preview-card.tsx +++ b/frontend/components/core/map/preview-card.tsx @@ -6,19 +6,29 @@ 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 { useMapStore } from "@/libs/stores/map-store" +import { useTrackModeOptionsStore } from "@/libs/stores/track-mode-options-store" +import { useShallow } from "zustand/react/shallow" export interface PreviewCardTypes { vesselInfo: VesselPosition } const PreviewCard: React.FC = ({ vesselInfo }) => { + const { setActivePosition } = useMapStore(useShallow((state) => ({ + setActivePosition: state.setActivePosition, + }))) + const { - setActivePosition, addTrackedVessel, trackedVesselIDs, removeTrackedVessel, - } = useMapStore((state) => state) + } = useTrackModeOptionsStore(useShallow((state) => ({ + addTrackedVessel: state.addTrackedVessel, + trackedVesselIDs: state.trackedVesselIDs, + removeTrackedVessel: state.removeTrackedVessel, + }))) + const { vessel: { id: vesselId, mmsi, ship_name, imo, length }, timestamp, @@ -27,13 +37,12 @@ const PreviewCard: React.FC = ({ vesselInfo }) => { return trackedVesselIDs.includes(vesselId) } - const handleDisplayTrail = async (vesselId: number) => { + const handleDisplayTrackedVessel = async (vesselId: number) => { if (isVesselTracked(vesselId)) { removeTrackedVessel(vesselId) - return + } else { + addTrackedVessel(vesselId) } - const response = await getVesselFirstExcursionSegments(vesselId) - addTrackedVessel(vesselId, response) } return (
@@ -73,14 +82,12 @@ const PreviewCard: React.FC = ({ vesselInfo }) => {

{timestamp}

- - {isVesselTracked(vesselId) && ( - - Show track details - - )}
diff --git a/frontend/components/core/map/zone-filter-modal.tsx b/frontend/components/core/map/zone-filter-modal.tsx index 6eb5d680..26c33a75 100644 --- a/frontend/components/core/map/zone-filter-modal.tsx +++ b/frontend/components/core/map/zone-filter-modal.tsx @@ -12,11 +12,14 @@ import { import IconButton from "@/components/ui/icon-button" import { FilterButton } from "./filter-button" +import { cn } from "@/libs/utils" +import { LayersIcon } from "lucide-react" interface ZoneFilterModalProps { activeZones: string[] setActiveZones: (zones: string[]) => void isLoading: boolean + className?: string } const ZONE_LABELS = [ @@ -38,6 +41,7 @@ export default function ZoneFilterModal({ activeZones, setActiveZones, isLoading, + className, }: ZoneFilterModalProps) { const zoneCategories = Object.values(ZoneCategory) @@ -55,28 +59,19 @@ export default function ZoneFilterModal({ {isLoading ? ( ) : ( - Layers + )} - + - - Layers + + Zones diff --git a/frontend/components/core/tracked-vessel/tracked-vessel-details.tsx b/frontend/components/core/tracked-vessel/tracked-vessel-details.tsx new file mode 100644 index 00000000..aad44bfa --- /dev/null +++ b/frontend/components/core/tracked-vessel/tracked-vessel-details.tsx @@ -0,0 +1,116 @@ +"use client" + +import { useMemo } from "react" +import { useShallow } from "zustand/react/shallow" + +import { Vessel, VesselExcursion, ExcursionMetrics } from "@/types/vessel" +import { convertDurationInSeconds, formatDuration } from "@/libs/dateUtils" +import { useLoaderStore } from "@/libs/stores/loader-store" +import { useTrackModeOptionsStore } from "@/libs/stores/track-mode-options-store" +import Spinner from "@/components/ui/custom/spinner" + +import TrackedVesselExcursion from "./tracked-vessel-excursion" +import TrackedVesselMetric from "./tracked-vessel-metric" + +export interface TrackedVesselDetailsProps { + vessel: Vessel + className?: string +} + +export default function TrackedVesselDetails({ + vessel, + className, +}: TrackedVesselDetailsProps) { + const { excursions } = useTrackModeOptionsStore( + useShallow((state) => ({ + excursions: state.excursions, + })) + ) + + const { excursionsLoading } = useLoaderStore( + useShallow((state) => ({ + excursionsLoading: state.excursionsLoading, + })) + ) + + const vesselExcursions = useMemo(() => { + return excursions[vessel.id] || [] + }, [excursions, vessel.id]) + + const computeExcursionMetrics = ( + excursions: VesselExcursion[] + ): ExcursionMetrics => { + const metrics: ExcursionMetrics = { + totalTimeFishing: 0, + mpa: 0, + frenchTerritorialWaters: 0, + zonesWithNoFishingRights: 0, + aisDefault: 0, + } + for (const excursion of excursions) { + metrics.totalTimeFishing += convertDurationInSeconds( + excursion.excursion_duration + ) + metrics.mpa += convertDurationInSeconds(excursion.total_time_in_amp) + metrics.frenchTerritorialWaters += convertDurationInSeconds( + excursion.total_time_in_territorial_waters + ) + metrics.zonesWithNoFishingRights += convertDurationInSeconds( + excursion.total_time_in_zones_with_no_fishing_rights + ) + metrics.aisDefault += convertDurationInSeconds( + excursion.total_time_default_ais + ) + } + return metrics + } + + const excursionsMetrics = useMemo(() => { + return computeExcursionMetrics(vesselExcursions) + }, [vesselExcursions]) + + if (excursionsLoading) { + return + } + + return ( +
+ + + + + + {vesselExcursions.map((excursion, index) => ( + + ))} +
+ ) +} diff --git a/frontend/components/core/tracked-vessel/tracked-vessel-excursion.tsx b/frontend/components/core/tracked-vessel/tracked-vessel-excursion.tsx new file mode 100644 index 00000000..e78c98f8 --- /dev/null +++ b/frontend/components/core/tracked-vessel/tracked-vessel-excursion.tsx @@ -0,0 +1,115 @@ +import { useEffect, useMemo, useState } from "react" +import { CrosshairIcon, EyeIcon } from "lucide-react" + +import { VesselExcursion, ExcursionMetrics } from "@/types/vessel" +import SidebarExpander from "@/components/ui/custom/sidebar-expander" + +import TrackedVesselMetric from "./tracked-vessel-metric" +import { useTrackModeOptionsStore } from "@/libs/stores" +import { useShallow } from "zustand/react/shallow" +import { convertDurationInSeconds } from "@/libs/dateUtils" + +export interface TrackedVesselExcursionProps { + index: number + excursion: VesselExcursion + className?: string +} + +export default function TrackedVesselExcursion({ + index, + excursion, + className, +}: TrackedVesselExcursionProps) { + const prettifyDate = (date: string) => { + return new Date(date).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }) + } + + const [detailsOpened, setDetailsOpened] = useState(false) + + const { excursionsIDsHidden, toggleExcursionVisibility, setFocusedExcursionID, focusedExcursionID } = useTrackModeOptionsStore(useShallow((state) => ({ + excursionsIDsHidden: state.excursionsIDsHidden, + toggleExcursionVisibility: state.toggleExcursionVisibility, + setFocusedExcursionID: state.setFocusedExcursionID, + focusedExcursionID: state.focusedExcursionID, + }))) + + const isHidden = useMemo(() => excursionsIDsHidden.includes(excursion.id), [excursionsIDsHidden, excursion.id]) + + const onToggleVisibility = () => { + toggleExcursionVisibility(excursion.id) + } + + const onFocusExcursion = () => { + setFocusedExcursionID(excursion.id) + } + + const isExcursionFocused = useMemo(() => { + return focusedExcursionID === excursion.id + }, [focusedExcursionID, excursion.id]) + + useEffect(() => { + if (isExcursionFocused) { + setDetailsOpened(true) + } + }, [isExcursionFocused]) + + const title = useMemo(() => { + let outputTitle = `Voyage ${index} | ${prettifyDate(excursion.departure_at)}` + + if (excursion.arrival_at) { + outputTitle = `${outputTitle} - ${prettifyDate(excursion.arrival_at)}` + } else { + outputTitle = `${outputTitle} - ?` + } + + return outputTitle + }, [index, excursion]) + + const metrics = useMemo(() => { + return { + totalTimeFishing: convertDurationInSeconds(excursion.excursion_duration), + mpa: convertDurationInSeconds(excursion.total_time_in_amp), + frenchTerritorialWaters: convertDurationInSeconds(excursion.total_time_in_territorial_waters), + zonesWithNoFishingRights: convertDurationInSeconds(excursion.total_time_in_zones_with_no_fishing_rights), + aisDefault: convertDurationInSeconds(excursion.total_time_default_ais), + } as ExcursionMetrics + }, [excursion]) + + return ( +
+ + +
{title}
+
+ + +
+
+ + +
+ + + + + +
+
+
+
+ ) +} diff --git a/frontend/components/core/tracked-vessel/tracked-vessel-item.tsx b/frontend/components/core/tracked-vessel/tracked-vessel-item.tsx new file mode 100644 index 00000000..123226dd --- /dev/null +++ b/frontend/components/core/tracked-vessel/tracked-vessel-item.tsx @@ -0,0 +1,119 @@ +import { useEffect, useMemo, useState } from "react" +import { EyeIcon, Loader2Icon, LoaderIcon, XIcon } from "lucide-react" + +import { Vessel } from "@/types/vessel" +import SidebarExpander from "@/components/ui/custom/sidebar-expander" +import { useMapStore } from "@/libs/stores/map-store" + +import TrackedVesselDetails from "./tracked-vessel-details" +import { useShallow } from "zustand/react/shallow" +import { useTrackModeOptionsStore } from "@/libs/stores/track-mode-options-store" +import { useLoaderStore } from "@/libs/stores/loader-store" +import { getVesselColorBg } from "@/libs/colors" + +export interface TrackedVesselItemProps { + listIndex: number + vessel: Vessel + className?: string +} + +export default function TrackedVesselItem({ + vessel, + listIndex = 0, + className, +}: TrackedVesselItemProps) { + const { mode: mapMode } = useMapStore(useShallow((state) => ({ + mode: state.mode, + }))) + + const { removeTrackedVessel, vesselsIDsHidden, toggleVesselVisibility, focusedExcursionID, excursions } = useTrackModeOptionsStore(useShallow((state) => ({ + excursions: state.excursions, + removeTrackedVessel: state.removeTrackedVessel, + vesselsIDsHidden: state.vesselsIDsHidden, + toggleVesselVisibility: state.toggleVesselVisibility, + focusedExcursionID: state.focusedExcursionID, + }))) + + const { excursionsLoading } = useLoaderStore(useShallow((state) => ({ + excursionsLoading: state.excursionsLoading, + }))) + + const isVesselExcursionFocused = useMemo(() => { + if (!excursions[vessel.id]) return false + + return excursions[vessel.id].some(excursion => focusedExcursionID === excursion.id) + }, [focusedExcursionID, excursions]) + + const [detailsOpened, setDetailsOpened] = useState(false) + + useEffect(() => { + if (isVesselExcursionFocused) { + setDetailsOpened(true) + } + }, [isVesselExcursionFocused]) + + const isHidden = useMemo(() => vesselsIDsHidden.includes(vessel.id), [vesselsIDsHidden, vessel.id]) + + const vesselBgColorClass = getVesselColorBg(listIndex); + const isTrackMode = mapMode === "track" + + const onRemove = () => { + removeTrackedVessel(vessel.id) + } + + const onToggleVisibility = () => { + toggleVesselVisibility(vessel.id) + } + + return ( +
+
+ + +
+
+ {isTrackMode && ( +
+ )} +
{vessel.ship_name}
+ {excursionsLoading && ( + + )} +
+
+

+ IMO {vessel.imo} / MMSI {vessel.mmsi} / {vessel.length}m +

+
+
+
+ + {isTrackMode && ( + + )} +
+
+ +
+ +
+
+
+
+
+ ) +} diff --git a/frontend/components/core/tracked-vessel/tracked-vessel-metric.tsx b/frontend/components/core/tracked-vessel/tracked-vessel-metric.tsx new file mode 100644 index 00000000..a507cd94 --- /dev/null +++ b/frontend/components/core/tracked-vessel/tracked-vessel-metric.tsx @@ -0,0 +1,62 @@ +import { Progress } from "@/components/ui/progress" +import { cn } from "@/libs/utils" +import SidebarExpander from "@/components/ui/custom/sidebar-expander" + +export interface TrackedVesselMetricProps { + title: string + value: number + unit?: 'time' + baseValue?: number + children?: React.ReactNode +} + +export default function TrackedVesselMetric({ title, value, baseValue, children, unit }: TrackedVesselMetricProps) { + return ( + + + + + + {children} + + + ) +} + + +function TrackedVesselMetricHeader({ title, value, baseValue, children, unit }: TrackedVesselMetricProps) { + const prettifyTime = (timeInSeconds: number) => { + const days = Math.floor(timeInSeconds / 86400) + const hours = Math.floor((timeInSeconds % 86400) / 3600) + const minutes = Math.floor((timeInSeconds % 3600) / 60) + + let prettifiedTime = "" + if (days > 0) prettifiedTime += `${days}d ` + if (hours > 0) prettifiedTime += `${hours}h ` + if (minutes > 0) prettifiedTime += `${minutes}mn` + + return prettifiedTime + } + + const showProgress = baseValue !== undefined + const progressValue = showProgress ? Number(((Number(value) / Number(baseValue)) * 100).toFixed(1)) : 0 + + const prettyValue = unit === 'time' ? prettifyTime(Number(value)) : value + const valueFontSize = showProgress ? 'text-xs' : 'text-sm' + + return ( +
+
+
{title}
+ {prettyValue} +
+ {showProgress && ( +
+

{progressValue}%

+ +
+ )} + {children} +
+ ) +} diff --git a/frontend/components/core/tracked-vessel/tracked-vessels-panel.tsx b/frontend/components/core/tracked-vessel/tracked-vessels-panel.tsx new file mode 100644 index 00000000..ee0af0e8 --- /dev/null +++ b/frontend/components/core/tracked-vessel/tracked-vessels-panel.tsx @@ -0,0 +1,337 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { PopoverContent, PopoverTrigger } from "@radix-ui/react-popover" +import { format } from "date-fns" +import { + CalendarIcon, + ChevronRight, + MinusIcon, + PenIcon, + Ship as ShipIcon, + XIcon, +} from "lucide-react" +import { DayPicker, getDefaultClassNames, Matcher } from "react-day-picker" + +import { Vessel } from "@/types/vessel" +import { cn } from "@/libs/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Popover } from "@/components/ui/popover" + +import { useVesselsStore } from "@/libs/stores/vessels-store" +import { useMapStore } from "@/libs/stores/map-store" +import { useTrackModeOptionsStore } from "@/libs/stores/track-mode-options-store" +import { useShallow } from "zustand/react/shallow" + +import TrackedVesselItem from "./tracked-vessel-item" +import Spinner from "@/components/ui/custom/spinner" + +function NoVesselsPlaceholder() { + return ( +

+ There is no vessels selected +

+ ) +} + +function VesselsActions({ + onCreateFleet = () => {}, + disabledCreateFleet = false, + onViewTracks = () => {}, + disabledViewTracks = false, +}: { + onCreateFleet?: () => void + disabledCreateFleet?: boolean + onViewTracks?: () => void + disabledViewTracks?: boolean +}) { + return ( +
+ + +
+ ) +} + +function TrackModeDatePicker({ + label, + date, + setDate, + maxDate, + minDate, + className, +}: { + label: string + date: Date | undefined + setDate: (date: Date | undefined) => void + maxDate?: Date + minDate?: Date + className?: string +}) { + const defaultClassNames = getDefaultClassNames() + const disabledMatchers: Matcher[] = [] + + if (minDate) { + disabledMatchers.push({ before: minDate }) + } + + if (maxDate) { + disabledMatchers.push({ after: maxDate }) + } + + const onClear = () => { + setDate(undefined) + } + + return ( + + + + + + + +
+ } + disabled={disabledMatchers} + /> + + + ) +} + +function TrackModeHeader() { + const { + setMode: setMapMode + } = useMapStore(useShallow((state) => ({ + setMode: state.setMode, + }))) + + const { startDate, endDate, setStartDate, setEndDate } = useTrackModeOptionsStore(useShallow((state) => ({ + startDate: state.startDate, + endDate: state.endDate, + setStartDate: state.setStartDate, + setEndDate: state.setEndDate, + }))) + + const [startDateSelected, setStartDateSelected] = useState(startDate); + const [endDateSelected, setEndDateSelected] = useState(endDate); + + const today = new Date(); + const minDate = startDate ? startDate : today; + + const onSetStartDate = (date: Date | undefined) => { + setStartDateSelected(date) + if (date && endDateSelected && date > endDateSelected) { + setEndDateSelected(undefined) + } + } + + const onSetEndDate = (date: Date | undefined) => { + setEndDateSelected(date) + } + + const onApply = () => { + setStartDate(startDateSelected) + setEndDate(endDateSelected) + } + + return ( +
+
+
TRACK MODE
+ setMapMode("position")} + /> +
+
+
+ + + + +
+
+
+ ) +} + +type TrackedVesselsPanelProps = { + wideMode: boolean +} + +export default function TrackedVesselsPanel({ + wideMode, +}: TrackedVesselsPanelProps) { + const { + mode: mapMode, + setMode: setMapMode, + setActivePosition: setActivePosition, + } = useMapStore(useShallow((state) => ({ + mode: state.mode, + setMode: state.setMode, + setActivePosition: state.setActivePosition, + }))) + + const { trackedVesselIDs, setTrackedVesselIDs } = useTrackModeOptionsStore(useShallow((state) => ({ + trackedVesselIDs: state.trackedVesselIDs, + setTrackedVesselIDs: state.setTrackedVesselIDs, + }))) + + const { vessels: allVessels } = useVesselsStore(useShallow((state) => ({ + vessels: state.vessels, + }))) + + const trackedVesselsDetails = useMemo(() => { + return allVessels.filter((vessel) => trackedVesselIDs.includes(vessel.id)) + }, [allVessels, trackedVesselIDs]) + + + const hasTrackedVessels = useMemo(() => { + return trackedVesselIDs.length > 0 + }, [trackedVesselIDs]) + + const onViewTracks = () => { + setMapMode("track") + setActivePosition(null) + } + + const vesselsSelectedCount = trackedVesselIDs.length + + const [animateBadge, setAnimateBadge] = useState(false); + + useEffect(() => { + if (vesselsSelectedCount > 0) { + setAnimateBadge(true); + const timer = setTimeout(() => setAnimateBadge(false), 300); + return () => clearTimeout(timer); + } + }, [vesselsSelectedCount]); + + const WideModeTab = () => { + return ( + <> + {mapMode === "track" && } + {!hasTrackedVessels && } + + {trackedVesselsDetails?.map((vessel: Vessel, index) => { + return ( + + ) + })} + + {mapMode === "position" && ( + + )} + + ) + } + + return ( + <> +
+
+ {!wideMode && ( +
+ + {vesselsSelectedCount > 0 && ( + + {vesselsSelectedCount} + + )} +
+ )} + {mapMode === "position" && wideMode && ( + {`Selected vessel (${trackedVesselIDs.length})`} + )} +
+
+ + {wideMode && } + + ) +} diff --git a/frontend/components/core/tracked-vessels-panel.tsx b/frontend/components/core/tracked-vessels-panel.tsx deleted file mode 100644 index 6072e16c..00000000 --- a/frontend/components/core/tracked-vessels-panel.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { useEffect, useState } from "react" -import { Ship as ShipIcon, X } from "lucide-react" - -import { Vessel } from "@/types/vessel" - -import { useMapStore } from "../providers/map-store-provider" -import { useVesselsStore } from "../providers/vessels-store-provider" - -type Props = { - wideMode: boolean - parentIsOpen: boolean - openParent: () => void -} - -export default function TrackedVesselsPanel({ - wideMode, - parentIsOpen, - openParent, -}: Props) { - const { trackedVesselIDs, removeTrackedVessel } = useMapStore( - (state) => state - ) - const { vessels: allVessels } = useVesselsStore((state) => state) - const [displayTrackedVessels, setDisplayTrackedVessels] = useState(false) - const [trackedVesselsDetails, setTrackedVesselsDetails] = useState() - - const showOrHideTrackedVessels = () => { - if (!parentIsOpen) { - openParent() - } - setDisplayTrackedVessels(!displayTrackedVessels) - } - - useEffect(() => { - const vesselsDetails = allVessels.filter((vessel) => - trackedVesselIDs.includes(vessel.id) - ) - setTrackedVesselsDetails(vesselsDetails) - }, [allVessels, trackedVesselIDs]) - - return ( - <> - - - {displayTrackedVessels && - parentIsOpen && - trackedVesselsDetails?.map((vessel: Vessel) => { - return ( -
-
-
{vessel.ship_name}
-
- IMO {vessel.imo} / MMSI {vessel.mmsi} / Length {vessel.length} - m -
-
- -
- ) - })} - - ) -} diff --git a/frontend/components/providers/map-store-provider.tsx b/frontend/components/providers/map-store-provider.tsx deleted file mode 100644 index c9b35116..00000000 --- a/frontend/components/providers/map-store-provider.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client" - -import { createContext, useContext, useRef, type ReactNode } from "react" -import { useStore, type StoreApi } from "zustand" - -import { createMapStore, type MapStore } from "@/libs/stores/map-store" - -export const MapStoreContext = createContext | null>(null) - -export interface MapStoreProviderProps { - children: ReactNode -} - -export const MapStoreProvider = ({ children }: MapStoreProviderProps) => { - const storeRef = useRef>() - if (!storeRef.current) { - storeRef.current = createMapStore() - } - - return ( - - {children} - - ) -} - -export const useMapStore = (selector: (store: MapStore) => T): T => { - const mapStoreContext = useContext(MapStoreContext) - - if (!mapStoreContext) { - throw new Error(`useMapStore must be use within MapStoreProvider`) - } - - return useStore(mapStoreContext, selector) -} diff --git a/frontend/components/providers/vessels-store-provider.tsx b/frontend/components/providers/vessels-store-provider.tsx deleted file mode 100644 index 633f8d32..00000000 --- a/frontend/components/providers/vessels-store-provider.tsx +++ /dev/null @@ -1,34 +0,0 @@ -"use client" - -import { createContext, useContext, useRef, type ReactNode } from "react" -import { useStore, type StoreApi } from "zustand" -import { VesselsState, createVesselsStore, type VesselsStore } from "@/libs/stores/vessels-store" - -export const VesselsStoreContext = createContext | null>(null) - -export interface VesselsStoreProviderProps { - children: ReactNode -} - -export const VesselsStoreProvider = ({ children }: VesselsStoreProviderProps) => { - const storeRef = useRef>() - if (!storeRef.current) { - storeRef.current = createVesselsStore() - } - - return ( - - {children} - - ) -} - -export const useVesselsStore = (selector: (store: VesselsStore) => T): T => { - const context = useContext(VesselsStoreContext) - - if (!context) { - throw new Error(`useVesselsStore must be use within VesselsStoreProvider`) - } - - return useStore(context, selector) -} diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx new file mode 100644 index 00000000..ab0403d2 --- /dev/null +++ b/frontend/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/libs/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/frontend/components/ui/custom/sidebar-expander.tsx b/frontend/components/ui/custom/sidebar-expander.tsx new file mode 100644 index 00000000..fb32097e --- /dev/null +++ b/frontend/components/ui/custom/sidebar-expander.tsx @@ -0,0 +1,125 @@ +import React, { useEffect, useState } from "react" +import { motion, useAnimationControls } from "framer-motion" +import { ChevronUpIcon } from "lucide-react" + +function SidebarExpanderHeader({ + disabled = false, + onClick, + children, + className, +}: { + disabled?: boolean + onClick?: () => void + children: React.ReactNode + className?: string +}) { + const baseClassName = disabled ? "" : "cursor-pointer" + return ( +
+ {children} +
+ ) +} + +function SidebarExpanderContent({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) { + return
{children}
+} + +export interface SidebarExpanderProps { + disabled?: boolean + children: React.ReactNode + className?: string + opened?: boolean + onToggle?: (opened: boolean) => void +} + +function SidebarExpander({ disabled = false, children, className, opened = false, onToggle }: SidebarExpanderProps) { + const [showContent, setShowContent] = useState(opened) + const svgControls = useAnimationControls() + + useEffect(() => { + const control = showContent ? "open" : "close" + if (onToggle) { + onToggle(showContent) + } + svgControls.start(control) + }, [showContent, svgControls, onToggle]) + + useEffect(() => { + setShowContent(opened) + }, [opened]) + + const svgVariants = { + close: { + rotate: 360, + }, + open: { + rotate: 180, + }, + } + + const onShowContent = (e: React.MouseEvent) => { + e.stopPropagation() + if (!disabled) { + const target = e.target as HTMLButtonElement + if (!target.closest("button")) { + setShowContent(!showContent) + } + } + } + + const findChildren = (type: React.ElementType) => { + return React.Children.toArray(children).find( + (child) => React.isValidElement(child) && child.type === type + ) as React.ReactElement | undefined + } + + const header = findChildren(SidebarExpanderHeader) + const content = findChildren(SidebarExpanderContent) + + const headerWithProps = + header && + React.cloneElement(header, { + onClick: onShowContent, + disabled, + }) + + return ( +
+ {!disabled && ( +
+ +
+ )} +
+ {headerWithProps} + {showContent && content} +
+
+ ) +} + +const SidebarExpanderComponents = { + Root: SidebarExpander, + Header: SidebarExpanderHeader, + Content: SidebarExpanderContent, +} +export default SidebarExpanderComponents; diff --git a/frontend/components/ui/icon-button.tsx b/frontend/components/ui/icon-button.tsx index ddff24f3..172490f7 100644 --- a/frontend/components/ui/icon-button.tsx +++ b/frontend/components/ui/icon-button.tsx @@ -1,3 +1,4 @@ +import { cn } from "@/libs/utils" import * as React from "react" type Props = { @@ -5,25 +6,28 @@ type Props = { onClick?: () => void children: React.ReactNode disabled?: boolean + className?: string } -const IconButton: React.FC = ({ +export default function IconButton({ onClick, children, description, disabled, -}) => { + className, +}: Props) { return ( ) } - -export default IconButton diff --git a/frontend/components/ui/popover.tsx b/frontend/components/ui/popover.tsx new file mode 100644 index 00000000..a864b512 --- /dev/null +++ b/frontend/components/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/libs/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/frontend/components/ui/progress.tsx b/frontend/components/ui/progress.tsx new file mode 100644 index 00000000..9b044036 --- /dev/null +++ b/frontend/components/ui/progress.tsx @@ -0,0 +1,31 @@ +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/libs/utils" + +interface CustomProgressProps + extends React.ComponentPropsWithoutRef { + indicatorColor?: string; +} + +const Progress = React.forwardRef< + React.ElementRef, + CustomProgressProps +>(({ className, value, indicatorColor = "bg-accent", ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/frontend/libs/colors.ts b/frontend/libs/colors.ts new file mode 100644 index 00000000..446f05ed --- /dev/null +++ b/frontend/libs/colors.ts @@ -0,0 +1,48 @@ +const colors = require("@/tailwind.config").theme.extend.colors; + +export const getVesselColor = (listIndex: number) => { + const nColors = 12; + return `vessel-color-${(listIndex - 1) % nColors + 1}` +} + +/* For Tailwind + bg-vessel-color-0 + bg-vessel-color-1 + bg-vessel-color-2 + bg-vessel-color-3 + bg-vessel-color-4 + bg-vessel-color-5 + bg-vessel-color-6 + bg-vessel-color-7 + bg-vessel-color-8 + bg-vessel-color-9 + bg-vessel-color-10 + bg-vessel-color-11 +*/ +export const getVesselColorBg = (listIndex: number) => { + return `bg-${getVesselColor(listIndex)}` +} + +/* For Tailwind + text-vessel-color-0 + text-vessel-color-1 + text-vessel-color-2 + text-vessel-color-3 + text-vessel-color-4 + text-vessel-color-5 + text-vessel-color-6 + text-vessel-color-7 + text-vessel-color-8 + text-vessel-color-9 + text-vessel-color-10 + text-vessel-color-11 +*/ +export const getVesselColorText = (listIndex: number) => { + return `text-${getVesselColor(listIndex)}` +} + +export const getVesselColorRGB = (listIndex: number) => { + const color = colors[`vessel-color-${listIndex}`] + const rgb = color.replace("rgb(", "").replace(")", "").split(",") + return rgb +} diff --git a/frontend/libs/dateUtils.ts b/frontend/libs/dateUtils.ts index f011f11d..f4d0df03 100644 --- a/frontend/libs/dateUtils.ts +++ b/frontend/libs/dateUtils.ts @@ -1,4 +1,4 @@ -export function convertDurationInHours(durationPattern: string): string { +export function convertDurationInSeconds(durationPattern: string): number { const matches = durationPattern.match( /P(?:(\d+)Y)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?/ ) @@ -16,18 +16,22 @@ export function convertDurationInHours(durationPattern: string): string { 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 + const totalSeconds = + parseInt(years) * 365 * 24 * 60 * 60 + // converting years to hours (approximate) + parseInt(days) * 24 * 60 * 60 + + parseInt(hours) * 60 * 60 + + parseInt(minutes) * 60 + + parseFloat(seconds) - return Math.round(totalHours).toLocaleString("fr-FR") + return Math.floor(totalSeconds) } -function padTwoDigits(num: number) { - return num.toString().padStart(2, "0") +function convertDurationInHours(durationPattern: string): number { + return Math.round(convertDurationInSeconds(durationPattern) / 3600) +} + +export function convertDurationInHoursStr(durationPattern: string): string { + return convertDurationInHours(durationPattern).toLocaleString("fr-FR") } export function getDateRange(days: number) { @@ -41,3 +45,10 @@ export function getDateRange(days: number) { endAt: today.toISOString(), } } + +export function formatDuration(tsInSeconds: number): string { + const hours = Math.floor(tsInSeconds / 3600) + const minutes = Math.floor((tsInSeconds - hours * 3600) / 60) + const seconds = tsInSeconds - hours * 3600 - minutes * 60 + return `${hours}h ${minutes}m ${seconds}s` +} diff --git a/frontend/libs/mapper.tsx b/frontend/libs/mapper.tsx index 87357ed6..965d3344 100644 --- a/frontend/libs/mapper.tsx +++ b/frontend/libs/mapper.tsx @@ -1,7 +1,7 @@ import { Item } from "@/types/item" import { VesselMetrics } from "@/types/vessel" import { ZoneMetrics } from "@/types/zone" -import { convertDurationInHours } from "@/libs/dateUtils" +import { convertDurationInHoursStr } from "@/libs/dateUtils" export function convertVesselDtoToItem(metrics: VesselMetrics[]): Item[] { return metrics @@ -11,7 +11,7 @@ export function convertVesselDtoToItem(metrics: VesselMetrics[]): Item[] { 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`, + value: `${convertDurationInHoursStr(vesselMetrics.total_time_at_sea)}h`, type: "vessel", countryIso3: vessel.country_iso3, } @@ -28,7 +28,7 @@ export function convertZoneDtoToItem(zoneMetrics: ZoneMetrics[]): Item[] { id: `${zone.id}`, title: zone.name, description: zone.sub_category, - value: `${convertDurationInHours(visiting_duration)}h`, + value: `${convertDurationInHoursStr(visiting_duration)}h`, type: "amp", } }) diff --git a/frontend/libs/stores/index.ts b/frontend/libs/stores/index.ts new file mode 100644 index 00000000..44a04f1a --- /dev/null +++ b/frontend/libs/stores/index.ts @@ -0,0 +1,5 @@ +export { useMapStore } from "./map-store" +export { useVesselsStore } from "./vessels-store" +export { useTrackModeOptionsStore } from "./track-mode-options-store" +export { useLoaderStore } from "./loader-store" + diff --git a/frontend/libs/stores/loader-store.ts b/frontend/libs/stores/loader-store.ts new file mode 100644 index 00000000..ca4d8e29 --- /dev/null +++ b/frontend/libs/stores/loader-store.ts @@ -0,0 +1,27 @@ +import { create } from "zustand" + +interface ILoaderState { + zonesLoading: boolean + positionsLoading: boolean + vesselsLoading: boolean + excursionsLoading: boolean +} + +interface ILoaderActions { + setZonesLoading: (isLoading: boolean) => void + setPositionsLoading: (isLoading: boolean) => void + setVesselsLoading: (isLoading: boolean) => void + setExcursionsLoading: (isLoading: boolean) => void +} + +export const useLoaderStore = create((set) => ({ + zonesLoading: false, + positionsLoading: false, + vesselsLoading: false, + excursionsLoading: false, + + setZonesLoading: (isLoading) => set({ zonesLoading: isLoading }), + setPositionsLoading: (isLoading) => set({ positionsLoading: isLoading }), + setVesselsLoading: (isLoading) => set({ vesselsLoading: isLoading }), + setExcursionsLoading: (isLoading) => set({ excursionsLoading: isLoading }), +})) diff --git a/frontend/libs/stores/map-store.ts b/frontend/libs/stores/map-store.ts index c55cb797..f9c4111e 100644 --- a/frontend/libs/stores/map-store.ts +++ b/frontend/libs/stores/map-store.ts @@ -1,53 +1,31 @@ import { MapViewState } from "@deck.gl/core" -import { createStore } from "zustand/vanilla" +import { create } from "zustand" -import { - VesselExcursionSegment, - VesselExcursionSegments, - VesselPosition, -} from "@/types/vessel" +import { VesselExcursion, VesselPosition } from "@/types/vessel" -export interface ViewState { - longitude: number - latitude: number - zoom: number - pitch?: number - bearing?: number - transitionDuration?: number - transitionInterpolator?: any -} - -export type MapState = { - count: number +interface IMapState { viewState: MapViewState latestPositions: VesselPosition[] - activePosition: VesselPosition | null - trackedVesselIDs: number[] - trackedVesselSegments: VesselExcursionSegments[] + mode: "position" | "track" displayedZones: string[] + activePosition: VesselPosition | null + leftPanelOpened: boolean } -export type MapActions = { - decrementCount: () => void - incrementCount: () => void +interface IMapActions { setViewState: (viewState: MapViewState) => void setZoom: (zoom: number) => void setLatestPositions: (latestPositions: VesselPosition[]) => void - setActivePosition: (activePosition: VesselPosition | null) => void - addTrackedVessel: ( - vesselID: number, - segments: VesselExcursionSegment[] - ) => void - removeTrackedVessel: (vesselID: number) => void clearLatestPositions: () => void - cleartrackedVessels: () => void + setMode: (mode: "position" | "track") => void setDisplayedZones: (zones: string[]) => void + setActivePosition: (activePosition: VesselPosition | null) => void + setLeftPanelOpened: (leftPanelOpened: boolean) => void } -export type MapStore = MapState & MapActions +type IMapStore = IMapState & IMapActions -export const defaultInitState: MapState = { - count: 0, +const defaultInitState: IMapState = { viewState: { longitude: 3.788086, latitude: 47.840291, @@ -56,83 +34,61 @@ export const defaultInitState: MapState = { bearing: 0, }, latestPositions: [], - activePosition: null, - trackedVesselIDs: [], - trackedVesselSegments: [], + mode: "position", displayedZones: [], + activePosition: null, + leftPanelOpened: false, } -export const createMapStore = (initState: MapState = defaultInitState) => { - return createStore()((set) => ({ - ...initState, - decrementCount: () => set((state) => ({ count: state.count - 1 })), - incrementCount: () => set((state) => ({ count: state.count + 1 })), - setViewState: (viewState?: MapViewState) => { - set((state) => ({ - ...state, - viewState, - })) - }, - setZoom: (zoom: number) => { - set((state) => ({ - ...state, - viewState: { ...state.viewState, zoom }, - })) - }, - setLatestPositions: (latestPositions: VesselPosition[]) => { - set((state) => ({ - ...state, - latestPositions, - })) - }, - setActivePosition: (activePosition: VesselPosition | null) => { - set((state) => ({ - ...state, - activePosition, - })) - }, - addTrackedVessel: ( - vesselId: number, - segments: VesselExcursionSegment[] - ) => { - set((state) => ({ - ...state, - trackedVesselIDs: [...state.trackedVesselIDs, vesselId], - trackedVesselSegments: [ - ...state.trackedVesselSegments, - { vesselId, segments }, - ], - })) - }, - removeTrackedVessel: (vesselId: number) => { - set((state) => ({ - ...state, - trackedVesselIDs: state.trackedVesselIDs.filter( - (id) => id !== vesselId - ), - trackedVesselSegments: state.trackedVesselSegments.filter( - ({ vesselId }) => vesselId !== vesselId - ), - })) - }, - clearLatestPositions: () => { - set((state) => ({ - ...state, - latestPositions: [], - })) - }, - cleartrackedVessels: () => { - set((state) => ({ - ...state, - trackedVesselIDs: [], - trackedVesselSegments: [], - })) - }, - setDisplayedZones: (displayedZones: string[]) => { - set((state) => ({ - ...state, - displayedZones, - })) - }, - })) -} +export const useMapStore = create()((set) => ({ + ...defaultInitState, + + setMode: (mode: "position" | "track") => { + set((state) => ({ + ...state, + mode, + })) + }, + setViewState: (viewState?: MapViewState) => { + set((state) => ({ + ...state, + viewState, + })) + }, + setZoom: (zoom: number) => { + set((state) => ({ + ...state, + viewState: { ...state.viewState, zoom }, + })) + }, + setLatestPositions: (latestPositions: VesselPosition[]) => { + set((state) => ({ + ...state, + latestPositions, + })) + }, + setActivePosition: (activePosition: VesselPosition | null) => { + set((state) => ({ + ...state, + activePosition, + })) + }, + clearLatestPositions: () => { + set((state) => ({ + ...state, + latestPositions: [], + })) + }, + setDisplayedZones: (displayedZones: string[]) => { + set((state) => ({ + ...state, + displayedZones, + })) + }, + setLeftPanelOpened: (leftPanelOpened: boolean) => { + set((state) => ({ + ...state, + leftPanelOpened, + })) + }, +})) diff --git a/frontend/libs/stores/track-mode-options-store.ts b/frontend/libs/stores/track-mode-options-store.ts new file mode 100644 index 00000000..117141a5 --- /dev/null +++ b/frontend/libs/stores/track-mode-options-store.ts @@ -0,0 +1,124 @@ +import { create } from "zustand" + +import { VesselExcursion } from "@/types/vessel" + +interface ITrackModeOptions { + startDate: Date | undefined + endDate: Date | undefined + trackedVesselIDs: number[] + excursions: { [vesselID: number]: VesselExcursion[] } + vesselsIDsHidden: number[] + excursionsIDsHidden: number[] + focusedExcursionID: number | null +} + +interface ITrackModeOptionsActions { + setStartDate: (startDate: Date | undefined) => void + setEndDate: (endDate: Date | undefined) => void + + // Tracked vessels + setTrackedVesselIDs: (trackedVesselIDs: number[]) => void + addTrackedVessel: (vesselID: number) => void + removeTrackedVessel: (vesselID: number) => void + clearTrackedVessels: () => void + setVesselVisibility: (vesselID: number, visible: boolean) => void + toggleVesselVisibility: (vesselID: number) => void + + // Excursions + setExcursions: (excursions: { [vesselID: number]: VesselExcursion[] }) => void + setVesselExcursions: (vesselID: number, excursions: VesselExcursion[]) => void + removeVesselExcursions: (vesselID: number, excursionID: number) => void + clearExcursions: () => void + setExcursionVisibility: (excursionID: number, visible: boolean) => void + toggleExcursionVisibility: (excursionID: number) => void + setFocusedExcursionID: (excursionID: number | null) => void +} + +type ITrackModeOptionsStore = ITrackModeOptions & ITrackModeOptionsActions + +const defaultInitState: ITrackModeOptions = { + startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + endDate: new Date(), + trackedVesselIDs: [], + excursions: {}, + vesselsIDsHidden: [], + excursionsIDsHidden: [], + focusedExcursionID: null, +} + +export const useTrackModeOptionsStore = create()( + (set) => ({ + ...defaultInitState, + + setStartDate: (startDate) => set((state) => ({ ...state, startDate })), + setEndDate: (endDate) => set((state) => ({ ...state, endDate })), + + setTrackedVesselIDs: (trackedVesselIDs) => + set((state) => ({ ...state, trackedVesselIDs })), + addTrackedVessel: (vesselID) => + set((state) => ({ + ...state, + trackedVesselIDs: [...state.trackedVesselIDs, vesselID], + })), + removeTrackedVessel: (vesselID) => + set((state) => ({ + ...state, + trackedVesselIDs: state.trackedVesselIDs.filter( + (id) => id !== vesselID + ), + })), + clearTrackedVessels: () => + set((state) => ({ ...state, trackedVesselIDs: [] })), + setVesselVisibility: (vesselID, visible) => + set((state) => ({ + ...state, + vesselsIDsHidden: visible + ? state.vesselsIDsHidden.filter((id) => id !== vesselID) + : [...state.vesselsIDsHidden, vesselID], + })), + toggleVesselVisibility: (vesselID) => + set((state) => ({ + ...state, + vesselsIDsHidden: state.vesselsIDsHidden.includes(vesselID) + ? state.vesselsIDsHidden.filter((id) => id !== vesselID) + : [...state.vesselsIDsHidden, vesselID], + })), + + setExcursions: (excursions) => set((state) => ({ ...state, excursions })), + setVesselExcursions: (vesselID, excursions) => + set((state) => ({ + ...state, + excursions: { + ...state.excursions, + [vesselID]: excursions, + }, + })), + removeVesselExcursions: (vesselID, excursionID) => + set((state) => ({ + ...state, + excursions: { + ...state.excursions, + [vesselID]: state.excursions[vesselID].filter( + (excursion) => excursion.id !== excursionID + ), + }, + })), + clearExcursions: () => set((state) => ({ ...state, excursions: {} })), + setExcursionVisibility: (excursionID, visible) => + set((state) => ({ + ...state, + excursionsIDsHidden: visible + ? state.excursionsIDsHidden.filter((id) => id !== excursionID) + : [...state.excursionsIDsHidden, excursionID], + })), + toggleExcursionVisibility: (excursionID) => + set((state) => ({ + ...state, + excursionsIDsHidden: state.excursionsIDsHidden.includes(excursionID) + ? state.excursionsIDsHidden.filter((id) => id !== excursionID) + : [...state.excursionsIDsHidden, excursionID], + })), + setFocusedExcursionID: (excursionID) => + set((state) => ({ ...state, focusedExcursionID: excursionID })), + }) +) diff --git a/frontend/libs/stores/vessels-store.ts b/frontend/libs/stores/vessels-store.ts index 01311725..78cdcdb0 100644 --- a/frontend/libs/stores/vessels-store.ts +++ b/frontend/libs/stores/vessels-store.ts @@ -1,34 +1,34 @@ +import { create } from "zustand" + import { Vessel } from "@/types/vessel" -import { createStore } from "zustand/vanilla" -export type VesselsState = { - count: number; - vessels: Vessel[]; +type VesselsState = { + count: number + vessels: Vessel[] } -export type MapActions = { +type MapActions = { decrementCount: () => void incrementCount: () => void setVessels: (vessels: Vessel[]) => void } -export type VesselsStore = VesselsState & MapActions +type VesselsStore = VesselsState & MapActions -export const defaultInitState: VesselsState = { +const defaultInitState: VesselsState = { count: 0, vessels: [], } -export const createVesselsStore = (initState: VesselsState = defaultInitState) => { - return createStore()((set) => ({ - ...initState, - decrementCount: () => set((state) => ({ count: state.count - 1 })), - incrementCount: () => set((state) => ({ count: state.count + 1 })), - setVessels: (vessels: Vessel[]) => { - set((state) => ({ - ...state, - vessels, - })) - } - })) -} +export const useVesselsStore = create()((set) => ({ + ...defaultInitState, + + decrementCount: () => set((state) => ({ count: state.count - 1 })), + incrementCount: () => set((state) => ({ count: state.count + 1 })), + setVessels: (vessels: Vessel[]) => { + set((state) => ({ + ...state, + vessels, + })) + }, +})) diff --git a/frontend/package.json b/frontend/package.json index de3215a8..5bf8f101 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,8 @@ "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@types/chroma-js": "^2.4.4", @@ -31,6 +33,7 @@ "class-variance-authority": "^0.4.0", "clsx": "^1.2.1", "cmdk": "^1.0.0", + "date-fns": "^3.6.0", "deck.gl": "^9.0.36", "framer-motion": "^11.1.3", "jotai": "^2.8.0", @@ -40,6 +43,7 @@ "next": "^13.5.7", "next-themes": "^0.2.1", "react": "^18.2.0", + "react-day-picker": "^9.4.1", "react-dom": "^18.2.0", "react-map-gl": "^7.1.7", "sharp": "^0.31.3", diff --git a/frontend/public/icons/card-file.svg b/frontend/public/icons/card-file.svg index aaeaf30a..37c99518 100644 --- a/frontend/public/icons/card-file.svg +++ b/frontend/public/icons/card-file.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/services/backend-rest-client.ts b/frontend/services/backend-rest-client.ts index 7d3a42db..f4f600f6 100644 --- a/frontend/services/backend-rest-client.ts +++ b/frontend/services/backend-rest-client.ts @@ -49,8 +49,15 @@ export function getVesselsLatestPositions() { return axios.get(url) } -export function getVesselExcursion(vesselId: number) { - const url = `${BASE_URL}/vessels/${vesselId}/excursions` +export function getVesselExcursions(vesselId: number, startDate?: Date, endDate?: Date) { + let queryParams: string[] = [] + if (startDate) { + queryParams.push(`start_at=${startDate.toISOString()}`); + } + if (endDate) { + queryParams.push(`end_at=${endDate.toISOString()}`); + } + const url = `${BASE_URL}/vessels/${vesselId}/excursions${queryParams.length > 0 ? `?${queryParams.join("&")}` : ""}` console.log(`GET ${url}`) return axios.get(url) } @@ -63,7 +70,7 @@ export function getVesselSegments(vesselId: number, excursionId: number) { export async function getVesselFirstExcursionSegments(vesselId: number) { try { - const response = await getVesselExcursion(vesselId) + const response = await getVesselExcursions(vesselId) const excursionId = response?.data[0]?.id if (!!excursionId) { const segments = await getVesselSegments(vesselId, excursionId) diff --git a/frontend/styles/globals.css b/frontend/styles/globals.css index 03f874d2..9029b9a1 100644 --- a/frontend/styles/globals.css +++ b/frontend/styles/globals.css @@ -43,8 +43,8 @@ --muted: 223 47% 11%; --muted-foreground: 215.4 16.3% 56.9%; - --accent: 216 34% 17%; - --accent-foreground: 210 40% 98%; + --accent: 223 33% 26%; + --accent-foreground: 222.2 47.4% 11.2%; --popover: 224 71% 4%; --popover-foreground: 215 20.2% 65.1%; @@ -55,11 +55,11 @@ --card: 224 71% 4%; --card-foreground: 213 31% 91%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 1.2%; + --primary: 164 76% 53%; + --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 222.2 47.4% 11.2%; - --secondary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; --destructive: 0 63% 31%; --destructive-foreground: 210 40% 98%; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 31742399..d7c5c59a 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -3,7 +3,7 @@ const { fontFamily } = require("tailwindcss/defaultTheme") /** @type {import('tailwindcss').Config} */ module.exports = { darkMode: ["class"], - content: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"], + content: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}", "libs/**/*.{ts,tsx}"], theme: { container: { center: true, @@ -63,6 +63,23 @@ module.exports = { "color-3": "#26314C", "color-4": "#A7ADBD", "color-5": "#374056", + + // Vessel colors + "vessel-color-0": "rgb(255, 90, 129)", + "vessel-color-1": "rgb(224, 181, 30)", + "vessel-color-2": "rgb(174, 255, 0)", + "vessel-color-3": "rgb(125, 255, 188)", + "vessel-color-4": "rgb(80, 144, 255)", + "vessel-color-5": "rgb(208, 30, 224)", + "vessel-color-6": "rgb(255, 125, 83)", + "vessel-color-7": "rgb(255, 185, 114)", + "vessel-color-8": "rgb(97, 255, 49)", + "vessel-color-9": "rgb(0, 224, 172)", + "vessel-color-10": "rgb(49, 204, 255)", + "vessel-color-11": "rgb(146, 127, 255)", + + // Progress Colors + "progress-color-1": "#6E83B7", }, borderRadius: { lg: `var(--radius)`, @@ -82,10 +99,22 @@ module.exports = { from: { height: "var(--radix-accordion-content-height)" }, to: { height: 0 }, }, + "grow-shrink": { + "0%": { + scale: "1", + }, + "50%": { + scale: "1.2", + }, + "100%": { + scale: "1", + }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", + "grow-shrink": "grow-shrink 0.3s ease-in-out", }, }, }, diff --git a/frontend/types/vessel.ts b/frontend/types/vessel.ts index f504340b..965236aa 100644 --- a/frontend/types/vessel.ts +++ b/frontend/types/vessel.ts @@ -1,3 +1,5 @@ +import type {Feature, Geometry} from 'geojson'; + export type Vessel = { id: number mmsi: number @@ -41,7 +43,7 @@ export type VesselPositions = VesselPosition[] export interface VesselPosition { arrival_port: string - excurision_id: number + excursion_id: number heading: number position: VesselPositionCoordinates speed: number @@ -55,23 +57,19 @@ export interface VesselPositionCoordinates { } export type VesselExcursionSegmentGeo = { + vessel_id: number + excursion_id: number speed: number heading?: number navigational_status: string - geometry: { - type: string - coordinates: number[][] - } } export type VesselExcursionSegmentsGeo = { - vesselId: number - type: any - features: any + type: "FeatureCollection" + features: Feature[] } export type VesselExcursionSegments = { - vesselId: number segments: VesselExcursionSegment[] } @@ -81,23 +79,40 @@ export type VesselExcursion = { departure_port_id: number departure_at: string departure_position: { + type: 'Point' coordinates: number[] } - arrival_port_id: number - arrival_at: number - arrival_position: number - excursion_duration: number + arrival_port_id: number | null + arrival_at: string | null + arrival_position: { + type: 'Point' + coordinates: number[] + } | null + excursion_duration: string total_time_at_sea: string total_time_in_amp: string total_time_in_territorial_waters: string - total_time_in_costal_waters: string + total_time_in_zones_with_no_fishing_rights: 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 + total_time_fishing_in_zones_with_no_fishing_rights: string + + total_time_default_ais: string + created_at: string updated_at: String + + segments?: VesselExcursionSegment[] +} + +export interface ExcursionMetrics { + totalTimeFishing: number + mpa: number + frenchTerritorialWaters: number + zonesWithNoFishingRights: number + aisDefault: number } export type VesselExcursionSegment = {