From 76968b1434a3171eb59406af110058e6d617f7c5 Mon Sep 17 00:00:00 2001 From: JoshuaVulcan <38018017+JoshuaVulcan@users.noreply.github.com> Date: Wed, 26 Feb 2025 14:16:07 -0800 Subject: [PATCH 01/15] very sloppy proof-of-concept code for track time-of-day styles --- .env.development | 2 +- docker-compose.yml | 22 +-- src/TracksLayer/track.js | 126 +++++++++++++++- src/hooks/index.js | 262 +++++++++++++++++++++++++++++++++- src/selectors/tracks/index.js | 33 ++--- src/utils/tracks.js | 139 ++++++++++++++++-- 6 files changed, 529 insertions(+), 55 deletions(-) diff --git a/.env.development b/.env.development index e1dba61d8..c2911b1d9 100644 --- a/.env.development +++ b/.env.development @@ -1,4 +1,4 @@ -REACT_APP_DAS_HOST=https://root.dev.pamdas.org +REACT_APP_DAS_HOST=https://easterisland.pamdas.org REACT_APP_GA_TRACKING_ID=UA-128569083-12 REACT_APP_GA4_TRACKING_ID=G-1MVMZ0CMWF REACT_APP_MOCK_EVENTS_API=false diff --git a/docker-compose.yml b/docker-compose.yml index 89dd78a07..c134a7ca9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,17 +14,17 @@ services: # volumes: # - /app/node_modules # - ./mock-api:/app - web_test: - build: - context: . - dockerfile: Dockerfile.dev - volumes: - - /app/node_modules - - .:/app - # environment: - # - CI=true - command: yarn test - restart: on-failure + # web_test: + # build: + # context: . + # dockerfile: Dockerfile.dev + # volumes: + # - /app/node_modules + # - .:/app + # # environment: + # # - CI=true + # command: yarn test + # restart: on-failure nginx: build: context: ./nginx diff --git a/src/TracksLayer/track.js b/src/TracksLayer/track.js index 6dd133d4b..6b15f8644 100644 --- a/src/TracksLayer/track.js +++ b/src/TracksLayer/track.js @@ -1,9 +1,15 @@ -import { memo, useContext } from 'react'; +import { memo, useContext, useMemo } from 'react'; import PropTypes from 'prop-types'; import { LAYER_IDS, MAP_ICON_SCALE } from '../constants'; import { MapContext } from '../App'; -import { useMapEventBinding, useMapLayer, useMapSource } from '../hooks'; +import { + useMapEventBinding, + useMapLayer, + useMapSource, + useMapSourceBatch, + useMapLayerBatch +} from '../hooks'; const { TRACKS_LINES, SUBJECT_SYMBOLS } = LAYER_IDS; @@ -24,7 +30,7 @@ const TRACK_LAYER_LINE_LAYOUT = { const TIMEPOINT_LAYER_LAYOUT = { 'icon-allow-overlap': ['step', ['zoom'], false, 15, true], 'icon-anchor': 'bottom', - 'icon-size': ['step', ['zoom'], 0, 11, 0.3/MAP_ICON_SCALE, 15, 0.5/MAP_ICON_SCALE], + 'icon-size': ['step', ['zoom'], 0, 11, 0.3 / MAP_ICON_SCALE, 15, 0.5 / MAP_ICON_SCALE], 'icon-rotate': ['get', 'bearing'], 'icon-image': 'track_arrow', 'icon-pitch-alignment': 'map', @@ -40,9 +46,19 @@ const TIMEPOINT_LAYER_PAINT = { }; const TrackLayer = ({ before, id, lineLayout, linePaint, onPointClick, showTimepoints, trackData }) => { + console.log('TrackLayer render start', { + trackDataExists: !!trackData, + trackExists: !!trackData?.track, + trackFeaturesCount: trackData?.track?.features?.length, + timeOfDaySegmentsExists: !!trackData?.time_of_day_segments, + timeOfDaySegmentsFeaturesCount: trackData?.time_of_day_segments?.features?.length + }); + const map = useContext(MapContext); - const trackId = id || trackData.track.features[0].properties.id; + const trackId = id || (trackData.track?.features?.[0]?.properties?.id || 'unknown-track'); + + console.log('TrackId:', trackId); const onSymbolMouseEnter = () => map.getCanvas().style.cursor = 'pointer'; const onSymbolMouseLeave = () => map.getCanvas().style.cursor = ''; @@ -53,17 +69,113 @@ 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' }); + // Always add the main sources + useMapSource(sourceId, trackData.time_of_day_segments || trackData.track, { tolerance: 1.5, type: 'geojson', lineMetrics: true }); useMapSource(pointSourceId, trackData.points); + let trackLayerPaintStyles = { ...TRACK_LAYER_LINE_PAINT, ...linePaint }; + + // Prepare color pair data + const { sourcesConfigs, layersConfigs, hasTimeOfDaySegments } = useMemo(() => { + if (!trackData?.time_of_day_segments?.features?.length) { + return { sourcesConfigs: [], layersConfigs: [], hasTimeOfDaySegments: false }; + } + + // Group segments by color combinations + const segmentsByColorPair = {}; + let segmentsWithMissingColors = 0; + + // Group segments with the same start/end color + trackData.time_of_day_segments.features.forEach((segment, idx) => { + // Skip segments without required color properties + if (!segment.properties?.startColor || !segment.properties?.endColor) { + segmentsWithMissingColors++; + if (idx < 5) { + console.warn(`Segment ${idx} missing color properties:`, segment.properties); + } + return; + } + + const key = `${segment.properties.startColor}|${segment.properties.endColor}`; + if (!segmentsByColorPair[key]) { + segmentsByColorPair[key] = []; + } + segmentsByColorPair[key].push(segment); + }); + + console.log('Segments with missing colors:', segmentsWithMissingColors); + console.log('Number of unique color pairs:', Object.keys(segmentsByColorPair).length); + + const sources = []; + const layers = []; + + Object.entries(segmentsByColorPair).forEach(([colorPairKey, segments], index) => { + const [startColor, endColor] = colorPairKey.split('|'); + const pairSourceId = `${sourceId}-colorpair-${index}`; + const pairLayerId = `${layerId}-colorpair-${index}`; + + console.log(`Color pair ${index}: ${startColor} -> ${endColor} (${segments.length} segments)`); + + // 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': trackLayerPaintStyles['line-width'], + 'line-width': 2, + 'line-gradient': [ + 'interpolate', + ['linear'], + ['line-progress'], + 0, startColor, + 1, endColor + ] + }, + layout: { ...TRACK_LAYER_LINE_LAYOUT, ...lineLayout }, + options: { + before: before || SUBJECT_SYMBOLS + } + }); + }); + + console.log('Sources created:', sources.length, 'Layers created:', layers.length); + + return { + sourcesConfigs: sources, + layersConfigs: layers, + hasTimeOfDaySegments: sources.length > 0 + }; + }, [trackData, sourceId, layerId, trackLayerPaintStyles, lineLayout, before]); + + useMapSourceBatch(sourcesConfigs, { tolerance: 1.5, type: 'geojson', lineMetrics: true }); + useMapLayerBatch(layersConfigs, { before: before || SUBJECT_SYMBOLS }); + + // Only create the normal layer if there are no time_of_day_segments useMapLayer( layerId, 'line', sourceId, - { ...TRACK_LAYER_LINE_PAINT, ...linePaint }, + trackLayerPaintStyles, { ...TRACK_LAYER_LINE_LAYOUT, ...lineLayout }, - { before: before || SUBJECT_SYMBOLS } + { before: before || SUBJECT_SYMBOLS, condition: !hasTimeOfDaySegments } ); + + // The timepoint layer is always created useMapLayer( pointLayerId, 'symbol', diff --git a/src/hooks/index.js b/src/hooks/index.js index a9c59b3a0..b2964da94 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -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)); }; @@ -209,3 +209,261 @@ export const useMemoCompare = (next, compare = isEqual) => { return isEqual ? previous : next; }; + +/** + * Hook to create multiple map sources at once + * @param {Array} sourcesConfigs - Array of source configurations + * @param {Object} defaultConfig - Default configuration for all sources + */ +export const useMapSourceBatch = (sourcesConfigs = [], defaultConfig = { type: 'geojson' }) => { + const map = useContext(MapContext); + + // Track all sourceIds for cleanup + const sourceIdsRef = useRef([]); + + useEffect(() => { + // Initialize sources that don't exist yet + if (map) { + sourcesConfigs.forEach(config => { + if (!config || !config.id || !config.data) return; + + const { id, data, options = {} } = config; + const sourceConfig = { ...defaultConfig, ...options }; + + if (!map.getSource(id)) { + map.addSource(id, { + ...sourceConfig, + data, + }); + sourceIdsRef.current.push(id); + } + }); + } + }, [map, sourcesConfigs, defaultConfig]); + + // Update data for existing sources + useEffect(() => { + let timeouts = []; + + sourcesConfigs.forEach(config => { + if (!config || !config.id || !config.data) return; + + const { id, data, options = {} } = config; + const enabled = options.hasOwnProperty('enabled') ? options.enabled : true; + + if (!enabled) return; + + const timeout = window.setTimeout(() => { + const source = map?.getSource?.(id); + if (source) { + source?.setData?.(data); + } + }); + + timeouts.push(timeout); + }); + + return () => { + timeouts.forEach(timeout => { + window.clearTimeout(timeout); + }); + }; + }, [map, sourcesConfigs]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (map) { + setTimeout(() => { + sourceIdsRef.current.forEach(id => { + if (map?.getSource(id)) { + map.removeSource(id); + } + }); + }); + } + }; + }, [map]); +}; + +/** + * Hook to create multiple map layers at once + * @param {Array} layersConfigs - Array of layer configurations + * @param {Object} defaultConfig - Default configuration for all layers + */ +export const useMapLayerBatch = (layersConfigs = [], defaultConfig = {}) => { + const map = useContext(MapContext); + + // Track all layerIds for cleanup + const layerIdsRef = useRef([]); + + // Add layers that don't exist yet + useEffect(() => { + if (!map) return; + + layersConfigs.forEach(config => { + try { + if (!config || !config.id || !config.type || !config.sourceId) return; + + const { id, type, sourceId, paint = {}, layout = {}, options = {} } = config; + const condition = options.hasOwnProperty('condition') ? options.condition : true; + const before = options.before || defaultConfig.before; + + // Check if source exists before adding layer + if (!map.getSource(sourceId)) { + console.warn(`Source ${sourceId} not found when creating layer ${id}`); + return; + } + + if (condition && !map.getLayer(id)) { + // Build layer object with validated properties + const layerObj = { + id, + source: sourceId, + type, + layout: layout || {}, + paint: paint || {} + }; + + // Only add filter if it's defined and is an array + const filterValue = options.filter || defaultConfig.filter; + if (Array.isArray(filterValue)) { + layerObj.filter = filterValue; + } + + // Handle line-gradient and line-color conflict + if (type === 'line' && paint?.['line-gradient'] && paint?.['line-color']) { + console.warn(`Layer ${id}: line-gradient and line-color cannot both be specified`); + delete layerObj.paint['line-color']; + } + + map.addLayer(layerObj, before); + layerIdsRef.current.push(id); + } + } catch (error) { + console.error('Error adding layer for config:', config, error); + } + }); + }, [map, layersConfigs, defaultConfig]); + + // Update layout properties for existing layers + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (!config || !config.id || !config.layout) return; + + const { id, layout, options = {} } = config; + const condition = options.hasOwnProperty('condition') ? options.condition : true; + + if (condition && map.getLayer(id) && layout) { + Object.entries(layout).forEach(([key, value]) => { + map.setLayoutProperty(id, key, value); + }); + } + }); + } + }, [map, layersConfigs]); + + // Update paint properties for existing layers + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (!config || !config.id || !config.paint) return; + + const { id, paint, options = {} } = config; + const condition = options.hasOwnProperty('condition') ? options.condition : true; + + if (condition && map.getLayer(id) && paint) { + Object.entries(paint).forEach(([key, value]) => { + map.setPaintProperty(id, key, value); + }); + } + }); + } + }, [map, layersConfigs]); + + // Update filters for existing layers + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (!config || !config.id) return; + + const { id, options = {} } = config; + const condition = options.hasOwnProperty('condition') ? options.condition : true; + const filter = options.filter || defaultConfig.filter; + + // Only set filter if it's valid (must be an array) + if (condition && Array.isArray(filter) && map.getLayer(id)) { + map.setFilter(id, filter); + } + }); + } + }, [map, layersConfigs, defaultConfig]); + + // Remove layers when condition becomes false + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (!config || !config.id) return; + + const { id, options = {} } = config; + const condition = options.hasOwnProperty('condition') ? options.condition : true; + + if (!condition && map.getLayer(id)) { + map.removeLayer(id); + } + }); + } + }, [map, layersConfigs]); + + // Update layer order based on before + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (!config || !config.id) return; + + const { id, options = {} } = config; + const before = options.before || defaultConfig.before; + + if (before && map.getLayer(id)) { + map.moveLayer(id, before); + } + }); + } + }, [map, layersConfigs, defaultConfig]); + + // Update zoom ranges + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (!config || !config.id) return; + + const { id, options = {} } = config; + const condition = options.hasOwnProperty('condition') ? options.condition : true; + const minzoom = options.minZoom || defaultConfig.minZoom || MIN_ZOOM; + const maxzoom = options.maxZoom || defaultConfig.maxZoom || MAX_ZOOM; + + if (condition && map.getLayer(id)) { + map.setLayerZoomRange(id, minzoom, maxzoom); + } + }); + } + }, [map, layersConfigs, defaultConfig]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (map) { + try { + layerIdsRef.current.forEach(id => { + if (map.getLayer(id)) { + map.removeLayer(id); + } + }); + } catch (error) { + // Silent error handling as in the original hook + } + } + }; + }, [map]); +}; diff --git a/src/selectors/tracks/index.js b/src/selectors/tracks/index.js index 7fa6151e0..4e2726557 100644 --- a/src/selectors/tracks/index.js +++ b/src/selectors/tracks/index.js @@ -4,7 +4,7 @@ import uniq from 'lodash/uniq'; import { TRACK_LENGTH_ORIGINS } from '../../ducks/tracks'; -import { getTimeOfDayPeriodBasedOnTime, trimTrackDataToTimeRange } from '../../utils/tracks'; +import { getTimeOfDayPeriodBasedOnTime, trimTrackDataToTimeRange, sliceLineStringByTimeOfDay } from '../../utils/tracks'; const selectEventFilter = (state) => state.data.eventFilter; const selectHeatmapSubjectIDs = (state) => state.view.heatmapSubjectIDs; @@ -97,34 +97,21 @@ export const selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod = [selectSubjectTracks, selectTrackTimeEnvelope, selectTrackSettings], (subjectTracks, trackTimeEnvelope, { timeOfDayTimeZone, isTimeOfDayColoringActive }) => subjectTracks.map( (subjectTrack) => { - const { - points: { - features, - ...otherPointsProps - }, - ...otherData - } = trimTrackDataToTimeRange( // Trim each subject tracks to the track time envelope. + const trimmed = trimTrackDataToTimeRange( // Trim each subject tracks to the track time envelope. subjectTrack, trackTimeEnvelope.from, trackTimeEnvelope.until ); + if (!isTimeOfDayColoringActive) { + return trimmed; + } + + const time_of_day_segments = sliceLineStringByTimeOfDay(trimmed.track, timeOfDayTimeZone); + return { - ...otherData, - points: { - ...otherPointsProps, - features: features.map(({ properties, ...otherFeaturesProps }) => { - return { - ...otherFeaturesProps, - properties: { - ...properties, - timeOfDayPeriod: isTimeOfDayColoringActive - ? getTimeOfDayPeriodBasedOnTime(properties.time, timeOfDayTimeZone) - : undefined - } - }; - }) - } + ...trimmed, + time_of_day_segments, }; } ) diff --git a/src/utils/tracks.js b/src/utils/tracks.js index 820a507ee..bb534be46 100644 --- a/src/utils/tracks.js +++ b/src/utils/tracks.js @@ -107,12 +107,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 +126,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 +181,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 +244,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 +258,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 +274,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 +361,126 @@ 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 trackTotalMinutesInTZ = ((parseInt(hour) * 60) + parseInt(min)) || 1440; + + // Find the period that matches this time + for (let i = 0; i < TIME_OF_DAY_PERIODS.length; i++) { + const period = TIME_OF_DAY_PERIODS[i]; + if (trackTotalMinutesInTZ >= period.rangeMinutesMin && + trackTotalMinutesInTZ <= period.rangeMinutesMax) { + return { + period, + index: i + }; + } + } + + // Default to the first period if no match found + return { + period: TIME_OF_DAY_PERIODS[0], + index: 0 + }; +}; + +// Map period indices to hex colors +export const TIME_PERIOD_COLORS = [ + '#f3e34b', // titaniumYellow (12:01 - 15:00) + '#ffbd00', // americanYellow (15:01 - 18:00) + '#de5285', // fandangoPink (18:01 - 21:00) + '#8d4e85', // purplePlum (21:01 - 00:00) + '#5b5ee9', // majorelleBlue (00:01 - 03:00) + '#1a5fb4', // lapisLazuli (03:01 - 06:00) + '#29a272', // spanishGreen (06:01 - 09:00) + '#2ec27e', // green (09:01 - 12:00) +]; + +/** + * Get color from time and timezone + * @param {string} timeString - ISO timestamp string + * @param {string} timeZone - IANA timezone string + * @returns {string} - CSS color class key + */ +export const getColorForTime = (timeString, timeZone) => { + const [hour, min] = getTimeInTimezone(new Date(timeString), timeZone).split(':'); + const totalMinutesInDay = ((parseInt(hour) * 60) + parseInt(min)) || 1440; + + // Find matching period index directly + let periodIndex = 0; + for (let i = 0; i < TIME_OF_DAY_PERIODS.length; i++) { + const period = TIME_OF_DAY_PERIODS[i]; + if (totalMinutesInDay >= period.rangeMinutesMin && + totalMinutesInDay <= period.rangeMinutesMax) { + periodIndex = i; + break; + } + } + + return TIME_PERIOD_COLORS[periodIndex] || TIME_PERIOD_COLORS[0]; +}; - return TIME_OF_DAY_PERIODS.findIndex((timeOfDayPeriod) => - trackTotalMinutesInTZ >= timeOfDayPeriod.rangeMinutesMin && trackTotalMinutesInTZ <= timeOfDayPeriod.rangeMinutesMax - ); +/** + * Slice a GeoJSON LineString into segments colored by time of day + * @param {Object} trackFeatureCollection - GeoJSON FeatureCollection containing a single LineString feature + * @param {string} timeZone - IANA timezone string (e.g. "America/New_York") + * @returns {Object} - FeatureCollection of line segments with color properties + */ +export const sliceLineStringByTimeOfDay = (trackFeatureCollection, timeZone) => { + // Validate input + if (!trackFeatureCollection || !trackFeatureCollection.features || !trackFeatureCollection.features.length) { + console.log('sliceLineStringByTimeOfDay: No features provided in track'); + return { type: 'FeatureCollection', features: [] }; + } + + const lineStringFeature = trackFeatureCollection.features[0]; + + if (!lineStringFeature || + !lineStringFeature.geometry || + lineStringFeature.geometry.type !== 'LineString' || + !lineStringFeature.properties?.coordinateProperties?.times) { + console.log('sliceLineStringByTimeOfDay: First feature is not a valid LineString with times'); + return { type: 'FeatureCollection', features: [] }; + } + + const coordinates = lineStringFeature.geometry.coordinates; + const times = lineStringFeature.properties.coordinateProperties.times; + + if (coordinates.length < 2 || coordinates.length !== times.length) { + console.log('sliceLineStringByTimeOfDay: Not enough coordinates or mismatch with times array'); + return { type: 'FeatureCollection', features: [] }; + } + + const segments = []; + + // Create a line segment for each pair of consecutive points + for (let i = 0; i < coordinates.length - 1; i++) { + try { + const startTime = times[i]; + const endTime = times[i + 1]; + + // Get colors for the time periods + const startColor = getColorForTime(startTime, timeZone); + const endColor = getColorForTime(endTime, timeZone); + + segments.push({ + type: 'Feature', + properties: { + startColor, + endColor, + startTime, + endTime + }, + geometry: { + type: 'LineString', + coordinates: [coordinates[i], coordinates[i + 1]] + } + }); + } catch (error) { + console.error(`Error creating segment ${i}:`, error); + } + } + + return { + type: 'FeatureCollection', + features: segments + }; }; From f3349b8be9cd78c2b3876ea0cfc28afbbd52b81a Mon Sep 17 00:00:00 2001 From: JoshuaVulcan <38018017+JoshuaVulcan@users.noreply.github.com> Date: Wed, 26 Feb 2025 14:18:20 -0800 Subject: [PATCH 02/15] tweak:removing a few extraneous console.logs --- src/TracksLayer/track.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/TracksLayer/track.js b/src/TracksLayer/track.js index 6b15f8644..a8a407b43 100644 --- a/src/TracksLayer/track.js +++ b/src/TracksLayer/track.js @@ -46,20 +46,10 @@ const TIMEPOINT_LAYER_PAINT = { }; const TrackLayer = ({ before, id, lineLayout, linePaint, onPointClick, showTimepoints, trackData }) => { - console.log('TrackLayer render start', { - trackDataExists: !!trackData, - trackExists: !!trackData?.track, - trackFeaturesCount: trackData?.track?.features?.length, - timeOfDaySegmentsExists: !!trackData?.time_of_day_segments, - timeOfDaySegmentsFeaturesCount: trackData?.time_of_day_segments?.features?.length - }); - const map = useContext(MapContext); const trackId = id || (trackData.track?.features?.[0]?.properties?.id || 'unknown-track'); - console.log('TrackId:', trackId); - const onSymbolMouseEnter = () => map.getCanvas().style.cursor = 'pointer'; const onSymbolMouseLeave = () => map.getCanvas().style.cursor = ''; @@ -114,8 +104,6 @@ const TrackLayer = ({ before, id, lineLayout, linePaint, onPointClick, showTimep const pairSourceId = `${sourceId}-colorpair-${index}`; const pairLayerId = `${layerId}-colorpair-${index}`; - console.log(`Color pair ${index}: ${startColor} -> ${endColor} (${segments.length} segments)`); - // Add source config sources.push({ id: pairSourceId, From 3270896f8df4a66798200a7d165232569530addb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20L=C3=B3pez?= Date: Thu, 27 Feb 2025 16:18:01 -0600 Subject: [PATCH 03/15] WIP: code refactor, adding test coverage, connect all features to redux coloring flag --- src/TracksLayer/track.js | 121 +++---------- src/TracksLayer/utils/index.js | 81 +++++++++ src/constants/index.js | 8 + src/hooks/index.js | 258 --------------------------- src/hooks/useMapLayerBatch/index.js | 174 ++++++++++++++++++ src/hooks/useMapSourceBatch/index.js | 74 ++++++++ src/selectors/tracks/index.js | 25 +-- src/utils/tracks.js | 147 ++++++--------- src/utils/tracks.test.js | 179 +++++++++++++++++-- 9 files changed, 590 insertions(+), 477 deletions(-) create mode 100644 src/TracksLayer/utils/index.js create mode 100644 src/hooks/useMapLayerBatch/index.js create mode 100644 src/hooks/useMapSourceBatch/index.js diff --git a/src/TracksLayer/track.js b/src/TracksLayer/track.js index a8a407b43..e051f3727 100644 --- a/src/TracksLayer/track.js +++ b/src/TracksLayer/track.js @@ -6,10 +6,13 @@ import { MapContext } from '../App'; import { useMapEventBinding, useMapLayer, - useMapSource, - useMapSourceBatch, - useMapLayerBatch + useMapSource } from '../hooks'; +import { generateMapSourcesAndLayersBasedOnTwoLineTrackPointsSegments } from './utils'; +import { useSelector } from 'react-redux'; +import { selectTrackSettings } from '../selectors/tracks'; +import useMapSourceBatch from '../hooks/useMapSourceBatch'; +import useMapLayerBatch from '../hooks/useMapLayerBatch'; const { TRACKS_LINES, SUBJECT_SYMBOLS } = LAYER_IDS; @@ -47,6 +50,7 @@ const TIMEPOINT_LAYER_PAINT = { const TrackLayer = ({ before, id, lineLayout, linePaint, onPointClick, showTimepoints, trackData }) => { const map = useContext(MapContext); + const { isTimeOfDayColoringActive } = useSelector(selectTrackSettings); const trackId = id || (trackData.track?.features?.[0]?.properties?.id || 'unknown-track'); @@ -59,96 +63,24 @@ const TrackLayer = ({ before, id, lineLayout, linePaint, onPointClick, showTimep const layerId = `${TRACKS_LINES}-${trackId}`; const pointLayerId = `${TRACKS_LINES}-points-${trackId}`; - // Always add the main sources - useMapSource(sourceId, trackData.time_of_day_segments || trackData.track, { tolerance: 1.5, type: 'geojson', lineMetrics: true }); - useMapSource(pointSourceId, trackData.points); - - let trackLayerPaintStyles = { ...TRACK_LAYER_LINE_PAINT, ...linePaint }; - - // Prepare color pair data - const { sourcesConfigs, layersConfigs, hasTimeOfDaySegments } = useMemo(() => { - if (!trackData?.time_of_day_segments?.features?.length) { - return { sourcesConfigs: [], layersConfigs: [], hasTimeOfDaySegments: false }; + const { + sourcesConfigs, + layersConfigs, + hasTimeOfDaySegments + } = useMemo(() => generateMapSourcesAndLayersBasedOnTwoLineTrackPointsSegments( + trackData, + isTimeOfDayColoringActive, + sourceId, + layerId, + { ...TRACK_LAYER_LINE_LAYOUT, ...lineLayout }, + { + before: before || SUBJECT_SYMBOLS } + ), [trackData, sourceId, layerId, lineLayout, before, isTimeOfDayColoringActive]); - // Group segments by color combinations - const segmentsByColorPair = {}; - let segmentsWithMissingColors = 0; - - // Group segments with the same start/end color - trackData.time_of_day_segments.features.forEach((segment, idx) => { - // Skip segments without required color properties - if (!segment.properties?.startColor || !segment.properties?.endColor) { - segmentsWithMissingColors++; - if (idx < 5) { - console.warn(`Segment ${idx} missing color properties:`, segment.properties); - } - return; - } - - const key = `${segment.properties.startColor}|${segment.properties.endColor}`; - if (!segmentsByColorPair[key]) { - segmentsByColorPair[key] = []; - } - segmentsByColorPair[key].push(segment); - }); - - console.log('Segments with missing colors:', segmentsWithMissingColors); - console.log('Number of unique color pairs:', Object.keys(segmentsByColorPair).length); - - const sources = []; - const layers = []; - - Object.entries(segmentsByColorPair).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': trackLayerPaintStyles['line-width'], - 'line-width': 2, - 'line-gradient': [ - 'interpolate', - ['linear'], - ['line-progress'], - 0, startColor, - 1, endColor - ] - }, - layout: { ...TRACK_LAYER_LINE_LAYOUT, ...lineLayout }, - options: { - before: before || SUBJECT_SYMBOLS - } - }); - }); - - console.log('Sources created:', sources.length, 'Layers created:', layers.length); - - return { - sourcesConfigs: sources, - layersConfigs: layers, - hasTimeOfDaySegments: sources.length > 0 - }; - }, [trackData, sourceId, layerId, trackLayerPaintStyles, lineLayout, before]); + + useMapSource(sourceId, trackData.twoPointLineStringTrackPoints || trackData.track, { tolerance: 1.5, type: 'geojson', lineMetrics: true }); + useMapSource(pointSourceId, trackData.points); useMapSourceBatch(sourcesConfigs, { tolerance: 1.5, type: 'geojson', lineMetrics: true }); useMapLayerBatch(layersConfigs, { before: before || SUBJECT_SYMBOLS }); @@ -158,12 +90,13 @@ const TrackLayer = ({ before, id, lineLayout, linePaint, onPointClick, showTimep layerId, 'line', sourceId, - trackLayerPaintStyles, + { ...TRACK_LAYER_LINE_PAINT, ...linePaint }, { ...TRACK_LAYER_LINE_LAYOUT, ...lineLayout }, - { before: before || SUBJECT_SYMBOLS, condition: !hasTimeOfDaySegments } + { + before: before || SUBJECT_SYMBOLS, + condition: !isTimeOfDayColoringActive && !hasTimeOfDaySegments } ); - // The timepoint layer is always created useMapLayer( pointLayerId, 'symbol', diff --git a/src/TracksLayer/utils/index.js b/src/TracksLayer/utils/index.js new file mode 100644 index 000000000..937220594 --- /dev/null +++ b/src/TracksLayer/utils/index.js @@ -0,0 +1,81 @@ + +export const segmentTrackPointsByTimeOfDayPeriodPairs = (twoPointLineStringTrackPoints) => { + const segmentsByColorPair = {}; + let segmentsWithMissingColors = 0; + + twoPointLineStringTrackPoints.features.forEach((segment) => { + // Skip segments without required color properties + if (!segment.properties?.startColor || !segment.properties?.endColor) { + segmentsWithMissingColors++; + return; + } + + const key = `${segment.properties.startColor}|${segment.properties.endColor}`; + if (!segmentsByColorPair[key]) { + segmentsByColorPair[key] = []; + } + segmentsByColorPair[key].push(segment); + }); + + if (segmentsWithMissingColors > 0){ + console.warn('Segments with missing colors:', segmentsWithMissingColors); + } + + return segmentsByColorPair; +}; + +export const generateMapSourcesAndLayersBasedOnTwoLineTrackPointsSegments = (trackData, isTimeOfDayColoringActive, sourceId, layerId, layerLayout, layerOptions) => { + + if (!trackData?.twoPointLineStringTrackPoints?.features?.length || !isTimeOfDayColoringActive) { + return { sourcesConfigs: [], layersConfigs: [], hasTimeOfDaySegments: false }; + } + + const sources = []; + const layers = []; + const trackPointsSegmentsByColorPair = segmentTrackPointsByTimeOfDayPeriodPairs(trackData.twoPointLineStringTrackPoints); + + 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, + hasTimeOfDaySegments: sources.length > 0 + }; +}; 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 b2964da94..dff27100f 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -209,261 +209,3 @@ export const useMemoCompare = (next, compare = isEqual) => { return isEqual ? previous : next; }; - -/** - * Hook to create multiple map sources at once - * @param {Array} sourcesConfigs - Array of source configurations - * @param {Object} defaultConfig - Default configuration for all sources - */ -export const useMapSourceBatch = (sourcesConfigs = [], defaultConfig = { type: 'geojson' }) => { - const map = useContext(MapContext); - - // Track all sourceIds for cleanup - const sourceIdsRef = useRef([]); - - useEffect(() => { - // Initialize sources that don't exist yet - if (map) { - sourcesConfigs.forEach(config => { - if (!config || !config.id || !config.data) return; - - const { id, data, options = {} } = config; - const sourceConfig = { ...defaultConfig, ...options }; - - if (!map.getSource(id)) { - map.addSource(id, { - ...sourceConfig, - data, - }); - sourceIdsRef.current.push(id); - } - }); - } - }, [map, sourcesConfigs, defaultConfig]); - - // Update data for existing sources - useEffect(() => { - let timeouts = []; - - sourcesConfigs.forEach(config => { - if (!config || !config.id || !config.data) return; - - const { id, data, options = {} } = config; - const enabled = options.hasOwnProperty('enabled') ? options.enabled : true; - - if (!enabled) return; - - const timeout = window.setTimeout(() => { - const source = map?.getSource?.(id); - if (source) { - source?.setData?.(data); - } - }); - - timeouts.push(timeout); - }); - - return () => { - timeouts.forEach(timeout => { - window.clearTimeout(timeout); - }); - }; - }, [map, sourcesConfigs]); - - // Cleanup on unmount - useEffect(() => { - return () => { - if (map) { - setTimeout(() => { - sourceIdsRef.current.forEach(id => { - if (map?.getSource(id)) { - map.removeSource(id); - } - }); - }); - } - }; - }, [map]); -}; - -/** - * Hook to create multiple map layers at once - * @param {Array} layersConfigs - Array of layer configurations - * @param {Object} defaultConfig - Default configuration for all layers - */ -export const useMapLayerBatch = (layersConfigs = [], defaultConfig = {}) => { - const map = useContext(MapContext); - - // Track all layerIds for cleanup - const layerIdsRef = useRef([]); - - // Add layers that don't exist yet - useEffect(() => { - if (!map) return; - - layersConfigs.forEach(config => { - try { - if (!config || !config.id || !config.type || !config.sourceId) return; - - const { id, type, sourceId, paint = {}, layout = {}, options = {} } = config; - const condition = options.hasOwnProperty('condition') ? options.condition : true; - const before = options.before || defaultConfig.before; - - // Check if source exists before adding layer - if (!map.getSource(sourceId)) { - console.warn(`Source ${sourceId} not found when creating layer ${id}`); - return; - } - - if (condition && !map.getLayer(id)) { - // Build layer object with validated properties - const layerObj = { - id, - source: sourceId, - type, - layout: layout || {}, - paint: paint || {} - }; - - // Only add filter if it's defined and is an array - const filterValue = options.filter || defaultConfig.filter; - if (Array.isArray(filterValue)) { - layerObj.filter = filterValue; - } - - // Handle line-gradient and line-color conflict - if (type === 'line' && paint?.['line-gradient'] && paint?.['line-color']) { - console.warn(`Layer ${id}: line-gradient and line-color cannot both be specified`); - delete layerObj.paint['line-color']; - } - - map.addLayer(layerObj, before); - layerIdsRef.current.push(id); - } - } catch (error) { - console.error('Error adding layer for config:', config, error); - } - }); - }, [map, layersConfigs, defaultConfig]); - - // Update layout properties for existing layers - useEffect(() => { - if (map) { - layersConfigs.forEach(config => { - if (!config || !config.id || !config.layout) return; - - const { id, layout, options = {} } = config; - const condition = options.hasOwnProperty('condition') ? options.condition : true; - - if (condition && map.getLayer(id) && layout) { - Object.entries(layout).forEach(([key, value]) => { - map.setLayoutProperty(id, key, value); - }); - } - }); - } - }, [map, layersConfigs]); - - // Update paint properties for existing layers - useEffect(() => { - if (map) { - layersConfigs.forEach(config => { - if (!config || !config.id || !config.paint) return; - - const { id, paint, options = {} } = config; - const condition = options.hasOwnProperty('condition') ? options.condition : true; - - if (condition && map.getLayer(id) && paint) { - Object.entries(paint).forEach(([key, value]) => { - map.setPaintProperty(id, key, value); - }); - } - }); - } - }, [map, layersConfigs]); - - // Update filters for existing layers - useEffect(() => { - if (map) { - layersConfigs.forEach(config => { - if (!config || !config.id) return; - - const { id, options = {} } = config; - const condition = options.hasOwnProperty('condition') ? options.condition : true; - const filter = options.filter || defaultConfig.filter; - - // Only set filter if it's valid (must be an array) - if (condition && Array.isArray(filter) && map.getLayer(id)) { - map.setFilter(id, filter); - } - }); - } - }, [map, layersConfigs, defaultConfig]); - - // Remove layers when condition becomes false - useEffect(() => { - if (map) { - layersConfigs.forEach(config => { - if (!config || !config.id) return; - - const { id, options = {} } = config; - const condition = options.hasOwnProperty('condition') ? options.condition : true; - - if (!condition && map.getLayer(id)) { - map.removeLayer(id); - } - }); - } - }, [map, layersConfigs]); - - // Update layer order based on before - useEffect(() => { - if (map) { - layersConfigs.forEach(config => { - if (!config || !config.id) return; - - const { id, options = {} } = config; - const before = options.before || defaultConfig.before; - - if (before && map.getLayer(id)) { - map.moveLayer(id, before); - } - }); - } - }, [map, layersConfigs, defaultConfig]); - - // Update zoom ranges - useEffect(() => { - if (map) { - layersConfigs.forEach(config => { - if (!config || !config.id) return; - - const { id, options = {} } = config; - const condition = options.hasOwnProperty('condition') ? options.condition : true; - const minzoom = options.minZoom || defaultConfig.minZoom || MIN_ZOOM; - const maxzoom = options.maxZoom || defaultConfig.maxZoom || MAX_ZOOM; - - if (condition && map.getLayer(id)) { - map.setLayerZoomRange(id, minzoom, maxzoom); - } - }); - } - }, [map, layersConfigs, defaultConfig]); - - // Cleanup on unmount - useEffect(() => { - return () => { - if (map) { - try { - layerIdsRef.current.forEach(id => { - if (map.getLayer(id)) { - map.removeLayer(id); - } - }); - } catch (error) { - // Silent error handling as in the original hook - } - } - }; - }, [map]); -}; diff --git a/src/hooks/useMapLayerBatch/index.js b/src/hooks/useMapLayerBatch/index.js new file mode 100644 index 000000000..8b2207351 --- /dev/null +++ b/src/hooks/useMapLayerBatch/index.js @@ -0,0 +1,174 @@ +import { useContext, useEffect, useRef } from 'react'; + +import { MapContext } from '../../App'; +import { MAX_ZOOM, MIN_ZOOM } from '../../constants'; + +const useMapLayerBatch = (layersConfigs = [], defaultConfig = {}) => { + const map = useContext(MapContext); + const layerIdsRef = useRef([]); + + // Add layers that don't exist yet + useEffect(() => { + if (!map) return; + + layersConfigs.forEach(config => { + try { + if (config?.id && config?.type && config?.sourceId){ + const { id, type, sourceId, paint = {}, layout = {}, options = {} } = config; + const condition = options.condition ?? true; + const before = options.before || defaultConfig.before; + + if (map.getSource(sourceId) && condition && !map.getLayer(id)){ + const layerObj = { + id, + source: sourceId, + type, + layout: layout || {}, + paint: paint || {} + }; + + // Only add filter if it's defined and is an array + const filterValue = options.filter || defaultConfig.filter; + if (Array.isArray(filterValue)) { + layerObj.filter = filterValue; + } + + // Handle line-gradient and line-color conflict + if (type === 'line' && paint?.['line-gradient'] && paint?.['line-color']) { + console.warn(`Layer ${id}: line-gradient and line-color cannot both be specified`); + delete layerObj.paint['line-color']; + } + + map.addLayer(layerObj, before); + layerIdsRef.current.push(id); + } + } + } catch (error) { + console.error('Error adding layer for config:', config, error); + } + }); + }, [map, layersConfigs, defaultConfig]); + + // Update layout properties for existing layers + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (!config || !config.id || !config.layout) return; + + const { id, layout, options = {} } = config; + const condition = options.hasOwnProperty('condition') ? options.condition : true; + + if (condition && map.getLayer(id) && layout) { + Object.entries(layout).forEach(([key, value]) => { + map.setLayoutProperty(id, key, value); + }); + } + }); + } + }, [map, layersConfigs]); + + // Update paint properties for existing layers + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (!config || !config.id || !config.paint) return; + + const { id, paint, options = {} } = config; + const condition = options.hasOwnProperty('condition') ? options.condition : true; + + if (condition && map.getLayer(id) && paint) { + Object.entries(paint).forEach(([key, value]) => { + map.setPaintProperty(id, key, value); + }); + } + }); + } + }, [map, layersConfigs]); + + // Update filters for existing layers + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (!config || !config.id) return; + + const { id, options = {} } = config; + const condition = options.hasOwnProperty('condition') ? options.condition : true; + const filter = options.filter || defaultConfig.filter; + + // Only set filter if it's valid (must be an array) + if (condition && Array.isArray(filter) && map.getLayer(id)) { + map.setFilter(id, filter); + } + }); + } + }, [map, layersConfigs, defaultConfig]); + + // Remove layers when condition becomes false + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (!config || !config.id) return; + + const { id, options = {} } = config; + const condition = options.hasOwnProperty('condition') ? options.condition : true; + + if (!condition && map.getLayer(id)) { + map.removeLayer(id); + } + }); + } + }, [map, layersConfigs]); + + // Update layer order based on before + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (!config || !config.id) return; + + const { id, options = {} } = config; + const before = options.before || defaultConfig.before; + + if (before && map.getLayer(id)) { + map.moveLayer(id, before); + } + }); + } + }, [map, layersConfigs, defaultConfig]); + + // Update zoom ranges + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (!config || !config.id) return; + + const { id, options = {} } = config; + const condition = options.hasOwnProperty('condition') ? options.condition : true; + const minzoom = options.minZoom || defaultConfig.minZoom || MIN_ZOOM; + const maxzoom = options.maxZoom || defaultConfig.maxZoom || MAX_ZOOM; + + if (condition && map.getLayer(id)) { + map.setLayerZoomRange(id, minzoom, maxzoom); + } + }); + } + }, [map, layersConfigs, defaultConfig]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (map) { + try { + layerIdsRef.current.forEach(id => { + if (map.getLayer(id)) { + map.removeLayer(id); + } + }); + } catch (error) { + // Silent error handling as in the original hook + } + } + }; + }, [map]); +}; + +export default useMapLayerBatch; diff --git a/src/hooks/useMapSourceBatch/index.js b/src/hooks/useMapSourceBatch/index.js new file mode 100644 index 000000000..da6d37329 --- /dev/null +++ b/src/hooks/useMapSourceBatch/index.js @@ -0,0 +1,74 @@ +import { useContext, useEffect, useRef } from 'react'; + +import { MapContext } from '../../App'; + +const useMapSourceBatch = (sourcesConfigs = [], defaultConfig = { type: 'geojson' }) => { + const map = useContext(MapContext); + const sourceIdsRef = useRef([]); + + useEffect(() => { + // Initialize sources that don't exist yet + if (map) { + sourcesConfigs.forEach(config => { + if (!!config?.id && !!config?.data){ + const { id, data, options = {} } = config; + const sourceConfig = { ...defaultConfig, ...options }; + + if (!map.getSource(id)) { + map.addSource(id, { + ...sourceConfig, + data, + }); + sourceIdsRef.current.push(id); + } + } + }); + } + }, [map, sourcesConfigs, defaultConfig]); + + // Update data for existing sources + useEffect(() => { + let timeouts = []; + + sourcesConfigs.forEach(config => { + if (!!config?.id && !!config?.data){ + const { id, data, options = {} } = config; + const enabled = options.hasOwnProperty('enabled') ? options.enabled : true; + + if (!enabled) return; + + const timeout = window.setTimeout(() => { + const source = map?.getSource?.(id); + if (source) { + source?.setData?.(data); + } + }); + + timeouts.push(timeout); + } + }); + + return () => { + timeouts.forEach(timeout => { + window.clearTimeout(timeout); + }); + }; + }, [map, sourcesConfigs]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (map) { + setTimeout(() => { + sourceIdsRef?.current.forEach(id => { + if (map?.getSource(id)) { + map.removeSource(id); + } + }); + }); + } + }; + }, [map]); +}; + +export default useMapSourceBatch; diff --git a/src/selectors/tracks/index.js b/src/selectors/tracks/index.js index 4e2726557..81125aa06 100644 --- a/src/selectors/tracks/index.js +++ b/src/selectors/tracks/index.js @@ -4,7 +4,10 @@ import uniq from 'lodash/uniq'; import { TRACK_LENGTH_ORIGINS } from '../../ducks/tracks'; -import { getTimeOfDayPeriodBasedOnTime, trimTrackDataToTimeRange, sliceLineStringByTimeOfDay } from '../../utils/tracks'; +import { + trimTrackDataToTimeRange, + buildFeatureCollectionOfTwoPointLineStringSegments +} from '../../utils/tracks'; const selectEventFilter = (state) => state.data.eventFilter; const selectHeatmapSubjectIDs = (state) => state.view.heatmapSubjectIDs; @@ -12,7 +15,7 @@ const selectPatrolStore = (state) => state.data.patrolStore; const selectPatrolTrackState = (state) => state.view.patrolTrackState; 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], @@ -97,22 +100,20 @@ export const selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod = [selectSubjectTracks, selectTrackTimeEnvelope, selectTrackSettings], (subjectTracks, trackTimeEnvelope, { timeOfDayTimeZone, isTimeOfDayColoringActive }) => subjectTracks.map( (subjectTrack) => { - const trimmed = trimTrackDataToTimeRange( // Trim each subject tracks to the track time envelope. + const trimmedTrackData = trimTrackDataToTimeRange( // Trim each subject tracks to the track time envelope. subjectTrack, trackTimeEnvelope.from, trackTimeEnvelope.until ); - if (!isTimeOfDayColoringActive) { - return trimmed; - } - - const time_of_day_segments = sliceLineStringByTimeOfDay(trimmed.track, timeOfDayTimeZone); + console.log(trimmedTrackData.track); - return { - ...trimmed, - time_of_day_segments, - }; + return isTimeOfDayColoringActive + ? { + ...trimmedTrackData, + twoPointLineStringTrackPoints: buildFeatureCollectionOfTwoPointLineStringSegments(trimmedTrackData.track, timeOfDayTimeZone) + } + : trimmedTrackData; } ) ); diff --git a/src/utils/tracks.js b/src/utils/tracks.js index bb534be46..bcf638c45 100644 --- a/src/utils/tracks.js +++ b/src/utils/tracks.js @@ -361,122 +361,79 @@ 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; - - // Find the period that matches this time - for (let i = 0; i < TIME_OF_DAY_PERIODS.length; i++) { - const period = TIME_OF_DAY_PERIODS[i]; - if (trackTotalMinutesInTZ >= period.rangeMinutesMin && - trackTotalMinutesInTZ <= period.rangeMinutesMax) { - return { - period, - index: i - }; - } - } + const trackTotalMinutesInTZ = ( (parseInt(hour) * 60) + parseInt(min) ) || 1440; - // Default to the first period if no match found - return { - period: TIME_OF_DAY_PERIODS[0], - index: 0 - }; -}; + const period = TIME_OF_DAY_PERIODS.find((timeOfDayPeriod) => + trackTotalMinutesInTZ >= timeOfDayPeriod.rangeMinutesMin && trackTotalMinutesInTZ <= timeOfDayPeriod.rangeMinutesMax + ); -// Map period indices to hex colors -export const TIME_PERIOD_COLORS = [ - '#f3e34b', // titaniumYellow (12:01 - 15:00) - '#ffbd00', // americanYellow (15:01 - 18:00) - '#de5285', // fandangoPink (18:01 - 21:00) - '#8d4e85', // purplePlum (21:01 - 00:00) - '#5b5ee9', // majorelleBlue (00:01 - 03:00) - '#1a5fb4', // lapisLazuli (03:01 - 06:00) - '#29a272', // spanishGreen (06:01 - 09:00) - '#2ec27e', // green (09:01 - 12:00) -]; - -/** - * Get color from time and timezone - * @param {string} timeString - ISO timestamp string - * @param {string} timeZone - IANA timezone string - * @returns {string} - CSS color class key - */ -export const getColorForTime = (timeString, timeZone) => { - const [hour, min] = getTimeInTimezone(new Date(timeString), timeZone).split(':'); - const totalMinutesInDay = ((parseInt(hour) * 60) + parseInt(min)) || 1440; - - // Find matching period index directly - let periodIndex = 0; - for (let i = 0; i < TIME_OF_DAY_PERIODS.length; i++) { - const period = TIME_OF_DAY_PERIODS[i]; - if (totalMinutesInDay >= period.rangeMinutesMin && - totalMinutesInDay <= period.rangeMinutesMax) { - periodIndex = i; - break; - } - } - - return TIME_PERIOD_COLORS[periodIndex] || TIME_PERIOD_COLORS[0]; + return period ?? TIME_OF_DAY_PERIODS[0]; }; -/** - * Slice a GeoJSON LineString into segments colored by time of day - * @param {Object} trackFeatureCollection - GeoJSON FeatureCollection containing a single LineString feature - * @param {string} timeZone - IANA timezone string (e.g. "America/New_York") - * @returns {Object} - FeatureCollection of line segments with color properties - */ -export const sliceLineStringByTimeOfDay = (trackFeatureCollection, timeZone) => { - // Validate input +export const buildFeatureCollectionOfTwoPointLineStringSegments = (trackFeatureCollection, timeZone) => { + const featureCollection = { + type: 'FeatureCollection', + features: [] + }; + if (!trackFeatureCollection || !trackFeatureCollection.features || !trackFeatureCollection.features.length) { - console.log('sliceLineStringByTimeOfDay: No features provided in track'); - return { type: 'FeatureCollection', features: [] }; + return featureCollection; } - const lineStringFeature = trackFeatureCollection.features[0]; + const [lineStringFeature] = trackFeatureCollection.features; - if (!lineStringFeature || - !lineStringFeature.geometry || - lineStringFeature.geometry.type !== 'LineString' || + // Checking if first feature is valid + if (!lineStringFeature?.geometry || + lineStringFeature.geometry?.type !== 'LineString' || !lineStringFeature.properties?.coordinateProperties?.times) { - console.log('sliceLineStringByTimeOfDay: First feature is not a valid LineString with times'); - return { type: 'FeatureCollection', features: [] }; + return featureCollection; } - const coordinates = lineStringFeature.geometry.coordinates; - const times = lineStringFeature.properties.coordinateProperties.times; + 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) { - console.log('sliceLineStringByTimeOfDay: Not enough coordinates or mismatch with times array'); - return { type: 'FeatureCollection', features: [] }; + return featureCollection; } const segments = []; // Create a line segment for each pair of consecutive points for (let i = 0; i < coordinates.length - 1; i++) { - try { - const startTime = times[i]; - const endTime = times[i + 1]; - - // Get colors for the time periods - const startColor = getColorForTime(startTime, timeZone); - const endColor = getColorForTime(endTime, timeZone); + const startTime = times[i]; + const endTime = times[i + 1]; + const startCoors = coordinates[i]; + const endCoors = coordinates[i + 1]; - segments.push({ - type: 'Feature', - properties: { - startColor, - endColor, - startTime, - endTime - }, - geometry: { - type: 'LineString', - coordinates: [coordinates[i], coordinates[i + 1]] - } - }); - } catch (error) { - console.error(`Error creating segment ${i}:`, error); + 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 { diff --git a/src/utils/tracks.test.js b/src/utils/tracks.test.js index 7df4cc0b2..f9b32fdfa 100644 --- a/src/utils/tracks.test.js +++ b/src/utils/tracks.test.js @@ -1,27 +1,170 @@ -import { getTimeOfDayPeriodBasedOnTime } from './tracks'; +import { buildFeatureCollectionOfTwoPointLineStringSegments, 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[0].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[0].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 twoPointFeatureCollection = buildFeatureCollectionOfTwoPointLineStringSegments(track, 'America/Monterrey'); + + expect(twoPointFeatureCollection.features.length).toBe(resultFeatures.length); + expect(twoPointFeatureCollection.type).toBe('FeatureCollection'); + + twoPointFeatureCollection.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); + }); + + }); + }); + + + + + }); \ No newline at end of file From 4ff472f6bc2a398b682f816ba9688e1037492559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20L=C3=B3pez?= Date: Mon, 3 Mar 2025 08:16:05 -0600 Subject: [PATCH 04/15] Adding batch support for useMap* hooks --- src/AnalyzersLayer/index.js | 66 +++--- src/BaseLayerRenderer/TileLayerRenderer.js | 18 +- src/ClustersLayer/index.js | 14 +- src/EventGeometryLayer/index.js | 15 +- src/EventsLayer/index.js | 4 +- src/FeatureLayer/index.js | 47 +++- src/HeatLayer/index.js | 15 +- src/LabeledSymbolLayer/index.js | 22 +- src/MapDrawingTools/MapLayers.js | 72 ++++-- src/MouseMarkerLayer/index.js | 12 +- src/PatrolStartStopLayer/layer.js | 13 +- src/StaticSensorsLayer/index.js | 31 +-- src/SubjectTrackLegend/index.test.js | 1 - src/SubjectsLayer/index.js | 11 +- src/TracksLayer/track.js | 41 ++-- src/TracksLayer/utils/index.test.js | 104 +++++++++ src/UserCurrentLocationLayer/index.js | 23 +- src/hooks/index.js | 50 +---- src/hooks/index.test.js | 211 +----------------- src/hooks/useClusterBufferPolygon/index.js | 15 +- src/hooks/useMapLayer/index.js | 167 ++++++++++++++ src/hooks/useMapLayer/index.test.js | 88 ++++++++ src/hooks/useMapLayerBatch/index.js | 174 --------------- .../index.js | 34 +-- src/hooks/useMapSource/index.test.js | 90 ++++++++ src/selectors/tracks/index.js | 2 - src/utils/tracks.test.js | 34 +++ 27 files changed, 804 insertions(+), 570 deletions(-) create mode 100644 src/TracksLayer/utils/index.test.js create mode 100644 src/hooks/useMapLayer/index.js create mode 100644 src/hooks/useMapLayer/index.test.js delete mode 100644 src/hooks/useMapLayerBatch/index.js rename src/hooks/{useMapSourceBatch => useMapSource}/index.js (64%) create mode 100644 src/hooks/useMapSource/index.test.js diff --git a/src/AnalyzersLayer/index.js b/src/AnalyzersLayer/index.js index f30b06dd3..582c1bdc1 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 useMapSource from '../hooks/useMapSource'; +import useMapLayer from '../hooks/useMapLayer'; const { ANALYZER_POLYS_WARNING, ANALYZER_POLYS_CRITICAL, ANALYZER_LINES_WARNING, ANALYZER_LINES_CRITICAL, SKY_LAYER } = LAYER_IDS; @@ -68,43 +70,51 @@ const AnalyzerLayer = ( condition: !!isSubjectSymbolsLayerReady, }), [isSubjectSymbolsLayerReady, minZoom]); - useMapSource(ANALYZER_POLYS_WARNING_SOURCE, warningPolys); + useMapSource({ id: ANALYZER_POLYS_WARNING_SOURCE, data: warningPolys }); useMapLayer( - ANALYZER_POLYS_WARNING, - 'line', - ANALYZER_POLYS_WARNING_SOURCE, - linePaint, - undefined, - layerConfig, + { + id: ANALYZER_POLYS_WARNING, + type: 'line', + sourceId: ANALYZER_POLYS_WARNING_SOURCE, + paint: linePaint, + ...layerConfig, + } ); - useMapSource(ANALYZER_POLYS_CRITICAL_SOURCE, criticalPolys); + useMapSource({ id: ANALYZER_POLYS_CRITICAL_SOURCE, data: criticalPolys }); useMapLayer( - ANALYZER_POLYS_CRITICAL, - 'line', - ANALYZER_POLYS_CRITICAL_SOURCE, - criticalLinePaint, lineLayout, - layerConfig, + { + id: ANALYZER_POLYS_CRITICAL, + type: 'line', + sourceId: ANALYZER_POLYS_CRITICAL_SOURCE, + paint: criticalLinePaint, + layout: lineLayout, + ...layerConfig, + } ); - useMapSource(ANALYZER_LINES_WARNING_SOURCE, warningLines); + useMapSource({ id: ANALYZER_LINES_WARNING_SOURCE, data: warningLines }); useMapLayer( - ANALYZER_LINES_WARNING, - 'line', - ANALYZER_LINES_WARNING_SOURCE, - linePaint, - lineLayout, - layerConfig, + { + id: ANALYZER_LINES_WARNING, + type: 'line', + sourceId: ANALYZER_LINES_WARNING_SOURCE, + paint: linePaint, + layout: lineLayout, + ...layerConfig, + } ); - useMapSource(ANALYZER_LINES_CRITICAL_SOURCE, criticalLines); + useMapSource({ id: ANALYZER_LINES_CRITICAL_SOURCE, data: criticalLines }); useMapLayer( - ANALYZER_LINES_CRITICAL, - 'line', - ANALYZER_LINES_CRITICAL_SOURCE, - criticalLinePaint, - lineLayout, - layerConfig, + { + id: ANALYZER_LINES_CRITICAL, + type: 'line', + sourceId: ANALYZER_LINES_CRITICAL_SOURCE, + paint: criticalLinePaint, + layout: lineLayout, + ...layerConfig, + } ); // (eventType = 'click', handlerFn = noop, layerId = null, condition = true) diff --git a/src/BaseLayerRenderer/TileLayerRenderer.js b/src/BaseLayerRenderer/TileLayerRenderer.js index 1833da5a9..a4e08a944 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 useMapSource from '../hooks/useMapSource'; +import useMapLayer from '../hooks/useMapLayer'; const { TOPMOST_STYLE_LAYER } = LAYER_IDS; @@ -25,7 +26,7 @@ const SourceComponent = ({ id, tileUrl, sourceConfig }) => { ...sourceConfig, }), [sourceConfig, tileUrl]); - useMapSource(id, null, config); + useMapSource({ id, data: {} }, config); return null; }; @@ -51,12 +52,13 @@ const TileLayerRenderer = (props) => { }, [map, mapConfig]); useMapLayer( - `tile-layer-${activeLayer?.id}`, - 'raster', - `layer-source-${activeLayer?.id}`, - undefined, - undefined, - { before: TOPMOST_STYLE_LAYER, condition: !!activeLayer } + { + id: `tile-layer-${activeLayer?.id}`, + type: 'raster', + sourceId: `layer-source-${activeLayer?.id}`, + before: TOPMOST_STYLE_LAYER, + condition: !!activeLayer + } ); return layers diff --git a/src/ClustersLayer/index.js b/src/ClustersLayer/index.js index 1e10bb482..79955b2f3 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 useMapSource from '../hooks/useMapSource'; +import useMapLayer from '../hooks/useMapLayer'; 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); + useMapSource({ id: CLUSTERS_SOURCE_ID, data: clustersSourceData }, CLUSTER_SOURCE_CONFIG); + useMapLayer({ + id: CLUSTERS_LAYER_ID, + type: 'circle', + sourceId: CLUSTERS_SOURCE_ID, + paint: CLUSTER_LAYER_PAINT, + ...CLUSTER_LAYER_CONFIG + }); const { removeClusterPolygon, renderClusterPolygon } = useClusterBufferPolygon(); diff --git a/src/EventGeometryLayer/index.js b/src/EventGeometryLayer/index.js index aa6d765d9..8e8afe44f 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 useMapSource from '../hooks/useMapSource'; +import useMapLayer from '../hooks/useMapLayer'; 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); + useMapSource({ id: EVENT_GEOMETRY, data: eventFeatureCollection }); - useMapLayer(EVENT_GEOMETRY_LAYER, 'fill', EVENT_GEOMETRY, paint, layout, layerConfig); + useMapLayer({ + id: EVENT_GEOMETRY_LAYER, + type: 'fill', + sourceId: EVENT_GEOMETRY, + paint, + layout, + ...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..d19f886a5 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 useMapSource from '../hooks/useMapSource'; 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); + useMapSource({ 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..84bf908c2 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 useMapSource from '../hooks/useMapSource'; +import useMapLayer from '../hooks/useMapLayer'; const { FEATURE_FILLS, FEATURE_LINES, FEATURE_SYMBOLS, SKY_LAYER } = LAYER_IDS; @@ -114,14 +116,41 @@ 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); + useMapSource({ id: MAP_FEATURES_LINES_SOURCE, data: lines }); + useMapSource({ id: MAP_FEATURES_POLYGONS_SOURCE, data: polygons }); + useMapSource({ id: MAP_FEATURES_SYMBOLS_SOURCE, data: symbols }); + + // (layerId, type, sourceId, paint, layout, filter, min-zoom, max-zoom, condition = true) + useMapLayer({ + id: FEATURE_FILLS, + type: 'fill', + sourceId: MAP_FEATURES_POLYGONS_SOURCE, + paint: fillPaint, + layout: fillLayout, + ...layerConfig + }); + + useMapLayer( + { + id: FEATURE_LINES, + type: 'line', + sourceId: MAP_FEATURES_LINES_SOURCE, + paint: linePaint, + layout: lineLayout, + ...layerConfig + } + ); + + useMapLayer( + { + id: FEATURE_SYMBOLS, + type: 'symbol', + sourceId: MAP_FEATURES_SYMBOLS_SOURCE, + paint: symbolPaint, + layout: layout, + ...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..51b6d382d 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 useMapSource from '../hooks/useMapSource'; +import useMapLayer from '../hooks/useMapLayer'; 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 }); + useMapSource({ id: `heatmap-source-${idRef.current}`, data: points }); + useMapLayer( + { + id: `${HEATMAP_LAYER}-${idRef.current}`, + type: 'heatmap', + sourceId: `heatmap-source-${idRef.current}`, + paint, + before: SKY_LAYER + } + ); return null; }; diff --git a/src/LabeledSymbolLayer/index.js b/src/LabeledSymbolLayer/index.js index 9a8046bbd..63cb3d8f3 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 useMapLayer from '../hooks/useMapLayer'; 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); + useMapLayer({ + id: id, + type: 'symbol', + sourceId, + paint: symbolPaint, + layout: symbolLayout, + ...layerConfig + }); + + useMapLayer({ + id: textLayerId, + type: 'symbol', + sourceId, + paint: labelPaint, + layout: labelLayout, + ...layerConfig + }); return null; }; diff --git a/src/MapDrawingTools/MapLayers.js b/src/MapDrawingTools/MapLayers.js index 0b5fd7c76..7bfdc6c42 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 useMapSource from '../hooks/useMapSource'; import { linePaint, @@ -13,6 +14,7 @@ import { lineSymbolLayout, polygonSymbolLayout, } from './layerStyles'; +import useMapLayer from '../hooks/useMapLayer'; export const LAYER_IDS = { POINTS: 'draw-layer-points', @@ -45,20 +47,60 @@ 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); + useMapSource({ id: SOURCE_IDS.FILL_SOURCE, data: fillPolygon }, { type: 'geojson' }); + useMapSource({ id: SOURCE_IDS.FILL_LABEL_SOURCE, data: fillLabelPoint }, { type: 'geojson' }); + useMapSource({ id: SOURCE_IDS.LINE_SOURCE, data: drawnLineSegments }, { type: 'geojson' }); + useMapSource({ id: SOURCE_IDS.POINT_SOURCE, data: drawnLinePoints }, { generateId: true, type: 'geojson' }); + + useMapLayer( + { + id: LAYER_IDS.LINE_LABELS, + type: 'symbol', + sourceId: SOURCE_IDS.LINE_SOURCE, + paint: symbolPaint, + layout: lineSymbolLayout, + condition: drawing || !isHoveringGeometry || draggedPoint, + } + ); + useMapLayer( + { + id: LAYER_IDS.FILL_LABEL, + type: 'symbol', + sourceId: SOURCE_IDS.FILL_LABEL_SOURCE, + paint: symbolPaint, + layout: polygonSymbolLayout, + condition: drawing || !isHoveringGeometry || draggedPoint, + } + ); + + useMapLayer( + { + id: LAYER_IDS.LINES, + type: 'line', + sourceId: SOURCE_IDS.LINE_SOURCE, + paint: linePaint, + layout: lineLayout + } + ); + + const [fillLayer] = useMapLayer( + { + id: LAYER_IDS.FILL, + type: 'fill', + sourceId: SOURCE_IDS.FILL_SOURCE, + paint: fillPaint, + layout: fillLayout + } + ); + + const [pointsLayer] = useMapLayer( + { + 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..66ee0450a 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 useMapSource from '../hooks/useMapSource'; +import useMapLayer from '../hooks/useMapLayer'; 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); + useMapSource({ id: MOUSE_MARKER_SOURCE, data: cursorPoint }); + useMapLayer({ + id: MOUSE_MARKER_LAYER, + type: 'symbol', + sourceId: MOUSE_MARKER_SOURCE, + layout + }); return null; }; diff --git a/src/PatrolStartStopLayer/layer.js b/src/PatrolStartStopLayer/layer.js index 4890c12da..3d416e5fa 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 useMapSource from '../hooks/useMapSource'; +import useMapLayer from '../hooks/useMapLayer'; 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); + useMapSource({ id: sourceId, data: patrolPointsSourceData }); + useMapLayer({ + 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..94e0f2344 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 useMapLayer from '../hooks/useMapLayer'; const { STATIC_SENSOR, CLUSTERED_STATIC_SENSORS_LAYER, UNCLUSTERED_STATIC_SENSORS_LAYER } = LAYER_IDS; @@ -105,21 +106,25 @@ const StaticSensorsLayer = () => { }, [map]); useMapLayer( - currentBackgroundLayerId, - 'symbol', - currentSourceId, - backgroundLayerStyles.paint, - backgroundLayoutObject, - layerConfig, + { + id: currentBackgroundLayerId, + type: 'symbol', + sourceId: currentSourceId, + paint: backgroundLayerStyles.paint, + layout: backgroundLayoutObject, + ...layerConfig, + } ); useMapLayer( - currentLayerId, - 'symbol', - currentSourceId, - labelLayerStyles.paint, - layoutObject, - layerConfig, + { + id: currentLayerId, + type: 'symbol', + sourceId: currentSourceId, + paint: labelLayerStyles.paint, + layout: layoutObject, + ...layerConfig, + } ); useMapEventBinding('click', onLayerClick); diff --git a/src/SubjectTrackLegend/index.test.js b/src/SubjectTrackLegend/index.test.js index 5cb4723cc..dd79735f6 100644 --- a/src/SubjectTrackLegend/index.test.js +++ b/src/SubjectTrackLegend/index.test.js @@ -6,7 +6,6 @@ import { render, screen, within } from '../test-utils'; import { mockStore } from '../__test-helpers/MockStore'; import { setIsTimeOfDayColoringActive, TRACK_LENGTH_ORIGINS } from '../ducks/tracks'; import { updateTrackState } from '../ducks/map-ui'; -import { useFeatureFlag } from '../hooks'; import SubjectTrackLegend from '.'; diff --git a/src/SubjectsLayer/index.js b/src/SubjectsLayer/index.js index 3bb7dd24c..06691d534 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 useMapSource from '../hooks/useMapSource'; import LabeledPatrolSymbolLayer from '../LabeledPatrolSymbolLayer'; import withMapViewConfig from '../WithMapViewConfig'; @@ -67,9 +67,12 @@ const SubjectsLayer = ({ mapImages, onSubjectClick }) => { } ), [map, onSubjectClick, subjectLayerIds]); - useMapSource(UNCLUSTERED_SOURCE_ID, { - ...mapSubjectFeatures, - features: !shouldSubjectsBeClustered ? mapSubjectFeatures.features : [], + useMapSource({ + id: UNCLUSTERED_SOURCE_ID, + data: { + ...mapSubjectFeatures, + features: !shouldSubjectsBeClustered ? mapSubjectFeatures.features : [], + } }); return <> diff --git a/src/TracksLayer/track.js b/src/TracksLayer/track.js index e051f3727..c8636b559 100644 --- a/src/TracksLayer/track.js +++ b/src/TracksLayer/track.js @@ -4,15 +4,13 @@ import PropTypes from 'prop-types'; import { LAYER_IDS, MAP_ICON_SCALE } from '../constants'; import { MapContext } from '../App'; import { - useMapEventBinding, - useMapLayer, - useMapSource + useMapEventBinding } from '../hooks'; import { generateMapSourcesAndLayersBasedOnTwoLineTrackPointsSegments } from './utils'; import { useSelector } from 'react-redux'; import { selectTrackSettings } from '../selectors/tracks'; -import useMapSourceBatch from '../hooks/useMapSourceBatch'; -import useMapLayerBatch from '../hooks/useMapLayerBatch'; +import useMapSource from '../hooks/useMapSource'; +import useMapLayer from '../hooks/useMapLayer'; const { TRACKS_LINES, SUBJECT_SYMBOLS } = LAYER_IDS; @@ -79,31 +77,34 @@ const TrackLayer = ({ before, id, lineLayout, linePaint, onPointClick, showTimep ), [trackData, sourceId, layerId, lineLayout, before, isTimeOfDayColoringActive]); - useMapSource(sourceId, trackData.twoPointLineStringTrackPoints || trackData.track, { tolerance: 1.5, type: 'geojson', lineMetrics: true }); - useMapSource(pointSourceId, trackData.points); + useMapSource({ id: sourceId, data: trackData.twoPointLineStringTrackPoints || trackData.track }, { tolerance: 1.5, type: 'geojson', lineMetrics: true }); + useMapSource({ id: pointSourceId, data: trackData.points }); - useMapSourceBatch(sourcesConfigs, { tolerance: 1.5, type: 'geojson', lineMetrics: true }); - useMapLayerBatch(layersConfigs, { before: before || SUBJECT_SYMBOLS }); + useMapSource(sourcesConfigs, { tolerance: 1.5, type: 'geojson', lineMetrics: true }); + useMapLayer(layersConfigs, { before: before || SUBJECT_SYMBOLS }); // Only create the normal layer if there are no time_of_day_segments useMapLayer( - layerId, - 'line', - sourceId, - { ...TRACK_LAYER_LINE_PAINT, ...linePaint }, - { ...TRACK_LAYER_LINE_LAYOUT, ...lineLayout }, { + id: layerId, + type: 'line', + sourceId, + paint: { ...TRACK_LAYER_LINE_PAINT, ...linePaint }, + layout: { ...TRACK_LAYER_LINE_LAYOUT, ...lineLayout }, before: before || SUBJECT_SYMBOLS, condition: !isTimeOfDayColoringActive && !hasTimeOfDaySegments } ); useMapLayer( - pointLayerId, - 'symbol', - pointSourceId, - TIMEPOINT_LAYER_PAINT, - TIMEPOINT_LAYER_LAYOUT, - { before: before || SUBJECT_SYMBOLS, condition: showTimepoints } + { + id: pointLayerId, + type: 'symbol', + sourceId: pointSourceId, + paint: TIMEPOINT_LAYER_PAINT, + layout: TIMEPOINT_LAYER_LAYOUT, + before: before || SUBJECT_SYMBOLS, + condition: showTimepoints + } ); useMapEventBinding('click', onPointClick, pointLayerId, showTimepoints); diff --git a/src/TracksLayer/utils/index.test.js b/src/TracksLayer/utils/index.test.js new file mode 100644 index 000000000..1d9f444da --- /dev/null +++ b/src/TracksLayer/utils/index.test.js @@ -0,0 +1,104 @@ +import { buildFeatureCollectionOfTwoPointLineStringSegments } from '../../utils/tracks'; +import { + generateMapSourcesAndLayersBasedOnTwoLineTrackPointsSegments, + 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 twoPointLineStringTrackPoints = buildFeatureCollectionOfTwoPointLineStringSegments(track, 'America/Monterrey'); + const segmentPairs = segmentTrackPointsByTimeOfDayPeriodPairs(twoPointLineStringTrackPoints); + + 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 = { twoPointLineStringTrackPoints }; + const configs = generateMapSourcesAndLayersBasedOnTwoLineTrackPointsSegments(trackData, true, sourceId, layerId, layoutOptions, layerOptions); + + expect(configs.hasTimeOfDaySegments).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..a132cab0b 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 useMapSource from '../hooks/useMapSource'; import GpsLocationIcon from '../common/images/icons/gps-location-icon-blue.svg'; +import useMapLayer from '../hooks/useMapLayer'; const { CURRENT_USER_LOCATION_SOURCE } = SOURCE_IDS; @@ -101,10 +103,23 @@ const UserCurrentLocationLayer = ({ onIconClick }) => { } }, [animationState, showLayer]); - useMapSource(CURRENT_USER_LOCATION_SOURCE, userLocationPoint); + useMapSource({ id: CURRENT_USER_LOCATION_SOURCE, data: 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); + useMapLayer({ + id: ICON_LAYER_ID, + type: 'symbol', + sourceId: CURRENT_USER_LOCATION_SOURCE, + layout: SYMBOL_LAYOUT, + ...layerConfig + }); + + useMapLayer({ + id: CIRCLE_LAYER_ID, + type: 'circle', + sourceId: CURRENT_USER_LOCATION_SOURCE, + paint: circlePaint, + ...layerConfig + }); useMapEventBinding('click', onCurrentLocationIconClick, ICON_LAYER_ID); diff --git a/src/hooks/index.js b/src/hooks/index.js index dff27100f..4fc284b60 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -74,46 +74,7 @@ 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 = {}) => { +export const useMapLayerZZZZ = (layerId, type, sourceId, paint, layout, config = {}) => { const map = useContext(MapContext); const layer = map?.getLayer(layerId); @@ -123,6 +84,7 @@ export const useMapLayer = (layerId, type, sourceId, paint, layout, config = {}) const minzoom = useMemoCompare(config?.minZoom); const maxzoom = useMemoCompare(config?.maxZoom); + // Add layers that don't exist yet useEffect(() => { if (condition && map && !layer) { if (!!map.getSource(sourceId)) { @@ -138,6 +100,7 @@ export const useMapLayer = (layerId, type, sourceId, paint, layout, config = {}) } }, [before, condition, config, filter, layer, layerId, layout, map, sourceId, paint, type]); + // Update layout properties for existing layers useEffect(() => { if (condition && layer && layout) { Object.entries(layout).forEach(([key, value]) => { @@ -146,6 +109,7 @@ export const useMapLayer = (layerId, type, sourceId, paint, layout, config = {}) } }, [condition, layer, layerId, layout, map]); + // Update paint properties for existing layers useEffect(() => { if (condition && layer && paint) { Object.entries(paint).forEach(([key, value]) => { @@ -154,18 +118,21 @@ export const useMapLayer = (layerId, type, sourceId, paint, layout, config = {}) } }, [condition, map, layer, layerId, paint]); + // Update filters for existing layers useEffect(() => { if (condition && map && map.getLayer(layerId)) { map.setFilter(layerId, filter); } }, [condition, filter, layer, layerId, map]); + // Remove layers when condition becomes false useEffect(() => { if (!condition && layer) { map.removeLayer(layerId); } }, [condition, layer, layerId, map]); + // Update layer order based on before useEffect(() => { return () => { if (map) { @@ -178,13 +145,14 @@ export const useMapLayer = (layerId, type, sourceId, paint, layout, config = {}) }; }, [layerId, map]); + // Update zoom ranges useEffect(() => { if (condition && map && layer && (minzoom || maxzoom)) { map.setLayerZoomRange(layerId, (minzoom || MIN_ZOOM), (maxzoom || MAX_ZOOM)); } }, [condition, layer, layerId, map, minzoom, maxzoom]); - + // Cleanup on unmount useEffect(() => { if (layerId && map && before) { map.getLayer(layerId) && map.moveLayer(layerId, before); 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..b6de7af0c 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 useMapSource from '../useMapSource'; +import useMapLayer from '../useMapLayer'; 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] = useMapSource({ id: CLUSTER_BUFFER_POLYGON_SOURCE_ID, data: clusterBufferPolygon }); + + useMapLayer({ + id: CLUSTER_BUFFER_POLYGON_LAYER_ID, + type: 'fill', + sourceId: CLUSTER_BUFFER_POLYGON_SOURCE_ID, + paint: CLUSTER_BUFFER_POLYGON_PAINT, + ...CLUSTER_BUFFER_POLYGON_LAYER_CONFIGURATION + }); const renderClusterPolygon = useCallback((clusterFeatureCollection) => { if (source && map.getZoom() < CLUSTERS_MAX_ZOOM) { diff --git a/src/hooks/useMapLayer/index.js b/src/hooks/useMapLayer/index.js new file mode 100644 index 000000000..b3bd558bb --- /dev/null +++ b/src/hooks/useMapLayer/index.js @@ -0,0 +1,167 @@ +import { useContext, useEffect, useMemo, useRef } from 'react'; + +import { MapContext } from '../../App'; +import { MAX_ZOOM, MIN_ZOOM } from '../../constants'; + +const useMapLayer = (layerConfig, defaultConfig = {}) => { + const map = useContext(MapContext); + const layerIdsRef = useRef([]); + const layersConfigs = useMemo(() => Array.isArray(layerConfig) ? layerConfig : [layerConfig], [layerConfig]); + + // Add layers that don't exist yet + useEffect(() => { + if (map){ + layersConfigs.forEach(config => { + if (config?.id && config?.type && config?.sourceId){ + const { id, type, sourceId, paint = {}, layout = {}, filter, condition, before } = config; + const conditionValue = condition ?? true; + const beforeValue = before || defaultConfig.before; + + if (map.getSource(sourceId) && conditionValue && !map.getLayer(id)){ + const layerObj = { + id, + source: sourceId, + type, + layout: layout || {}, + paint: paint || {} + }; + + // Only add filter if it's defined and is an array + const filterValue = filter || defaultConfig.filter; + if (Array.isArray(filterValue)) { + layerObj.filter = filterValue; + } + + // Handle line-gradient and line-color conflict + if (type === 'line' && paint?.['line-gradient'] && paint?.['line-color']) { + console.warn(`Layer ${id}: line-gradient and line-color cannot both be specified`); + delete layerObj.paint['line-color']; + } + + map.addLayer(layerObj, beforeValue); + layerIdsRef.current.push(id); + } + } + }); + } + }, [map, layerConfig, defaultConfig, layersConfigs]); + + // Update layout properties for existing layers + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (config?.id && config.layout){ + const { id, layout, condition } = config; + + if (( condition ?? true ) && map.getLayer(id) && layout) { + Object.entries(layout).forEach(([key, value]) => { + map.setLayoutProperty(id, key, value); + }); + } + } + }); + } + }, [map, layersConfigs, layerConfig]); + + // Update paint properties for existing layers + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (config?.id && config?.paint){ + const { id, paint, condition } = config; + if (( condition ?? true ) && map.getLayer(id) && paint) { + Object.entries(paint).forEach(([key, value]) => { + map.setPaintProperty(id, key, value); + }); + } + } + }); + } + }, [map, layersConfigs, layerConfig]); + + // Update filters for existing layers + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (config?.id){ + const { id, filter, condition } = config; + const filterValue = filter || defaultConfig.filter; + + // Only set filter if it's valid (must be an array) + if (( condition ?? true ) && Array.isArray(filterValue) && map.getLayer(id)) { + map.setFilter(id, filterValue); + } + } + }); + } + }, [map, layersConfigs, defaultConfig, layerConfig]); + + // Remove layers when condition becomes false + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (config?.id){ + const { id, condition } = config; + if (!(condition ?? true) && map.getLayer(id)) { + map.removeLayer(id); + } + } + }); + } + }, [map, layersConfigs, layerConfig]); + + // Update layer order based on before + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (config?.id){ + const { id, before } = config; + const beforeValue = before || defaultConfig.before; + + if (beforeValue && map.getLayer(id)) { + map.moveLayer(id, beforeValue); + } + } + }); + } + }, [map, layersConfigs, defaultConfig, layerConfig]); + + // Update zoom ranges + useEffect(() => { + if (map) { + layersConfigs.forEach(config => { + if (!config || !config.id) return; + + const { id, condition, minZoom, maxZoom } = config; + const minZoomValue = minZoom || defaultConfig.minZoom || MIN_ZOOM; + const maxZoomValue = maxZoom || defaultConfig.maxZoom || MAX_ZOOM; + + if (( condition ?? true ) && map.getLayer(id)) { + map.setLayerZoomRange(id, minZoomValue, maxZoomValue); + } + }); + } + }, [map, layersConfigs, defaultConfig, layerConfig]); + + // Cleanup on unmount + useEffect(() => { + const refs = layerIdsRef.current; + return () => { + if (map) { + try { + refs.forEach(id => { + if (map.getLayer(id)) { + map.removeLayer(id); + } + }); + } catch (error) { + // Silent error handling as in the original hook + } + } + }; + }, [map]); + + return layersConfigs.map((layer) => map?.getLayer(layer.id)); +}; + +export default useMapLayer; diff --git a/src/hooks/useMapLayer/index.test.js b/src/hooks/useMapLayer/index.test.js new file mode 100644 index 000000000..9c31d0b00 --- /dev/null +++ b/src/hooks/useMapLayer/index.test.js @@ -0,0 +1,88 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { MapContext } from '../../App'; + +import useMapLayerBatch from './'; + +describe('hooks - useMapLayer', () => { + const layerId = 'sourceId'; + const baseMap = { + getSource: jest.fn(), + getLayer: jest.fn(), + addLayer: jest.fn(), + setLayoutProperty: jest.fn(), + setPaintProperty: jest.fn(), + setFilter: jest.fn(), + removeLayer: jest.fn(), + moveLayer: jest.fn(), + setLayerZoomRange: jest.fn(), + }; + + // eslint-disable-next-line react/display-name + const wrapper = (map) => ({ children }) => + {children} + ; + + const renderUserMapLayer = (layerConfig, map, defaultConfig) => + renderHook( + () => useMapLayerBatch(layerConfig, defaultConfig), + { wrapper: wrapper(map) } + ); + + test('adding a layer to the map', () => { + const map = { + ...baseMap, + getSource: jest.fn(() => true) + }; + renderUserMapLayer({ + id: layerId, + type: 'string', + sourceId: 'whatever-source-id' + }, map); + + expect(map.addLayer).toHaveBeenCalled(); + }); + + test('not adding a layer if no map is available', () => { + renderUserMapLayer(); + expect(baseMap.addLayer).not.toHaveBeenCalled(); + }); + + describe('when the layer is present', () => { + beforeEach(() => { + baseMap.getLayer.mockReturnValue({ whatever: 'ok' }); + }); + + test('setting and changing paint props', () => { + let config = { + id: layerId, + type: 'string', + sourceId: 'whatever-source-id', + paint: { value1: 'yellow', value2: 0.6 } + }; + + const { rerender } = renderUserMapLayer(config, baseMap); + + Object.entries(config.paint).forEach(([key, value]) => { + expect(baseMap.setPaintProperty).toHaveBeenCalledWith(layerId, key, value); + }); + + const newConfig = { + id: layerId, + type: 'string', + sourceId: 'whatever-source-id', + paint: { + whatever: true + } + }; + + rerender(newConfig); + + Object.entries(config.paint).forEach(([key, value]) => { + expect(baseMap.setPaintProperty).toHaveBeenCalledWith(layerId, key, value); + }); + }); + + }); +}); diff --git a/src/hooks/useMapLayerBatch/index.js b/src/hooks/useMapLayerBatch/index.js deleted file mode 100644 index 8b2207351..000000000 --- a/src/hooks/useMapLayerBatch/index.js +++ /dev/null @@ -1,174 +0,0 @@ -import { useContext, useEffect, useRef } from 'react'; - -import { MapContext } from '../../App'; -import { MAX_ZOOM, MIN_ZOOM } from '../../constants'; - -const useMapLayerBatch = (layersConfigs = [], defaultConfig = {}) => { - const map = useContext(MapContext); - const layerIdsRef = useRef([]); - - // Add layers that don't exist yet - useEffect(() => { - if (!map) return; - - layersConfigs.forEach(config => { - try { - if (config?.id && config?.type && config?.sourceId){ - const { id, type, sourceId, paint = {}, layout = {}, options = {} } = config; - const condition = options.condition ?? true; - const before = options.before || defaultConfig.before; - - if (map.getSource(sourceId) && condition && !map.getLayer(id)){ - const layerObj = { - id, - source: sourceId, - type, - layout: layout || {}, - paint: paint || {} - }; - - // Only add filter if it's defined and is an array - const filterValue = options.filter || defaultConfig.filter; - if (Array.isArray(filterValue)) { - layerObj.filter = filterValue; - } - - // Handle line-gradient and line-color conflict - if (type === 'line' && paint?.['line-gradient'] && paint?.['line-color']) { - console.warn(`Layer ${id}: line-gradient and line-color cannot both be specified`); - delete layerObj.paint['line-color']; - } - - map.addLayer(layerObj, before); - layerIdsRef.current.push(id); - } - } - } catch (error) { - console.error('Error adding layer for config:', config, error); - } - }); - }, [map, layersConfigs, defaultConfig]); - - // Update layout properties for existing layers - useEffect(() => { - if (map) { - layersConfigs.forEach(config => { - if (!config || !config.id || !config.layout) return; - - const { id, layout, options = {} } = config; - const condition = options.hasOwnProperty('condition') ? options.condition : true; - - if (condition && map.getLayer(id) && layout) { - Object.entries(layout).forEach(([key, value]) => { - map.setLayoutProperty(id, key, value); - }); - } - }); - } - }, [map, layersConfigs]); - - // Update paint properties for existing layers - useEffect(() => { - if (map) { - layersConfigs.forEach(config => { - if (!config || !config.id || !config.paint) return; - - const { id, paint, options = {} } = config; - const condition = options.hasOwnProperty('condition') ? options.condition : true; - - if (condition && map.getLayer(id) && paint) { - Object.entries(paint).forEach(([key, value]) => { - map.setPaintProperty(id, key, value); - }); - } - }); - } - }, [map, layersConfigs]); - - // Update filters for existing layers - useEffect(() => { - if (map) { - layersConfigs.forEach(config => { - if (!config || !config.id) return; - - const { id, options = {} } = config; - const condition = options.hasOwnProperty('condition') ? options.condition : true; - const filter = options.filter || defaultConfig.filter; - - // Only set filter if it's valid (must be an array) - if (condition && Array.isArray(filter) && map.getLayer(id)) { - map.setFilter(id, filter); - } - }); - } - }, [map, layersConfigs, defaultConfig]); - - // Remove layers when condition becomes false - useEffect(() => { - if (map) { - layersConfigs.forEach(config => { - if (!config || !config.id) return; - - const { id, options = {} } = config; - const condition = options.hasOwnProperty('condition') ? options.condition : true; - - if (!condition && map.getLayer(id)) { - map.removeLayer(id); - } - }); - } - }, [map, layersConfigs]); - - // Update layer order based on before - useEffect(() => { - if (map) { - layersConfigs.forEach(config => { - if (!config || !config.id) return; - - const { id, options = {} } = config; - const before = options.before || defaultConfig.before; - - if (before && map.getLayer(id)) { - map.moveLayer(id, before); - } - }); - } - }, [map, layersConfigs, defaultConfig]); - - // Update zoom ranges - useEffect(() => { - if (map) { - layersConfigs.forEach(config => { - if (!config || !config.id) return; - - const { id, options = {} } = config; - const condition = options.hasOwnProperty('condition') ? options.condition : true; - const minzoom = options.minZoom || defaultConfig.minZoom || MIN_ZOOM; - const maxzoom = options.maxZoom || defaultConfig.maxZoom || MAX_ZOOM; - - if (condition && map.getLayer(id)) { - map.setLayerZoomRange(id, minzoom, maxzoom); - } - }); - } - }, [map, layersConfigs, defaultConfig]); - - // Cleanup on unmount - useEffect(() => { - return () => { - if (map) { - try { - layerIdsRef.current.forEach(id => { - if (map.getLayer(id)) { - map.removeLayer(id); - } - }); - } catch (error) { - // Silent error handling as in the original hook - } - } - }; - }, [map]); -}; - -export default useMapLayerBatch; diff --git a/src/hooks/useMapSourceBatch/index.js b/src/hooks/useMapSource/index.js similarity index 64% rename from src/hooks/useMapSourceBatch/index.js rename to src/hooks/useMapSource/index.js index da6d37329..a03ecabba 100644 --- a/src/hooks/useMapSourceBatch/index.js +++ b/src/hooks/useMapSource/index.js @@ -1,10 +1,11 @@ -import { useContext, useEffect, useRef } from 'react'; +import { useContext, useEffect, useMemo, useRef } from 'react'; import { MapContext } from '../../App'; -const useMapSourceBatch = (sourcesConfigs = [], defaultConfig = { type: 'geojson' }) => { +const useMapSource = (sourceConfig, defaultConfig = { type: 'geojson' }) => { const map = useContext(MapContext); const sourceIdsRef = useRef([]); + const sourcesConfigs = useMemo(() => Array.isArray(sourceConfig) ? sourceConfig : [sourceConfig], [sourceConfig]); useEffect(() => { // Initialize sources that don't exist yet @@ -29,22 +30,18 @@ const useMapSourceBatch = (sourcesConfigs = [], defaultConfig = { type: 'geojson // Update data for existing sources useEffect(() => { let timeouts = []; - sourcesConfigs.forEach(config => { if (!!config?.id && !!config?.data){ const { id, data, options = {} } = config; - const enabled = options.hasOwnProperty('enabled') ? options.enabled : true; - - if (!enabled) return; - - const timeout = window.setTimeout(() => { - const source = map?.getSource?.(id); - if (source) { - source?.setData?.(data); - } - }); - - timeouts.push(timeout); + if (!!options.enabled){ + const timeout = window.setTimeout(() => { + const source = map?.getSource?.(id); + if (source) { + source?.setData?.(data); + } + }); + timeouts.push(timeout); + } } }); @@ -57,10 +54,11 @@ const useMapSourceBatch = (sourcesConfigs = [], defaultConfig = { type: 'geojson // Cleanup on unmount useEffect(() => { + const refs = sourceIdsRef?.current; return () => { if (map) { setTimeout(() => { - sourceIdsRef?.current.forEach(id => { + refs.forEach(id => { if (map?.getSource(id)) { map.removeSource(id); } @@ -69,6 +67,8 @@ const useMapSourceBatch = (sourcesConfigs = [], defaultConfig = { type: 'geojson } }; }, [map]); + + return sourcesConfigs.map((source) => map?.getSource(source.id)); }; -export default useMapSourceBatch; +export default useMapSource; diff --git a/src/hooks/useMapSource/index.test.js b/src/hooks/useMapSource/index.test.js new file mode 100644 index 000000000..4b62c7a63 --- /dev/null +++ b/src/hooks/useMapSource/index.test.js @@ -0,0 +1,90 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { MapContext } from '../../App'; + +import useMapSource 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( + () => useMapSource(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('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: { + tolerance: 1.5, + type: 'geojson', + lineMetrics: true, + enabled: true + } + }; + + renderUserMapSource(sourceConfig, map); + + jest.runAllTimers(); + + expect(map.getSource).toHaveBeenCalledTimes(3); // Get called 3 times by: adding source check, updating data, returning the source + expect(map.getSource).toHaveBeenCalledWith(sourceConfig.id); + expect(source.setData).toHaveBeenCalledTimes(1); + 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 81125aa06..b79953381 100644 --- a/src/selectors/tracks/index.js +++ b/src/selectors/tracks/index.js @@ -106,8 +106,6 @@ export const selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod = trackTimeEnvelope.until ); - console.log(trimmedTrackData.track); - return isTimeOfDayColoringActive ? { ...trimmedTrackData, diff --git a/src/utils/tracks.test.js b/src/utils/tracks.test.js index f9b32fdfa..d053248ad 100644 --- a/src/utils/tracks.test.js +++ b/src/utils/tracks.test.js @@ -160,6 +160,40 @@ describe('utils - tracks', () => { }); + test('returns empty feature collection when track data has no features', () => { + const emptyTracks = { ...track }; + emptyTracks.features = []; + + const emptyFeatureCollection = buildFeatureCollectionOfTwoPointLineStringSegments(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 = buildFeatureCollectionOfTwoPointLineStringSegments(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 = buildFeatureCollectionOfTwoPointLineStringSegments(notEnoughFeaturesTracks, 'America/Monterrey'); + + expect(emptyFeatureCollection.features.length).toBe(0); + expect(emptyFeatureCollection.type).toBe('FeatureCollection'); + }); + + }); From 80abadf5c780163602fc67cfd756cf45b368c471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20L=C3=B3pez?= Date: Mon, 3 Mar 2025 08:19:25 -0600 Subject: [PATCH 05/15] Reseting .env --- .env.development | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.development b/.env.development index c2911b1d9..e1dba61d8 100644 --- a/.env.development +++ b/.env.development @@ -1,4 +1,4 @@ -REACT_APP_DAS_HOST=https://easterisland.pamdas.org +REACT_APP_DAS_HOST=https://root.dev.pamdas.org REACT_APP_GA_TRACKING_ID=UA-128569083-12 REACT_APP_GA4_TRACKING_ID=G-1MVMZ0CMWF REACT_APP_MOCK_EVENTS_API=false From 114c3ace8c789c1a7f559606cbfba063d6a92dd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20L=C3=B3pez?= Date: Mon, 3 Mar 2025 08:20:20 -0600 Subject: [PATCH 06/15] Resseting compose file --- docker-compose.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c134a7ca9..89dd78a07 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,17 +14,17 @@ services: # volumes: # - /app/node_modules # - ./mock-api:/app - # web_test: - # build: - # context: . - # dockerfile: Dockerfile.dev - # volumes: - # - /app/node_modules - # - .:/app - # # environment: - # # - CI=true - # command: yarn test - # restart: on-failure + web_test: + build: + context: . + dockerfile: Dockerfile.dev + volumes: + - /app/node_modules + - .:/app + # environment: + # - CI=true + command: yarn test + restart: on-failure nginx: build: context: ./nginx From 8687201c2fa3a0df7bc81b79e10d08b6be8c7bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20L=C3=B3pez?= Date: Mon, 3 Mar 2025 09:42:03 -0600 Subject: [PATCH 07/15] Adding options object to useMapSource hook, removing unnecessary enabled prop from source --- src/AnalyzersLayer/index.js | 8 ++--- src/BaseLayerRenderer/TileLayerRenderer.js | 6 ++-- src/ClustersLayer/index.js | 2 +- src/EventGeometryLayer/index.js | 2 +- src/FeatureLayer/index.js | 6 ++-- src/HeatLayer/index.js | 4 ++- src/LabeledSymbolLayer/index.js | 4 +-- src/MapDrawingTools/MapLayers.js | 8 +++-- src/StaticSensorsLayer/index.js | 4 +-- src/TracksLayer/track.js | 15 ++++++--- src/UserCurrentLocationLayer/index.js | 8 +++-- src/hooks/useClusterBufferPolygon/index.js | 2 +- src/hooks/useMapLayer/index.js | 36 +++++++++++++--------- src/hooks/useMapSource/index.js | 18 +++++------ 14 files changed, 71 insertions(+), 52 deletions(-) diff --git a/src/AnalyzersLayer/index.js b/src/AnalyzersLayer/index.js index 582c1bdc1..951289c17 100644 --- a/src/AnalyzersLayer/index.js +++ b/src/AnalyzersLayer/index.js @@ -77,7 +77,7 @@ const AnalyzerLayer = ( type: 'line', sourceId: ANALYZER_POLYS_WARNING_SOURCE, paint: linePaint, - ...layerConfig, + options: layerConfig, } ); @@ -89,7 +89,7 @@ const AnalyzerLayer = ( sourceId: ANALYZER_POLYS_CRITICAL_SOURCE, paint: criticalLinePaint, layout: lineLayout, - ...layerConfig, + options: layerConfig, } ); @@ -101,7 +101,7 @@ const AnalyzerLayer = ( sourceId: ANALYZER_LINES_WARNING_SOURCE, paint: linePaint, layout: lineLayout, - ...layerConfig, + options: layerConfig, } ); @@ -113,7 +113,7 @@ const AnalyzerLayer = ( sourceId: ANALYZER_LINES_CRITICAL_SOURCE, paint: criticalLinePaint, layout: lineLayout, - ...layerConfig, + options: layerConfig, } ); diff --git a/src/BaseLayerRenderer/TileLayerRenderer.js b/src/BaseLayerRenderer/TileLayerRenderer.js index a4e08a944..2120c02d3 100644 --- a/src/BaseLayerRenderer/TileLayerRenderer.js +++ b/src/BaseLayerRenderer/TileLayerRenderer.js @@ -56,8 +56,10 @@ const TileLayerRenderer = (props) => { id: `tile-layer-${activeLayer?.id}`, type: 'raster', sourceId: `layer-source-${activeLayer?.id}`, - before: TOPMOST_STYLE_LAYER, - condition: !!activeLayer + options: { + before: TOPMOST_STYLE_LAYER, + condition: !!activeLayer + } } ); diff --git a/src/ClustersLayer/index.js b/src/ClustersLayer/index.js index 79955b2f3..2ca1bb4fb 100644 --- a/src/ClustersLayer/index.js +++ b/src/ClustersLayer/index.js @@ -61,7 +61,7 @@ const ClustersLayer = ({ onShowClusterSelectPopup }) => { type: 'circle', sourceId: CLUSTERS_SOURCE_ID, paint: CLUSTER_LAYER_PAINT, - ...CLUSTER_LAYER_CONFIG + options: CLUSTER_LAYER_CONFIG }); const { removeClusterPolygon, renderClusterPolygon } = useClusterBufferPolygon(); diff --git a/src/EventGeometryLayer/index.js b/src/EventGeometryLayer/index.js index 8e8afe44f..974ed5f3e 100644 --- a/src/EventGeometryLayer/index.js +++ b/src/EventGeometryLayer/index.js @@ -71,7 +71,7 @@ const EventGeometryLayer = ({ onClick }) => { sourceId: EVENT_GEOMETRY, paint, layout, - ...layerConfig + options: layerConfig }); useMapEventBinding('click', onClick, EVENT_GEOMETRY_LAYER); diff --git a/src/FeatureLayer/index.js b/src/FeatureLayer/index.js index 84bf908c2..cb545c8a0 100644 --- a/src/FeatureLayer/index.js +++ b/src/FeatureLayer/index.js @@ -127,7 +127,7 @@ const FeatureLayer = ({ symbols, lines, polygons, onFeatureSymbolClick, mapUserL sourceId: MAP_FEATURES_POLYGONS_SOURCE, paint: fillPaint, layout: fillLayout, - ...layerConfig + options: layerConfig }); useMapLayer( @@ -137,7 +137,7 @@ const FeatureLayer = ({ symbols, lines, polygons, onFeatureSymbolClick, mapUserL sourceId: MAP_FEATURES_LINES_SOURCE, paint: linePaint, layout: lineLayout, - ...layerConfig + options: layerConfig } ); @@ -148,7 +148,7 @@ const FeatureLayer = ({ symbols, lines, polygons, onFeatureSymbolClick, mapUserL sourceId: MAP_FEATURES_SYMBOLS_SOURCE, paint: symbolPaint, layout: layout, - ...layerConfig + options: layerConfig } ); diff --git a/src/HeatLayer/index.js b/src/HeatLayer/index.js index 51b6d382d..18946e6fa 100644 --- a/src/HeatLayer/index.js +++ b/src/HeatLayer/index.js @@ -38,7 +38,9 @@ const HeatLayer = ({ points }) => { type: 'heatmap', sourceId: `heatmap-source-${idRef.current}`, paint, - before: SKY_LAYER + options: { + before: SKY_LAYER + } } ); diff --git a/src/LabeledSymbolLayer/index.js b/src/LabeledSymbolLayer/index.js index 63cb3d8f3..1beef4494 100644 --- a/src/LabeledSymbolLayer/index.js +++ b/src/LabeledSymbolLayer/index.js @@ -86,7 +86,7 @@ const LabeledSymbolLayer = ( sourceId, paint: symbolPaint, layout: symbolLayout, - ...layerConfig + options: layerConfig }); useMapLayer({ @@ -95,7 +95,7 @@ const LabeledSymbolLayer = ( sourceId, paint: labelPaint, layout: labelLayout, - ...layerConfig + options: layerConfig }); return null; diff --git a/src/MapDrawingTools/MapLayers.js b/src/MapDrawingTools/MapLayers.js index 7bfdc6c42..ba4deb468 100644 --- a/src/MapDrawingTools/MapLayers.js +++ b/src/MapDrawingTools/MapLayers.js @@ -59,7 +59,9 @@ const MapDrawingLayers = ({ sourceId: SOURCE_IDS.LINE_SOURCE, paint: symbolPaint, layout: lineSymbolLayout, - condition: drawing || !isHoveringGeometry || draggedPoint, + options: { + condition: drawing || !isHoveringGeometry || draggedPoint + } } ); useMapLayer( @@ -69,7 +71,9 @@ const MapDrawingLayers = ({ sourceId: SOURCE_IDS.FILL_LABEL_SOURCE, paint: symbolPaint, layout: polygonSymbolLayout, - condition: drawing || !isHoveringGeometry || draggedPoint, + options: { + condition: drawing || !isHoveringGeometry || draggedPoint + } } ); diff --git a/src/StaticSensorsLayer/index.js b/src/StaticSensorsLayer/index.js index 94e0f2344..d63c031ea 100644 --- a/src/StaticSensorsLayer/index.js +++ b/src/StaticSensorsLayer/index.js @@ -112,7 +112,7 @@ const StaticSensorsLayer = () => { sourceId: currentSourceId, paint: backgroundLayerStyles.paint, layout: backgroundLayoutObject, - ...layerConfig, + options: layerConfig, } ); @@ -123,7 +123,7 @@ const StaticSensorsLayer = () => { sourceId: currentSourceId, paint: labelLayerStyles.paint, layout: layoutObject, - ...layerConfig, + options: layerConfig, } ); diff --git a/src/TracksLayer/track.js b/src/TracksLayer/track.js index c8636b559..da251ef6e 100644 --- a/src/TracksLayer/track.js +++ b/src/TracksLayer/track.js @@ -77,7 +77,7 @@ const TrackLayer = ({ before, id, lineLayout, linePaint, onPointClick, showTimep ), [trackData, sourceId, layerId, lineLayout, before, isTimeOfDayColoringActive]); - useMapSource({ id: sourceId, data: trackData.twoPointLineStringTrackPoints || trackData.track }, { tolerance: 1.5, type: 'geojson', lineMetrics: true }); + useMapSource({ id: sourceId, data: trackData.track }, { tolerance: 1.5, type: 'geojson', lineMetrics: true }); useMapSource({ id: pointSourceId, data: trackData.points }); useMapSource(sourcesConfigs, { tolerance: 1.5, type: 'geojson', lineMetrics: true }); @@ -91,8 +91,11 @@ const TrackLayer = ({ before, id, lineLayout, linePaint, onPointClick, showTimep sourceId, paint: { ...TRACK_LAYER_LINE_PAINT, ...linePaint }, layout: { ...TRACK_LAYER_LINE_LAYOUT, ...lineLayout }, - before: before || SUBJECT_SYMBOLS, - condition: !isTimeOfDayColoringActive && !hasTimeOfDaySegments } + options: { + before: before || SUBJECT_SYMBOLS, + condition: !isTimeOfDayColoringActive && !hasTimeOfDaySegments + } + } ); useMapLayer( @@ -102,8 +105,10 @@ const TrackLayer = ({ before, id, lineLayout, linePaint, onPointClick, showTimep sourceId: pointSourceId, paint: TIMEPOINT_LAYER_PAINT, layout: TIMEPOINT_LAYER_LAYOUT, - before: before || SUBJECT_SYMBOLS, - condition: showTimepoints + options: { + before: before || SUBJECT_SYMBOLS, + condition: showTimepoints + } } ); diff --git a/src/UserCurrentLocationLayer/index.js b/src/UserCurrentLocationLayer/index.js index a132cab0b..c6e6b8fcb 100644 --- a/src/UserCurrentLocationLayer/index.js +++ b/src/UserCurrentLocationLayer/index.js @@ -81,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); @@ -110,7 +112,7 @@ const UserCurrentLocationLayer = ({ onIconClick }) => { type: 'symbol', sourceId: CURRENT_USER_LOCATION_SOURCE, layout: SYMBOL_LAYOUT, - ...layerConfig + options: layerConfig }); useMapLayer({ @@ -118,7 +120,7 @@ const UserCurrentLocationLayer = ({ onIconClick }) => { type: 'circle', sourceId: CURRENT_USER_LOCATION_SOURCE, paint: circlePaint, - ...layerConfig + options: layerConfig }); useMapEventBinding('click', onCurrentLocationIconClick, ICON_LAYER_ID); diff --git a/src/hooks/useClusterBufferPolygon/index.js b/src/hooks/useClusterBufferPolygon/index.js index b6de7af0c..bd9d13fa3 100644 --- a/src/hooks/useClusterBufferPolygon/index.js +++ b/src/hooks/useClusterBufferPolygon/index.js @@ -30,7 +30,7 @@ const useClusterBufferPolygon = () => { type: 'fill', sourceId: CLUSTER_BUFFER_POLYGON_SOURCE_ID, paint: CLUSTER_BUFFER_POLYGON_PAINT, - ...CLUSTER_BUFFER_POLYGON_LAYER_CONFIGURATION + options: CLUSTER_BUFFER_POLYGON_LAYER_CONFIGURATION }); const renderClusterPolygon = useCallback((clusterFeatureCollection) => { diff --git a/src/hooks/useMapLayer/index.js b/src/hooks/useMapLayer/index.js index b3bd558bb..a82023c7d 100644 --- a/src/hooks/useMapLayer/index.js +++ b/src/hooks/useMapLayer/index.js @@ -13,7 +13,8 @@ const useMapLayer = (layerConfig, defaultConfig = {}) => { if (map){ layersConfigs.forEach(config => { if (config?.id && config?.type && config?.sourceId){ - const { id, type, sourceId, paint = {}, layout = {}, filter, condition, before } = config; + const { id, type, sourceId, paint = {}, layout = {}, options = {} } = config; + const { filter, condition, before } = options; const conditionValue = condition ?? true; const beforeValue = before || defaultConfig.before; @@ -44,15 +45,15 @@ const useMapLayer = (layerConfig, defaultConfig = {}) => { } }); } - }, [map, layerConfig, defaultConfig, layersConfigs]); + }, [map, defaultConfig, layersConfigs]); // Update layout properties for existing layers useEffect(() => { if (map) { layersConfigs.forEach(config => { if (config?.id && config.layout){ - const { id, layout, condition } = config; - + const { id, layout, options = {} } = config; + const { condition } = options; if (( condition ?? true ) && map.getLayer(id) && layout) { Object.entries(layout).forEach(([key, value]) => { map.setLayoutProperty(id, key, value); @@ -61,14 +62,15 @@ const useMapLayer = (layerConfig, defaultConfig = {}) => { } }); } - }, [map, layersConfigs, layerConfig]); + }, [map, layersConfigs]); // Update paint properties for existing layers useEffect(() => { if (map) { layersConfigs.forEach(config => { if (config?.id && config?.paint){ - const { id, paint, condition } = config; + const { id, paint, options = {} } = config; + const { condition } = options; if (( condition ?? true ) && map.getLayer(id) && paint) { Object.entries(paint).forEach(([key, value]) => { map.setPaintProperty(id, key, value); @@ -77,14 +79,15 @@ const useMapLayer = (layerConfig, defaultConfig = {}) => { } }); } - }, [map, layersConfigs, layerConfig]); + }, [map, layersConfigs]); // Update filters for existing layers useEffect(() => { if (map) { layersConfigs.forEach(config => { if (config?.id){ - const { id, filter, condition } = config; + const { id, options = {} } = config; + const { filter, condition } = options; const filterValue = filter || defaultConfig.filter; // Only set filter if it's valid (must be an array) @@ -94,28 +97,30 @@ const useMapLayer = (layerConfig, defaultConfig = {}) => { } }); } - }, [map, layersConfigs, defaultConfig, layerConfig]); + }, [map, layersConfigs, defaultConfig]); // Remove layers when condition becomes false useEffect(() => { if (map) { layersConfigs.forEach(config => { if (config?.id){ - const { id, condition } = config; + const { id, options = {} } = config; + const { condition } = options; if (!(condition ?? true) && map.getLayer(id)) { map.removeLayer(id); } } }); } - }, [map, layersConfigs, layerConfig]); + }, [map, layersConfigs]); // Update layer order based on before useEffect(() => { if (map) { layersConfigs.forEach(config => { if (config?.id){ - const { id, before } = config; + const { id, options = {} } = config; + const { before } = options; const beforeValue = before || defaultConfig.before; if (beforeValue && map.getLayer(id)) { @@ -124,7 +129,7 @@ const useMapLayer = (layerConfig, defaultConfig = {}) => { } }); } - }, [map, layersConfigs, defaultConfig, layerConfig]); + }, [map, layersConfigs, defaultConfig]); // Update zoom ranges useEffect(() => { @@ -132,7 +137,8 @@ const useMapLayer = (layerConfig, defaultConfig = {}) => { layersConfigs.forEach(config => { if (!config || !config.id) return; - const { id, condition, minZoom, maxZoom } = config; + const { id, options = {} } = config; + const { condition, minZoom, maxZoom } = options; const minZoomValue = minZoom || defaultConfig.minZoom || MIN_ZOOM; const maxZoomValue = maxZoom || defaultConfig.maxZoom || MAX_ZOOM; @@ -141,7 +147,7 @@ const useMapLayer = (layerConfig, defaultConfig = {}) => { } }); } - }, [map, layersConfigs, defaultConfig, layerConfig]); + }, [map, layersConfigs, defaultConfig]); // Cleanup on unmount useEffect(() => { diff --git a/src/hooks/useMapSource/index.js b/src/hooks/useMapSource/index.js index a03ecabba..53431f7a4 100644 --- a/src/hooks/useMapSource/index.js +++ b/src/hooks/useMapSource/index.js @@ -32,16 +32,14 @@ const useMapSource = (sourceConfig, defaultConfig = { type: 'geojson' }) => { let timeouts = []; sourcesConfigs.forEach(config => { if (!!config?.id && !!config?.data){ - const { id, data, options = {} } = config; - if (!!options.enabled){ - const timeout = window.setTimeout(() => { - const source = map?.getSource?.(id); - if (source) { - source?.setData?.(data); - } - }); - timeouts.push(timeout); - } + const { id, data } = config; + const timeout = window.setTimeout(() => { + const source = map?.getSource?.(id); + if (source) { + source?.setData?.(data); + } + }); + timeouts.push(timeout); } }); From d0d06c23cc83c2ef01647c85b05410428a0db77c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20L=C3=B3pez?= Date: Mon, 3 Mar 2025 09:43:25 -0600 Subject: [PATCH 08/15] Removed unused hook --- src/hooks/index.js | 89 ---------------------------------------------- 1 file changed, 89 deletions(-) diff --git a/src/hooks/index.js b/src/hooks/index.js index 4fc284b60..984fcd3ba 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -74,95 +74,6 @@ export const useMapEventBinding = (eventType = 'click', handlerFn = noop, layerI }, [map, condition, eventType, layerId, handlerFn]); }; -export const useMapLayerZZZZ = (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); - - // Add layers that don't exist yet - 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]); - - // Update layout properties for existing layers - useEffect(() => { - if (condition && layer && layout) { - Object.entries(layout).forEach(([key, value]) => { - map.setLayoutProperty(layerId, key, value); - }); - } - }, [condition, layer, layerId, layout, map]); - - // Update paint properties for existing layers - useEffect(() => { - if (condition && layer && paint) { - Object.entries(paint).forEach(([key, value]) => { - map.setPaintProperty(layerId, key, value); - }); - } - }, [condition, map, layer, layerId, paint]); - - // Update filters for existing layers - useEffect(() => { - if (condition && map && map.getLayer(layerId)) { - map.setFilter(layerId, filter); - } - }, [condition, filter, layer, layerId, map]); - - // Remove layers when condition becomes false - useEffect(() => { - if (!condition && layer) { - map.removeLayer(layerId); - } - }, [condition, layer, layerId, map]); - - // Update layer order based on before - useEffect(() => { - return () => { - if (map) { - try { - map.getLayer(layerId) && map.removeLayer(layerId); - } catch (error) { - // console.warn('map unmount error', error); - } - } - }; - }, [layerId, map]); - - // Update zoom ranges - useEffect(() => { - if (condition && map && layer && (minzoom || maxzoom)) { - map.setLayerZoomRange(layerId, (minzoom || MIN_ZOOM), (maxzoom || MAX_ZOOM)); - } - }, [condition, layer, layerId, map, minzoom, maxzoom]); - - // Cleanup on unmount - 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; From 244beb2f7f56f231dcdf454f96e8122066225a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20L=C3=B3pez?= Date: Mon, 3 Mar 2025 12:07:57 -0600 Subject: [PATCH 09/15] Adding missing coverage to useMapLayer --- src/hooks/useMapLayer/index.test.js | 224 +++++++++++++++++++++------- 1 file changed, 171 insertions(+), 53 deletions(-) diff --git a/src/hooks/useMapLayer/index.test.js b/src/hooks/useMapLayer/index.test.js index 9c31d0b00..3ff4953a3 100644 --- a/src/hooks/useMapLayer/index.test.js +++ b/src/hooks/useMapLayer/index.test.js @@ -1,88 +1,206 @@ 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 useMapLayerBatch from './'; +import useMapLayer from './'; describe('hooks - useMapLayer', () => { - const layerId = 'sourceId'; - const baseMap = { - getSource: jest.fn(), - getLayer: jest.fn(), - addLayer: jest.fn(), - setLayoutProperty: jest.fn(), - setPaintProperty: jest.fn(), - setFilter: jest.fn(), - removeLayer: jest.fn(), - moveLayer: jest.fn(), - setLayerZoomRange: jest.fn(), - }; - - // eslint-disable-next-line react/display-name - const wrapper = (map) => ({ children }) => - {children} - ; - - const renderUserMapLayer = (layerConfig, map, defaultConfig) => - renderHook( - () => useMapLayerBatch(layerConfig, defaultConfig), - { wrapper: wrapper(map) } - ); + 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', () => { - const map = { - ...baseMap, - getSource: jest.fn(() => true) - }; - renderUserMapLayer({ - id: layerId, - type: 'string', - sourceId: 'whatever-source-id' - }, map); + renderHook(() => useMapLayer({ id: layerId, type: 'string', sourceId: 'whatever-source-id' }), { wrapper }); expect(map.addLayer).toHaveBeenCalled(); }); test('not adding a layer if no map is available', () => { - renderUserMapLayer(); - expect(baseMap.addLayer).not.toHaveBeenCalled(); + renderHook(() => useMapLayer()); // no context wrapper means there's no map available; + + expect(map.addLayer).not.toHaveBeenCalled(); }); describe('when the layer is present', () => { beforeEach(() => { - baseMap.getLayer.mockReturnValue({ whatever: 'ok' }); + map.getLayer.mockReturnValue({ whatever: 'ok' }); }); - test('setting and changing paint props', () => { - let config = { + let paintObject = { value1: 'yellow', value2: 0.6 }; + + const { rerender } = renderHook(() => useMapLayer({ id: layerId, type: 'string', sourceId: 'whatever-source-id', - paint: { value1: 'yellow', value2: 0.6 } - }; + paint: paintObject + }), { wrapper }); - const { rerender } = renderUserMapLayer(config, baseMap); + Object.entries(paintObject).forEach(([key, value]) => { + expect(map.setPaintProperty).toHaveBeenCalledWith(layerId, key, value); + }); + + paintObject = { whatever: true }; + + + rerender(); - Object.entries(config.paint).forEach(([key, value]) => { - expect(baseMap.setPaintProperty).toHaveBeenCalledWith(layerId, key, value); + Object.entries(paintObject).forEach(([key, value]) => { + expect(map.setPaintProperty).toHaveBeenCalledWith(layerId, key, value); }); - const newConfig = { + }); + + test('setting and changing layout props', () => { + let layoutObject = { value1: 'yellow', value2: 0.6 }; + + const { rerender } = renderHook(() => useMapLayer({ id: layerId, type: 'string', sourceId: 'whatever-source-id', - paint: { - whatever: true - } - }; + layout: layoutObject + }), { wrapper }); - rerender(newConfig); + Object.entries(layoutObject).forEach(([key, value]) => { + expect(map.setLayoutProperty).toHaveBeenCalledWith(layerId, key, value); + }); - Object.entries(config.paint).forEach(([key, value]) => { - expect(baseMap.setPaintProperty).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({ + 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(() => useMapLayer({ + 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']); + + }); + + test('.before sets and changes', async () => { + let before = null; + + const { rerender } = renderHook(() => useMapLayer({ + id: layerId, + type: 'string', + sourceId: 'whatever-source-id', + options: { 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({ + id: layerId, + type: 'string', + sourceId: 'whatever-source-id', + options: 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({ + 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(); + }); + }); + + }); + }); }); -}); + +}); \ No newline at end of file From 4bf25850abea183f967b7f3a10d997369c32d626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20L=C3=B3pez?= Date: Mon, 3 Mar 2025 13:53:53 -0600 Subject: [PATCH 10/15] Adding PR feedbacl --- src/BaseLayerRenderer/TileLayerRenderer.js | 2 +- src/TracksLayer/track.js | 11 +- src/TracksLayer/utils/index.js | 21 +-- src/TracksLayer/utils/index.test.js | 14 +- src/hooks/index.js | 2 +- src/hooks/useMapLayer/index.js | 192 ++++++++++----------- src/hooks/useMapLayer/index.test.js | 4 +- src/hooks/useMapSource/index.js | 46 +++-- src/selectors/tracks/index.js | 4 +- src/utils/tracks.js | 9 +- src/utils/tracks.test.js | 10 +- 11 files changed, 147 insertions(+), 168 deletions(-) diff --git a/src/BaseLayerRenderer/TileLayerRenderer.js b/src/BaseLayerRenderer/TileLayerRenderer.js index 2120c02d3..89d7660a1 100644 --- a/src/BaseLayerRenderer/TileLayerRenderer.js +++ b/src/BaseLayerRenderer/TileLayerRenderer.js @@ -26,7 +26,7 @@ const SourceComponent = ({ id, tileUrl, sourceConfig }) => { ...sourceConfig, }), [sourceConfig, tileUrl]); - useMapSource({ id, data: {} }, config); + useMapSource({ id }, config); return null; }; diff --git a/src/TracksLayer/track.js b/src/TracksLayer/track.js index da251ef6e..1543c9ddc 100644 --- a/src/TracksLayer/track.js +++ b/src/TracksLayer/track.js @@ -6,7 +6,7 @@ import { MapContext } from '../App'; import { useMapEventBinding } from '../hooks'; -import { generateMapSourcesAndLayersBasedOnTwoLineTrackPointsSegments } from './utils'; +import { getTimeOfDaySourceAndLayerConfigurations } from './utils'; import { useSelector } from 'react-redux'; import { selectTrackSettings } from '../selectors/tracks'; import useMapSource from '../hooks/useMapSource'; @@ -50,7 +50,7 @@ const TrackLayer = ({ before, id, lineLayout, linePaint, onPointClick, showTimep const map = useContext(MapContext); const { isTimeOfDayColoringActive } = useSelector(selectTrackSettings); - const trackId = id || (trackData.track?.features?.[0]?.properties?.id || 'unknown-track'); + const trackId = id || 'unknown-track'; const onSymbolMouseEnter = () => map.getCanvas().style.cursor = 'pointer'; const onSymbolMouseLeave = () => map.getCanvas().style.cursor = ''; @@ -63,9 +63,8 @@ const TrackLayer = ({ before, id, lineLayout, linePaint, onPointClick, showTimep const { sourcesConfigs, - layersConfigs, - hasTimeOfDaySegments - } = useMemo(() => generateMapSourcesAndLayersBasedOnTwoLineTrackPointsSegments( + layersConfigs + } = useMemo(() => getTimeOfDaySourceAndLayerConfigurations( trackData, isTimeOfDayColoringActive, sourceId, @@ -93,7 +92,7 @@ const TrackLayer = ({ before, id, lineLayout, linePaint, onPointClick, showTimep layout: { ...TRACK_LAYER_LINE_LAYOUT, ...lineLayout }, options: { before: before || SUBJECT_SYMBOLS, - condition: !isTimeOfDayColoringActive && !hasTimeOfDaySegments + condition: !isTimeOfDayColoringActive && sourcesConfigs.length === 0 } } ); diff --git a/src/TracksLayer/utils/index.js b/src/TracksLayer/utils/index.js index 937220594..a461949e0 100644 --- a/src/TracksLayer/utils/index.js +++ b/src/TracksLayer/utils/index.js @@ -1,12 +1,10 @@ -export const segmentTrackPointsByTimeOfDayPeriodPairs = (twoPointLineStringTrackPoints) => { +export const segmentTrackPointsByTimeOfDayPeriodPairs = (timeOfDayFeatureCollection) => { const segmentsByColorPair = {}; - let segmentsWithMissingColors = 0; - twoPointLineStringTrackPoints.features.forEach((segment) => { + timeOfDayFeatureCollection.features.forEach((segment) => { // Skip segments without required color properties if (!segment.properties?.startColor || !segment.properties?.endColor) { - segmentsWithMissingColors++; return; } @@ -17,22 +15,18 @@ export const segmentTrackPointsByTimeOfDayPeriodPairs = (twoPointLineStringTrack segmentsByColorPair[key].push(segment); }); - if (segmentsWithMissingColors > 0){ - console.warn('Segments with missing colors:', segmentsWithMissingColors); - } - return segmentsByColorPair; }; -export const generateMapSourcesAndLayersBasedOnTwoLineTrackPointsSegments = (trackData, isTimeOfDayColoringActive, sourceId, layerId, layerLayout, layerOptions) => { +export const getTimeOfDaySourceAndLayerConfigurations = (trackData, isTimeOfDayColoringActive, sourceId, layerId, layerLayout, layerOptions) => { - if (!trackData?.twoPointLineStringTrackPoints?.features?.length || !isTimeOfDayColoringActive) { - return { sourcesConfigs: [], layersConfigs: [], hasTimeOfDaySegments: false }; + if (!trackData?.timeOfDayFeatureCollection?.features?.length || !isTimeOfDayColoringActive) { + return { sourcesConfigs: [], layersConfigs: [] }; } const sources = []; const layers = []; - const trackPointsSegmentsByColorPair = segmentTrackPointsByTimeOfDayPeriodPairs(trackData.twoPointLineStringTrackPoints); + const trackPointsSegmentsByColorPair = segmentTrackPointsByTimeOfDayPeriodPairs(trackData.timeOfDayFeatureCollection); Object.entries(trackPointsSegmentsByColorPair).forEach(([colorPairKey, segments], index) => { const [startColor, endColor] = colorPairKey.split('|'); @@ -75,7 +69,6 @@ export const generateMapSourcesAndLayersBasedOnTwoLineTrackPointsSegments = (tra return { sourcesConfigs: sources, - layersConfigs: layers, - hasTimeOfDaySegments: sources.length > 0 + layersConfigs: layers }; }; diff --git a/src/TracksLayer/utils/index.test.js b/src/TracksLayer/utils/index.test.js index 1d9f444da..74148b304 100644 --- a/src/TracksLayer/utils/index.test.js +++ b/src/TracksLayer/utils/index.test.js @@ -1,6 +1,6 @@ -import { buildFeatureCollectionOfTwoPointLineStringSegments } from '../../utils/tracks'; +import { buildTimeOfDayFeatureCollection } from '../../utils/tracks'; import { - generateMapSourcesAndLayersBasedOnTwoLineTrackPointsSegments, + getTimeOfDaySourceAndLayerConfigurations, segmentTrackPointsByTimeOfDayPeriodPairs } from './'; @@ -49,8 +49,8 @@ describe('TracksLayer - utils', () => { } ] }; - const twoPointLineStringTrackPoints = buildFeatureCollectionOfTwoPointLineStringSegments(track, 'America/Monterrey'); - const segmentPairs = segmentTrackPointsByTimeOfDayPeriodPairs(twoPointLineStringTrackPoints); + const timeOfDayFeatureCollection = buildTimeOfDayFeatureCollection(track, 'America/Monterrey'); + const segmentPairs = segmentTrackPointsByTimeOfDayPeriodPairs(timeOfDayFeatureCollection); test('segments track points by pairs of time of day periods', () => { const layoutOptions = { @@ -62,10 +62,10 @@ describe('TracksLayer - utils', () => { }; const sourceId = 'aSourceId'; const layerId = 'aLayerId'; - const trackData = { twoPointLineStringTrackPoints }; - const configs = generateMapSourcesAndLayersBasedOnTwoLineTrackPointsSegments(trackData, true, sourceId, layerId, layoutOptions, layerOptions); + const trackData = { timeOfDayFeatureCollection }; + const configs = getTimeOfDaySourceAndLayerConfigurations(trackData, true, sourceId, layerId, layoutOptions, layerOptions); - expect(configs.hasTimeOfDaySegments).toBe(true); + expect(configs.sourcesConfigs).toBe(true); Object.entries(segmentPairs).forEach(([pairKey, segment], index) => { const [startColor, endColor] = pairKey.split('|'); diff --git a/src/hooks/index.js b/src/hooks/index.js index 984fcd3ba..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]); diff --git a/src/hooks/useMapLayer/index.js b/src/hooks/useMapLayer/index.js index a82023c7d..1b81adb1c 100644 --- a/src/hooks/useMapLayer/index.js +++ b/src/hooks/useMapLayer/index.js @@ -1,4 +1,4 @@ -import { useContext, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useContext, useEffect, useMemo, useRef } from 'react'; import { MapContext } from '../../App'; import { MAX_ZOOM, MIN_ZOOM } from '../../constants'; @@ -6,158 +6,142 @@ import { MAX_ZOOM, MIN_ZOOM } from '../../constants'; const useMapLayer = (layerConfig, defaultConfig = {}) => { const map = useContext(MapContext); const layerIdsRef = useRef([]); - const layersConfigs = useMemo(() => Array.isArray(layerConfig) ? layerConfig : [layerConfig], [layerConfig]); + const layerConfigsBatch = useMemo(() => Array.isArray(layerConfig) ? layerConfig : [layerConfig], [layerConfig]); + + const shouldUpdateMapLayer = useCallback((config) => config?.id && config?.condition !== false && map.getLayer(config.id), [map]); - // Add layers that don't exist yet useEffect(() => { if (map){ - layersConfigs.forEach(config => { - if (config?.id && config?.type && config?.sourceId){ - const { id, type, sourceId, paint = {}, layout = {}, options = {} } = config; - const { filter, condition, before } = options; - const conditionValue = condition ?? true; - const beforeValue = before || defaultConfig.before; - - if (map.getSource(sourceId) && conditionValue && !map.getLayer(id)){ - const layerObj = { - id, - source: sourceId, - type, - layout: layout || {}, - paint: paint || {} - }; - - // Only add filter if it's defined and is an array - const filterValue = filter || defaultConfig.filter; - if (Array.isArray(filterValue)) { - layerObj.filter = filterValue; - } + layerConfigsBatch.forEach(layerConfig => { + if ( + layerConfig?.id + && layerConfig?.condition !== false + && !map.getLayer(layerConfig.id) + && layerConfig?.type + && layerConfig?.sourceId + && map.getSource(layerConfig.sourceId) + ){ + const { + id, + type, + sourceId, + paint = {}, + layout = {}, + options: { + filter, + before + } = {} + } = layerConfig; + + const layerObj = { + id, + source: sourceId, + type, + layout: layout, + paint: paint + }; - // Handle line-gradient and line-color conflict - if (type === 'line' && paint?.['line-gradient'] && paint?.['line-color']) { - console.warn(`Layer ${id}: line-gradient and line-color cannot both be specified`); - delete layerObj.paint['line-color']; - } + const filterValue = filter || defaultConfig.filter; + if (Array.isArray(filterValue)) { + layerObj.filter = filterValue; + } - map.addLayer(layerObj, beforeValue); - layerIdsRef.current.push(id); + // Handle line-gradient and line-color conflict + if (type === 'line' && paint?.['line-gradient'] && paint?.['line-color']) { + console.warn(`Layer ${id}: line-gradient and line-color cannot both be specified`); + delete layerObj.paint['line-color']; } + + map.addLayer(layerObj, before || defaultConfig.before); + layerIdsRef.current.push(id); } }); } - }, [map, defaultConfig, layersConfigs]); + }, [map, defaultConfig, layerConfigsBatch, shouldUpdateMapLayer]); - // Update layout properties for existing layers useEffect(() => { if (map) { - layersConfigs.forEach(config => { - if (config?.id && config.layout){ - const { id, layout, options = {} } = config; - const { condition } = options; - if (( condition ?? true ) && map.getLayer(id) && layout) { - Object.entries(layout).forEach(([key, value]) => { - map.setLayoutProperty(id, key, value); - }); - } + layerConfigsBatch.forEach(layerConfig => { + if ( shouldUpdateMapLayer(layerConfig) && layerConfig.layout ){ + Object.entries(layerConfig.layout).forEach(([name, value]) => { + map.setLayoutProperty(layerConfig.id, name, value); + }); } }); } - }, [map, layersConfigs]); + }, [map, layerConfigsBatch, shouldUpdateMapLayer]); - // Update paint properties for existing layers useEffect(() => { if (map) { - layersConfigs.forEach(config => { - if (config?.id && config?.paint){ - const { id, paint, options = {} } = config; - const { condition } = options; - if (( condition ?? true ) && map.getLayer(id) && paint) { - Object.entries(paint).forEach(([key, value]) => { - map.setPaintProperty(id, key, value); - }); - } + layerConfigsBatch.forEach(layerConfig => { + if ( shouldUpdateMapLayer(layerConfig) && layerConfig.paint ){ + Object.entries(layerConfig.paint).forEach(([name, value]) => { + map.setPaintProperty(layerConfig.id, name, value); + }); } }); } - }, [map, layersConfigs]); + }, [map, layerConfigsBatch, shouldUpdateMapLayer]); - // Update filters for existing layers useEffect(() => { if (map) { - layersConfigs.forEach(config => { - if (config?.id){ - const { id, options = {} } = config; - const { filter, condition } = options; - const filterValue = filter || defaultConfig.filter; - - // Only set filter if it's valid (must be an array) - if (( condition ?? true ) && Array.isArray(filterValue) && map.getLayer(id)) { - map.setFilter(id, filterValue); - } + layerConfigsBatch.forEach(layerConfig => { + const filter = layerConfig?.options?.filter || defaultConfig.filter; + if (shouldUpdateMapLayer(layerConfig) && Array.isArray(filter)){ + map.setFilter(layerConfig.id, filter); } }); } - }, [map, layersConfigs, defaultConfig]); + }, [map, layerConfigsBatch, defaultConfig, shouldUpdateMapLayer]); - // Remove layers when condition becomes false useEffect(() => { if (map) { - layersConfigs.forEach(config => { - if (config?.id){ - const { id, options = {} } = config; - const { condition } = options; - if (!(condition ?? true) && map.getLayer(id)) { - map.removeLayer(id); - } + layerConfigsBatch.forEach(layerConfig => { + if ( shouldUpdateMapLayer(layerConfig) ){ + map.removeLayer(layerConfig.id); } }); } - }, [map, layersConfigs]); + }, [map, layerConfigsBatch, shouldUpdateMapLayer]); - // Update layer order based on before useEffect(() => { if (map) { - layersConfigs.forEach(config => { - if (config?.id){ - const { id, options = {} } = config; - const { before } = options; - const beforeValue = before || defaultConfig.before; - - if (beforeValue && map.getLayer(id)) { - map.moveLayer(id, beforeValue); - } + layerConfigsBatch.forEach(layerConfig => { + const before = layerConfig?.options?.before || defaultConfig.before; + if ( + layerConfig?.id + && before + && map.getLayer(layerConfig.id) + ){ + map.moveLayer(layerConfig.id, before); } }); } - }, [map, layersConfigs, defaultConfig]); + }, [map, layerConfigsBatch, defaultConfig]); - // Update zoom ranges useEffect(() => { if (map) { - layersConfigs.forEach(config => { - if (!config || !config.id) return; - - const { id, options = {} } = config; - const { condition, minZoom, maxZoom } = options; - const minZoomValue = minZoom || defaultConfig.minZoom || MIN_ZOOM; - const maxZoomValue = maxZoom || defaultConfig.maxZoom || MAX_ZOOM; - - if (( condition ?? true ) && map.getLayer(id)) { - map.setLayerZoomRange(id, minZoomValue, maxZoomValue); + layerConfigsBatch.forEach(layerConfig => { + if ( shouldUpdateMapLayer(layerConfig) ) { + const { options: { minZoom, maxZoom } = {} } = layerConfig; + map.setLayerZoomRange( + layerConfig.id, + minZoom || defaultConfig.minZoom || MIN_ZOOM, + maxZoom || defaultConfig.maxZoom || MAX_ZOOM + ); } }); } - }, [map, layersConfigs, defaultConfig]); + }, [map, layerConfigsBatch, defaultConfig, shouldUpdateMapLayer]); - // Cleanup on unmount useEffect(() => { const refs = layerIdsRef.current; return () => { if (map) { try { - refs.forEach(id => { - if (map.getLayer(id)) { - map.removeLayer(id); + refs.forEach(layerId => { + if (map.getLayer(layerId)) { + map.removeLayer(layerId); } }); } catch (error) { @@ -167,7 +151,9 @@ const useMapLayer = (layerConfig, defaultConfig = {}) => { }; }, [map]); - return layersConfigs.map((layer) => map?.getLayer(layer.id)); + return layerConfigsBatch + .map((layerConfig) => layerConfig?.id ? map?.getLayer(layerConfig.id) : null) + .filter(layerConfig => !!layerConfig); }; export default useMapLayer; diff --git a/src/hooks/useMapLayer/index.test.js b/src/hooks/useMapLayer/index.test.js index 3ff4953a3..d38c7b50e 100644 --- a/src/hooks/useMapLayer/index.test.js +++ b/src/hooks/useMapLayer/index.test.js @@ -179,7 +179,7 @@ describe('hooks - useMapLayer', () => { options: config }), { wrapper }); - expect(map.addLayer).not.toHaveBeenCalled(); + expect(map.addLayer).toHaveBeenCalled(); config.condition = true; @@ -203,4 +203,4 @@ describe('hooks - useMapLayer', () => { }); }); -}); \ No newline at end of file +}); diff --git a/src/hooks/useMapSource/index.js b/src/hooks/useMapSource/index.js index 53431f7a4..6312c133b 100644 --- a/src/hooks/useMapSource/index.js +++ b/src/hooks/useMapSource/index.js @@ -2,42 +2,35 @@ import { useContext, useEffect, useMemo, useRef } from 'react'; import { MapContext } from '../../App'; + const useMapSource = (sourceConfig, defaultConfig = { type: 'geojson' }) => { const map = useContext(MapContext); const sourceIdsRef = useRef([]); - const sourcesConfigs = useMemo(() => Array.isArray(sourceConfig) ? sourceConfig : [sourceConfig], [sourceConfig]); + const sourceConfigsBatch = useMemo(() => Array.isArray(sourceConfig) ? sourceConfig : [sourceConfig], [sourceConfig]); useEffect(() => { - // Initialize sources that don't exist yet if (map) { - sourcesConfigs.forEach(config => { - if (!!config?.id && !!config?.data){ - const { id, data, options = {} } = config; - const sourceConfig = { ...defaultConfig, ...options }; - - if (!map.getSource(id)) { - map.addSource(id, { - ...sourceConfig, - data, - }); - sourceIdsRef.current.push(id); - } + 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, sourcesConfigs, defaultConfig]); + }, [map, sourceConfigsBatch, defaultConfig]); - // Update data for existing sources useEffect(() => { let timeouts = []; - sourcesConfigs.forEach(config => { - if (!!config?.id && !!config?.data){ - const { id, data } = config; + sourceConfigsBatch.forEach(sourceConfig => { + const source = map?.getSource?.(sourceConfig?.id); + if (sourceConfig?.id && sourceConfig?.data && source){ const timeout = window.setTimeout(() => { - const source = map?.getSource?.(id); - if (source) { - source?.setData?.(data); - } + source.setData(sourceConfig.data); }); timeouts.push(timeout); } @@ -48,9 +41,8 @@ const useMapSource = (sourceConfig, defaultConfig = { type: 'geojson' }) => { window.clearTimeout(timeout); }); }; - }, [map, sourcesConfigs]); + }, [map, sourceConfigsBatch]); - // Cleanup on unmount useEffect(() => { const refs = sourceIdsRef?.current; return () => { @@ -66,7 +58,9 @@ const useMapSource = (sourceConfig, defaultConfig = { type: 'geojson' }) => { }; }, [map]); - return sourcesConfigs.map((source) => map?.getSource(source.id)); + return sourceConfigsBatch + .map((sourceConfig) => sourceConfig.id ? map?.getSource(sourceConfig.id) : null) + .filter(sourceConfig => !!sourceConfig); }; export default useMapSource; diff --git a/src/selectors/tracks/index.js b/src/selectors/tracks/index.js index b79953381..6f018d619 100644 --- a/src/selectors/tracks/index.js +++ b/src/selectors/tracks/index.js @@ -6,7 +6,7 @@ import { TRACK_LENGTH_ORIGINS } from '../../ducks/tracks'; import { trimTrackDataToTimeRange, - buildFeatureCollectionOfTwoPointLineStringSegments + buildTimeOfDayFeatureCollection } from '../../utils/tracks'; const selectEventFilter = (state) => state.data.eventFilter; @@ -109,7 +109,7 @@ export const selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod = return isTimeOfDayColoringActive ? { ...trimmedTrackData, - twoPointLineStringTrackPoints: buildFeatureCollectionOfTwoPointLineStringSegments(trimmedTrackData.track, timeOfDayTimeZone) + timeOfDayFeatureCollection: buildTimeOfDayFeatureCollection(trimmedTrackData.track, timeOfDayTimeZone) } : trimmedTrackData; } diff --git a/src/utils/tracks.js b/src/utils/tracks.js index bcf638c45..46d4d231e 100644 --- a/src/utils/tracks.js +++ b/src/utils/tracks.js @@ -370,7 +370,14 @@ export const getTimeOfDayPeriodBasedOnTime = (datetimeString, timeZone) => { return period ?? TIME_OF_DAY_PERIODS[0]; }; -export const buildFeatureCollectionOfTwoPointLineStringSegments = (trackFeatureCollection, timeZone) => { +/* +* 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 buildTimeOfDayFeatureCollection = (trackFeatureCollection, timeZone) => { const featureCollection = { type: 'FeatureCollection', features: [] diff --git a/src/utils/tracks.test.js b/src/utils/tracks.test.js index d053248ad..afca9b9cf 100644 --- a/src/utils/tracks.test.js +++ b/src/utils/tracks.test.js @@ -1,4 +1,4 @@ -import { buildFeatureCollectionOfTwoPointLineStringSegments, getTimeOfDayPeriodBasedOnTime } from './tracks'; +import { buildTimeOfDayFeatureCollection, getTimeOfDayPeriodBasedOnTime } from './tracks'; import { TIME_OF_DAY_PERIODS } from '../constants'; @@ -136,7 +136,7 @@ describe('utils - tracks', () => { } ]; - const twoPointFeatureCollection = buildFeatureCollectionOfTwoPointLineStringSegments(track, 'America/Monterrey'); + const twoPointFeatureCollection = buildTimeOfDayFeatureCollection(track, 'America/Monterrey'); expect(twoPointFeatureCollection.features.length).toBe(resultFeatures.length); expect(twoPointFeatureCollection.type).toBe('FeatureCollection'); @@ -164,7 +164,7 @@ describe('utils - tracks', () => { const emptyTracks = { ...track }; emptyTracks.features = []; - const emptyFeatureCollection = buildFeatureCollectionOfTwoPointLineStringSegments(emptyTracks, 'America/Monterrey'); + const emptyFeatureCollection = buildTimeOfDayFeatureCollection(emptyTracks, 'America/Monterrey'); expect(emptyFeatureCollection.features.length).toBe(0); expect(emptyFeatureCollection.type).toBe('FeatureCollection'); @@ -174,7 +174,7 @@ describe('utils - tracks', () => { const invalidFeaturesTrack = { ...track }; invalidFeaturesTrack.features = [{}]; - const emptyFeatureCollection = buildFeatureCollectionOfTwoPointLineStringSegments(invalidFeaturesTrack, 'America/Monterrey'); + const emptyFeatureCollection = buildTimeOfDayFeatureCollection(invalidFeaturesTrack, 'America/Monterrey'); expect(emptyFeatureCollection.features.length).toBe(0); expect(emptyFeatureCollection.type).toBe('FeatureCollection'); @@ -187,7 +187,7 @@ describe('utils - tracks', () => { const notEnoughFeaturesTracks = { ...track }; notEnoughFeaturesTracks.features = [feature]; - const emptyFeatureCollection = buildFeatureCollectionOfTwoPointLineStringSegments(notEnoughFeaturesTracks, 'America/Monterrey'); + const emptyFeatureCollection = buildTimeOfDayFeatureCollection(notEnoughFeaturesTracks, 'America/Monterrey'); expect(emptyFeatureCollection.features.length).toBe(0); expect(emptyFeatureCollection.type).toBe('FeatureCollection'); From 7d7e99dba54a930a38ae94f9af0ab4cb34df0403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20L=C3=B3pez?= Date: Tue, 4 Mar 2025 09:56:50 -0600 Subject: [PATCH 11/15] Adding 2nd review feedback --- src/AnalyzersLayer/index.js | 88 ++++----- src/BaseLayerRenderer/TileLayerRenderer.js | 24 ++- src/ClustersLayer/index.js | 10 +- src/EventGeometryLayer/index.js | 10 +- src/EventsLayer/index.js | 4 +- src/FeatureLayer/index.js | 54 +++--- src/HeatLayer/index.js | 24 ++- src/LabeledSymbolLayer/index.js | 10 +- src/MapDrawingTools/MapLayers.js | 106 +++++------ src/MouseMarkerLayer/index.js | 10 +- src/PatrolStartStopLayer/layer.js | 10 +- src/StaticSensorsLayer/index.js | 40 ++-- src/SubjectsLayer/index.js | 6 +- src/TracksLayer/track.js | 58 +++--- src/TracksLayer/utils/index.js | 8 +- src/TracksLayer/utils/index.test.js | 10 +- src/UserCurrentLocationLayer/index.js | 14 +- src/hooks/useClusterBufferPolygon/index.js | 10 +- .../{useMapLayer => useMapLayers}/index.js | 7 +- .../index.test.js | 36 ++-- src/hooks/useMapSource/index.test.js | 90 --------- .../{useMapSource => useMapSources}/index.js | 7 +- src/hooks/useMapSources/index.test.js | 173 ++++++++++++++++++ src/selectors/tracks/index.js | 4 +- src/selectors/tracks/index.test.js | 112 ++++++++++++ src/utils/tracks.js | 26 +-- src/utils/tracks.test.js | 20 +- 27 files changed, 565 insertions(+), 406 deletions(-) rename src/hooks/{useMapLayer => useMapLayers}/index.js (94%) rename src/hooks/{useMapLayer => useMapLayers}/index.test.js (83%) delete mode 100644 src/hooks/useMapSource/index.test.js rename src/hooks/{useMapSource => useMapSources}/index.js (84%) create mode 100644 src/hooks/useMapSources/index.test.js diff --git a/src/AnalyzersLayer/index.js b/src/AnalyzersLayer/index.js index 951289c17..30d77ff4d 100644 --- a/src/AnalyzersLayer/index.js +++ b/src/AnalyzersLayer/index.js @@ -3,8 +3,8 @@ import withMapViewConfig from '../WithMapViewConfig'; import { LAYER_IDS, SOURCE_IDS } from '../constants'; import { useMapEventBinding } from '../hooks'; -import useMapSource from '../hooks/useMapSource'; -import useMapLayer from '../hooks/useMapLayer'; +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; @@ -70,52 +70,44 @@ const AnalyzerLayer = ( condition: !!isSubjectSymbolsLayerReady, }), [isSubjectSymbolsLayerReady, minZoom]); - useMapSource({ id: ANALYZER_POLYS_WARNING_SOURCE, data: warningPolys }); - useMapLayer( - { - id: ANALYZER_POLYS_WARNING, - type: 'line', - sourceId: ANALYZER_POLYS_WARNING_SOURCE, - paint: linePaint, - options: layerConfig, - } - ); - - useMapSource({ id: ANALYZER_POLYS_CRITICAL_SOURCE, data: criticalPolys }); - useMapLayer( - { - id: ANALYZER_POLYS_CRITICAL, - type: 'line', - sourceId: ANALYZER_POLYS_CRITICAL_SOURCE, - paint: criticalLinePaint, - layout: lineLayout, - options: layerConfig, - } - ); - - useMapSource({ id: ANALYZER_LINES_WARNING_SOURCE, data: warningLines }); - useMapLayer( - { - id: ANALYZER_LINES_WARNING, - type: 'line', - sourceId: ANALYZER_LINES_WARNING_SOURCE, - paint: linePaint, - layout: lineLayout, - options: layerConfig, - } - ); - - useMapSource({ id: ANALYZER_LINES_CRITICAL_SOURCE, data: criticalLines }); - useMapLayer( - { - id: ANALYZER_LINES_CRITICAL, - type: 'line', - sourceId: ANALYZER_LINES_CRITICAL_SOURCE, - paint: criticalLinePaint, - layout: lineLayout, - options: 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 89d7660a1..67648eb4f 100644 --- a/src/BaseLayerRenderer/TileLayerRenderer.js +++ b/src/BaseLayerRenderer/TileLayerRenderer.js @@ -4,8 +4,8 @@ import { MapContext } from '../App'; import { TILE_LAYER_SOURCE_TYPES, LAYER_IDS, MAX_ZOOM, MIN_ZOOM } from '../constants'; import { calcConfigForMapAndSourceFromLayer } from '../utils/layers'; -import useMapSource from '../hooks/useMapSource'; -import useMapLayer from '../hooks/useMapLayer'; +import useMapSources from '../hooks/useMapSources'; +import useMapLayers from '../hooks/useMapLayers'; const { TOPMOST_STYLE_LAYER } = LAYER_IDS; @@ -26,7 +26,7 @@ const SourceComponent = ({ id, tileUrl, sourceConfig }) => { ...sourceConfig, }), [sourceConfig, tileUrl]); - useMapSource({ id }, config); + useMapSources([{ id }], config); return null; }; @@ -51,17 +51,15 @@ const TileLayerRenderer = (props) => { } }, [map, mapConfig]); - useMapLayer( - { - id: `tile-layer-${activeLayer?.id}`, - type: 'raster', - sourceId: `layer-source-${activeLayer?.id}`, - options: { - 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 2ca1bb4fb..13452ee01 100644 --- a/src/ClustersLayer/index.js +++ b/src/ClustersLayer/index.js @@ -11,8 +11,8 @@ import { getShouldEventsBeClustered, getShouldSubjectsBeClustered } from '../sel import { MapContext } from '../App'; import useClusterBufferPolygon from '../hooks/useClusterBufferPolygon'; import { useMapEventBinding } from '../hooks'; -import useMapSource from '../hooks/useMapSource'; -import useMapLayer from '../hooks/useMapLayer'; +import useMapSources from '../hooks/useMapSources'; +import useMapLayers from '../hooks/useMapLayers'; const { CLUSTERS_LAYER_ID, @@ -55,14 +55,14 @@ const ClustersLayer = ({ onShowClusterSelectPopup }) => { subjectFeatureCollection.features, ]); - useMapSource({ id: CLUSTERS_SOURCE_ID, data: clustersSourceData }, CLUSTER_SOURCE_CONFIG); - useMapLayer({ + 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 974ed5f3e..a6899dfed 100644 --- a/src/EventGeometryLayer/index.js +++ b/src/EventGeometryLayer/index.js @@ -10,8 +10,8 @@ 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 } from '../hooks'; -import useMapSource from '../hooks/useMapSource'; -import useMapLayer from '../hooks/useMapLayer'; +import useMapSources from '../hooks/useMapSources'; +import useMapLayers from '../hooks/useMapLayers'; const { EVENT_GEOMETRY_LAYER, EVENT_SYMBOLS } = LAYER_IDS; @@ -63,16 +63,16 @@ const EventGeometryLayer = ({ onClick }) => { const onMouseEnter = () => map.getCanvas().style.cursor = 'pointer'; const onMouseLeave = () => map.getCanvas().style.cursor = ''; - useMapSource({ id: EVENT_GEOMETRY, data: eventFeatureCollection }); + useMapSources([{ id: EVENT_GEOMETRY, data: eventFeatureCollection }]); - useMapLayer({ + 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 d19f886a5..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/useMapSource'; +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({ id: UNCLUSTERED_EVENTS_SOURCE, data: 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 cb545c8a0..4de0940ae 100644 --- a/src/FeatureLayer/index.js +++ b/src/FeatureLayer/index.js @@ -11,8 +11,8 @@ 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 } from '../hooks'; -import useMapSource from '../hooks/useMapSource'; -import useMapLayer from '../hooks/useMapLayer'; +import useMapSources from '../hooks/useMapSources'; +import useMapLayers from '../hooks/useMapLayers'; const { FEATURE_FILLS, FEATURE_LINES, FEATURE_SYMBOLS, SKY_LAYER } = LAYER_IDS; @@ -116,41 +116,37 @@ const FeatureLayer = ({ symbols, lines, polygons, onFeatureSymbolClick, mapUserL const layerConfig = { minZoom, before: SKY_LAYER }; - useMapSource({ id: MAP_FEATURES_LINES_SOURCE, data: lines }); - useMapSource({ id: MAP_FEATURES_POLYGONS_SOURCE, data: polygons }); - useMapSource({ id: MAP_FEATURES_SYMBOLS_SOURCE, data: symbols }); + 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) - useMapLayer({ + useMapLayers([{ id: FEATURE_FILLS, type: 'fill', sourceId: MAP_FEATURES_POLYGONS_SOURCE, paint: fillPaint, layout: fillLayout, options: layerConfig - }); - - useMapLayer( - { - id: FEATURE_LINES, - type: 'line', - sourceId: MAP_FEATURES_LINES_SOURCE, - paint: linePaint, - layout: lineLayout, - options: layerConfig - } - ); - - useMapLayer( - { - id: FEATURE_SYMBOLS, - type: 'symbol', - sourceId: MAP_FEATURES_SYMBOLS_SOURCE, - paint: symbolPaint, - layout: layout, - 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 18946e6fa..e9193b99e 100644 --- a/src/HeatLayer/index.js +++ b/src/HeatLayer/index.js @@ -6,8 +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 useMapSource from '../hooks/useMapSource'; -import useMapLayer from '../hooks/useMapLayer'; +import useMapSources from '../hooks/useMapSources'; +import useMapLayers from '../hooks/useMapLayers'; const { HEATMAP_LAYER, SKY_LAYER } = LAYER_IDS; @@ -31,18 +31,16 @@ const HeatLayer = ({ points }) => { }; }, [heatmapStyles.intensity, heatmapStyles.radiusInMeters, points]); - useMapSource({ id: `heatmap-source-${idRef.current}`, data: points }); - useMapLayer( - { - id: `${HEATMAP_LAYER}-${idRef.current}`, - type: 'heatmap', - sourceId: `heatmap-source-${idRef.current}`, - paint, - options: { - 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 1beef4494..8d6f4c6a9 100644 --- a/src/LabeledSymbolLayer/index.js +++ b/src/LabeledSymbolLayer/index.js @@ -6,7 +6,7 @@ import { withMap } from '../EarthRangerMap'; import withMapViewConfig from '../WithMapViewConfig'; import { useMapEventBinding } from '../hooks'; -import useMapLayer from '../hooks/useMapLayer'; +import useMapLayers from '../hooks/useMapLayers'; const LabeledSymbolLayer = ( { before, paint, layout, textPaint, textLayout, id, sourceId, map, mapUserLayoutConfigByLayerId, onClick, onInit, @@ -80,23 +80,23 @@ const LabeledSymbolLayer = ( useMapEventBinding('mouseleave', handleMouseLeave, id); useMapEventBinding('mouseleave', handleMouseLeave, textLayerId); - useMapLayer({ + useMapLayers([{ id: id, type: 'symbol', sourceId, paint: symbolPaint, layout: symbolLayout, options: layerConfig - }); + }]); - useMapLayer({ + 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 ba4deb468..fc8128cf8 100644 --- a/src/MapDrawingTools/MapLayers.js +++ b/src/MapDrawingTools/MapLayers.js @@ -2,7 +2,7 @@ import React, { memo, useCallback, useContext, useEffect, useState } from 'react import { MapContext } from '../App'; import { useMapEventBinding } from '../hooks'; -import useMapSource from '../hooks/useMapSource'; +import useMapSources from '../hooks/useMapSources'; import { linePaint, @@ -14,7 +14,7 @@ import { lineSymbolLayout, polygonSymbolLayout, } from './layerStyles'; -import useMapLayer from '../hooks/useMapLayer'; +import useMapLayers from '../hooks/useMapLayers'; export const LAYER_IDS = { POINTS: 'draw-layer-points', @@ -47,64 +47,54 @@ const MapDrawingLayers = ({ const [isHoveringPolygonFill, setIsHoveringPolygonFill] = useState(false); const [isHoveringCircle, setIsHoveringCircle] = useState(false); - useMapSource({ id: SOURCE_IDS.FILL_SOURCE, data: fillPolygon }, { type: 'geojson' }); - useMapSource({ id: SOURCE_IDS.FILL_LABEL_SOURCE, data: fillLabelPoint }, { type: 'geojson' }); - useMapSource({ id: SOURCE_IDS.LINE_SOURCE, data: drawnLineSegments }, { type: 'geojson' }); - useMapSource({ id: SOURCE_IDS.POINT_SOURCE, data: drawnLinePoints }, { generateId: true, type: 'geojson' }); - - useMapLayer( - { - id: LAYER_IDS.LINE_LABELS, - type: 'symbol', - sourceId: SOURCE_IDS.LINE_SOURCE, - paint: symbolPaint, - layout: lineSymbolLayout, - options: { - condition: drawing || !isHoveringGeometry || draggedPoint - } + 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 } - ); - useMapLayer( - { - 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.FILL_LABEL, + type: 'symbol', + sourceId: SOURCE_IDS.FILL_LABEL_SOURCE, + paint: symbolPaint, + layout: polygonSymbolLayout, + options: { + condition: drawing || !isHoveringGeometry || draggedPoint } - ); - - useMapLayer( - { - id: LAYER_IDS.LINES, - type: 'line', - sourceId: SOURCE_IDS.LINE_SOURCE, - paint: linePaint, - layout: lineLayout - } - ); - - const [fillLayer] = useMapLayer( - { - id: LAYER_IDS.FILL, - type: 'fill', - sourceId: SOURCE_IDS.FILL_SOURCE, - paint: fillPaint, - layout: fillLayout - } - ); - - const [pointsLayer] = useMapLayer( - { - id: LAYER_IDS.POINTS, - type: 'circle', - sourceId: SOURCE_IDS.POINT_SOURCE, - paint: circlePaint - } - ); + }]); + + 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 66ee0450a..adaef4d9a 100644 --- a/src/MouseMarkerLayer/index.js +++ b/src/MouseMarkerLayer/index.js @@ -2,8 +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 useMapSource from '../hooks/useMapSource'; -import useMapLayer from '../hooks/useMapLayer'; +import useMapSources from '../hooks/useMapSources'; +import useMapLayers from '../hooks/useMapLayers'; const { MOUSE_MARKER_SOURCE } = SOURCE_IDS; const { MOUSE_MARKER_LAYER } = LAYER_IDS; @@ -23,13 +23,13 @@ const MouseMarkerLayer = ({ location }) => { , [location.lat, location.lng]); - useMapSource({ id: MOUSE_MARKER_SOURCE, data: cursorPoint }); - useMapLayer({ + 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/PatrolStartStopLayer/layer.js b/src/PatrolStartStopLayer/layer.js index 3d416e5fa..06f247780 100644 --- a/src/PatrolStartStopLayer/layer.js +++ b/src/PatrolStartStopLayer/layer.js @@ -9,8 +9,8 @@ import { withMap } from '../EarthRangerMap'; import { uuid } from '../utils/string'; import LabeledPatrolSymbolLayer from '../LabeledPatrolSymbolLayer'; import withMapViewConfig from '../WithMapViewConfig'; -import useMapSource from '../hooks/useMapSource'; -import useMapLayer from '../hooks/useMapLayer'; +import useMapSources from '../hooks/useMapSources'; +import useMapLayers from '../hooks/useMapLayers'; const { PATROL_SYMBOLS } = LAYER_IDS; @@ -90,14 +90,14 @@ const StartStopLayer = (props) => { const layerSymbolPaint = useMemo(() => ({ ...symbolPaint, 'text-color': ['get', 'stroke'] }), []); const layerLinePaint = useMemo(() => ({ ...linePaint, 'line-color': ['get', 'stroke'] }), []); - useMapSource({ id: sourceId, data: patrolPointsSourceData }); - useMapLayer({ + 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 d63c031ea..cf6b70363 100644 --- a/src/StaticSensorsLayer/index.js +++ b/src/StaticSensorsLayer/index.js @@ -10,7 +10,7 @@ import { MapContext } from '../App'; import { showPopup } from '../ducks/popup'; import { useMapEventBinding } from '../hooks'; import { backgroundLayerStyles, calcDynamicBackgroundLayerLayout, calcDynamicLabelLayerLayoutStyles, labelLayerStyles } from './layerStyles'; -import useMapLayer from '../hooks/useMapLayer'; +import useMapLayers from '../hooks/useMapLayers'; const { STATIC_SENSOR, CLUSTERED_STATIC_SENSORS_LAYER, UNCLUSTERED_STATIC_SENSORS_LAYER } = LAYER_IDS; @@ -105,27 +105,23 @@ const StaticSensorsLayer = () => { map.getCanvas().style.cursor = ''; }, [map]); - useMapLayer( - { - id: currentBackgroundLayerId, - type: 'symbol', - sourceId: currentSourceId, - paint: backgroundLayerStyles.paint, - layout: backgroundLayoutObject, - options: layerConfig, - } - ); - - useMapLayer( - { - id: currentLayerId, - type: 'symbol', - sourceId: currentSourceId, - paint: labelLayerStyles.paint, - layout: layoutObject, - options: 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 06691d534..0b56fb723 100644 --- a/src/SubjectsLayer/index.js +++ b/src/SubjectsLayer/index.js @@ -9,7 +9,7 @@ import { getShouldSubjectsBeClustered } from '../selectors/clusters'; import { LAYER_IDS, SOURCE_IDS, SUBJECT_FEATURE_CONTENT_TYPE } from '../constants'; import { MapContext } from '../App'; import { withMultiLayerHandlerAwareness } from '../utils/map-handlers'; -import useMapSource from '../hooks/useMapSource'; +import useMapSources from '../hooks/useMapSources'; import LabeledPatrolSymbolLayer from '../LabeledPatrolSymbolLayer'; import withMapViewConfig from '../WithMapViewConfig'; @@ -67,13 +67,13 @@ const SubjectsLayer = ({ mapImages, onSubjectClick }) => { } ), [map, onSubjectClick, subjectLayerIds]); - useMapSource({ + useMapSources([{ id: UNCLUSTERED_SOURCE_ID, data: { ...mapSubjectFeatures, features: !shouldSubjectsBeClustered ? mapSubjectFeatures.features : [], } - }); + }]); return <> { +export const segmentTrackPointsByTimeOfDayPeriodPairs = (trackSegments) => { const segmentsByColorPair = {}; - timeOfDayFeatureCollection.features.forEach((segment) => { + trackSegments.features.forEach((segment) => { // Skip segments without required color properties if (!segment.properties?.startColor || !segment.properties?.endColor) { return; @@ -20,13 +20,13 @@ export const segmentTrackPointsByTimeOfDayPeriodPairs = (timeOfDayFeatureCollect export const getTimeOfDaySourceAndLayerConfigurations = (trackData, isTimeOfDayColoringActive, sourceId, layerId, layerLayout, layerOptions) => { - if (!trackData?.timeOfDayFeatureCollection?.features?.length || !isTimeOfDayColoringActive) { + if (!trackData?.trackSegments?.features?.length || !isTimeOfDayColoringActive) { return { sourcesConfigs: [], layersConfigs: [] }; } const sources = []; const layers = []; - const trackPointsSegmentsByColorPair = segmentTrackPointsByTimeOfDayPeriodPairs(trackData.timeOfDayFeatureCollection); + const trackPointsSegmentsByColorPair = segmentTrackPointsByTimeOfDayPeriodPairs(trackData.trackSegments); Object.entries(trackPointsSegmentsByColorPair).forEach(([colorPairKey, segments], index) => { const [startColor, endColor] = colorPairKey.split('|'); diff --git a/src/TracksLayer/utils/index.test.js b/src/TracksLayer/utils/index.test.js index 74148b304..3b01b0896 100644 --- a/src/TracksLayer/utils/index.test.js +++ b/src/TracksLayer/utils/index.test.js @@ -1,4 +1,4 @@ -import { buildTimeOfDayFeatureCollection } from '../../utils/tracks'; +import { buildTrackSegments } from '../../utils/tracks'; import { getTimeOfDaySourceAndLayerConfigurations, segmentTrackPointsByTimeOfDayPeriodPairs @@ -49,8 +49,8 @@ describe('TracksLayer - utils', () => { } ] }; - const timeOfDayFeatureCollection = buildTimeOfDayFeatureCollection(track, 'America/Monterrey'); - const segmentPairs = segmentTrackPointsByTimeOfDayPeriodPairs(timeOfDayFeatureCollection); + const trackSegments = buildTrackSegments(track, 'America/Monterrey'); + const segmentPairs = segmentTrackPointsByTimeOfDayPeriodPairs(trackSegments); test('segments track points by pairs of time of day periods', () => { const layoutOptions = { @@ -62,10 +62,10 @@ describe('TracksLayer - utils', () => { }; const sourceId = 'aSourceId'; const layerId = 'aLayerId'; - const trackData = { timeOfDayFeatureCollection }; + const trackData = { trackSegments }; const configs = getTimeOfDaySourceAndLayerConfigurations(trackData, true, sourceId, layerId, layoutOptions, layerOptions); - expect(configs.sourcesConfigs).toBe(true); + expect(configs.sourcesConfigs.length > 0).toBe(true); Object.entries(segmentPairs).forEach(([pairKey, segment], index) => { const [startColor, endColor] = pairKey.split('|'); diff --git a/src/UserCurrentLocationLayer/index.js b/src/UserCurrentLocationLayer/index.js index c6e6b8fcb..c14ce3fb7 100644 --- a/src/UserCurrentLocationLayer/index.js +++ b/src/UserCurrentLocationLayer/index.js @@ -8,10 +8,10 @@ import { bboxBoundsPolygon, userLocationCanBeShown as userLocationCanBeShownSele import { MAP_ICON_SCALE, SOURCE_IDS } from '../constants'; import { MapContext } from '../App'; import { useMapEventBinding } from '../hooks'; -import useMapSource from '../hooks/useMapSource'; +import useMapSources from '../hooks/useMapSources'; import GpsLocationIcon from '../common/images/icons/gps-location-icon-blue.svg'; -import useMapLayer from '../hooks/useMapLayer'; +import useMapLayers from '../hooks/useMapLayers'; const { CURRENT_USER_LOCATION_SOURCE } = SOURCE_IDS; @@ -105,23 +105,23 @@ const UserCurrentLocationLayer = ({ onIconClick }) => { } }, [animationState, showLayer]); - useMapSource({ id: CURRENT_USER_LOCATION_SOURCE, data: userLocationPoint }); + useMapSources([{ id: CURRENT_USER_LOCATION_SOURCE, data: userLocationPoint }]); - useMapLayer({ + useMapLayers([{ id: ICON_LAYER_ID, type: 'symbol', sourceId: CURRENT_USER_LOCATION_SOURCE, layout: SYMBOL_LAYOUT, options: layerConfig - }); + }]); - useMapLayer({ + 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/hooks/useClusterBufferPolygon/index.js b/src/hooks/useClusterBufferPolygon/index.js index bd9d13fa3..5ea80b167 100644 --- a/src/hooks/useClusterBufferPolygon/index.js +++ b/src/hooks/useClusterBufferPolygon/index.js @@ -3,8 +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 useMapSource from '../useMapSource'; -import useMapLayer from '../useMapLayer'; +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; @@ -23,15 +23,15 @@ const useClusterBufferPolygon = () => { const [clusterBufferPolygon, setClusterBufferPolygon] = useState(featureCollection([])); const map = useContext(MapContext); - const [source] = useMapSource({ id: CLUSTER_BUFFER_POLYGON_SOURCE_ID, data: clusterBufferPolygon }); + const [source] = useMapSources([{ id: CLUSTER_BUFFER_POLYGON_SOURCE_ID, data: clusterBufferPolygon }]); - useMapLayer({ + 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/useMapLayer/index.js b/src/hooks/useMapLayers/index.js similarity index 94% rename from src/hooks/useMapLayer/index.js rename to src/hooks/useMapLayers/index.js index 1b81adb1c..7dab92909 100644 --- a/src/hooks/useMapLayer/index.js +++ b/src/hooks/useMapLayers/index.js @@ -1,12 +1,11 @@ -import { useCallback, useContext, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useContext, useEffect, useRef } from 'react'; import { MapContext } from '../../App'; import { MAX_ZOOM, MIN_ZOOM } from '../../constants'; -const useMapLayer = (layerConfig, defaultConfig = {}) => { +const useMapLayers = (layerConfigsBatch = [], defaultConfig = {}) => { const map = useContext(MapContext); const layerIdsRef = useRef([]); - const layerConfigsBatch = useMemo(() => Array.isArray(layerConfig) ? layerConfig : [layerConfig], [layerConfig]); const shouldUpdateMapLayer = useCallback((config) => config?.id && config?.condition !== false && map.getLayer(config.id), [map]); @@ -156,4 +155,4 @@ const useMapLayer = (layerConfig, defaultConfig = {}) => { .filter(layerConfig => !!layerConfig); }; -export default useMapLayer; +export default useMapLayers; diff --git a/src/hooks/useMapLayer/index.test.js b/src/hooks/useMapLayers/index.test.js similarity index 83% rename from src/hooks/useMapLayer/index.test.js rename to src/hooks/useMapLayers/index.test.js index d38c7b50e..425138b33 100644 --- a/src/hooks/useMapLayer/index.test.js +++ b/src/hooks/useMapLayers/index.test.js @@ -4,9 +4,9 @@ import { waitFor } from '@testing-library/react'; import { createMapMock } from '../../__test-helpers/mocks'; import { MapContext } from '../../App'; -import useMapLayer from './'; +import useMapLayers from './'; -describe('hooks - useMapLayer', () => { +describe('hooks - useMapLayers', () => { let wrapper, map; const layerId = 'test-layer-id'; @@ -20,13 +20,13 @@ describe('hooks - useMapLayer', () => { }); test('adding a layer to the map', () => { - renderHook(() => useMapLayer({ id: layerId, type: 'string', sourceId: 'whatever-source-id' }), { wrapper }); + 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(() => useMapLayer()); // no context wrapper means there's no map available; + renderHook(() => useMapLayers()); // no context wrapper means there's no map available; expect(map.addLayer).not.toHaveBeenCalled(); }); @@ -38,12 +38,12 @@ describe('hooks - useMapLayer', () => { test('setting and changing paint props', () => { let paintObject = { value1: 'yellow', value2: 0.6 }; - const { rerender } = renderHook(() => useMapLayer({ + const { rerender } = renderHook(() => useMapLayers([{ id: layerId, type: 'string', sourceId: 'whatever-source-id', paint: paintObject - }), { wrapper }); + }]), { wrapper }); Object.entries(paintObject).forEach(([key, value]) => { expect(map.setPaintProperty).toHaveBeenCalledWith(layerId, key, value); @@ -63,12 +63,12 @@ describe('hooks - useMapLayer', () => { test('setting and changing layout props', () => { let layoutObject = { value1: 'yellow', value2: 0.6 }; - const { rerender } = renderHook(() => useMapLayer({ + const { rerender } = renderHook(() => useMapLayers([{ id: layerId, type: 'string', sourceId: 'whatever-source-id', layout: layoutObject - }), { wrapper }); + }]), { wrapper }); Object.entries(layoutObject).forEach(([key, value]) => { expect(map.setLayoutProperty).toHaveBeenCalledWith(layerId, key, value); @@ -86,11 +86,11 @@ describe('hooks - useMapLayer', () => { test('returning the layer value', () => { - const { result } = renderHook(() => useMapLayer({ + const { result } = renderHook(() => useMapLayers([{ id: layerId, type: 'string', sourceId: 'whatever-source-id', - }), { wrapper }); + }]), { wrapper }); expect(result.current).toEqual([{ whatever: 'ok' }]); }); @@ -99,12 +99,12 @@ describe('hooks - useMapLayer', () => { test('.filter sets and changes', () => { let filter = ['==', [['get', 'subject_subtype'], 'ranger']]; - const { rerender } = renderHook(() => useMapLayer({ + const { rerender } = renderHook(() => useMapLayers([{ id: layerId, type: 'string', sourceId: 'whatever-source-id', options: { filter } - }), { wrapper }); + }]), { wrapper }); expect(map.setFilter).toHaveBeenCalledWith(layerId, filter); @@ -119,12 +119,12 @@ describe('hooks - useMapLayer', () => { test('.before sets and changes', async () => { let before = null; - const { rerender } = renderHook(() => useMapLayer({ + const { rerender } = renderHook(() => useMapLayers([{ id: layerId, type: 'string', sourceId: 'whatever-source-id', options: { before } - }), { wrapper }); + }]), { wrapper }); expect(map.moveLayer).not.toHaveBeenCalled(); @@ -143,12 +143,12 @@ describe('hooks - useMapLayer', () => { minZoom: 1 }; - const { rerender } = renderHook(() => useMapLayer({ + const { rerender } = renderHook(() => useMapLayers([{ id: layerId, type: 'string', sourceId: 'whatever-source-id', options: config - }), { wrapper }); + }]), { wrapper }); expect(map.setLayerZoomRange).toHaveBeenCalledWith(layerId, 1, 15); @@ -172,12 +172,12 @@ describe('hooks - useMapLayer', () => { const config = { condition: false }; - const { rerender } = renderHook(() => useMapLayer({ + const { rerender } = renderHook(() => useMapLayers([{ id: layerId, type: 'string', sourceId: 'whatever-source-id', options: config - }), { wrapper }); + }]), { wrapper }); expect(map.addLayer).toHaveBeenCalled(); diff --git a/src/hooks/useMapSource/index.test.js b/src/hooks/useMapSource/index.test.js deleted file mode 100644 index 4b62c7a63..000000000 --- a/src/hooks/useMapSource/index.test.js +++ /dev/null @@ -1,90 +0,0 @@ -import React from 'react'; -import { renderHook } from '@testing-library/react-hooks'; - -import { MapContext } from '../../App'; - -import useMapSource 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( - () => useMapSource(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('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: { - tolerance: 1.5, - type: 'geojson', - lineMetrics: true, - enabled: true - } - }; - - renderUserMapSource(sourceConfig, map); - - jest.runAllTimers(); - - expect(map.getSource).toHaveBeenCalledTimes(3); // Get called 3 times by: adding source check, updating data, returning the source - expect(map.getSource).toHaveBeenCalledWith(sourceConfig.id); - expect(source.setData).toHaveBeenCalledTimes(1); - expect(source.setData).toHaveBeenCalledWith(sourceConfig.data); - - jest.useRealTimers(); - }); - -}); \ No newline at end of file diff --git a/src/hooks/useMapSource/index.js b/src/hooks/useMapSources/index.js similarity index 84% rename from src/hooks/useMapSource/index.js rename to src/hooks/useMapSources/index.js index 6312c133b..523b46268 100644 --- a/src/hooks/useMapSource/index.js +++ b/src/hooks/useMapSources/index.js @@ -1,12 +1,11 @@ -import { useContext, useEffect, useMemo, useRef } from 'react'; +import { useContext, useEffect, useRef } from 'react'; import { MapContext } from '../../App'; -const useMapSource = (sourceConfig, defaultConfig = { type: 'geojson' }) => { +const useMapSources = (sourceConfigsBatch = [], defaultConfig = { type: 'geojson' }) => { const map = useContext(MapContext); const sourceIdsRef = useRef([]); - const sourceConfigsBatch = useMemo(() => Array.isArray(sourceConfig) ? sourceConfig : [sourceConfig], [sourceConfig]); useEffect(() => { if (map) { @@ -63,4 +62,4 @@ const useMapSource = (sourceConfig, defaultConfig = { type: 'geojson' }) => { .filter(sourceConfig => !!sourceConfig); }; -export default useMapSource; +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 6f018d619..ba8a308a5 100644 --- a/src/selectors/tracks/index.js +++ b/src/selectors/tracks/index.js @@ -6,7 +6,7 @@ import { TRACK_LENGTH_ORIGINS } from '../../ducks/tracks'; import { trimTrackDataToTimeRange, - buildTimeOfDayFeatureCollection + buildTrackSegments } from '../../utils/tracks'; const selectEventFilter = (state) => state.data.eventFilter; @@ -109,7 +109,7 @@ export const selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod = return isTimeOfDayColoringActive ? { ...trimmedTrackData, - timeOfDayFeatureCollection: buildTimeOfDayFeatureCollection(trimmedTrackData.track, timeOfDayTimeZone) + trackSegments: buildTrackSegments(trimmedTrackData.track, timeOfDayTimeZone) } : trimmedTrackData; } diff --git a/src/selectors/tracks/index.test.js b/src/selectors/tracks/index.test.js index 951890c01..41e162825 100644 --- a/src/selectors/tracks/index.test.js +++ b/src/selectors/tracks/index.test.js @@ -143,6 +143,7 @@ describe('Selectors - Tracks', () => { test('builds the subject tracks from the subjects with heatmap active trimmed to the time envelope', () => { state.view.subjectTrackState.visible = ['123']; + state.view.trackSettings.isTimeOfDayColoringActive = false; state.data.tracks = { 123: { points: { @@ -184,5 +185,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': '#8d4e85', + '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 46d4d231e..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; @@ -361,13 +362,15 @@ 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); - const period = TIME_OF_DAY_PERIODS.find((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 ); - - return period ?? TIME_OF_DAY_PERIODS[0]; }; /* @@ -377,14 +380,14 @@ export const getTimeOfDayPeriodBasedOnTime = (datetimeString, timeZone) => { * 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 buildTimeOfDayFeatureCollection = (trackFeatureCollection, timeZone) => { - const featureCollection = { +export const buildTrackSegments = (trackFeatureCollection, timeZone) => { + const emptyFeatureCollection = { type: 'FeatureCollection', features: [] }; if (!trackFeatureCollection || !trackFeatureCollection.features || !trackFeatureCollection.features.length) { - return featureCollection; + return emptyFeatureCollection; } const [lineStringFeature] = trackFeatureCollection.features; @@ -393,7 +396,7 @@ export const buildTimeOfDayFeatureCollection = (trackFeatureCollection, timeZone if (!lineStringFeature?.geometry || lineStringFeature.geometry?.type !== 'LineString' || !lineStringFeature.properties?.coordinateProperties?.times) { - return featureCollection; + return emptyFeatureCollection; } const { @@ -409,7 +412,7 @@ export const buildTimeOfDayFeatureCollection = (trackFeatureCollection, timeZone // 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 featureCollection; + return emptyFeatureCollection; } const segments = []; @@ -443,8 +446,5 @@ export const buildTimeOfDayFeatureCollection = (trackFeatureCollection, timeZone }); } - return { - type: 'FeatureCollection', - features: segments - }; + return featureCollection(segments); }; diff --git a/src/utils/tracks.test.js b/src/utils/tracks.test.js index afca9b9cf..128d43091 100644 --- a/src/utils/tracks.test.js +++ b/src/utils/tracks.test.js @@ -1,4 +1,4 @@ -import { buildTimeOfDayFeatureCollection, getTimeOfDayPeriodBasedOnTime } from './tracks'; +import { buildTrackSegments, getTimeOfDayPeriodBasedOnTime } from './tracks'; import { TIME_OF_DAY_PERIODS } from '../constants'; @@ -81,7 +81,7 @@ describe('utils - tracks', () => { { properties: { startColor: TIME_OF_DAY_PERIODS[1].color, - endColor: TIME_OF_DAY_PERIODS[0].color, + endColor: TIME_OF_DAY_PERIODS[3].color, startTime: '2025-02-27T21:42:01+00:00', endTime: '2025-02-24T06:06:05+00:00' }, @@ -94,7 +94,7 @@ describe('utils - tracks', () => { }, { properties: { - startColor: TIME_OF_DAY_PERIODS[0].color, + startColor: TIME_OF_DAY_PERIODS[3].color, endColor: TIME_OF_DAY_PERIODS[3].color, startTime: '2025-02-24T06:06:05+00:00', endTime: '2025-02-24T03:58:02+00:00' @@ -136,12 +136,12 @@ describe('utils - tracks', () => { } ]; - const twoPointFeatureCollection = buildTimeOfDayFeatureCollection(track, 'America/Monterrey'); + const trackSegments = buildTrackSegments(track, 'America/Monterrey'); - expect(twoPointFeatureCollection.features.length).toBe(resultFeatures.length); - expect(twoPointFeatureCollection.type).toBe('FeatureCollection'); + expect(trackSegments.features.length).toBe(resultFeatures.length); + expect(trackSegments.type).toBe('FeatureCollection'); - twoPointFeatureCollection.features.forEach((feature, index) => { + trackSegments.features.forEach((feature, index) => { const expectedFeature = resultFeatures[index]; expect(feature.type).toBe('Feature'); @@ -164,7 +164,7 @@ describe('utils - tracks', () => { const emptyTracks = { ...track }; emptyTracks.features = []; - const emptyFeatureCollection = buildTimeOfDayFeatureCollection(emptyTracks, 'America/Monterrey'); + const emptyFeatureCollection = buildTrackSegments(emptyTracks, 'America/Monterrey'); expect(emptyFeatureCollection.features.length).toBe(0); expect(emptyFeatureCollection.type).toBe('FeatureCollection'); @@ -174,7 +174,7 @@ describe('utils - tracks', () => { const invalidFeaturesTrack = { ...track }; invalidFeaturesTrack.features = [{}]; - const emptyFeatureCollection = buildTimeOfDayFeatureCollection(invalidFeaturesTrack, 'America/Monterrey'); + const emptyFeatureCollection = buildTrackSegments(invalidFeaturesTrack, 'America/Monterrey'); expect(emptyFeatureCollection.features.length).toBe(0); expect(emptyFeatureCollection.type).toBe('FeatureCollection'); @@ -187,7 +187,7 @@ describe('utils - tracks', () => { const notEnoughFeaturesTracks = { ...track }; notEnoughFeaturesTracks.features = [feature]; - const emptyFeatureCollection = buildTimeOfDayFeatureCollection(notEnoughFeaturesTracks, 'America/Monterrey'); + const emptyFeatureCollection = buildTrackSegments(notEnoughFeaturesTracks, 'America/Monterrey'); expect(emptyFeatureCollection.features.length).toBe(0); expect(emptyFeatureCollection.type).toBe('FeatureCollection'); From 2a436ea37ecbe28997a8183b4564f26030e7d3a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20L=C3=B3pez?= Date: Tue, 4 Mar 2025 15:08:57 -0600 Subject: [PATCH 12/15] Solving removing layer bug --- src/hooks/useMapLayers/index.js | 42 +++++++++++++++++----------- src/hooks/useMapLayers/index.test.js | 2 +- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/hooks/useMapLayers/index.js b/src/hooks/useMapLayers/index.js index 7dab92909..c22cc4494 100644 --- a/src/hooks/useMapLayers/index.js +++ b/src/hooks/useMapLayers/index.js @@ -1,24 +1,34 @@ -import { useCallback, useContext, useEffect, useRef } from 'react'; +import { useContext, useEffect, useRef } from 'react'; import { MapContext } from '../../App'; import { MAX_ZOOM, MIN_ZOOM } from '../../constants'; +const hasLayerCondition = (layerConfig) => layerConfig?.options?.hasOwnProperty('condition') + ? layerConfig.options.condition + : true; + +const shouldUpdateMapLayer = (layerConfig, map) => { + return !!( + !!layerConfig?.id + && hasLayerCondition(layerConfig) + && !!map.getLayer(layerConfig.id) + ); +}; + const useMapLayers = (layerConfigsBatch = [], defaultConfig = {}) => { const map = useContext(MapContext); const layerIdsRef = useRef([]); - const shouldUpdateMapLayer = useCallback((config) => config?.id && config?.condition !== false && map.getLayer(config.id), [map]); - useEffect(() => { if (map){ layerConfigsBatch.forEach(layerConfig => { if ( layerConfig?.id - && layerConfig?.condition !== false - && !map.getLayer(layerConfig.id) && layerConfig?.type && layerConfig?.sourceId && map.getSource(layerConfig.sourceId) + && layerConfig?.options?.condition !== false + && !map.getLayer(layerConfig.id) ){ const { id, @@ -56,52 +66,52 @@ const useMapLayers = (layerConfigsBatch = [], defaultConfig = {}) => { } }); } - }, [map, defaultConfig, layerConfigsBatch, shouldUpdateMapLayer]); + }, [map, defaultConfig, layerConfigsBatch]); useEffect(() => { if (map) { layerConfigsBatch.forEach(layerConfig => { - if ( shouldUpdateMapLayer(layerConfig) && layerConfig.layout ){ + if ( layerConfig.layout && shouldUpdateMapLayer(layerConfig, map) ){ Object.entries(layerConfig.layout).forEach(([name, value]) => { map.setLayoutProperty(layerConfig.id, name, value); }); } }); } - }, [map, layerConfigsBatch, shouldUpdateMapLayer]); + }, [map, layerConfigsBatch]); useEffect(() => { if (map) { layerConfigsBatch.forEach(layerConfig => { - if ( shouldUpdateMapLayer(layerConfig) && layerConfig.paint ){ + if ( layerConfig?.paint && shouldUpdateMapLayer(layerConfig, map) ){ Object.entries(layerConfig.paint).forEach(([name, value]) => { map.setPaintProperty(layerConfig.id, name, value); }); } }); } - }, [map, layerConfigsBatch, shouldUpdateMapLayer]); + }, [map, layerConfigsBatch]); useEffect(() => { if (map) { layerConfigsBatch.forEach(layerConfig => { const filter = layerConfig?.options?.filter || defaultConfig.filter; - if (shouldUpdateMapLayer(layerConfig) && Array.isArray(filter)){ + if (Array.isArray(filter) && shouldUpdateMapLayer(layerConfig, map)){ map.setFilter(layerConfig.id, filter); } }); } - }, [map, layerConfigsBatch, defaultConfig, shouldUpdateMapLayer]); + }, [map, layerConfigsBatch, defaultConfig]); useEffect(() => { if (map) { layerConfigsBatch.forEach(layerConfig => { - if ( shouldUpdateMapLayer(layerConfig) ){ + if ( layerConfig?.id && !hasLayerCondition(layerConfig) && map.getLayer(layerConfig.id) ){ map.removeLayer(layerConfig.id); } }); } - }, [map, layerConfigsBatch, shouldUpdateMapLayer]); + }, [map, layerConfigsBatch]); useEffect(() => { if (map) { @@ -121,7 +131,7 @@ const useMapLayers = (layerConfigsBatch = [], defaultConfig = {}) => { useEffect(() => { if (map) { layerConfigsBatch.forEach(layerConfig => { - if ( shouldUpdateMapLayer(layerConfig) ) { + if ( shouldUpdateMapLayer(layerConfig, map) ) { const { options: { minZoom, maxZoom } = {} } = layerConfig; map.setLayerZoomRange( layerConfig.id, @@ -131,7 +141,7 @@ const useMapLayers = (layerConfigsBatch = [], defaultConfig = {}) => { } }); } - }, [map, layerConfigsBatch, defaultConfig, shouldUpdateMapLayer]); + }, [map, layerConfigsBatch, defaultConfig]); useEffect(() => { const refs = layerIdsRef.current; diff --git a/src/hooks/useMapLayers/index.test.js b/src/hooks/useMapLayers/index.test.js index 425138b33..63067139f 100644 --- a/src/hooks/useMapLayers/index.test.js +++ b/src/hooks/useMapLayers/index.test.js @@ -179,7 +179,7 @@ describe('hooks - useMapLayers', () => { options: config }]), { wrapper }); - expect(map.addLayer).toHaveBeenCalled(); + expect(map.addLayer).not.toHaveBeenCalled(); config.condition = true; From a3202cf127177e32915d52aafa070d69a3d306af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20L=C3=B3pez?= Date: Tue, 4 Mar 2025 15:35:13 -0600 Subject: [PATCH 13/15] Turning FF off --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env b/.env index b1e95a494..98fda7d21 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=true +REACT_APP_TIME_OF_DAY_TRACKING=false From 24adc9af7d0ed211bb90410031ef7583ad2a6b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20L=C3=B3pez?= Date: Tue, 4 Mar 2025 15:35:52 -0600 Subject: [PATCH 14/15] Turning FF on --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 229aac1703f1c96cc048780d7d22b194260eb1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20L=C3=B3pez?= Date: Thu, 6 Mar 2025 10:43:50 -0600 Subject: [PATCH 15/15] Fixing track length bug, adding feedback PR --- src/TracksLayer/track.js | 2 +- src/hooks/useMapLayers/index.js | 121 +++++++++------------------ src/hooks/useMapLayers/index.test.js | 50 ----------- src/hooks/useMapSources/index.js | 12 +-- src/selectors/tracks/index.js | 2 +- src/selectors/tracks/index.test.js | 2 +- src/utils/tracks.test.js | 4 +- 7 files changed, 46 insertions(+), 147 deletions(-) diff --git a/src/TracksLayer/track.js b/src/TracksLayer/track.js index ef202582e..0d6689b23 100644 --- a/src/TracksLayer/track.js +++ b/src/TracksLayer/track.js @@ -80,7 +80,7 @@ const TrackLayer = ({ before, id, lineLayout, linePaint, onPointClick, showTimep useMapSources([{ id: pointSourceId, data: trackData.points }]); useMapSources(sourcesConfigs, { tolerance: 1.5, type: 'geojson', lineMetrics: true }); - useMapLayers(layersConfigs, { before: before || SUBJECT_SYMBOLS }); + useMapLayers(layersConfigs); // Only create the normal layer if there are no time_of_day_segments useMapLayers([{ diff --git a/src/hooks/useMapLayers/index.js b/src/hooks/useMapLayers/index.js index c22cc4494..ca9b7d0af 100644 --- a/src/hooks/useMapLayers/index.js +++ b/src/hooks/useMapLayers/index.js @@ -1,72 +1,62 @@ import { useContext, useEffect, useRef } from 'react'; import { MapContext } from '../../App'; -import { MAX_ZOOM, MIN_ZOOM } from '../../constants'; -const hasLayerCondition = (layerConfig) => layerConfig?.options?.hasOwnProperty('condition') - ? layerConfig.options.condition - : true; +const assertLayerCondition = (layerConfig) => layerConfig?.options?.condition ?? true; -const shouldUpdateMapLayer = (layerConfig, map) => { - return !!( - !!layerConfig?.id - && hasLayerCondition(layerConfig) - && !!map.getLayer(layerConfig.id) - ); -}; +const shouldUpdateMapLayer = (layerConfig, map) => !!( + layerConfig?.id + && assertLayerCondition(layerConfig) + && map.getLayer(layerConfig.id) +); -const useMapLayers = (layerConfigsBatch = [], defaultConfig = {}) => { +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) - && layerConfig?.options?.condition !== false + && assertLayerCondition(layerConfig) && !map.getLayer(layerConfig.id) ){ - const { - id, - type, - sourceId, - paint = {}, - layout = {}, - options: { - filter, - before - } = {} - } = layerConfig; - - const layerObj = { - id, - source: sourceId, - type, - layout: layout, - paint: paint - }; - - const filterValue = filter || defaultConfig.filter; - if (Array.isArray(filterValue)) { - layerObj.filter = filterValue; - } - - // Handle line-gradient and line-color conflict - if (type === 'line' && paint?.['line-gradient'] && paint?.['line-color']) { - console.warn(`Layer ${id}: line-gradient and line-color cannot both be specified`); - delete layerObj.paint['line-color']; - } + 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 + ); - map.addLayer(layerObj, before || defaultConfig.before); - layerIdsRef.current.push(id); + layerIdsRef.current.push(layerConfig.id); } }); } - }, [map, defaultConfig, layerConfigsBatch]); + }, [map, layerConfigsBatch]); useEffect(() => { if (map) { @@ -95,54 +85,23 @@ const useMapLayers = (layerConfigsBatch = [], defaultConfig = {}) => { useEffect(() => { if (map) { layerConfigsBatch.forEach(layerConfig => { - const filter = layerConfig?.options?.filter || defaultConfig.filter; - if (Array.isArray(filter) && shouldUpdateMapLayer(layerConfig, map)){ - map.setFilter(layerConfig.id, filter); + if (Array.isArray(layerConfig?.options?.filter) && shouldUpdateMapLayer(layerConfig, map)){ + map.setFilter(layerConfig.id, layerConfig.options.filter); } }); } - }, [map, layerConfigsBatch, defaultConfig]); + }, [map, layerConfigsBatch]); useEffect(() => { if (map) { layerConfigsBatch.forEach(layerConfig => { - if ( layerConfig?.id && !hasLayerCondition(layerConfig) && map.getLayer(layerConfig.id) ){ + if ( layerConfig?.id && !assertLayerCondition(layerConfig) && map.getLayer(layerConfig.id) ){ map.removeLayer(layerConfig.id); } }); } }, [map, layerConfigsBatch]); - useEffect(() => { - if (map) { - layerConfigsBatch.forEach(layerConfig => { - const before = layerConfig?.options?.before || defaultConfig.before; - if ( - layerConfig?.id - && before - && map.getLayer(layerConfig.id) - ){ - map.moveLayer(layerConfig.id, before); - } - }); - } - }, [map, layerConfigsBatch, defaultConfig]); - - useEffect(() => { - if (map) { - layerConfigsBatch.forEach(layerConfig => { - if ( shouldUpdateMapLayer(layerConfig, map) ) { - const { options: { minZoom, maxZoom } = {} } = layerConfig; - map.setLayerZoomRange( - layerConfig.id, - minZoom || defaultConfig.minZoom || MIN_ZOOM, - maxZoom || defaultConfig.maxZoom || MAX_ZOOM - ); - } - }); - } - }, [map, layerConfigsBatch, defaultConfig]); - useEffect(() => { const refs = layerIdsRef.current; return () => { diff --git a/src/hooks/useMapLayers/index.test.js b/src/hooks/useMapLayers/index.test.js index 63067139f..27b19678b 100644 --- a/src/hooks/useMapLayers/index.test.js +++ b/src/hooks/useMapLayers/index.test.js @@ -116,56 +116,6 @@ describe('hooks - useMapLayers', () => { }); - test('.before sets and changes', async () => { - let before = null; - - const { rerender } = renderHook(() => useMapLayers([{ - id: layerId, - type: 'string', - sourceId: 'whatever-source-id', - options: { 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(() => useMapLayers([{ - id: layerId, - type: 'string', - sourceId: 'whatever-source-id', - options: 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); diff --git a/src/hooks/useMapSources/index.js b/src/hooks/useMapSources/index.js index 523b46268..122855202 100644 --- a/src/hooks/useMapSources/index.js +++ b/src/hooks/useMapSources/index.js @@ -24,22 +24,12 @@ const useMapSources = (sourceConfigsBatch = [], defaultConfig = { type: 'geojson }, [map, sourceConfigsBatch, defaultConfig]); useEffect(() => { - let timeouts = []; sourceConfigsBatch.forEach(sourceConfig => { const source = map?.getSource?.(sourceConfig?.id); if (sourceConfig?.id && sourceConfig?.data && source){ - const timeout = window.setTimeout(() => { - source.setData(sourceConfig.data); - }); - timeouts.push(timeout); + source.setData?.(sourceConfig.data); } }); - - return () => { - timeouts.forEach(timeout => { - window.clearTimeout(timeout); - }); - }; }, [map, sourceConfigsBatch]); useEffect(() => { diff --git a/src/selectors/tracks/index.js b/src/selectors/tracks/index.js index ba8a308a5..1cdb9c9a6 100644 --- a/src/selectors/tracks/index.js +++ b/src/selectors/tracks/index.js @@ -98,7 +98,7 @@ const selectSubjectTracks = createSelector( export const selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod = createSelector( [selectSubjectTracks, selectTrackTimeEnvelope, selectTrackSettings], - (subjectTracks, trackTimeEnvelope, { timeOfDayTimeZone, isTimeOfDayColoringActive }) => subjectTracks.map( + (subjectTracks, trackTimeEnvelope, { isTimeOfDayColoringActive, timeOfDayTimeZone }) => subjectTracks.map( (subjectTrack) => { const trimmedTrackData = trimTrackDataToTimeRange( // Trim each subject tracks to the track time envelope. subjectTrack, diff --git a/src/selectors/tracks/index.test.js b/src/selectors/tracks/index.test.js index 2f6711e9c..d9472de1c 100644 --- a/src/selectors/tracks/index.test.js +++ b/src/selectors/tracks/index.test.js @@ -265,7 +265,7 @@ describe('Selectors - Tracks', () => { 'type': 'Feature', 'properties': { 'startColor': '#ffbd00', - 'endColor': '#8d4e85', + 'endColor': '#5b5ee9', 'startTime': '2025-02-27T21:42:01+00:00', 'endTime': '2025-02-24T06:06:05+00:00' }, diff --git a/src/utils/tracks.test.js b/src/utils/tracks.test.js index 128d43091..c4e76dbe2 100644 --- a/src/utils/tracks.test.js +++ b/src/utils/tracks.test.js @@ -81,7 +81,7 @@ describe('utils - tracks', () => { { properties: { startColor: TIME_OF_DAY_PERIODS[1].color, - endColor: TIME_OF_DAY_PERIODS[3].color, + endColor: TIME_OF_DAY_PERIODS[4].color, startTime: '2025-02-27T21:42:01+00:00', endTime: '2025-02-24T06:06:05+00:00' }, @@ -94,7 +94,7 @@ describe('utils - tracks', () => { }, { properties: { - startColor: TIME_OF_DAY_PERIODS[3].color, + 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'