diff --git a/.env b/.env index 98fda7d21..b1e95a494 100644 --- a/.env +++ b/.env @@ -14,4 +14,4 @@ REACT_APP_DEFAULT_PATROL_FILTER_FROM_DAYS=1 # Feature flags REACT_APP_LEGACY_RT_ENABLED=false REACT_APP_EFB_FORM_SCHEMA_SUPPORT_ENABLED=false -REACT_APP_TIME_OF_DAY_TRACKING=false +REACT_APP_TIME_OF_DAY_TRACKING=true diff --git a/src/AnalyzersLayer/index.js b/src/AnalyzersLayer/index.js index f30b06dd3..30d77ff4d 100644 --- a/src/AnalyzersLayer/index.js +++ b/src/AnalyzersLayer/index.js @@ -2,7 +2,9 @@ import React, { memo, useMemo } from 'react'; import withMapViewConfig from '../WithMapViewConfig'; import { LAYER_IDS, SOURCE_IDS } from '../constants'; -import { useMapEventBinding, useMapLayer, useMapSource } from '../hooks'; +import { useMapEventBinding } from '../hooks'; +import useMapSources from '../hooks/useMapSources'; +import useMapLayers from '../hooks/useMapLayers'; const { ANALYZER_POLYS_WARNING, ANALYZER_POLYS_CRITICAL, ANALYZER_LINES_WARNING, ANALYZER_LINES_CRITICAL, SKY_LAYER } = LAYER_IDS; @@ -68,44 +70,44 @@ const AnalyzerLayer = ( condition: !!isSubjectSymbolsLayerReady, }), [isSubjectSymbolsLayerReady, minZoom]); - useMapSource(ANALYZER_POLYS_WARNING_SOURCE, warningPolys); - useMapLayer( - ANALYZER_POLYS_WARNING, - 'line', - ANALYZER_POLYS_WARNING_SOURCE, - linePaint, - undefined, - layerConfig, - ); - - useMapSource(ANALYZER_POLYS_CRITICAL_SOURCE, criticalPolys); - useMapLayer( - ANALYZER_POLYS_CRITICAL, - 'line', - ANALYZER_POLYS_CRITICAL_SOURCE, - criticalLinePaint, lineLayout, - layerConfig, - ); - - useMapSource(ANALYZER_LINES_WARNING_SOURCE, warningLines); - useMapLayer( - ANALYZER_LINES_WARNING, - 'line', - ANALYZER_LINES_WARNING_SOURCE, - linePaint, - lineLayout, - layerConfig, - ); - - useMapSource(ANALYZER_LINES_CRITICAL_SOURCE, criticalLines); - useMapLayer( - ANALYZER_LINES_CRITICAL, - 'line', - ANALYZER_LINES_CRITICAL_SOURCE, - criticalLinePaint, - lineLayout, - layerConfig, - ); + useMapSources([{ id: ANALYZER_POLYS_WARNING_SOURCE, data: warningPolys }]); + useMapLayers([{ + id: ANALYZER_POLYS_WARNING, + type: 'line', + sourceId: ANALYZER_POLYS_WARNING_SOURCE, + paint: linePaint, + options: layerConfig, + }]); + + useMapSources([{ id: ANALYZER_POLYS_CRITICAL_SOURCE, data: criticalPolys }]); + useMapLayers([{ + id: ANALYZER_POLYS_CRITICAL, + type: 'line', + sourceId: ANALYZER_POLYS_CRITICAL_SOURCE, + paint: criticalLinePaint, + layout: lineLayout, + options: layerConfig, + }]); + + useMapSources([{ id: ANALYZER_LINES_WARNING_SOURCE, data: warningLines }]); + useMapLayers([{ + id: ANALYZER_LINES_WARNING, + type: 'line', + sourceId: ANALYZER_LINES_WARNING_SOURCE, + paint: linePaint, + layout: lineLayout, + options: layerConfig, + }]); + + useMapSources([{ id: ANALYZER_LINES_CRITICAL_SOURCE, data: criticalLines }]); + useMapLayers([{ + id: ANALYZER_LINES_CRITICAL, + type: 'line', + sourceId: ANALYZER_LINES_CRITICAL_SOURCE, + paint: criticalLinePaint, + layout: lineLayout, + options: layerConfig, + }]); // (eventType = 'click', handlerFn = noop, layerId = null, condition = true) useMapEventBinding('mouseenter', onAnalyzerFeatureEnter, ANALYZER_POLYS_WARNING); diff --git a/src/BaseLayerRenderer/TileLayerRenderer.js b/src/BaseLayerRenderer/TileLayerRenderer.js index 1833da5a9..67648eb4f 100644 --- a/src/BaseLayerRenderer/TileLayerRenderer.js +++ b/src/BaseLayerRenderer/TileLayerRenderer.js @@ -2,9 +2,10 @@ import React, { memo, useContext, useMemo, useEffect } from 'react'; import { MapContext } from '../App'; import { TILE_LAYER_SOURCE_TYPES, LAYER_IDS, MAX_ZOOM, MIN_ZOOM } from '../constants'; -import { useMapLayer, useMapSource } from '../hooks'; import { calcConfigForMapAndSourceFromLayer } from '../utils/layers'; +import useMapSources from '../hooks/useMapSources'; +import useMapLayers from '../hooks/useMapLayers'; const { TOPMOST_STYLE_LAYER } = LAYER_IDS; @@ -25,7 +26,7 @@ const SourceComponent = ({ id, tileUrl, sourceConfig }) => { ...sourceConfig, }), [sourceConfig, tileUrl]); - useMapSource(id, null, config); + useMapSources([{ id }], config); return null; }; @@ -50,14 +51,15 @@ const TileLayerRenderer = (props) => { } }, [map, mapConfig]); - useMapLayer( - `tile-layer-${activeLayer?.id}`, - 'raster', - `layer-source-${activeLayer?.id}`, - undefined, - undefined, - { before: TOPMOST_STYLE_LAYER, condition: !!activeLayer } - ); + useMapLayers([{ + id: `tile-layer-${activeLayer?.id}`, + type: 'raster', + sourceId: `layer-source-${activeLayer?.id}`, + options: { + before: TOPMOST_STYLE_LAYER, + condition: !!activeLayer + } + }]); return layers .filter(layer => TILE_LAYER_SOURCE_TYPES.includes(layer.attributes.type)) diff --git a/src/ClustersLayer/index.js b/src/ClustersLayer/index.js index 1e10bb482..13452ee01 100644 --- a/src/ClustersLayer/index.js +++ b/src/ClustersLayer/index.js @@ -10,7 +10,9 @@ import { getMapSubjectFeatureCollectionWithVirtualPositioning } from '../selecto import { getShouldEventsBeClustered, getShouldSubjectsBeClustered } from '../selectors/clusters'; import { MapContext } from '../App'; import useClusterBufferPolygon from '../hooks/useClusterBufferPolygon'; -import { useMapEventBinding, useMapLayer, useMapSource } from '../hooks'; +import { useMapEventBinding } from '../hooks'; +import useMapSources from '../hooks/useMapSources'; +import useMapLayers from '../hooks/useMapLayers'; const { CLUSTERS_LAYER_ID, @@ -53,8 +55,14 @@ const ClustersLayer = ({ onShowClusterSelectPopup }) => { subjectFeatureCollection.features, ]); - useMapSource(CLUSTERS_SOURCE_ID, clustersSourceData, CLUSTER_SOURCE_CONFIG); - useMapLayer(CLUSTERS_LAYER_ID, 'circle', CLUSTERS_SOURCE_ID, CLUSTER_LAYER_PAINT, null, CLUSTER_LAYER_CONFIG); + useMapSources([{ id: CLUSTERS_SOURCE_ID, data: clustersSourceData }], CLUSTER_SOURCE_CONFIG); + useMapLayers([{ + id: CLUSTERS_LAYER_ID, + type: 'circle', + sourceId: CLUSTERS_SOURCE_ID, + paint: CLUSTER_LAYER_PAINT, + options: CLUSTER_LAYER_CONFIG + }]); const { removeClusterPolygon, renderClusterPolygon } = useClusterBufferPolygon(); diff --git a/src/EventGeometryLayer/index.js b/src/EventGeometryLayer/index.js index aa6d765d9..a6899dfed 100644 --- a/src/EventGeometryLayer/index.js +++ b/src/EventGeometryLayer/index.js @@ -9,7 +9,9 @@ import { getShowReportsOnMap } from '../selectors/clusters'; import { LAYER_IDS, SOURCE_IDS } from '../constants'; import { MAP_LOCATION_SELECTION_MODES } from '../ducks/map-ui'; import { PRIORITY_COLOR_MAP } from '../utils/events'; -import { useMapEventBinding, useMapLayer, useMapSource } from '../hooks'; +import { useMapEventBinding } from '../hooks'; +import useMapSources from '../hooks/useMapSources'; +import useMapLayers from '../hooks/useMapLayers'; const { EVENT_GEOMETRY_LAYER, EVENT_SYMBOLS } = LAYER_IDS; @@ -61,9 +63,16 @@ const EventGeometryLayer = ({ onClick }) => { const onMouseEnter = () => map.getCanvas().style.cursor = 'pointer'; const onMouseLeave = () => map.getCanvas().style.cursor = ''; - useMapSource(EVENT_GEOMETRY, eventFeatureCollection); + useMapSources([{ id: EVENT_GEOMETRY, data: eventFeatureCollection }]); - useMapLayer(EVENT_GEOMETRY_LAYER, 'fill', EVENT_GEOMETRY, paint, layout, layerConfig); + useMapLayers([{ + id: EVENT_GEOMETRY_LAYER, + type: 'fill', + sourceId: EVENT_GEOMETRY, + paint, + layout, + options: layerConfig + }]); useMapEventBinding('click', onClick, EVENT_GEOMETRY_LAYER); useMapEventBinding('mouseenter', onMouseEnter, EVENT_GEOMETRY_LAYER); diff --git a/src/EventsLayer/index.js b/src/EventsLayer/index.js index fdf10e4a7..04b7e9e5e 100644 --- a/src/EventsLayer/index.js +++ b/src/EventsLayer/index.js @@ -18,7 +18,7 @@ import { getMapEventSymbolPointsWithVirtualDate } from '../selectors/events'; import { getShouldEventsBeClustered, getShowReportsOnMap } from '../selectors/clusters'; import { MapContext } from '../App'; import MapImageFromSvgSpriteRenderer, { calcSvgImageIconId } from '../MapImageFromSvgSpriteRenderer'; -import { useMapSource } from '../hooks'; +import useMapSources from '../hooks/useMapSources'; import { withMultiLayerHandlerAwareness } from '../utils/map-handlers'; import EventGeometryLayer from '../EventGeometryLayer'; @@ -241,7 +241,7 @@ const EventsLayer = ({ ...mapEventFeatures, features: !shouldEventsBeClustered && !!showReportsOnMap ? mapEventFeatures.features : [], }; - useMapSource(UNCLUSTERED_EVENTS_SOURCE, geoJson); + useMapSources([{ id: UNCLUSTERED_EVENTS_SOURCE, data: geoJson }]); const isSubjectSymbolsLayerReady = !!map.getLayer(SUBJECT_SYMBOLS); const isClustersSourceReady = !!map.getSource(CLUSTERS_SOURCE_ID); diff --git a/src/FeatureLayer/index.js b/src/FeatureLayer/index.js index 90863b4a4..4de0940ae 100644 --- a/src/FeatureLayer/index.js +++ b/src/FeatureLayer/index.js @@ -10,7 +10,9 @@ import { LAYER_IDS, DEFAULT_SYMBOL_LAYOUT, DEFAULT_SYMBOL_PAINT, SOURCE_IDS } fr import MarkerImage from '../common/images/icons/mapbox-blue-marker-icon.png'; import RangerStationsImage from '../common/images/icons/ranger-stations.png'; -import { useMapEventBinding, useMapLayer, useMapSource } from '../hooks'; +import { useMapEventBinding } from '../hooks'; +import useMapSources from '../hooks/useMapSources'; +import useMapLayers from '../hooks/useMapLayers'; const { FEATURE_FILLS, FEATURE_LINES, FEATURE_SYMBOLS, SKY_LAYER } = LAYER_IDS; @@ -114,14 +116,37 @@ const FeatureLayer = ({ symbols, lines, polygons, onFeatureSymbolClick, mapUserL const layerConfig = { minZoom, before: SKY_LAYER }; - useMapSource(MAP_FEATURES_LINES_SOURCE, lines); - useMapSource(MAP_FEATURES_POLYGONS_SOURCE, polygons); - useMapSource(MAP_FEATURES_SYMBOLS_SOURCE, symbols); - - // (layerId, type, sourceId, paint, layout, filter, minzoom, maxzoom, condition = true) - useMapLayer(FEATURE_FILLS, 'fill', MAP_FEATURES_POLYGONS_SOURCE, fillPaint, fillLayout, layerConfig); - useMapLayer(FEATURE_LINES, 'line', MAP_FEATURES_LINES_SOURCE, linePaint, lineLayout, layerConfig); - useMapLayer(FEATURE_SYMBOLS, 'symbol', MAP_FEATURES_SYMBOLS_SOURCE, symbolPaint, layout, layerConfig); + useMapSources([{ id: MAP_FEATURES_LINES_SOURCE, data: lines }]); + useMapSources([{ id: MAP_FEATURES_POLYGONS_SOURCE, data: polygons }]); + useMapSources([{ id: MAP_FEATURES_SYMBOLS_SOURCE, data: symbols }]); + + // (layerId, type, sourceId, paint, layout, filter, min-zoom, max-zoom, condition = true) + useMapLayers([{ + id: FEATURE_FILLS, + type: 'fill', + sourceId: MAP_FEATURES_POLYGONS_SOURCE, + paint: fillPaint, + layout: fillLayout, + options: layerConfig + }]); + + useMapLayers([{ + id: FEATURE_LINES, + type: 'line', + sourceId: MAP_FEATURES_LINES_SOURCE, + paint: linePaint, + layout: lineLayout, + options: layerConfig + }]); + + useMapLayers([{ + id: FEATURE_SYMBOLS, + type: 'symbol', + sourceId: MAP_FEATURES_SYMBOLS_SOURCE, + paint: symbolPaint, + layout: layout, + options: layerConfig + }]); useMapEventBinding('click', onSymbolClick, FEATURE_SYMBOLS); useMapEventBinding('mouseenter', onSymbolMouseEnter, FEATURE_SYMBOLS); diff --git a/src/HeatLayer/index.js b/src/HeatLayer/index.js index 87519185f..e9193b99e 100644 --- a/src/HeatLayer/index.js +++ b/src/HeatLayer/index.js @@ -6,7 +6,8 @@ import { useSelector } from 'react-redux'; import { LAYER_IDS, MAX_ZOOM } from '../constants'; import { metersToPixelsAtMaxZoom } from '../utils/map'; import { uuid } from '../utils/string'; -import { useMapLayer, useMapSource } from '../hooks'; +import useMapSources from '../hooks/useMapSources'; +import useMapLayers from '../hooks/useMapLayers'; const { HEATMAP_LAYER, SKY_LAYER } = LAYER_IDS; @@ -30,8 +31,16 @@ const HeatLayer = ({ points }) => { }; }, [heatmapStyles.intensity, heatmapStyles.radiusInMeters, points]); - useMapSource(`heatmap-source-${idRef.current}`, points); - useMapLayer(`${HEATMAP_LAYER}-${idRef.current}`, 'heatmap', `heatmap-source-${idRef.current}`, paint, null, { before: SKY_LAYER }); + useMapSources([{ id: `heatmap-source-${idRef.current}`, data: points }]); + useMapLayers([{ + id: `${HEATMAP_LAYER}-${idRef.current}`, + type: 'heatmap', + sourceId: `heatmap-source-${idRef.current}`, + paint, + options: { + before: SKY_LAYER + } + }]); return null; }; diff --git a/src/LabeledSymbolLayer/index.js b/src/LabeledSymbolLayer/index.js index 9a8046bbd..8d6f4c6a9 100644 --- a/src/LabeledSymbolLayer/index.js +++ b/src/LabeledSymbolLayer/index.js @@ -5,7 +5,8 @@ import { DEFAULT_SYMBOL_LAYOUT, DEFAULT_SYMBOL_PAINT } from '../constants'; import { withMap } from '../EarthRangerMap'; import withMapViewConfig from '../WithMapViewConfig'; -import { useMapEventBinding, useMapLayer } from '../hooks'; +import { useMapEventBinding } from '../hooks'; +import useMapLayers from '../hooks/useMapLayers'; const LabeledSymbolLayer = ( { before, paint, layout, textPaint, textLayout, id, sourceId, map, mapUserLayoutConfigByLayerId, onClick, onInit, @@ -79,8 +80,23 @@ const LabeledSymbolLayer = ( useMapEventBinding('mouseleave', handleMouseLeave, id); useMapEventBinding('mouseleave', handleMouseLeave, textLayerId); - useMapLayer(id, 'symbol', sourceId, symbolPaint, symbolLayout, layerConfig); - useMapLayer(textLayerId, 'symbol', sourceId, labelPaint, labelLayout, layerConfig); + useMapLayers([{ + id: id, + type: 'symbol', + sourceId, + paint: symbolPaint, + layout: symbolLayout, + options: layerConfig + }]); + + useMapLayers([{ + id: textLayerId, + type: 'symbol', + sourceId, + paint: labelPaint, + layout: labelLayout, + options: layerConfig + }]); return null; }; diff --git a/src/MapDrawingTools/MapLayers.js b/src/MapDrawingTools/MapLayers.js index 0b5fd7c76..fc8128cf8 100644 --- a/src/MapDrawingTools/MapLayers.js +++ b/src/MapDrawingTools/MapLayers.js @@ -1,7 +1,8 @@ import React, { memo, useCallback, useContext, useEffect, useState } from 'react'; import { MapContext } from '../App'; -import { useMapEventBinding, useMapLayer, useMapSource } from '../hooks'; +import { useMapEventBinding } from '../hooks'; +import useMapSources from '../hooks/useMapSources'; import { linePaint, @@ -13,6 +14,7 @@ import { lineSymbolLayout, polygonSymbolLayout, } from './layerStyles'; +import useMapLayers from '../hooks/useMapLayers'; export const LAYER_IDS = { POINTS: 'draw-layer-points', @@ -45,20 +47,54 @@ const MapDrawingLayers = ({ const [isHoveringPolygonFill, setIsHoveringPolygonFill] = useState(false); const [isHoveringCircle, setIsHoveringCircle] = useState(false); - useMapSource(SOURCE_IDS.FILL_SOURCE, fillPolygon, { type: 'geojson' }); - useMapSource(SOURCE_IDS.FILL_LABEL_SOURCE, fillLabelPoint, { type: 'geojson' }); - useMapSource(SOURCE_IDS.LINE_SOURCE, drawnLineSegments, { type: 'geojson' }); - useMapSource(SOURCE_IDS.POINT_SOURCE, drawnLinePoints, { generateId: true, type: 'geojson' }); - - useMapLayer(LAYER_IDS.LINE_LABELS, 'symbol', SOURCE_IDS.LINE_SOURCE, symbolPaint, lineSymbolLayout, { - condition: drawing || !isHoveringGeometry || draggedPoint, - }); - useMapLayer(LAYER_IDS.FILL_LABEL, 'symbol', SOURCE_IDS.FILL_LABEL_SOURCE, symbolPaint, polygonSymbolLayout, { - condition: drawing || !isHoveringGeometry || draggedPoint, - }); - useMapLayer(LAYER_IDS.LINES, 'line', SOURCE_IDS.LINE_SOURCE, linePaint, lineLayout); - const fillLayer = useMapLayer(LAYER_IDS.FILL, 'fill', SOURCE_IDS.FILL_SOURCE, fillPaint, fillLayout); - const pointsLayer = useMapLayer(LAYER_IDS.POINTS, 'circle', SOURCE_IDS.POINT_SOURCE, circlePaint); + useMapSources([{ id: SOURCE_IDS.FILL_SOURCE, data: fillPolygon }], { type: 'geojson' }); + useMapSources([{ id: SOURCE_IDS.FILL_LABEL_SOURCE, data: fillLabelPoint }], { type: 'geojson' }); + useMapSources([{ id: SOURCE_IDS.LINE_SOURCE, data: drawnLineSegments }], { type: 'geojson' }); + useMapSources([{ id: SOURCE_IDS.POINT_SOURCE, data: drawnLinePoints }], { generateId: true, type: 'geojson' }); + + useMapLayers([{ + id: LAYER_IDS.LINE_LABELS, + type: 'symbol', + sourceId: SOURCE_IDS.LINE_SOURCE, + paint: symbolPaint, + layout: lineSymbolLayout, + options: { + condition: drawing || !isHoveringGeometry || draggedPoint + } + }]); + useMapLayers([{ + id: LAYER_IDS.FILL_LABEL, + type: 'symbol', + sourceId: SOURCE_IDS.FILL_LABEL_SOURCE, + paint: symbolPaint, + layout: polygonSymbolLayout, + options: { + condition: drawing || !isHoveringGeometry || draggedPoint + } + }]); + + useMapLayers([{ + id: LAYER_IDS.LINES, + type: 'line', + sourceId: SOURCE_IDS.LINE_SOURCE, + paint: linePaint, + layout: lineLayout + }]); + + const [fillLayer] = useMapLayers([{ + id: LAYER_IDS.FILL, + type: 'fill', + sourceId: SOURCE_IDS.FILL_SOURCE, + paint: fillPaint, + layout: fillLayout + }]); + + const [pointsLayer] = useMapLayers([{ + id: LAYER_IDS.POINTS, + type: 'circle', + sourceId: SOURCE_IDS.POINT_SOURCE, + paint: circlePaint + }]); const onCircleMouseEnter = useCallback((event) => { setIsHoveringCircle(true); diff --git a/src/MouseMarkerLayer/index.js b/src/MouseMarkerLayer/index.js index e5f84832d..adaef4d9a 100644 --- a/src/MouseMarkerLayer/index.js +++ b/src/MouseMarkerLayer/index.js @@ -2,7 +2,8 @@ import React, { memo, useMemo } from 'react'; import { point } from '@turf/turf'; import { SYMBOL_ICON_SIZE_EXPRESSION, LAYER_IDS, SOURCE_IDS } from '../constants'; -import { useMapLayer, useMapSource } from '../hooks'; +import useMapSources from '../hooks/useMapSources'; +import useMapLayers from '../hooks/useMapLayers'; const { MOUSE_MARKER_SOURCE } = SOURCE_IDS; const { MOUSE_MARKER_LAYER } = LAYER_IDS; @@ -22,8 +23,13 @@ const MouseMarkerLayer = ({ location }) => { , [location.lat, location.lng]); - useMapSource(MOUSE_MARKER_SOURCE, cursorPoint); - useMapLayer(MOUSE_MARKER_LAYER, 'symbol', MOUSE_MARKER_SOURCE, null, layout); + useMapSources([{ id: MOUSE_MARKER_SOURCE, data: cursorPoint }]); + useMapLayers([{ + id: MOUSE_MARKER_LAYER, + type: 'symbol', + sourceId: MOUSE_MARKER_SOURCE, + layout + }]); return null; }; diff --git a/src/PatrolFilter/index.test.js b/src/PatrolFilter/index.test.js index 550c49389..bd4dd3865 100644 --- a/src/PatrolFilter/index.test.js +++ b/src/PatrolFilter/index.test.js @@ -21,7 +21,7 @@ jest.mock('redux-persist', () => { }; }); -describe('PatrolFilter', () => { + describe('PatrolFilter', () => { let store, updatePatrolFilterMock; beforeEach(() => { updatePatrolFilterMock = jest.fn(() => () => {}); @@ -45,6 +45,7 @@ describe('PatrolFilter', () => { results: [], }, subjectStore: {}, + patrolStore: {}, }, }; diff --git a/src/PatrolStartStopLayer/layer.js b/src/PatrolStartStopLayer/layer.js index 7e2b8c015..464c15387 100644 --- a/src/PatrolStartStopLayer/layer.js +++ b/src/PatrolStartStopLayer/layer.js @@ -9,7 +9,8 @@ import { withMap } from '../EarthRangerMap'; import { uuid } from '../utils/string'; import LabeledPatrolSymbolLayer from '../LabeledPatrolSymbolLayer'; import withMapViewConfig from '../WithMapViewConfig'; -import { useMapLayer, useMapSource } from '../hooks'; +import useMapSources from '../hooks/useMapSources'; +import useMapLayers from '../hooks/useMapLayers'; const { PATROL_SYMBOLS } = LAYER_IDS; @@ -89,8 +90,14 @@ const StartStopLayer = (props) => { const layerSymbolPaint = useMemo(() => ({ ...symbolPaint, 'text-color': ['get', 'stroke'] }), []); const layerLinePaint = useMemo(() => ({ ...linePaint, 'line-color': ['get', 'stroke'] }), []); - useMapSource(sourceId, patrolPointsSourceData); - useMapLayer(`${layerId}-lines`, 'line', sourceId, layerLinePaint, lineLayout); + useMapSources([{ id: sourceId, data: patrolPointsSourceData }]); + useMapLayers([{ + id: `${layerId}-lines`, + type: 'line', + sourceId, + paint: layerLinePaint, + layout: lineLayout + }]); if (!points && !lines) return null; diff --git a/src/StaticSensorsLayer/index.js b/src/StaticSensorsLayer/index.js index 8ad85c387..cf6b70363 100644 --- a/src/StaticSensorsLayer/index.js +++ b/src/StaticSensorsLayer/index.js @@ -8,8 +8,9 @@ import { LAYER_IDS, SOURCE_IDS, SUBJECT_FEATURE_CONTENT_TYPE } from '../constant import LayerBackground from '../common/images/sprites/layer-background-sprite.png'; import { MapContext } from '../App'; import { showPopup } from '../ducks/popup'; -import { useMapEventBinding, useMapLayer } from '../hooks'; +import { useMapEventBinding } from '../hooks'; import { backgroundLayerStyles, calcDynamicBackgroundLayerLayout, calcDynamicLabelLayerLayoutStyles, labelLayerStyles } from './layerStyles'; +import useMapLayers from '../hooks/useMapLayers'; const { STATIC_SENSOR, CLUSTERED_STATIC_SENSORS_LAYER, UNCLUSTERED_STATIC_SENSORS_LAYER } = LAYER_IDS; @@ -104,23 +105,23 @@ const StaticSensorsLayer = () => { map.getCanvas().style.cursor = ''; }, [map]); - useMapLayer( - currentBackgroundLayerId, - 'symbol', - currentSourceId, - backgroundLayerStyles.paint, - backgroundLayoutObject, - layerConfig, - ); - - useMapLayer( - currentLayerId, - 'symbol', - currentSourceId, - labelLayerStyles.paint, - layoutObject, - layerConfig, - ); + useMapLayers([{ + id: currentBackgroundLayerId, + type: 'symbol', + sourceId: currentSourceId, + paint: backgroundLayerStyles.paint, + layout: backgroundLayoutObject, + options: layerConfig, + }]); + + useMapLayers([{ + id: currentLayerId, + type: 'symbol', + sourceId: currentSourceId, + paint: labelLayerStyles.paint, + layout: layoutObject, + options: layerConfig, + }]); useMapEventBinding('click', onLayerClick); useMapEventBinding('mouseenter', onLayerMouseEnter, currentBackgroundLayerId); diff --git a/src/SubjectsLayer/index.js b/src/SubjectsLayer/index.js index 3bb7dd24c..0b56fb723 100644 --- a/src/SubjectsLayer/index.js +++ b/src/SubjectsLayer/index.js @@ -8,8 +8,8 @@ import { getMapSubjectFeatureCollectionWithVirtualPositioning } from '../selecto import { getShouldSubjectsBeClustered } from '../selectors/clusters'; import { LAYER_IDS, SOURCE_IDS, SUBJECT_FEATURE_CONTENT_TYPE } from '../constants'; import { MapContext } from '../App'; -import { useMapSource } from '../hooks'; import { withMultiLayerHandlerAwareness } from '../utils/map-handlers'; +import useMapSources from '../hooks/useMapSources'; import LabeledPatrolSymbolLayer from '../LabeledPatrolSymbolLayer'; import withMapViewConfig from '../WithMapViewConfig'; @@ -67,10 +67,13 @@ const SubjectsLayer = ({ mapImages, onSubjectClick }) => { } ), [map, onSubjectClick, subjectLayerIds]); - useMapSource(UNCLUSTERED_SOURCE_ID, { - ...mapSubjectFeatures, - features: !shouldSubjectsBeClustered ? mapSubjectFeatures.features : [], - }); + useMapSources([{ + id: UNCLUSTERED_SOURCE_ID, + data: { + ...mapSubjectFeatures, + features: !shouldSubjectsBeClustered ? mapSubjectFeatures.features : [], + } + }]); return <> { const map = useContext(MapContext); + const { isTimeOfDayColoringActive } = useSelector(selectTrackSettings); - const trackId = id || trackData.track.features[0].properties.id; + const trackId = id || 'unknown-track'; const onSymbolMouseEnter = () => map.getCanvas().style.cursor = 'pointer'; const onSymbolMouseLeave = () => map.getCanvas().style.cursor = ''; @@ -53,25 +61,51 @@ const TrackLayer = ({ before, id, lineLayout, linePaint, onPointClick, showTimep const layerId = `${TRACKS_LINES}-${trackId}`; const pointLayerId = `${TRACKS_LINES}-points-${trackId}`; - useMapSource(sourceId, trackData.track, { tolerance: 1.5, type: 'geojson' }); - useMapSource(pointSourceId, trackData.points); - - useMapLayer( - layerId, - 'line', + const { + sourcesConfigs, + layersConfigs + } = useMemo(() => getTimeOfDaySourceAndLayerConfigurations( + trackData, + isTimeOfDayColoringActive, sourceId, - { ...TRACK_LAYER_LINE_PAINT, ...linePaint }, + layerId, { ...TRACK_LAYER_LINE_LAYOUT, ...lineLayout }, - { before: before || SUBJECT_SYMBOLS } - ); - useMapLayer( - pointLayerId, - 'symbol', - pointSourceId, - TIMEPOINT_LAYER_PAINT, - TIMEPOINT_LAYER_LAYOUT, - { before: before || SUBJECT_SYMBOLS, condition: showTimepoints } - ); + { + before: before || SUBJECT_SYMBOLS + } + ), [trackData, sourceId, layerId, lineLayout, before, isTimeOfDayColoringActive]); + + + useMapSources([{ id: sourceId, data: trackData.track }], { tolerance: 1.5, type: 'geojson', lineMetrics: true }); + useMapSources([{ id: pointSourceId, data: trackData.points }]); + + useMapSources(sourcesConfigs, { tolerance: 1.5, type: 'geojson', lineMetrics: true }); + useMapLayers(layersConfigs); + + // Only create the normal layer if there are no time_of_day_segments + useMapLayers([{ + id: layerId, + type: 'line', + sourceId, + paint: { ...TRACK_LAYER_LINE_PAINT, ...linePaint }, + layout: { ...TRACK_LAYER_LINE_LAYOUT, ...lineLayout }, + options: { + before: before || SUBJECT_SYMBOLS, + condition: !isTimeOfDayColoringActive && sourcesConfigs.length === 0 + } + }]); + + useMapLayers([{ + id: pointLayerId, + type: 'symbol', + sourceId: pointSourceId, + paint: TIMEPOINT_LAYER_PAINT, + layout: TIMEPOINT_LAYER_LAYOUT, + options: { + before: before || SUBJECT_SYMBOLS, + condition: showTimepoints + } + }]); useMapEventBinding('click', onPointClick, pointLayerId, showTimepoints); useMapEventBinding('mouseenter', onSymbolMouseEnter, pointLayerId, showTimepoints); diff --git a/src/TracksLayer/utils/index.js b/src/TracksLayer/utils/index.js new file mode 100644 index 000000000..a80cf0c0a --- /dev/null +++ b/src/TracksLayer/utils/index.js @@ -0,0 +1,74 @@ + +export const segmentTrackPointsByTimeOfDayPeriodPairs = (trackSegments) => { + const segmentsByColorPair = {}; + + trackSegments.features.forEach((segment) => { + // Skip segments without required color properties + if (!segment.properties?.startColor || !segment.properties?.endColor) { + return; + } + + const key = `${segment.properties.startColor}|${segment.properties.endColor}`; + if (!segmentsByColorPair[key]) { + segmentsByColorPair[key] = []; + } + segmentsByColorPair[key].push(segment); + }); + + return segmentsByColorPair; +}; + +export const getTimeOfDaySourceAndLayerConfigurations = (trackData, isTimeOfDayColoringActive, sourceId, layerId, layerLayout, layerOptions) => { + + if (!trackData?.trackSegments?.features?.length || !isTimeOfDayColoringActive) { + return { sourcesConfigs: [], layersConfigs: [] }; + } + + const sources = []; + const layers = []; + const trackPointsSegmentsByColorPair = segmentTrackPointsByTimeOfDayPeriodPairs(trackData.trackSegments); + + Object.entries(trackPointsSegmentsByColorPair).forEach(([colorPairKey, segments], index) => { + const [startColor, endColor] = colorPairKey.split('|'); + const pairSourceId = `${sourceId}-colorpair-${index}`; + const pairLayerId = `${layerId}-colorpair-${index}`; + + // Add source config + sources.push({ + id: pairSourceId, + data: { + type: 'FeatureCollection', + features: segments + }, + options: { + tolerance: 1.5, + type: 'geojson', + lineMetrics: true + } + }); + + // Add layer config + layers.push({ + id: pairLayerId, + type: 'line', + sourceId: pairSourceId, + paint: { + 'line-width': 2, + 'line-gradient': [ + 'interpolate', + ['linear'], + ['line-progress'], + 0, startColor, + 1, endColor + ] + }, + layout: layerLayout, + options: layerOptions + }); + }); + + return { + sourcesConfigs: sources, + layersConfigs: layers + }; +}; diff --git a/src/TracksLayer/utils/index.test.js b/src/TracksLayer/utils/index.test.js new file mode 100644 index 000000000..3b01b0896 --- /dev/null +++ b/src/TracksLayer/utils/index.test.js @@ -0,0 +1,104 @@ +import { buildTrackSegments } from '../../utils/tracks'; +import { + getTimeOfDaySourceAndLayerConfigurations, + segmentTrackPointsByTimeOfDayPeriodPairs +} from './'; + +describe('TracksLayer - utils', () => { + const track = { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'geometry': { + 'type': 'LineString', + 'coordinates': [ + [ + -109.41014560634443, + -27.166035291320892 + ], + [ + -109.41937192180515, + -27.161194427154097 + ], + [ + -109.42032127709739, + -27.17047350291985 + ], + [ + -109.37653132599918, + -27.08924213333757 + ], + [ + -109.38397002503892, + -27.114204665851712 + ] + ] + }, + 'properties': { + 'coordinateProperties': { + 'times': [ + '2025-02-27T21:42:01+00:00', + '2025-02-24T06:06:05+00:00', + '2025-02-24T03:58:02+00:00', + '2025-02-17T00:16:01+00:00', + '2025-02-14T12:24:01+00:00' + ] + } + } + } + ] + }; + const trackSegments = buildTrackSegments(track, 'America/Monterrey'); + const segmentPairs = segmentTrackPointsByTimeOfDayPeriodPairs(trackSegments); + + test('segments track points by pairs of time of day periods', () => { + const layoutOptions = { + 'line-join': 'round', + 'line-cap': 'round', + }; + const layerOptions = { + before: 'subject-symbol-layer' + }; + const sourceId = 'aSourceId'; + const layerId = 'aLayerId'; + const trackData = { trackSegments }; + const configs = getTimeOfDaySourceAndLayerConfigurations(trackData, true, sourceId, layerId, layoutOptions, layerOptions); + + expect(configs.sourcesConfigs.length > 0).toBe(true); + + Object.entries(segmentPairs).forEach(([pairKey, segment], index) => { + const [startColor, endColor] = pairKey.split('|'); + const segmentSourceId = `${sourceId}-colorpair-${index}`; + const sourceConfig = configs.sourcesConfigs.find((source) => source.id === segmentSourceId); + const segmentLayerId = `${layerId}-colorpair-${index}`; + const layerConfig = configs.layersConfigs.find((layer) => layer.id === segmentLayerId); + const [ + interpolate, + linear, + lineProgress, + firstStop, + firstStopColor, + secondStop, + secondStopColor + ] = layerConfig.paint['line-gradient']; + + + expect(sourceConfig.data.type).toBe('FeatureCollection'); + expect(sourceConfig.data.features).toStrictEqual(segment); + + expect(layerConfig.type).toBe('line'); + expect(layerConfig.sourceId).toBe(segmentSourceId); + expect(layerConfig.layout).toStrictEqual(layoutOptions); + expect(layerConfig.options).toStrictEqual(layerOptions); + + expect(interpolate).toBe('interpolate'); + expect(linear).toStrictEqual(['linear']); + expect(lineProgress).toStrictEqual(['line-progress']); + expect(firstStop).toBe(0); + expect(firstStopColor).toBe(startColor); + expect(secondStop).toBe(1); + expect(secondStopColor).toBe(endColor); + }); + }); +}); \ No newline at end of file diff --git a/src/UserCurrentLocationLayer/index.js b/src/UserCurrentLocationLayer/index.js index 4d2b2977c..c14ce3fb7 100644 --- a/src/UserCurrentLocationLayer/index.js +++ b/src/UserCurrentLocationLayer/index.js @@ -7,9 +7,11 @@ import { addMapImage } from '../utils/map'; import { bboxBoundsPolygon, userLocationCanBeShown as userLocationCanBeShownSelector } from '../selectors'; import { MAP_ICON_SCALE, SOURCE_IDS } from '../constants'; import { MapContext } from '../App'; -import { useMapEventBinding, useMapLayer, useMapSource } from '../hooks'; +import { useMapEventBinding } from '../hooks'; +import useMapSources from '../hooks/useMapSources'; import GpsLocationIcon from '../common/images/icons/gps-location-icon-blue.svg'; +import useMapLayers from '../hooks/useMapLayers'; const { CURRENT_USER_LOCATION_SOURCE } = SOURCE_IDS; @@ -79,7 +81,9 @@ const UserCurrentLocationLayer = ({ onIconClick }) => { 'circle-stroke-opacity': animationState.opacity, }; - const layerConfig = { minZoom: 6, condition: !!showLayer }; + const layerConfig = useMemo(() => ( + { minZoom: 6, condition: !!showLayer } + ), [showLayer]); const onCurrentLocationIconClick = useCallback(() => { onIconClick(userLocation); @@ -101,10 +105,23 @@ const UserCurrentLocationLayer = ({ onIconClick }) => { } }, [animationState, showLayer]); - useMapSource(CURRENT_USER_LOCATION_SOURCE, userLocationPoint); - - useMapLayer(ICON_LAYER_ID, 'symbol', CURRENT_USER_LOCATION_SOURCE, null, SYMBOL_LAYOUT, layerConfig); - useMapLayer(CIRCLE_LAYER_ID, 'circle', CURRENT_USER_LOCATION_SOURCE, circlePaint, null, layerConfig); + useMapSources([{ id: CURRENT_USER_LOCATION_SOURCE, data: userLocationPoint }]); + + useMapLayers([{ + id: ICON_LAYER_ID, + type: 'symbol', + sourceId: CURRENT_USER_LOCATION_SOURCE, + layout: SYMBOL_LAYOUT, + options: layerConfig + }]); + + useMapLayers([{ + id: CIRCLE_LAYER_ID, + type: 'circle', + sourceId: CURRENT_USER_LOCATION_SOURCE, + paint: circlePaint, + options: layerConfig + }]); useMapEventBinding('click', onCurrentLocationIconClick, ICON_LAYER_ID); diff --git a/src/constants/index.js b/src/constants/index.js index ad9ab6ac7..bfbcdea83 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -350,40 +350,48 @@ export const TIME_OF_DAY_PERIODS = [ rangeString: '12:01 - 15:00', rangeMinutesMin: 721, rangeMinutesMax: 900, + color: '#f3e34b' }, { rangeString: '15:01 - 18:00', rangeMinutesMin: 901, rangeMinutesMax: 1080, + color: '#ffbd00' }, { rangeString: '18:01 - 21:00', rangeMinutesMin: 1081, rangeMinutesMax: 1260, + color: '#de5285' }, { rangeString: '21:01 - 00:00', rangeMinutesMin: 1261, rangeMinutesMax: 1440, + color: '#8d4e85' }, { rangeString: '00:01 - 03:00', rangeMinutesMin: 1, rangeMinutesMax: 180, + color: '#5b5ee9' }, { rangeString: '03:01 - 06:00', rangeMinutesMin: 181, rangeMinutesMax: 360, + color: '#1a5fb4' }, { rangeString: '06:01 - 09:00', rangeMinutesMin: 361, rangeMinutesMax: 54, + color: '#29a272' }, { rangeString: '09:01 - 12:00', rangeMinutesMin: 55, rangeMinutesMax: 720, + color: '#2ec27e' } ]; diff --git a/src/hooks/index.js b/src/hooks/index.js index a9c59b3a0..0e0e7d73b 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -6,7 +6,7 @@ import noop from 'lodash/noop'; import { MapContext } from '../App'; -import { DEVELOPMENT_FEATURE_FLAGS, MIN_ZOOM, MAX_ZOOM } from '../constants'; +import { DEVELOPMENT_FEATURE_FLAGS } from '../constants'; export const useSystemConfigFlag = (flag) => useSelector((state) => !!state?.view?.systemConfig?.[flag]); @@ -25,14 +25,14 @@ export const useFeatureFlag = (flagName) => { }; -export const usePermissions = (permissionKey, ...permissions) => { +export const usePermissions = (permissionKey, ...permissions) => { const permissionSet = useSelector(state => { const permissionsSource = state.data.selectedUserProfile?.id ? state.data.selectedUserProfile : state.data.user; return permissionsSource?.permissions?.[permissionKey]; } ) - || []; + || []; return permissions.every(item => permissionSet.includes(item)); }; @@ -74,127 +74,6 @@ export const useMapEventBinding = (eventType = 'click', handlerFn = noop, layerI }, [map, condition, eventType, layerId, handlerFn]); }; -export const useMapSource = (sourceId, data, config = { type: 'geojson' }) => { - const map = useContext(MapContext); - useEffect(() => { - if (map && !map?.getSource(sourceId)) { - map.addSource(sourceId, { - ...config, - data, - }); - } - }, [sourceId, config, data, map]); - - useEffect(() => { - let timeout; - timeout = window.setTimeout(() => { - const source = map?.getSource?.(sourceId); - - if (source) { - source?.setData?.(data); - } - - }); - return () => { - window.clearTimeout(timeout); - }; - }, [data, map, sourceId]); - - useEffect(() => { - return () => { - if (map) { - setTimeout(() => { - map?.getSource(sourceId) && map.removeSource(sourceId); - }); - } - }; - }, [sourceId, map]); - - return map?.getSource(sourceId); -}; - -export const useMapLayer = (layerId, type, sourceId, paint, layout, config = {}) => { - const map = useContext(MapContext); - const layer = map?.getLayer(layerId); - - const before = useMemoCompare(config?.before); - const condition = useMemoCompare(config?.hasOwnProperty('condition') ? config.condition : true); - const filter = useMemoCompare(config?.filter); - const minzoom = useMemoCompare(config?.minZoom); - const maxzoom = useMemoCompare(config?.maxZoom); - - useEffect(() => { - if (condition && map && !layer) { - if (!!map.getSource(sourceId)) { - map.addLayer({ - id: layerId, - source: sourceId, - type, - filter: config.hasOwnProperty(filter) ? filter : true, - layout: layout || {}, - paint: paint || {}, - }, before); - } - } - }, [before, condition, config, filter, layer, layerId, layout, map, sourceId, paint, type]); - - useEffect(() => { - if (condition && layer && layout) { - Object.entries(layout).forEach(([key, value]) => { - map.setLayoutProperty(layerId, key, value); - }); - } - }, [condition, layer, layerId, layout, map]); - - useEffect(() => { - if (condition && layer && paint) { - Object.entries(paint).forEach(([key, value]) => { - map.setPaintProperty(layerId, key, value); - }); - } - }, [condition, map, layer, layerId, paint]); - - useEffect(() => { - if (condition && map && map.getLayer(layerId)) { - map.setFilter(layerId, filter); - } - }, [condition, filter, layer, layerId, map]); - - useEffect(() => { - if (!condition && layer) { - map.removeLayer(layerId); - } - }, [condition, layer, layerId, map]); - - useEffect(() => { - return () => { - if (map) { - try { - map.getLayer(layerId) && map.removeLayer(layerId); - } catch (error) { - // console.warn('map unmount error', error); - } - } - }; - }, [layerId, map]); - - useEffect(() => { - if (condition && map && layer && (minzoom || maxzoom)) { - map.setLayerZoomRange(layerId, (minzoom || MIN_ZOOM), (maxzoom || MAX_ZOOM)); - } - }, [condition, layer, layerId, map, minzoom, maxzoom]); - - - useEffect(() => { - if (layerId && map && before) { - map.getLayer(layerId) && map.moveLayer(layerId, before); - } - }, [before, layer, layerId, map]); - - return layer; -}; - - export const useMemoCompare = (next, compare = isEqual) => { const previousRef = useRef(); const previous = previousRef.current; diff --git a/src/hooks/index.test.js b/src/hooks/index.test.js index 823a8ec43..15456ac84 100644 --- a/src/hooks/index.test.js +++ b/src/hooks/index.test.js @@ -1,8 +1,6 @@ import React from 'react'; -import { point } from '@turf/turf'; import { Provider } from 'react-redux'; import { renderHook } from '@testing-library/react-hooks'; -import { waitFor } from '@testing-library/dom'; import { FEATURE_FLAG_LABELS, DEVELOPMENT_FEATURE_FLAGS } from '../constants'; @@ -11,7 +9,7 @@ import { MapContext } from '../App'; import { createMapMock } from '../__test-helpers/mocks'; import { mockStore } from '../__test-helpers/MockStore'; -import { useFeatureFlag, useMemoCompare, useMapEventBinding, useMapLayer, useMapSource } from './'; +import { useFeatureFlag, useMemoCompare, useMapEventBinding } from './'; describe('#useMapEventBinding', () => { let map, wrapper, handler; @@ -53,213 +51,6 @@ describe('#useMapEventBinding', () => { }); }); -describe('#useMapSource', () => { - let map, data, wrapper; - const sourceId = 'source-id-ok'; - - beforeEach(() => { - map = createMapMock(); - data = point([-1, -4]); - wrapper = ({ children }) => {children}; /* eslint-disable-line react/display-name */ - }); - - test('adding a source to the map', async () => { - map.getSource.mockReturnValue(null); - renderHook(() => useMapSource(sourceId, data), { wrapper }); - - await waitFor(() => { - expect(map.addSource).toHaveBeenCalledWith(sourceId, { - type: 'geojson', - data, - }); - }); - }); - - test('@param config adds configuration to the source creation options', async () => { - const mockConfig = { type: 'bananas', hello: true }; - - map.getSource.mockReturnValue(null); - - renderHook(() => useMapSource(sourceId, data, mockConfig), { wrapper }); - - await waitFor(() => { - expect(map.addSource).toHaveBeenCalledWith(sourceId, { - ...mockConfig, - data, - }); - }); - }); -}); - -describe('#useMapLayer', () => { - let wrapper, map; - - const layerId = 'test-layer-id'; - - beforeEach(() => { - map = createMapMock({ - getSource: jest.fn(() => true), - }); - wrapper = ({ children }) => {children}; // eslint-disable-line react/display-name - }); - - test('adding a layer to the map', () => { - renderHook(() => useMapLayer(layerId, 'string', 'whatever-source-id'), { wrapper }); - - expect(map.addLayer).toHaveBeenCalled(); - }); - - test('not adding a layer if no map is available', () => { - renderHook(() => useMapLayer()); // no context wrapper means there's no map available; - - expect(map.addLayer).not.toHaveBeenCalled(); - }); - - describe('when the layer is present', () => { - beforeEach(() => { - map.getLayer.mockReturnValue({ whatever: 'ok' }); - }); - test('setting and changing paint props', () => { - let paintObject = { value1: 'yellow', value2: 0.6 }; - - const { rerender } = renderHook(() => useMapLayer(layerId, 'string', 'whatever-source-id', paintObject), { wrapper }); - Object.entries(paintObject).forEach(([key, value]) => { - expect(map.setPaintProperty).toHaveBeenCalledWith(layerId, key, value); - }); - - paintObject = { whatever: true }; - - - rerender(); - - Object.entries(paintObject).forEach(([key, value]) => { - expect(map.setPaintProperty).toHaveBeenCalledWith(layerId, key, value); - }); - - }); - - test('setting and changing layout props', () => { - let layoutObject = { value1: 'yellow', value2: 0.6 }; - - const { rerender } = renderHook(() => useMapLayer(layerId, 'string', 'whatever-source-id', null, layoutObject), { wrapper }); - Object.entries(layoutObject).forEach(([key, value]) => { - expect(map.setLayoutProperty).toHaveBeenCalledWith(layerId, key, value); - }); - - layoutObject = { whatever: true }; - - rerender(); - - Object.entries(layoutObject).forEach(([key, value]) => { - expect(map.setLayoutProperty).toHaveBeenCalledWith(layerId, key, value); - }); - - }); - - test('returning the layer value', () => { - - const { result } = renderHook(() => useMapLayer(layerId, 'string', 'whatever-source-id'), { wrapper }); - - expect(result.current).toEqual({ whatever: 'ok' }); - }); - - test('removing a layer on unmount', () => { - const { unmount } = renderHook(() => useMapLayer(layerId, 'string', 'whatever-source-id'), { wrapper }); - - unmount(); - - expect(map.removeLayer).toHaveBeenCalledWith(layerId); - }); - - describe('@param config', () => { - test('.filter sets and changes', () => { - let filter = ['==', [['get', 'subject_subtype'], 'ranger']]; - - const { rerender } = renderHook(() => useMapLayer(layerId, 'string', 'whatever-source-id', null, null, { filter }), { wrapper }); - expect(map.setFilter).toHaveBeenCalledWith(layerId, filter); - - filter = 'oh whatever dude'; - - rerender(); - - expect(map.setFilter).toHaveBeenCalledWith(layerId, 'oh whatever dude'); - - }); - - test('.before sets and changes', async () => { - let before = null; - - const { rerender } = renderHook(() => useMapLayer(layerId, 'string', 'whatever-source-id', null, null, { before }), { wrapper }); - - expect(map.moveLayer).not.toHaveBeenCalled(); - - before = 'how'; - - rerender(); - - await waitFor(() => { - expect(map.moveLayer).toHaveBeenCalledWith(layerId, 'how'); - }); - }); - - test('zoom config sets and changes', () => { - const config = { - maxZoom: 15, - minZoom: 1 - }; - - const { rerender } = renderHook(() => useMapLayer(layerId, 'string', 'whatever-source-id', null, null, config), { wrapper }); - - expect(map.setLayerZoomRange).toHaveBeenCalledWith(layerId, 1, 15); - - config.maxZoom = 20; - - rerender(); - - expect(map.setLayerZoomRange).toHaveBeenCalledWith(layerId, 1, 20); - - config.minZoom = 7; - - rerender(); - - expect(map.setLayerZoomRange).toHaveBeenCalledWith(layerId, 7, 20); - - }); - - describe('.condition', () => { - test('adds and removes a layer when toggled', async () => { - map.getLayer.mockReturnValue(undefined); - - const config = { condition: false }; - - const { rerender } = renderHook(() => useMapLayer(layerId, 'string', 'whatever-source-id', null, null, config), { wrapper }); - - expect(map.addLayer).not.toHaveBeenCalled(); - - config.condition = true; - - rerender(); - - await waitFor(() => { - expect(map.removeLayer).not.toHaveBeenCalled(); - expect(map.addLayer).toHaveBeenCalled(); - }); - - map.getLayer.mockReturnValue({ whatever: 'ok' }); - config.condition = false; - - rerender(); - await waitFor(() => { - expect(map.removeLayer).toHaveBeenCalled(); - }); - }); - - }); - }); - }); - -}); - describe('#useMemoCompare', () => { test('returning the first value on first render', () => { let value = { whatever: 123 }; diff --git a/src/hooks/useClusterBufferPolygon/index.js b/src/hooks/useClusterBufferPolygon/index.js index d8811bd7c..5ea80b167 100644 --- a/src/hooks/useClusterBufferPolygon/index.js +++ b/src/hooks/useClusterBufferPolygon/index.js @@ -3,7 +3,8 @@ import { buffer, concave, featureCollection, simplify } from '@turf/turf'; import { CLUSTERS_MAX_ZOOM, LAYER_IDS, SOURCE_IDS } from '../../constants'; import { MapContext } from '../../App'; -import { useMapLayer, useMapSource } from '..'; +import useMapSources from '../useMapSources'; +import useMapLayers from '../useMapLayers'; const { CLUSTER_BUFFER_POLYGON_LAYER_ID, CLUSTERS_LAYER_ID } = LAYER_IDS; const { CLUSTER_BUFFER_POLYGON_SOURCE_ID } = SOURCE_IDS; @@ -22,9 +23,15 @@ const useClusterBufferPolygon = () => { const [clusterBufferPolygon, setClusterBufferPolygon] = useState(featureCollection([])); const map = useContext(MapContext); - const source = useMapSource(CLUSTER_BUFFER_POLYGON_SOURCE_ID, clusterBufferPolygon); - - useMapLayer(CLUSTER_BUFFER_POLYGON_LAYER_ID, 'fill', CLUSTER_BUFFER_POLYGON_SOURCE_ID, CLUSTER_BUFFER_POLYGON_PAINT, null, CLUSTER_BUFFER_POLYGON_LAYER_CONFIGURATION); + const [source] = useMapSources([{ id: CLUSTER_BUFFER_POLYGON_SOURCE_ID, data: clusterBufferPolygon }]); + + useMapLayers([{ + id: CLUSTER_BUFFER_POLYGON_LAYER_ID, + type: 'fill', + sourceId: CLUSTER_BUFFER_POLYGON_SOURCE_ID, + paint: CLUSTER_BUFFER_POLYGON_PAINT, + options: CLUSTER_BUFFER_POLYGON_LAYER_CONFIGURATION + }]); const renderClusterPolygon = useCallback((clusterFeatureCollection) => { if (source && map.getZoom() < CLUSTERS_MAX_ZOOM) { diff --git a/src/hooks/useMapLayers/index.js b/src/hooks/useMapLayers/index.js new file mode 100644 index 000000000..ca9b7d0af --- /dev/null +++ b/src/hooks/useMapLayers/index.js @@ -0,0 +1,127 @@ +import { useContext, useEffect, useRef } from 'react'; + +import { MapContext } from '../../App'; + +const assertLayerCondition = (layerConfig) => layerConfig?.options?.condition ?? true; + +const shouldUpdateMapLayer = (layerConfig, map) => !!( + layerConfig?.id + && assertLayerCondition(layerConfig) + && map.getLayer(layerConfig.id) +); + +const useMapLayers = (layerConfigsBatch = []) => { + const map = useContext(MapContext); + const layerIdsRef = useRef([]); + + useEffect(() => { + if (map){ + + // Remove layers that are no longer in the configs + const existingLayers = layerConfigsBatch.map(config => config.id).filter((id) => !!id); + layerIdsRef.current.forEach(id => { + if (!existingLayers.includes(id) && map.getLayer(id)) { + map.removeLayer(id); + } + }); + layerIdsRef.current = existingLayers.slice(); + + // Add new layers + layerConfigsBatch.forEach(layerConfig => { + if ( + layerConfig?.id + && layerConfig?.type + && layerConfig?.sourceId + && map.getSource(layerConfig.sourceId) + && assertLayerCondition(layerConfig) + && !map.getLayer(layerConfig.id) + ){ + map.addLayer( + { + id: layerConfig.id, + source: layerConfig.sourceId, + type: layerConfig.type, + layout: layerConfig.layout ?? {}, + paint: layerConfig.paint ?? {}, + ...( + Array.isArray(layerConfig.filter) + ? { filter: layerConfig.filter } + : {} + ) + }, + layerConfig?.options?.before + ); + + layerIdsRef.current.push(layerConfig.id); + } + }); + } + }, [map, layerConfigsBatch]); + + useEffect(() => { + if (map) { + layerConfigsBatch.forEach(layerConfig => { + if ( layerConfig.layout && shouldUpdateMapLayer(layerConfig, map) ){ + Object.entries(layerConfig.layout).forEach(([name, value]) => { + map.setLayoutProperty(layerConfig.id, name, value); + }); + } + }); + } + }, [map, layerConfigsBatch]); + + useEffect(() => { + if (map) { + layerConfigsBatch.forEach(layerConfig => { + if ( layerConfig?.paint && shouldUpdateMapLayer(layerConfig, map) ){ + Object.entries(layerConfig.paint).forEach(([name, value]) => { + map.setPaintProperty(layerConfig.id, name, value); + }); + } + }); + } + }, [map, layerConfigsBatch]); + + useEffect(() => { + if (map) { + layerConfigsBatch.forEach(layerConfig => { + if (Array.isArray(layerConfig?.options?.filter) && shouldUpdateMapLayer(layerConfig, map)){ + map.setFilter(layerConfig.id, layerConfig.options.filter); + } + }); + } + }, [map, layerConfigsBatch]); + + useEffect(() => { + if (map) { + layerConfigsBatch.forEach(layerConfig => { + if ( layerConfig?.id && !assertLayerCondition(layerConfig) && map.getLayer(layerConfig.id) ){ + map.removeLayer(layerConfig.id); + } + }); + } + }, [map, layerConfigsBatch]); + + useEffect(() => { + const refs = layerIdsRef.current; + return () => { + if (map) { + try { + refs.forEach(layerId => { + if (map.getLayer(layerId)) { + map.removeLayer(layerId); + } + }); + } catch (error) { + // Silent error handling as in the original hook + } + } + }; + }, [map]); + + return layerConfigsBatch + .map((layerConfig) => layerConfig?.id ? map?.getLayer(layerConfig.id) : null) + .filter(layerConfig => !!layerConfig); +}; + +export default useMapLayers; diff --git a/src/hooks/useMapLayers/index.test.js b/src/hooks/useMapLayers/index.test.js new file mode 100644 index 000000000..27b19678b --- /dev/null +++ b/src/hooks/useMapLayers/index.test.js @@ -0,0 +1,156 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; + +import { createMapMock } from '../../__test-helpers/mocks'; +import { MapContext } from '../../App'; +import useMapLayers from './'; + +describe('hooks - useMapLayers', () => { + let wrapper, map; + + const layerId = 'test-layer-id'; + + beforeEach(() => { + map = createMapMock({ + getSource: jest.fn(() => true), + }); + + wrapper = ({ children }) => {children}; // eslint-disable-line react/display-name + }); + + test('adding a layer to the map', () => { + renderHook(() => useMapLayers([{ id: layerId, type: 'string', sourceId: 'whatever-source-id' }]), { wrapper }); + + expect(map.addLayer).toHaveBeenCalled(); + }); + + test('not adding a layer if no map is available', () => { + renderHook(() => useMapLayers()); // no context wrapper means there's no map available; + + expect(map.addLayer).not.toHaveBeenCalled(); + }); + + describe('when the layer is present', () => { + beforeEach(() => { + map.getLayer.mockReturnValue({ whatever: 'ok' }); + }); + test('setting and changing paint props', () => { + let paintObject = { value1: 'yellow', value2: 0.6 }; + + const { rerender } = renderHook(() => useMapLayers([{ + id: layerId, + type: 'string', + sourceId: 'whatever-source-id', + paint: paintObject + }]), { wrapper }); + + Object.entries(paintObject).forEach(([key, value]) => { + expect(map.setPaintProperty).toHaveBeenCalledWith(layerId, key, value); + }); + + paintObject = { whatever: true }; + + + rerender(); + + Object.entries(paintObject).forEach(([key, value]) => { + expect(map.setPaintProperty).toHaveBeenCalledWith(layerId, key, value); + }); + + }); + + test('setting and changing layout props', () => { + let layoutObject = { value1: 'yellow', value2: 0.6 }; + + const { rerender } = renderHook(() => useMapLayers([{ + id: layerId, + type: 'string', + sourceId: 'whatever-source-id', + layout: layoutObject + }]), { wrapper }); + + Object.entries(layoutObject).forEach(([key, value]) => { + expect(map.setLayoutProperty).toHaveBeenCalledWith(layerId, key, value); + }); + + layoutObject = { whatever: true }; + + rerender(); + + Object.entries(layoutObject).forEach(([key, value]) => { + expect(map.setLayoutProperty).toHaveBeenCalledWith(layerId, key, value); + }); + + }); + + test('returning the layer value', () => { + + const { result } = renderHook(() => useMapLayers([{ + id: layerId, + type: 'string', + sourceId: 'whatever-source-id', + }]), { wrapper }); + + expect(result.current).toEqual([{ whatever: 'ok' }]); + }); + + describe('@param config', () => { + test('.filter sets and changes', () => { + let filter = ['==', [['get', 'subject_subtype'], 'ranger']]; + + const { rerender } = renderHook(() => useMapLayers([{ + id: layerId, + type: 'string', + sourceId: 'whatever-source-id', + options: { filter } + }]), { wrapper }); + + expect(map.setFilter).toHaveBeenCalledWith(layerId, filter); + + filter = ['oh whatever dude']; + + rerender(); + + expect(map.setFilter).toHaveBeenCalledWith(layerId, ['oh whatever dude']); + + }); + + describe('.condition', () => { + test('adds and removes a layer when toggled', async () => { + map.getLayer.mockReturnValue(undefined); + + const config = { condition: false }; + + const { rerender } = renderHook(() => useMapLayers([{ + id: layerId, + type: 'string', + sourceId: 'whatever-source-id', + options: config + }]), { wrapper }); + + expect(map.addLayer).not.toHaveBeenCalled(); + + config.condition = true; + + rerender(); + + await waitFor(() => { + expect(map.removeLayer).not.toHaveBeenCalled(); + expect(map.addLayer).toHaveBeenCalled(); + }); + + map.getLayer.mockReturnValue({ whatever: 'ok' }); + config.condition = false; + + rerender(); + await waitFor(() => { + expect(map.removeLayer).toHaveBeenCalled(); + }); + }); + + }); + }); + }); + +}); diff --git a/src/hooks/useMapSources/index.js b/src/hooks/useMapSources/index.js new file mode 100644 index 000000000..122855202 --- /dev/null +++ b/src/hooks/useMapSources/index.js @@ -0,0 +1,55 @@ +import { useContext, useEffect, useRef } from 'react'; + +import { MapContext } from '../../App'; + + +const useMapSources = (sourceConfigsBatch = [], defaultConfig = { type: 'geojson' }) => { + const map = useContext(MapContext); + const sourceIdsRef = useRef([]); + + useEffect(() => { + if (map) { + sourceConfigsBatch.forEach(sourceConfig => { + if (sourceConfig?.id && !map.getSource(sourceConfig.id)){ + const { id, data = {}, options = {} } = sourceConfig; + const fullSourceConfig = { ...defaultConfig, ...options }; + map.addSource(id, { + ...fullSourceConfig, + data, + }); + sourceIdsRef.current.push(id); + } + }); + } + }, [map, sourceConfigsBatch, defaultConfig]); + + useEffect(() => { + sourceConfigsBatch.forEach(sourceConfig => { + const source = map?.getSource?.(sourceConfig?.id); + if (sourceConfig?.id && sourceConfig?.data && source){ + source.setData?.(sourceConfig.data); + } + }); + }, [map, sourceConfigsBatch]); + + useEffect(() => { + const refs = sourceIdsRef?.current; + return () => { + if (map) { + setTimeout(() => { + refs.forEach(id => { + if (map?.getSource(id)) { + map.removeSource(id); + } + }); + }); + } + }; + }, [map]); + + return sourceConfigsBatch + .map((sourceConfig) => sourceConfig.id ? map?.getSource(sourceConfig.id) : null) + .filter(sourceConfig => !!sourceConfig); +}; + +export default useMapSources; diff --git a/src/hooks/useMapSources/index.test.js b/src/hooks/useMapSources/index.test.js new file mode 100644 index 000000000..98d2d47f7 --- /dev/null +++ b/src/hooks/useMapSources/index.test.js @@ -0,0 +1,173 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { MapContext } from '../../App'; + +import useMapSources from './'; + +describe('hooks - useMapSource', () => { + + const baseMap = { + getSource: jest.fn(), + addSource: jest.fn(), + setData: jest.fn(), + removeSource: jest.fn(), + }; + + // eslint-disable-next-line react/display-name + const wrapper = (map) => ({ children }) => + {children} + ; + + const renderUserMapSource = (sourcesConfig, map, defaultConfig) => + renderHook( + () => useMapSources(sourcesConfig, defaultConfig), + { wrapper: wrapper(map) } + ); + + test('adds source to map properly', () => { + const sourceConfig = { + id: 'id', + data: { + type: 'FeatureCollection', + features: [] + }, + options: { + tolerance: 1.5, + type: 'geojson', + lineMetrics: true, + enabled: true + } + }; + + renderUserMapSource([sourceConfig], baseMap); + + expect(baseMap.addSource).toHaveBeenCalledTimes(1); + expect(baseMap.addSource).toHaveBeenCalledWith(sourceConfig.id, { + ...sourceConfig.options, + data: sourceConfig.data + }); + }); + + test('adds multiple sources to map properly', () => { + const configs = [ + { + id: 'firstConfig', + data: { + type: 'FeatureCollection', + features: [] + }, + options: { + type: 'geojson', + } + }, + { + id: 'secondConfig', + data: { + type: 'FeatureCollection', + features: [{ some: 'data' }] + }, + options: { + type: 'geojson', + } + }, + ]; + + renderUserMapSource(configs, baseMap); + + expect(baseMap.addSource).toHaveBeenCalledTimes(configs.length); + + configs.forEach((sourceConfig) => { + expect(baseMap.addSource).toHaveBeenCalledWith(sourceConfig.id, { + ...sourceConfig.options, + data: sourceConfig.data + }); + }); + }); + + test('updates data of existing source', () => { + jest.useFakeTimers(); + + const source = { + setData: jest.fn() + }; + const map = { + ...baseMap, + getSource: jest.fn(() => { + return source; + }) + }; + const sourceConfig = { + id: 'id', + data: { + type: 'FeatureCollection', + features: [] + }, + options: { + type: 'geojson', + } + }; + + renderUserMapSource([sourceConfig], map); + + jest.runAllTimers(); + + expect(map.getSource).toHaveBeenCalledTimes(3); // Get called 3 times by: adding source check, updating data, returning the source + expect(source.setData).toHaveBeenCalledTimes(1); + expect(map.getSource).toHaveBeenCalledWith(sourceConfig.id); + expect(source.setData).toHaveBeenCalledWith(sourceConfig.data); + + jest.useRealTimers(); + }); + + test('updates data of multiple existing sources', () => { + jest.useFakeTimers(); + + const source = { + setData: jest.fn() + }; + const map = { + ...baseMap, + getSource: jest.fn(() => { + return source; + }) + }; + const sourcesConfig = [ + { + id: 'id', + data: { + type: 'FeatureCollection', + features: [] + }, + options: { + type: 'geojson', + } + }, + { + id: 'idSource', + data: { + type: 'FeatureCollection', + features: [{ data: 3 }] + }, + options: { + type: 'geojson', + } + } + ]; + + renderUserMapSource(sourcesConfig, map); + + jest.runAllTimers(); + + expect(map.getSource).toHaveBeenCalledTimes(3 * sourcesConfig.length ); // Get called 3 times by: adding source check, updating data, returning the source + expect(source.setData).toHaveBeenCalledTimes(2); + + sourcesConfig.forEach((sourceConfig) => { + expect(map.getSource).toHaveBeenCalledWith(sourceConfig.id); + expect(source.setData).toHaveBeenCalledWith(sourceConfig.data); + }); + + jest.useRealTimers(); + }); + +}); \ No newline at end of file diff --git a/src/selectors/tracks/index.js b/src/selectors/tracks/index.js index 3b5c61a06..42cd48c29 100644 --- a/src/selectors/tracks/index.js +++ b/src/selectors/tracks/index.js @@ -4,13 +4,16 @@ import uniq from 'lodash/uniq'; import { TRACK_LENGTH_ORIGINS } from '../../ducks/tracks'; -import { getTimeOfDayPeriodBasedOnTime, trimTrackDataToTimeRange } from '../../utils/tracks'; +import { + trimTrackDataToTimeRange, + buildTrackSegments +} from '../../utils/tracks'; const selectEventFilter = (state) => state.data.eventFilter; const selectHeatmapSubjectIDs = (state) => state.view.heatmapSubjectIDs; const selectSubjectTrackState = (state) => state.view.subjectTrackState; const selectTimeSliderState = (state) => state.view.timeSliderState; -const selectTrackSettings = (state) => state.view.trackSettings; +export const selectTrackSettings = (state) => state.view.trackSettings; const selectTracks = (state) => state.data.tracks; export const selectTrackTimeEnvelope = createSelector([selectEventFilter, selectTimeSliderState, selectTrackSettings], @@ -74,33 +77,23 @@ const selectSubjectTracks = createSelector( .map((subjectId) => tracks[subjectId]) ); + export const selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod = createSelector( [selectSubjectTracks, selectTrackTimeEnvelope, selectTrackSettings], - (subjectTracks, trackTimeEnvelope, trackSettings) => subjectTracks.map( + (subjectTracks, trackTimeEnvelope, { isTimeOfDayColoringActive, timeOfDayTimeZone }) => subjectTracks.map( (subjectTrack) => { - // Trim each subject tracks to the track time envelope. - const trimmedTrackData = trimTrackDataToTimeRange(subjectTrack, trackTimeEnvelope.from, trackTimeEnvelope.until); - - if (trackSettings.isTimeOfDayColoringActive) { - // If time of day coloring is active we add the time of day period to each point feature. - const pointFeaturesWithTimeOfDayPeriod = trimmedTrackData.points.features.map((feature) => ({ - ...feature, - properties: { - ...feature.properties, - timeOfDayPeriod: getTimeOfDayPeriodBasedOnTime(feature.properties.time, trackSettings.timeOfDayTimeZone), - }, - })); + const trimmedTrackData = trimTrackDataToTimeRange( // Trim each subject tracks to the track time envelope. + subjectTrack, + trackTimeEnvelope.from, + trackTimeEnvelope.until + ); - return { + return isTimeOfDayColoringActive + ? { ...trimmedTrackData, - points: { - ...trimmedTrackData.points, - features: pointFeaturesWithTimeOfDayPeriod, - } - }; - } - - return trimmedTrackData; + trackSegments: buildTrackSegments(trimmedTrackData.track, timeOfDayTimeZone) + } + : trimmedTrackData; } ) ); diff --git a/src/selectors/tracks/index.test.js b/src/selectors/tracks/index.test.js index b6186a758..0f71639a7 100644 --- a/src/selectors/tracks/index.test.js +++ b/src/selectors/tracks/index.test.js @@ -132,6 +132,7 @@ describe('Selectors - Tracks', () => { test('builds the subject tracks from the subjects with tracks active trimmed to the time envelope', () => { state.view.subjectTrackState.visible = ['123']; + state.view.trackSettings.isTimeOfDayColoringActive = false; state.data.tracks = { 123: { points: { @@ -173,5 +174,116 @@ describe('Selectors - Tracks', () => { }, }]); }); + + test('builds the subject tracks with trimmed to the time envelope and track segments', () => { + state.view.subjectTrackState.visible = ['123']; + state.view.trackSettings.isTimeOfDayColoringActive = true; + state.view.trackSettings.timeOfDayTimeZone = 'America/Monterrey'; + state.data.tracks = { + 123: { + points: { + features: [], + }, + track: { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'geometry': { + 'type': 'LineString', + 'coordinates': [ + [ + -109.41014560634443, + -27.166035291320892 + ], + [ + -109.41937192180515, + -27.161194427154097 + ], + ] + }, + 'properties': { + 'coordinateProperties': { + 'times': [ + '2025-02-27T21:42:01+00:00', + '2025-02-24T06:06:05+00:00', + ] + } + } + } + ] + }, + }, + }; + expect(selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod(state)) + .toEqual([ + { + 'track': { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'geometry': { + 'type': 'LineString', + 'coordinates': [ + [ + -109.41014560634443, + -27.166035291320892 + ], + [ + -109.41937192180515, + -27.161194427154097 + ] + ] + }, + 'properties': { + 'coordinateProperties': { + 'times': [ + '2025-02-27T21:42:01+00:00', + '2025-02-24T06:06:05+00:00' + ] + } + } + } + ] + }, + 'points': { + 'features': [ + + ] + }, + 'indices': { + 'from': 1 + }, + 'trackSegments': { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'properties': { + 'startColor': '#ffbd00', + 'endColor': '#5b5ee9', + 'startTime': '2025-02-27T21:42:01+00:00', + 'endTime': '2025-02-24T06:06:05+00:00' + }, + 'geometry': { + 'type': 'LineString', + 'coordinates': [ + [ + -109.41014560634443, + -27.166035291320892 + ], + [ + -109.41937192180515, + -27.161194427154097 + ] + ] + } + } + ] + } + } + ]); + }); }); }); diff --git a/src/utils/tracks.js b/src/utils/tracks.js index 820a507ee..50c607ad3 100644 --- a/src/utils/tracks.js +++ b/src/utils/tracks.js @@ -18,6 +18,7 @@ import { TIME_OF_DAY_PERIODS } from '../constants'; const MAX_ABSOLUTE_LONGITUDE = 180; const WORLD_TOTAL_LONGITUDE = 360; +const MAX_MINUTES_PER_DAY = 1440; export const fixAntimeridianCrossing = (trackFeatureCollection) => { if (!trackFeatureCollection?.features?.length) return trackFeatureCollection; @@ -107,12 +108,12 @@ export const findTimeEnvelopeIndices = (times, from = null, until = null) => { from, until, }; - const earliestTime = times[times.length -1]; + const earliestTime = times[times.length - 1]; const mostRecentTime = times[0]; if (from) { results.from = dateIsAtOrAfterDate(earliestTime, from) - ? times.length -1 + ? times.length - 1 : findDateIndexInRange(times, from); } if (until) { @@ -126,7 +127,7 @@ export const findTimeEnvelopeIndices = (times, from = null, until = null) => { ) { results.until = times.length; } else if (untilIndex > -1) { - results.until = isEqualDate(new Date(times[untilIndex]), new Date(until)) ? untilIndex : untilIndex + 1; + results.until = isEqualDate(new Date(times[untilIndex]), new Date(until)) ? untilIndex : untilIndex + 1; } } return results; @@ -181,7 +182,7 @@ export const trackHasDataWithinTimeRange = (trackData, since = null, until = nul }; const trackFetchState = {}; -export const fetchTracksIfNecessary = (ids, config) => { +export const fetchTracksIfNecessary = (ids, config) => { const optionalDateBoundaries = config?.optionalDateBoundaries; const { data: { tracks, virtualDate, eventFilter }, view: { trackSettings, timeSliderState } } = store.getState(); @@ -244,7 +245,7 @@ export const fetchTracksIfNecessary = (ids, config) => { } } else if (!!oldRange?.until) { if (!dateRange.until - || new Date(dateRange.until).getTime() > new Date(oldRange.until).getTime()) { + || new Date(dateRange.until).getTime() > new Date(oldRange.until).getTime()) { shouldCancelPriorRequest = true; } } @@ -258,7 +259,7 @@ export const fetchTracksIfNecessary = (ids, config) => { }; if (!trackData - || !trackHasDataWithinTimeRange(trackData, dateRange.since, dateRange.until)) { + || !trackHasDataWithinTimeRange(trackData, dateRange.since, dateRange.until)) { return handleFetch(); } return trackData; @@ -274,7 +275,7 @@ export const trimTrackDataToTimeRange = (trackData, from = null, until = null) = const [originalTrack] = track.features; if ((!from && !until) || !originalTrack.geometry) return { track, points }; - const indices = findTimeEnvelopeIndices(originalTrack.properties.coordinateProperties.times, from ? new Date(from) : null, until? new Date(until) : until); + const indices = findTimeEnvelopeIndices(originalTrack.properties.coordinateProperties.times, from ? new Date(from) : null, until ? new Date(until) : until); if (window.isNaN(indices.from) && window.isNaN(indices.until)) { return { track, points }; @@ -361,9 +362,89 @@ export const addSocketStatusUpdateToTrack = (tracks, newData) => { export const getTimeOfDayPeriodBasedOnTime = (datetimeString, timeZone) => { const [hour, min] = getTimeInTimezone(new Date(datetimeString), timeZone).split(':'); - const trackTotalMinutesInTZ = ( (parseInt(hour) * 60) + parseInt(min) ) || 1440; + const parsedHour = (parseInt(hour) * 60); - return TIME_OF_DAY_PERIODS.findIndex((timeOfDayPeriod) => + const trackTotalMinutesInTZ = parsedHour === MAX_MINUTES_PER_DAY + ? MAX_MINUTES_PER_DAY + : ( parsedHour + parseInt(min) ) || MAX_MINUTES_PER_DAY; + + return TIME_OF_DAY_PERIODS.find((timeOfDayPeriod) => trackTotalMinutesInTZ >= timeOfDayPeriod.rangeMinutesMin && trackTotalMinutesInTZ <= timeOfDayPeriod.rangeMinutesMax ); }; + +/* +* This method split all track points by segments formed by a line-string object of 2 points having: +* - Coors and times of points A and B +* - assigning a startColor and endColor based on the time of each point +* The segmentation will allow us to set a line gradient with specific stop colors to each line. +* This being a workaround of the issue of MapBox not being able to apply dynamically data-drive stop colors for a gradient line. +* */ +export const buildTrackSegments = (trackFeatureCollection, timeZone) => { + const emptyFeatureCollection = { + type: 'FeatureCollection', + features: [] + }; + + if (!trackFeatureCollection || !trackFeatureCollection.features || !trackFeatureCollection.features.length) { + return emptyFeatureCollection; + } + + const [lineStringFeature] = trackFeatureCollection.features; + + // Checking if first feature is valid + if (!lineStringFeature?.geometry || + lineStringFeature.geometry?.type !== 'LineString' || + !lineStringFeature.properties?.coordinateProperties?.times) { + return emptyFeatureCollection; + } + + const { + geometry: { + coordinates + }, + properties: { + coordinateProperties: { + times + } + } + } = lineStringFeature; + + // At least there should be 2 points to create the line string and the amount of times should be the same as the amount of coordinates + if (coordinates.length < 2 || coordinates.length !== times.length) { + return emptyFeatureCollection; + } + + const segments = []; + + // Create a line segment for each pair of consecutive points + for (let i = 0; i < coordinates.length - 1; i++) { + const startTime = times[i]; + const endTime = times[i + 1]; + const startCoors = coordinates[i]; + const endCoors = coordinates[i + 1]; + + if (!endTime || !endCoors){ + break; + } + + const { color: startColor } = getTimeOfDayPeriodBasedOnTime(startTime, timeZone); + const { color: endColor } = getTimeOfDayPeriodBasedOnTime(endTime, timeZone); + + segments.push({ + type: 'Feature', + properties: { + startColor, + endColor, + startTime, + endTime + }, + geometry: { + type: 'LineString', + coordinates: [startCoors, endCoors] + } + }); + } + + return featureCollection(segments); +}; diff --git a/src/utils/tracks.test.js b/src/utils/tracks.test.js index 7df4cc0b2..c4e76dbe2 100644 --- a/src/utils/tracks.test.js +++ b/src/utils/tracks.test.js @@ -1,27 +1,204 @@ -import { getTimeOfDayPeriodBasedOnTime } from './tracks'; +import { buildTrackSegments, getTimeOfDayPeriodBasedOnTime } from './tracks'; + +import { TIME_OF_DAY_PERIODS } from '../constants'; describe('utils - tracks', () => { - const baseDateTimeString = '2025-02-21T21:41:14.677Z'; + describe('getTimeOfDayPeriodBasedOnTime', () => { + const baseDateTimeString = '2025-02-21T21:41:14.677Z'; + + test('calculate proper time of day range based on time', () => { + expect( + // time being converted to 15:41 based on Monterrey time, having 941 minutes therefore falling into period #1 + getTimeOfDayPeriodBasedOnTime( + baseDateTimeString, + 'America/Monterrey' + ) + ).toBe(TIME_OF_DAY_PERIODS[1]); + }); - test('calculate proper time of day range based on time', () => { - expect( - // time being converted to 15:41 based on Monterrey time, having 941 minutes therefore falling into period #1 - getTimeOfDayPeriodBasedOnTime( - baseDateTimeString, - 'America/Monterrey' - ) - ).toBe(1); + test('calculate proper time of day range based on time', () => { + expect( + // time being converted to 05:41 based on Hong Kong time, having 341 minutes therefore falling into period #1 + getTimeOfDayPeriodBasedOnTime( + baseDateTimeString, + 'Asia/Hong_Kong' + ) + ).toBe(TIME_OF_DAY_PERIODS[5]); + }); }); - test.only('calculate proper time of day range based on time', () => { - expect( - // time being converted to 05:41 based on Hong Kong time, having 341 minutes therefore falling into period #1 - getTimeOfDayPeriodBasedOnTime( - baseDateTimeString, - 'Asia/Hong_Kong' - ) - ).toBe(5); + describe('buildFeatureCollectionOfTwoPointLineStringSegments', () => { + + const track = { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'geometry': { + 'type': 'LineString', + 'coordinates': [ + [ + -109.41014560634443, + -27.166035291320892 + ], + [ + -109.41937192180515, + -27.161194427154097 + ], + [ + -109.42032127709739, + -27.17047350291985 + ], + [ + -109.37653132599918, + -27.08924213333757 + ], + [ + -109.38397002503892, + -27.114204665851712 + ] + ] + }, + 'properties': { + 'coordinateProperties': { + 'times': [ + '2025-02-27T21:42:01+00:00', + '2025-02-24T06:06:05+00:00', + '2025-02-24T03:58:02+00:00', + '2025-02-17T00:16:01+00:00', + '2025-02-14T12:24:01+00:00' + ] + } + } + } + ] + }; + + test('builds feature collection of two-point line string segments', () => { + + const resultFeatures = [ + { + properties: { + startColor: TIME_OF_DAY_PERIODS[1].color, + endColor: TIME_OF_DAY_PERIODS[4].color, + startTime: '2025-02-27T21:42:01+00:00', + endTime: '2025-02-24T06:06:05+00:00' + }, + geometry: { + coordinates: [ + [-109.41014560634443, -27.166035291320892], + [-109.41937192180515, -27.161194427154097] + ] + } + }, + { + properties: { + startColor: TIME_OF_DAY_PERIODS[4].color, + endColor: TIME_OF_DAY_PERIODS[3].color, + startTime: '2025-02-24T06:06:05+00:00', + endTime: '2025-02-24T03:58:02+00:00' + }, + geometry: { + coordinates: [ + [-109.41937192180515, -27.161194427154097], + [-109.42032127709739, -27.17047350291985] + ] + } + }, + { + properties: { + startColor: TIME_OF_DAY_PERIODS[3].color, + endColor: TIME_OF_DAY_PERIODS[2].color, + startTime: '2025-02-24T03:58:02+00:00', + endTime: '2025-02-17T00:16:01+00:00' + }, + geometry: { + coordinates: [ + [-109.42032127709739, -27.17047350291985], + [-109.37653132599918, -27.08924213333757] + ] + } + }, + { + properties: { + startColor: TIME_OF_DAY_PERIODS[2].color, + endColor: TIME_OF_DAY_PERIODS[7].color, + startTime: '2025-02-17T00:16:01+00:00', + endTime: '2025-02-14T12:24:01+00:00' + }, + geometry: { + coordinates: [ + [-109.37653132599918, -27.08924213333757], + [-109.38397002503892, -27.114204665851712] + ] + } + } + ]; + + const trackSegments = buildTrackSegments(track, 'America/Monterrey'); + + expect(trackSegments.features.length).toBe(resultFeatures.length); + expect(trackSegments.type).toBe('FeatureCollection'); + + trackSegments.features.forEach((feature, index) => { + const expectedFeature = resultFeatures[index]; + + expect(feature.type).toBe('Feature'); + + // expected properties + expect(feature.properties.startColor).toBe(expectedFeature.properties.startColor); + expect(feature.properties.endColor).toBe(expectedFeature.properties.endColor); + expect(feature.properties.endColor).toBe(expectedFeature.properties.endColor); + expect(feature.properties.startTime).toBe(expectedFeature.properties.startTime); + expect(feature.properties.endTime).toBe(expectedFeature.properties.endTime); + + // expected geometry + expect(feature.geometry.type).toBe('LineString'); + expect(feature.geometry.coordinates).toStrictEqual(expectedFeature.geometry.coordinates); + }); + + }); + + test('returns empty feature collection when track data has no features', () => { + const emptyTracks = { ...track }; + emptyTracks.features = []; + + const emptyFeatureCollection = buildTrackSegments(emptyTracks, 'America/Monterrey'); + + expect(emptyFeatureCollection.features.length).toBe(0); + expect(emptyFeatureCollection.type).toBe('FeatureCollection'); + }); + + test('returns empty feature collection when track data has no valid features', () => { + const invalidFeaturesTrack = { ...track }; + invalidFeaturesTrack.features = [{}]; + + const emptyFeatureCollection = buildTrackSegments(invalidFeaturesTrack, 'America/Monterrey'); + + expect(emptyFeatureCollection.features.length).toBe(0); + expect(emptyFeatureCollection.type).toBe('FeatureCollection'); + }); + + test('returns empty feature collection when track feature has no enough data', () => { + const feature = { ...track.features[0] }; + feature.geometry.coordinates = []; + + const notEnoughFeaturesTracks = { ...track }; + notEnoughFeaturesTracks.features = [feature]; + + const emptyFeatureCollection = buildTrackSegments(notEnoughFeaturesTracks, 'America/Monterrey'); + + expect(emptyFeatureCollection.features.length).toBe(0); + expect(emptyFeatureCollection.type).toBe('FeatureCollection'); + }); + + }); + + + + + }); \ No newline at end of file